فهرست منبع

Add: 支持通过执行脚本抓取资源并上传至系统抓取报告

iaun 2 ماه پیش
والد
کامیت
8f52c214eb

+ 502 - 0
.kiro/specs/cloudshell-scanner/design.md

@@ -0,0 +1,502 @@
+# 设计文档
+
+## 概述
+
+本设计实现 CloudShell 扫描器功能,允许用户在 AWS CloudShell 环境中运行独立的 Python 脚本扫描 AWS 资源,并将结果上传到系统生成报告。
+
+### 核心组件
+
+1. **CloudShell 扫描脚本** (`cloudshell_scanner.py`) - 独立的单文件 Python 脚本
+2. **前端上传组件** - React 组件,支持 JSON 文件上传和验证
+3. **后端上传 API** - Flask 端点,处理 JSON 数据上传和任务创建
+4. **数据处理服务** - 将上传的 JSON 数据转换为报告
+
+## 架构
+
+```mermaid
+flowchart TB
+    subgraph CloudShell["AWS CloudShell"]
+        Scanner["cloudshell_scanner.py"]
+        JSON["scan_result.json"]
+        Scanner --> JSON
+    end
+    
+    subgraph Frontend["前端 (React)"]
+        Upload["上传组件"]
+        Validate["JSON 验证"]
+        Upload --> Validate
+    end
+    
+    subgraph Backend["后端 (Flask)"]
+        API["上传 API"]
+        Task["任务创建"]
+        Worker["Celery Worker"]
+        Report["报告生成器"]
+        API --> Task
+        Task --> Worker
+        Worker --> Report
+    end
+    
+    JSON -.->|用户下载| Upload
+    Validate --> API
+    Report --> Download["报告下载"]
+```
+
+## 组件和接口
+
+### 1. CloudShell 扫描脚本
+
+#### 文件结构
+```
+cloudshell_scanner.py  # 单体文件,包含所有扫描逻辑
+```
+
+#### 命令行接口
+```bash
+# 扫描所有区域
+python cloudshell_scanner.py
+
+# 扫描指定区域
+python cloudshell_scanner.py --regions us-east-1,ap-northeast-1
+
+# 指定输出文件
+python cloudshell_scanner.py --output my_scan.json
+
+# 扫描指定服务
+python cloudshell_scanner.py --services ec2,vpc,rds
+```
+
+#### 核心类
+
+```python
+class CloudShellScanner:
+    """CloudShell 环境下的 AWS 资源扫描器"""
+    
+    SUPPORTED_SERVICES: List[str]  # 支持的服务列表
+    GLOBAL_SERVICES: List[str]     # 全局服务列表
+    
+    def __init__(self):
+        """初始化扫描器,自动获取 CloudShell 凭证"""
+        pass
+    
+    def get_account_id(self) -> str:
+        """获取当前 AWS 账户 ID"""
+        pass
+    
+    def list_regions(self) -> List[str]:
+        """列出所有可用区域"""
+        pass
+    
+    def scan_resources(
+        self,
+        regions: List[str],
+        services: Optional[List[str]] = None
+    ) -> Dict[str, Any]:
+        """扫描指定区域和服务的资源"""
+        pass
+    
+    def export_json(self, result: Dict[str, Any], output_path: str) -> None:
+        """导出扫描结果为 JSON 文件"""
+        pass
+```
+
+### 2. JSON 数据格式
+
+```typescript
+interface ScanData {
+  metadata: {
+    account_id: string;
+    scan_timestamp: string;  // ISO 8601 格式
+    regions_scanned: string[];
+    services_scanned: string[];
+    scanner_version: string;
+    total_resources: number;
+    total_errors: number;
+  };
+  resources: {
+    [service: string]: ResourceData[];
+  };
+  errors: ErrorData[];
+}
+
+interface ResourceData {
+  account_id: string;
+  region: string;
+  service: string;
+  resource_type: string;
+  resource_id: string;
+  name: string;
+  attributes: Record<string, any>;
+}
+
+interface ErrorData {
+  service: string;
+  region: string;
+  error: string;
+  error_type: string;
+  details: Record<string, any> | null;
+}
+```
+
+### 3. 前端上传组件
+
+#### 组件结构
+```
+frontend/src/components/
+├── Upload/
+│   ├── JsonUploader.tsx      # JSON 文件上传组件
+│   └── ScanDataValidator.ts  # JSON 数据验证工具
+```
+
+#### JsonUploader 组件接口
+```typescript
+interface JsonUploaderProps {
+  onUploadSuccess: (data: ScanData, file: File) => void;
+  onUploadError: (error: string) => void;
+  maxFileSize?: number;  // 默认 50MB
+}
+```
+
+### 4. 后端上传 API
+
+#### 新增端点
+
+```
+POST /api/tasks/upload-scan
+```
+
+**请求体 (multipart/form-data):**
+```json
+{
+  "scan_data": "<JSON 文件>",
+  "project_metadata": {
+    "clientName": "客户名称",
+    "projectName": "项目名称",
+    "bdManager": "BD 经理",
+    "bdManagerEmail": "bd@example.com",
+    "solutionsArchitect": "解决方案架构师",
+    "solutionsArchitectEmail": "sa@example.com",
+    "cloudEngineer": "云工程师",
+    "cloudEngineerEmail": "ce@example.com"
+  },
+  "network_diagram": "<可选:网络拓扑图>"
+}
+```
+
+**响应:**
+```json
+{
+  "message": "任务创建成功",
+  "task": {
+    "id": 123,
+    "name": "CloudShell Scan - 2024-01-15",
+    "status": "pending",
+    "source": "upload"
+  }
+}
+```
+
+### 5. 数据处理服务
+
+#### 新增服务类
+
+```python
+class ScanDataProcessor:
+    """处理上传的扫描数据"""
+    
+    def validate_scan_data(self, data: Dict[str, Any]) -> Tuple[bool, List[str]]:
+        """验证扫描数据结构
+        
+        Returns:
+            (is_valid, error_messages)
+        """
+        pass
+    
+    def convert_to_scan_result(self, data: Dict[str, Any]) -> ScanResult:
+        """将 JSON 数据转换为 ScanResult 对象"""
+        pass
+```
+
+## 数据模型
+
+### Task 模型扩展
+
+在现有 Task 模型中添加字段:
+
+```python
+class Task(db.Model):
+    # ... 现有字段 ...
+    
+    # 新增字段
+    source = db.Column(db.String(20), default='credential')  # 'credential' 或 'upload'
+    scan_data_path = db.Column(db.String(500), nullable=True)  # 上传的 JSON 文件路径
+```
+
+### 数据库迁移
+
+```python
+# migrations/versions/xxx_add_task_source_field.py
+def upgrade():
+    op.add_column('task', sa.Column('source', sa.String(20), default='credential'))
+    op.add_column('task', sa.Column('scan_data_path', sa.String(500), nullable=True))
+
+def downgrade():
+    op.drop_column('task', 'source')
+    op.drop_column('task', 'scan_data_path')
+```
+
+
+## 正确性属性
+
+*正确性属性是一种特征或行为,应该在系统的所有有效执行中保持为真——本质上是关于系统应该做什么的形式化陈述。属性作为人类可读规范和机器可验证正确性保证之间的桥梁。*
+
+### Property 1: JSON 数据往返一致性
+
+*对于任意* 有效的 ScanResult 对象,将其序列化为 JSON 然后反序列化,应该产生与原始对象等价的数据结构。
+
+**验证: 需求 2.4, 2.5**
+
+### Property 2: JSON 结构完整性
+
+*对于任意* CloudShell_Scanner 生成的 Scan_Data,必须包含以下所有字段:
+- metadata.account_id
+- metadata.scan_timestamp
+- metadata.regions_scanned
+- metadata.services_scanned
+- resources(按服务类型组织)
+- errors(错误列表)
+
+**验证: 需求 2.1, 2.2, 2.3**
+
+### Property 3: 区域扫描约束
+
+*对于任意* 指定的区域列表,CloudShell_Scanner 扫描的资源应该只来自这些指定的区域。
+
+**验证: 需求 1.3**
+
+### Property 4: 服务类型一致性
+
+*对于任意* CloudShell_Scanner 实例,其 SUPPORTED_SERVICES 列表应该与现有 AWSScanner 的 SUPPORTED_SERVICES 列表完全相同。
+
+**验证: 需求 1.5**
+
+### Property 5: 错误容错性
+
+*对于任意* 扫描过程中的服务失败,CloudShell_Scanner 应该记录错误并继续扫描其他服务,最终结果应该包含成功扫描的资源和失败的错误记录。
+
+**验证: 需求 1.8**
+
+### Property 6: JSON 验证完整性
+
+*对于任意* 上传的 JSON 数据,如果缺少必要字段,Upload_Handler 应该返回包含所有缺失字段名称的错误信息。
+
+**验证: 需求 3.4, 4.2, 6.2**
+
+### Property 7: 无效 JSON 错误处理
+
+*对于任意* 无效的 JSON 格式输入,系统应该返回明确的错误信息而不是崩溃。
+
+**验证: 需求 3.3, 3.5, 4.5**
+
+### Property 8: 报告生成一致性
+
+*对于任意* 有效的 Scan_Data,通过上传方式生成的报告应该与通过凭证扫描方式生成的报告具有相同的结构和格式。
+
+**验证: 需求 5.1, 5.2**
+
+## 错误处理
+
+### CloudShell 扫描脚本错误处理
+
+| 错误类型 | 处理方式 |
+|---------|---------|
+| 凭证无效 | 显示错误信息,提示用户检查 IAM 权限 |
+| 区域不可用 | 跳过该区域,记录警告,继续扫描其他区域 |
+| 服务 API 错误 | 记录错误详情,继续扫描其他服务 |
+| 网络超时 | 重试 3 次,使用指数退避策略 |
+| 权限不足 | 记录缺少的权限,继续扫描有权限的资源 |
+
+### 后端上传处理错误处理
+
+| 错误类型 | HTTP 状态码 | 响应 |
+|---------|------------|------|
+| JSON 解析失败 | 400 | `{"error": "无效的 JSON 格式", "details": "..."}` |
+| 缺少必要字段 | 400 | `{"error": "缺少必要字段", "missing_fields": [...]}` |
+| 文件过大 | 413 | `{"error": "文件大小超过限制", "max_size": "50MB"}` |
+| 服务器错误 | 500 | `{"error": "服务器内部错误", "request_id": "..."}` |
+
+## 测试策略
+
+### 双重测试方法
+
+本功能采用单元测试和属性测试相结合的方式:
+
+- **单元测试**: 验证特定示例、边界情况和错误条件
+- **属性测试**: 验证跨所有输入的通用属性
+
+### 属性测试配置
+
+- 使用 **Hypothesis** 库进行 Python 属性测试
+- 使用 **fast-check** 库进行 TypeScript 属性测试
+- 每个属性测试至少运行 100 次迭代
+- 每个属性测试必须引用设计文档中的属性编号
+
+### 测试覆盖范围
+
+| 组件 | 单元测试 | 属性测试 |
+|-----|---------|---------|
+| CloudShell 扫描脚本 | 各服务扫描方法 | Property 2, 3, 4, 5 |
+| JSON 序列化/反序列化 | 边界情况 | Property 1 |
+| 前端 JSON 验证 | 特定错误场景 | Property 6, 7 |
+| 后端上传处理 | API 端点测试 | Property 6, 7 |
+| 报告生成 | 格式验证 | Property 8 |
+
+### 测试标签格式
+
+```python
+# Feature: cloudshell-scanner, Property 1: JSON 数据往返一致性
+@given(scan_result=scan_result_strategy())
+def test_json_round_trip(scan_result):
+    ...
+```
+
+
+## 扩展性:添加新服务支持
+
+### 概述
+
+CloudShell 扫描脚本设计为易于扩展,添加新的 AWS 服务只需要以下步骤:
+
+### 步骤 1:在 CloudShell 扫描脚本中添加服务
+
+在 `cloudshell_scanner.py` 中:
+
+```python
+class CloudShellScanner:
+    # 1. 在 SUPPORTED_SERVICES 列表中添加新服务
+    SUPPORTED_SERVICES = [
+        'vpc', 'subnet', 'ec2', ...,
+        'new_service'  # 添加新服务标识
+    ]
+    
+    # 2. 如果是全局服务,添加到 GLOBAL_SERVICES
+    GLOBAL_SERVICES = ['cloudfront', 'route53', ..., 'new_global_service']
+    
+    # 3. 在 _get_scanner_method 中注册扫描方法
+    def _get_scanner_method(self, service: str):
+        scanner_methods = {
+            ...
+            'new_service': self._scan_new_service,
+        }
+        return scanner_methods.get(service)
+    
+    # 4. 实现扫描方法
+    def _scan_new_service(self, account_id: str, region: str) -> List[Dict]:
+        """扫描新服务资源"""
+        client = boto3.client('new-service', region_name=region)
+        resources = []
+        
+        try:
+            response = client.list_resources()  # 根据实际 API 调整
+            for item in response.get('Resources', []):
+                resources.append({
+                    'account_id': account_id,
+                    'region': region,
+                    'service': 'new_service',
+                    'resource_type': 'NewResourceType',
+                    'resource_id': item['ResourceId'],
+                    'name': item.get('Name', ''),
+                    'attributes': {
+                        # 添加服务特定属性
+                        'attribute1': item.get('Attribute1'),
+                        'attribute2': item.get('Attribute2'),
+                    }
+                })
+        except Exception as e:
+            # 错误会被上层捕获并记录
+            raise
+        
+        return resources
+```
+
+### 步骤 2:在后端 AWSScanner 中同步添加(保持一致性)
+
+在 `backend/app/scanners/aws_scanner.py` 中进行相同的修改,确保两个扫描器支持相同的服务。
+
+### 步骤 3:在报告生成器中添加服务配置
+
+在 `backend/app/services/report_generator.py` 中:
+
+```python
+SERVICE_CONFIG = {
+    ...
+    'new_service': {
+        'layout': TableLayout.HORIZONTAL,  # 或 VERTICAL
+        'title': 'New Service',
+        'columns': ['Name', 'ID', 'Attribute1', 'Attribute2'],
+    },
+}
+
+# 添加到服务顺序
+SERVICE_ORDER = [..., 'new_service']
+
+# 添加到服务分组
+SERVICE_GROUPS = {
+    ...
+    'new_service': 'NewServiceGroup',
+}
+```
+
+### 步骤 4:更新测试
+
+确保新服务在以下测试中被覆盖:
+- 服务类型一致性测试(Property 4)
+- JSON 结构完整性测试(Property 2)
+
+### 维护检查清单
+
+添加新服务时,请确保:
+
+- [ ] CloudShell 扫描脚本中添加了服务
+- [ ] 后端 AWSScanner 中添加了相同的服务
+- [ ] 报告生成器中配置了服务的表格布局
+- [ ] 两个扫描器的 SUPPORTED_SERVICES 列表保持一致
+- [ ] 添加了必要的 IAM 权限说明
+- [ ] 更新了相关文档
+
+### 服务扫描方法模板
+
+```python
+def _scan_service_template(self, account_id: str, region: str) -> List[Dict]:
+    """
+    服务扫描方法模板
+    
+    Args:
+        account_id: AWS 账户 ID
+        region: 区域名称
+    
+    Returns:
+        资源列表,每个资源包含标准字段
+    """
+    client = boto3.client('service-name', region_name=region)
+    resources = []
+    
+    # 使用分页器处理大量资源
+    paginator = client.get_paginator('list_resources')
+    
+    for page in paginator.paginate():
+        for item in page.get('Resources', []):
+            resources.append({
+                'account_id': account_id,
+                'region': region,
+                'service': 'service_key',
+                'resource_type': 'ResourceType',
+                'resource_id': item['Id'],
+                'name': self._get_name_from_tags(item.get('Tags', [])),
+                'attributes': {
+                    # 服务特定属性
+                }
+            })
+    
+    return resources
+```

+ 91 - 0
.kiro/specs/cloudshell-scanner/requirements.md

@@ -0,0 +1,91 @@
+# 需求文档
+
+## 简介
+
+本功能允许用户在没有 AWS Access Key 的情况下,通过 AWS CloudShell 环境运行独立的 Python 扫描脚本来收集 AWS 资源数据。用户将扫描结果(JSON 文件)上传到前端界面,由后端 Worker 处理并生成报告。这种方式适用于安全策略严格、不允许创建长期访问密钥的 AWS 账户。
+
+## 术语表
+
+- **CloudShell_Scanner**: 在 AWS CloudShell 环境中运行的独立 Python 扫描脚本
+- **Scan_Data**: CloudShell_Scanner 生成的 JSON 格式扫描结果数据
+- **Upload_Handler**: 后端处理上传 JSON 数据的服务组件
+- **Report_Generator**: 现有的报告生成服务,用于将扫描数据转换为 Word 文档
+- **Worker**: 后端 Celery 异步任务处理器
+
+## 需求
+
+### 需求 1:CloudShell 扫描脚本
+
+**用户故事:** 作为 AWS 管理员,我希望在 CloudShell 中运行一个独立的 Python 脚本来扫描 AWS 资源,以便在不创建 Access Key 的情况下收集资源数据。
+
+#### 验收标准
+
+1. THE CloudShell_Scanner SHALL 是一个单体 Python 文件,仅依赖 boto3 和 Python 标准库
+2. WHEN CloudShell_Scanner 启动时,THE CloudShell_Scanner SHALL 自动使用 CloudShell 环境的 IAM 凭证
+3. WHEN 用户指定扫描区域时,THE CloudShell_Scanner SHALL 仅扫描指定的区域
+4. WHEN 用户未指定区域时,THE CloudShell_Scanner SHALL 扫描所有可用区域
+5. THE CloudShell_Scanner SHALL 扫描与现有 AWSScanner 相同的所有服务类型
+6. WHEN 扫描完成时,THE CloudShell_Scanner SHALL 将结果导出为 JSON 文件
+7. THE CloudShell_Scanner SHALL 在扫描过程中显示进度信息
+8. IF 扫描某个服务或区域失败,THEN THE CloudShell_Scanner SHALL 记录错误并继续扫描其他资源
+
+### 需求 2:JSON 数据格式
+
+**用户故事:** 作为开发者,我希望扫描数据使用标准化的 JSON 格式,以便后端能够正确解析和处理。
+
+#### 验收标准
+
+1. THE Scan_Data SHALL 包含元数据字段:account_id、scan_timestamp、regions_scanned、services_scanned
+2. THE Scan_Data SHALL 包含 resources 字段,按服务类型组织资源数据
+3. THE Scan_Data SHALL 包含 errors 字段,记录扫描过程中的错误信息
+4. WHEN 序列化 Scan_Data 时,THE CloudShell_Scanner SHALL 使用 JSON 格式编码
+5. WHEN 解析 Scan_Data 时,THE Upload_Handler SHALL 能够还原完整的资源数据结构(往返一致性)
+
+### 需求 3:前端上传界面
+
+**用户故事:** 作为用户,我希望通过前端界面上传 JSON 扫描数据,以便系统能够处理并生成报告。
+
+#### 验收标准
+
+1. WHEN 用户访问任务创建页面时,THE System SHALL 显示两种数据来源选项:使用凭证扫描或上传 JSON 文件
+2. WHEN 用户选择上传 JSON 文件时,THE System SHALL 显示文件上传组件和项目元数据表单
+3. WHEN 用户上传文件时,THE System SHALL 验证文件格式为有效的 JSON
+4. WHEN 用户上传文件时,THE System SHALL 验证 JSON 结构符合 Scan_Data 格式
+5. IF 文件格式无效,THEN THE System SHALL 显示明确的错误提示
+6. WHEN 用户提交上传表单时,THE System SHALL 将 JSON 数据和项目元数据发送到后端
+
+### 需求 4:后端上传处理
+
+**用户故事:** 作为系统,我需要处理上传的 JSON 数据并创建报告生成任务。
+
+#### 验收标准
+
+1. THE Upload_Handler SHALL 提供 API 端点接收上传的 JSON 数据
+2. WHEN 接收到上传数据时,THE Upload_Handler SHALL 验证 JSON 结构的完整性
+3. WHEN 验证通过时,THE Upload_Handler SHALL 创建一个新的任务记录
+4. WHEN 任务创建成功时,THE Upload_Handler SHALL 触发 Worker 处理数据并生成报告
+5. IF JSON 数据验证失败,THEN THE Upload_Handler SHALL 返回详细的错误信息
+6. THE Upload_Handler SHALL 支持大文件上传(最大 50MB)
+
+### 需求 5:报告生成集成
+
+**用户故事:** 作为用户,我希望从上传的 JSON 数据生成与现有扫描任务相同格式的报告。
+
+#### 验收标准
+
+1. WHEN Worker 处理上传的 Scan_Data 时,THE Report_Generator SHALL 生成与现有扫描任务相同格式的 Word 报告
+2. THE Report_Generator SHALL 使用上传数据中的 account_id 作为报告的账户标识
+3. WHEN 报告生成完成时,THE System SHALL 更新任务状态为已完成
+4. WHEN 报告生成完成时,THE System SHALL 允许用户下载生成的报告
+5. IF 报告生成失败,THEN THE System SHALL 记录错误并更新任务状态为失败
+
+### 需求 6:错误处理和日志
+
+**用户故事:** 作为用户,我希望在出现问题时能够看到清晰的错误信息,以便排查问题。
+
+#### 验收标准
+
+1. WHEN CloudShell_Scanner 遇到权限错误时,THE CloudShell_Scanner SHALL 显示缺少的权限信息
+2. WHEN 上传的 JSON 缺少必要字段时,THE System SHALL 返回缺失字段的列表
+3. WHEN 任务处理失败时,THE System SHALL 在任务详情中显示错误日志
+4. THE System SHALL 记录所有上传和处理操作的审计日志

+ 153 - 0
.kiro/specs/cloudshell-scanner/tasks.md

@@ -0,0 +1,153 @@
+# 实现计划:CloudShell Scanner
+
+## 概述
+
+本计划将 CloudShell 扫描器功能分解为可执行的编码任务,包括独立的 Python 扫描脚本、前端上传组件、后端 API 和数据处理服务。
+
+## 任务
+
+- [x] 1. 创建 CloudShell 扫描脚本
+  - [x] 1.1 创建 `cloudshell_scanner.py` 基础结构
+    - 创建单体 Python 文件,包含命令行参数解析
+    - 实现 CloudShellScanner 类的基本框架
+    - 添加进度显示和日志输出功能
+    - _需求: 1.1, 1.2, 1.7_
+
+  - [x] 1.2 实现 VPC 相关服务扫描
+    - 实现 VPC、Subnet、Route Table、Internet Gateway 扫描
+    - 实现 NAT Gateway、Security Group、VPC Endpoint 扫描
+    - 实现 VPC Peering、Customer Gateway、VPN Connection 扫描
+    - _需求: 1.5_
+
+  - [x] 1.3 实现 EC2 和计算服务扫描
+    - 实现 EC2 实例、Elastic IP 扫描
+    - 实现 Auto Scaling Group、ELB、Target Group 扫描
+    - 实现 Lambda、EKS 扫描
+    - _需求: 1.5_
+
+  - [x] 1.4 实现数据库和存储服务扫描
+    - 实现 RDS、ElastiCache 扫描
+    - 实现 S3 Bucket、S3 Event Notification 扫描
+    - _需求: 1.5_
+
+  - [x] 1.5 实现全局和监控服务扫描
+    - 实现 CloudFront、Route53、ACM、WAF 扫描
+    - 实现 SNS、CloudWatch、EventBridge、CloudTrail、Config 扫描
+    - _需求: 1.5_
+
+  - [x] 1.6 实现区域过滤和错误处理
+    - 实现区域列表获取和过滤逻辑
+    - 实现错误捕获和继续扫描逻辑
+    - 实现重试机制(指数退避)
+    - _需求: 1.3, 1.4, 1.8_
+
+  - [x] 1.7 实现 JSON 导出功能
+    - 实现 ScanData 数据结构
+    - 实现 JSON 序列化和文件导出
+    - 添加元数据字段(account_id, timestamp, regions, services)
+    - _需求: 1.6, 2.1, 2.2, 2.3, 2.4_
+
+  - [ ]* 1.8 编写属性测试:JSON 往返一致性
+    - **Property 1: JSON 数据往返一致性**
+    - **验证: 需求 2.4, 2.5**
+
+  - [ ]* 1.9 编写属性测试:服务类型一致性
+    - **Property 4: 服务类型一致性**
+    - **验证: 需求 1.5**
+
+- [x] 2. 检查点 - 确保 CloudShell 扫描脚本可用
+  - 确保所有测试通过,如有问题请询问用户
+
+- [x] 3. 扩展后端数据模型
+  - [x] 3.1 创建数据库迁移
+    - 在 Task 模型中添加 source 字段('credential' 或 'upload')
+    - 在 Task 模型中添加 scan_data_path 字段
+    - 创建并应用迁移脚本
+    - _需求: 4.3_
+
+  - [x] 3.2 创建 ScanDataProcessor 服务
+    - 创建 `backend/app/services/scan_data_processor.py`
+    - 实现 validate_scan_data 方法
+    - 实现 convert_to_scan_result 方法
+    - _需求: 4.2, 5.1_
+
+  - [ ]* 3.3 编写属性测试:JSON 验证完整性
+    - **Property 6: JSON 验证完整性**
+    - **验证: 需求 3.4, 4.2, 6.2**
+
+  - [ ]* 3.4 编写属性测试:无效 JSON 错误处理
+    - **Property 7: 无效 JSON 错误处理**
+    - **验证: 需求 3.3, 3.5, 4.5**
+
+- [x] 4. 创建后端上传 API
+  - [x] 4.1 实现上传 API 端点
+    - 创建 POST /api/tasks/upload-scan 端点
+    - 实现文件上传处理(最大 50MB)
+    - 实现 JSON 数据验证
+    - _需求: 4.1, 4.2, 4.5, 4.6_
+
+  - [x] 4.2 实现任务创建和 Worker 触发
+    - 创建任务记录(source='upload')
+    - 保存上传的 JSON 数据
+    - 触发 Celery Worker 处理任务
+    - _需求: 4.3, 4.4_
+
+  - [x] 4.3 创建上传数据处理 Celery 任务
+    - 创建 process_uploaded_scan 任务
+    - 集成 ScanDataProcessor 和 ReportGenerator
+    - 实现任务状态更新和错误处理
+    - _需求: 5.1, 5.2, 5.3, 5.5_
+
+  - [ ]* 4.4 编写单元测试:上传 API
+    - 测试有效数据上传
+    - 测试无效数据错误响应
+    - 测试文件大小限制
+    - _需求: 4.1, 4.5, 4.6_
+
+- [x] 5. 检查点 - 确保后端 API 可用
+  - 确保所有测试通过,如有问题请询问用户
+
+- [x] 6. 创建前端上传组件
+  - [x] 6.1 创建 JSON 验证工具
+    - 创建 `frontend/src/utils/scanDataValidator.ts`
+    - 实现 ScanData 类型定义
+    - 实现 JSON 结构验证函数
+    - _需求: 3.3, 3.4_
+
+  - [x] 6.2 创建 JsonUploader 组件
+    - 创建 `frontend/src/components/Upload/JsonUploader.tsx`
+    - 实现文件拖拽和选择上传
+    - 实现上传前验证和错误提示
+    - _需求: 3.2, 3.5_
+
+  - [x] 6.3 修改任务创建页面
+    - 在 Tasks.tsx 中添加数据来源选择
+    - 集成 JsonUploader 组件
+    - 实现上传模式的表单提交
+    - _需求: 3.1, 3.6_
+
+  - [ ]* 6.4 编写前端单元测试
+    - 测试 JSON 验证函数
+    - 测试 JsonUploader 组件
+    - _需求: 3.3, 3.4, 3.5_
+
+- [x] 7. 集成和端到端测试
+  - [x] 7.1 集成报告生成
+    - 确保上传数据能正确生成报告
+    - 验证报告格式与凭证扫描一致
+    - _需求: 5.1, 5.2, 5.4_
+
+  - [ ]* 7.2 编写属性测试:报告生成一致性
+    - **Property 8: 报告生成一致性**
+    - **验证: 需求 5.1, 5.2**
+
+- [x] 8. 最终检查点 - 确保所有功能正常
+  - 确保所有测试通过,如有问题请询问用户
+
+## 备注
+
+- 标记为 `*` 的任务为可选任务,可以跳过以加快 MVP 开发
+- 每个任务都引用了具体的需求以确保可追溯性
+- 检查点用于确保增量验证
+- 属性测试验证通用正确性属性
+- 单元测试验证特定示例和边界情况

+ 257 - 1
backend/app/api/tasks.py

@@ -4,13 +4,17 @@ Task Management API endpoints
 Provides endpoints for:
 - GET /api/tasks - Get paginated list of tasks with status filtering
 - POST /api/tasks/create - Create a new scan task
+- POST /api/tasks/upload-scan - Upload CloudShell scan data and create task
 - GET /api/tasks/detail - Get task details
 - POST /api/tasks/delete - Delete a task
 - GET /api/tasks/logs - Get task logs with pagination
 
-Requirements: 3.1, 3.4
+Requirements: 3.1, 3.4, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6
 """
 import os
+import json
+import uuid
+from datetime import datetime
 from flask import jsonify, request, current_app
 from werkzeug.utils import secure_filename
 
@@ -18,6 +22,7 @@ from app import db
 from app.api import api_bp
 from app.models import Task, TaskLog, AWSCredential, UserCredential
 from app.services import login_required, admin_required, get_current_user_from_context, check_credential_access
+from app.services.scan_data_processor import ScanDataProcessor
 from app.errors import ValidationError, NotFoundError, AuthorizationError
 
 
@@ -303,6 +308,257 @@ def create_task():
     }), 201
 
 
+@api_bp.route('/tasks/upload-scan', methods=['POST'])
+@login_required
+def upload_scan():
+    """
+    Upload CloudShell scan data and create a task.
+    
+    This endpoint accepts JSON scan data from CloudShell scanner and creates
+    a task for report generation without requiring AWS credentials.
+    
+    Request Body (multipart/form-data):
+        scan_data: JSON file containing CloudShell scan results (required, max 50MB)
+        project_metadata: JSON string with project metadata (required)
+            - clientName: Client name (required)
+            - projectName: Project name (required)
+            - bdManager: BD Manager name (optional)
+            - bdManagerEmail: BD Manager email (optional)
+            - solutionsArchitect: Solutions Architect name (optional)
+            - solutionsArchitectEmail: Solutions Architect email (optional)
+            - cloudEngineer: Cloud Engineer name (optional)
+            - cloudEngineerEmail: Cloud Engineer email (optional)
+        network_diagram: Network diagram image file (optional)
+    
+    Returns:
+        JSON with created task details
+    
+    Requirements:
+        - 4.1: Provide API endpoint to receive uploaded JSON data
+        - 4.2: Validate JSON structure completeness
+        - 4.5: Return detailed error information if validation fails
+        - 4.6: Support large file uploads (max 50MB)
+    """
+    import re
+    import redis
+    
+    current_user = get_current_user_from_context()
+    
+    # Check content type
+    if not request.content_type or 'multipart/form-data' not in request.content_type:
+        raise ValidationError(
+            message="Content-Type must be multipart/form-data",
+            details={"expected": "multipart/form-data", "received": request.content_type}
+        )
+    
+    # Get scan_data file
+    scan_data_file = request.files.get('scan_data')
+    if not scan_data_file or not scan_data_file.filename:
+        raise ValidationError(
+            message="scan_data file is required",
+            details={"missing_fields": ["scan_data"]}
+        )
+    
+    # Validate file extension
+    if not scan_data_file.filename.lower().endswith('.json'):
+        raise ValidationError(
+            message="scan_data must be a JSON file",
+            details={"field": "scan_data", "reason": "invalid_file_type", "expected": ".json"}
+        )
+    
+    # Read and parse JSON data
+    try:
+        scan_data_content = scan_data_file.read()
+        # Check file size (50MB limit)
+        max_size = current_app.config.get('MAX_CONTENT_LENGTH', 50 * 1024 * 1024)
+        if len(scan_data_content) > max_size:
+            raise ValidationError(
+                message="File size exceeds limit",
+                details={"max_size": f"{max_size // (1024 * 1024)}MB", "field": "scan_data"}
+            )
+        
+        scan_data = json.loads(scan_data_content.decode('utf-8'))
+    except json.JSONDecodeError as e:
+        raise ValidationError(
+            message="Invalid JSON format",
+            details={"error": str(e), "field": "scan_data"}
+        )
+    except UnicodeDecodeError as e:
+        raise ValidationError(
+            message="Invalid file encoding. File must be UTF-8 encoded",
+            details={"error": str(e), "field": "scan_data"}
+        )
+    
+    # Validate scan data structure using ScanDataProcessor
+    processor = ScanDataProcessor()
+    is_valid, validation_errors = processor.validate_scan_data(scan_data)
+    
+    if not is_valid:
+        raise ValidationError(
+            message="Invalid scan data structure",
+            details={"validation_errors": validation_errors, "missing_fields": validation_errors}
+        )
+    
+    # Parse project_metadata from form data
+    project_metadata_str = request.form.get('project_metadata')
+    if not project_metadata_str:
+        raise ValidationError(
+            message="project_metadata is required",
+            details={"missing_fields": ["project_metadata"]}
+        )
+    
+    try:
+        project_metadata = json.loads(project_metadata_str)
+    except json.JSONDecodeError as e:
+        raise ValidationError(
+            message="Invalid project_metadata JSON format",
+            details={"error": str(e), "field": "project_metadata"}
+        )
+    
+    if not isinstance(project_metadata, dict):
+        raise ValidationError(
+            message="project_metadata must be a JSON object",
+            details={"field": "project_metadata", "reason": "invalid_type"}
+        )
+    
+    # Validate required project metadata fields
+    required_metadata = ['clientName', 'projectName']
+    missing_metadata = [field for field in required_metadata if not project_metadata.get(field)]
+    if missing_metadata:
+        raise ValidationError(
+            message="Missing required project metadata fields",
+            details={"missing_fields": missing_metadata}
+        )
+    
+    # Validate clientName and projectName don't contain invalid filename characters
+    invalid_chars_pattern = r'[<>\/\\|*:?"]'
+    client_name = project_metadata.get('clientName', '')
+    project_name = project_metadata.get('projectName', '')
+    
+    if re.search(invalid_chars_pattern, client_name):
+        raise ValidationError(
+            message="Client name contains invalid characters",
+            details={"field": "clientName", "reason": "Cannot contain < > / \\ | * : ? \""}
+        )
+    
+    if re.search(invalid_chars_pattern, project_name):
+        raise ValidationError(
+            message="Project name contains invalid characters",
+            details={"field": "projectName", "reason": "Cannot contain < > / \\ | * : ? \""}
+        )
+    
+    # Handle network diagram upload
+    network_diagram_path = None
+    network_diagram = request.files.get('network_diagram')
+    if network_diagram and network_diagram.filename:
+        if not allowed_file(network_diagram.filename):
+            raise ValidationError(
+                message="Invalid file type for network diagram. Allowed: png, jpg, jpeg, gif, bmp",
+                details={"field": "network_diagram", "reason": "invalid_file_type"}
+            )
+        
+        # Save the file
+        uploads_folder = current_app.config.get('UPLOAD_FOLDER', 'uploads')
+        os.makedirs(uploads_folder, exist_ok=True)
+        
+        filename = secure_filename(network_diagram.filename)
+        import time
+        filename = f"{int(time.time())}_{filename}"
+        network_diagram_path = os.path.join(uploads_folder, filename)
+        network_diagram.save(network_diagram_path)
+        
+        # Store path in project metadata
+        project_metadata['network_diagram_path'] = network_diagram_path
+    
+    # Save scan data to file
+    scan_data_folder = current_app.config.get('SCAN_DATA_FOLDER', 'uploads/scan_data')
+    os.makedirs(scan_data_folder, exist_ok=True)
+    
+    # Generate unique filename for scan data
+    scan_data_filename = f"scan_{uuid.uuid4().hex}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
+    scan_data_path = os.path.join(scan_data_folder, scan_data_filename)
+    
+    # Write scan data to file
+    with open(scan_data_path, 'w', encoding='utf-8') as f:
+        json.dump(scan_data, f, ensure_ascii=False, indent=2)
+    
+    # Extract metadata from scan data
+    metadata = scan_data.get('metadata', {})
+    account_id = metadata.get('account_id', 'Unknown')
+    regions_scanned = metadata.get('regions_scanned', [])
+    
+    # Create task name
+    task_name = f"CloudShell Scan - {account_id} - {datetime.now().strftime('%Y-%m-%d %H:%M')}"
+    
+    # Create task with source='upload'
+    task = Task(
+        name=task_name,
+        status='pending',
+        progress=0,
+        created_by=current_user.id,
+        source='upload',
+        scan_data_path=scan_data_path
+    )
+    task.credential_ids = []  # No credentials for upload tasks
+    task.regions = regions_scanned
+    task.project_metadata = project_metadata
+    
+    db.session.add(task)
+    db.session.commit()
+    
+    # Dispatch to Celery for processing
+    celery_task = None
+    
+    try:
+        # Test Redis connection
+        r = redis.Redis(host='localhost', port=6379, db=0)
+        r.ping()
+        
+        # Initialize Celery
+        from app.celery_app import celery_app, init_celery
+        init_celery(current_app._get_current_object())
+        
+        # Import and dispatch task
+        from app.tasks.scan_tasks import process_uploaded_scan
+        
+        celery_task = process_uploaded_scan.delay(
+            task_id=task.id,
+            scan_data_path=scan_data_path,
+            project_metadata=project_metadata
+        )
+        
+    except redis.ConnectionError as e:
+        # Redis connection failed, delete task and scan data file
+        db.session.delete(task)
+        db.session.commit()
+        if os.path.exists(scan_data_path):
+            os.remove(scan_data_path)
+        raise ValidationError(
+            message="Redis服务不可用,无法创建任务。请确保Redis服务已启动。",
+            details={"error": str(e)}
+        )
+    except Exception as e:
+        # Other errors
+        db.session.delete(task)
+        db.session.commit()
+        if os.path.exists(scan_data_path):
+            os.remove(scan_data_path)
+        raise ValidationError(
+            message="任务提交失败",
+            details={"error": str(e), "error_type": type(e).__name__}
+        )
+    
+    # Store Celery task ID
+    task.celery_task_id = celery_task.id
+    db.session.commit()
+    
+    return jsonify({
+        'message': '任务创建成功',
+        'task': task.to_dict(),
+        'celery_task_id': celery_task.id
+    }), 201
+
+
 @api_bp.route('/tasks/detail', methods=['GET'])
 @login_required
 def get_task_detail():

+ 7 - 1
backend/app/models/task.py

@@ -36,6 +36,10 @@ class Task(db.Model):
     _regions = db.Column('regions', db.Text)
     _project_metadata = db.Column('project_metadata', db.Text)
     
+    # CloudShell Scanner fields
+    source = db.Column(db.String(20), default='credential')  # 'credential' 或 'upload'
+    scan_data_path = db.Column(db.String(500), nullable=True)  # 上传的 JSON 文件路径
+    
     # Relationships
     created_by_user = db.relationship('User', back_populates='tasks')
     logs = db.relationship('TaskLog', back_populates='task', cascade='all, delete-orphan')
@@ -90,7 +94,9 @@ class Task(db.Model):
             'completed_at': format_datetime(self.completed_at),
             'credential_ids': self.credential_ids,
             'regions': self.regions,
-            'project_metadata': self.project_metadata
+            'project_metadata': self.project_metadata,
+            'source': self.source,
+            'scan_data_path': self.scan_data_path
         }
     
     def __repr__(self):

+ 3 - 1
backend/app/services/__init__.py

@@ -18,6 +18,7 @@ from app.services.report_generator import (
     generate_report_filename
 )
 from app.services.report_service import ReportService
+from app.services.scan_data_processor import ScanDataProcessor
 
 __all__ = [
     'AuthService',
@@ -34,5 +35,6 @@ __all__ = [
     'SERVICE_CONFIG',
     'SERVICE_ORDER',
     'generate_report_filename',
-    'ReportService'
+    'ReportService',
+    'ScanDataProcessor'
 ]

+ 477 - 0
backend/app/services/scan_data_processor.py

@@ -0,0 +1,477 @@
+"""
+Scan Data Processor Service
+
+This module handles validation and conversion of uploaded CloudShell scan data.
+It validates the JSON structure and converts the data to ScanResult objects
+compatible with the existing ReportGenerator.
+
+Requirements:
+    - 4.2: Validate JSON structure completeness when receiving uploaded data
+    - 5.1: Generate reports in the same format as existing scan tasks
+"""
+
+from typing import Any, Dict, List, Tuple, Optional
+from datetime import datetime
+import logging
+
+from app.scanners.base import ResourceData, ScanResult
+
+logger = logging.getLogger(__name__)
+
+
+class ScanDataProcessor:
+    """
+    Processes uploaded CloudShell scan data.
+    
+    This class provides functionality to:
+    - Validate the structure of uploaded JSON scan data
+    - Convert validated data to ScanResult objects for report generation
+    
+    Requirements:
+        - 4.2: Validate JSON structure completeness
+        - 5.1: Convert to format compatible with existing ReportGenerator
+    """
+    
+    # Required metadata fields based on design document ScanData interface
+    REQUIRED_METADATA_FIELDS = [
+        'account_id',
+        'scan_timestamp',
+        'regions_scanned',
+        'services_scanned',
+    ]
+    
+    # Optional metadata fields
+    OPTIONAL_METADATA_FIELDS = [
+        'scanner_version',
+        'total_resources',
+        'total_errors',
+    ]
+    
+    # Required top-level fields
+    REQUIRED_TOP_LEVEL_FIELDS = [
+        'metadata',
+        'resources',
+        'errors',
+    ]
+    
+    # Required resource fields based on ResourceData interface
+    REQUIRED_RESOURCE_FIELDS = [
+        'account_id',
+        'region',
+        'service',
+        'resource_type',
+        'resource_id',
+        'name',
+    ]
+    
+    # Required error fields based on ErrorData interface
+    REQUIRED_ERROR_FIELDS = [
+        'service',
+        'region',
+        'error',
+        'error_type',
+    ]
+    
+    def validate_scan_data(self, data: Dict[str, Any]) -> Tuple[bool, List[str]]:
+        """
+        Validate the structure of uploaded scan data.
+        
+        This method performs comprehensive validation of the JSON structure
+        to ensure it conforms to the ScanData interface defined in the design.
+        
+        Args:
+            data: Dictionary containing the uploaded scan data
+        
+        Returns:
+            Tuple of (is_valid, error_messages):
+                - is_valid: True if data is valid, False otherwise
+                - error_messages: List of validation error messages (empty if valid)
+        
+        Requirements:
+            - 4.2: Validate JSON structure completeness
+            - 6.2: Return list of missing fields when validation fails
+        
+        Validates:
+            - Property 2: JSON structure completeness (metadata, resources, errors)
+            - Property 6: Returns all missing field names on validation failure
+        """
+        errors: List[str] = []
+        
+        # Check if data is a dictionary
+        if not isinstance(data, dict):
+            errors.append("Data must be a JSON object (dictionary)")
+            return False, errors
+        
+        # Validate top-level fields
+        missing_top_level = self._validate_required_fields(
+            data, 
+            self.REQUIRED_TOP_LEVEL_FIELDS, 
+            "top-level"
+        )
+        errors.extend(missing_top_level)
+        
+        # If top-level fields are missing, we can't continue validation
+        if missing_top_level:
+            return False, errors
+        
+        # Validate metadata structure
+        metadata_errors = self._validate_metadata(data.get('metadata', {}))
+        errors.extend(metadata_errors)
+        
+        # Validate resources structure
+        resources_errors = self._validate_resources(data.get('resources', {}))
+        errors.extend(resources_errors)
+        
+        # Validate errors structure
+        errors_field_errors = self._validate_errors_field(data.get('errors', []))
+        errors.extend(errors_field_errors)
+        
+        is_valid = len(errors) == 0
+        
+        if is_valid:
+            logger.info("Scan data validation passed")
+        else:
+            logger.warning(f"Scan data validation failed with {len(errors)} errors")
+        
+        return is_valid, errors
+    
+    def _validate_required_fields(
+        self, 
+        data: Dict[str, Any], 
+        required_fields: List[str], 
+        context: str
+    ) -> List[str]:
+        """
+        Validate that all required fields are present.
+        
+        Args:
+            data: Dictionary to validate
+            required_fields: List of required field names
+            context: Context string for error messages
+        
+        Returns:
+            List of error messages for missing fields
+        """
+        errors = []
+        missing_fields = []
+        
+        for field in required_fields:
+            if field not in data:
+                missing_fields.append(field)
+        
+        if missing_fields:
+            errors.append(
+                f"Missing required {context} fields: {', '.join(missing_fields)}"
+            )
+        
+        return errors
+    
+    def _validate_metadata(self, metadata: Any) -> List[str]:
+        """
+        Validate the metadata section of scan data.
+        
+        Args:
+            metadata: Metadata dictionary to validate
+        
+        Returns:
+            List of validation error messages
+        
+        Validates:
+            - Property 2: metadata.account_id, metadata.scan_timestamp,
+                         metadata.regions_scanned, metadata.services_scanned
+        """
+        errors = []
+        
+        # Check if metadata is a dictionary
+        if not isinstance(metadata, dict):
+            errors.append("metadata must be a JSON object (dictionary)")
+            return errors
+        
+        # Check required metadata fields
+        missing_fields = []
+        for field in self.REQUIRED_METADATA_FIELDS:
+            if field not in metadata:
+                missing_fields.append(field)
+        
+        if missing_fields:
+            errors.append(
+                f"Missing required metadata fields: {', '.join(missing_fields)}"
+            )
+            return errors
+        
+        # Validate field types
+        if not isinstance(metadata.get('account_id'), str):
+            errors.append("metadata.account_id must be a string")
+        
+        if not isinstance(metadata.get('scan_timestamp'), str):
+            errors.append("metadata.scan_timestamp must be a string (ISO 8601 format)")
+        else:
+            # Validate timestamp format
+            timestamp_error = self._validate_timestamp(metadata['scan_timestamp'])
+            if timestamp_error:
+                errors.append(timestamp_error)
+        
+        if not isinstance(metadata.get('regions_scanned'), list):
+            errors.append("metadata.regions_scanned must be an array")
+        
+        if not isinstance(metadata.get('services_scanned'), list):
+            errors.append("metadata.services_scanned must be an array")
+        
+        return errors
+    
+    def _validate_timestamp(self, timestamp: str) -> Optional[str]:
+        """
+        Validate ISO 8601 timestamp format.
+        
+        Args:
+            timestamp: Timestamp string to validate
+        
+        Returns:
+            Error message if invalid, None if valid
+        """
+        # Try parsing common ISO 8601 formats
+        formats = [
+            '%Y-%m-%dT%H:%M:%S.%fZ',
+            '%Y-%m-%dT%H:%M:%SZ',
+            '%Y-%m-%dT%H:%M:%S.%f+00:00',
+            '%Y-%m-%dT%H:%M:%S+00:00',
+            '%Y-%m-%dT%H:%M:%S.%f',
+            '%Y-%m-%dT%H:%M:%S',
+        ]
+        
+        for fmt in formats:
+            try:
+                datetime.strptime(timestamp, fmt)
+                return None
+            except ValueError:
+                continue
+        
+        # Try fromisoformat as fallback (Python 3.7+)
+        try:
+            # Handle 'Z' suffix
+            ts = timestamp.replace('Z', '+00:00')
+            datetime.fromisoformat(ts)
+            return None
+        except ValueError:
+            pass
+        
+        return f"metadata.scan_timestamp '{timestamp}' is not a valid ISO 8601 timestamp"
+    
+    def _validate_resources(self, resources: Any) -> List[str]:
+        """
+        Validate the resources section of scan data.
+        
+        Args:
+            resources: Resources dictionary to validate
+        
+        Returns:
+            List of validation error messages
+        
+        Validates:
+            - Property 2: resources organized by service type
+        """
+        errors = []
+        
+        # Check if resources is a dictionary
+        if not isinstance(resources, dict):
+            errors.append("resources must be a JSON object (dictionary) organized by service type")
+            return errors
+        
+        # Validate each service's resources
+        for service, resource_list in resources.items():
+            if not isinstance(resource_list, list):
+                errors.append(f"resources.{service} must be an array of resources")
+                continue
+            
+            # Validate each resource in the list
+            for idx, resource in enumerate(resource_list):
+                resource_errors = self._validate_resource(resource, service, idx)
+                errors.extend(resource_errors)
+        
+        return errors
+    
+    def _validate_resource(
+        self, 
+        resource: Any, 
+        service: str, 
+        index: int
+    ) -> List[str]:
+        """
+        Validate a single resource entry.
+        
+        Args:
+            resource: Resource dictionary to validate
+            service: Service name for context
+            index: Index in the resource list for context
+        
+        Returns:
+            List of validation error messages
+        """
+        errors = []
+        context = f"resources.{service}[{index}]"
+        
+        if not isinstance(resource, dict):
+            errors.append(f"{context} must be a JSON object (dictionary)")
+            return errors
+        
+        # Check required resource fields
+        missing_fields = []
+        for field in self.REQUIRED_RESOURCE_FIELDS:
+            if field not in resource:
+                missing_fields.append(field)
+        
+        if missing_fields:
+            errors.append(
+                f"{context} missing required fields: {', '.join(missing_fields)}"
+            )
+        
+        # Validate attributes field if present (should be a dict)
+        if 'attributes' in resource and not isinstance(resource['attributes'], dict):
+            errors.append(f"{context}.attributes must be a JSON object (dictionary)")
+        
+        return errors
+    
+    def _validate_errors_field(self, errors_list: Any) -> List[str]:
+        """
+        Validate the errors section of scan data.
+        
+        Args:
+            errors_list: Errors list to validate
+        
+        Returns:
+            List of validation error messages
+        
+        Validates:
+            - Property 2: errors list with error information
+        """
+        validation_errors = []
+        
+        # Check if errors is a list
+        if not isinstance(errors_list, list):
+            validation_errors.append("errors must be an array")
+            return validation_errors
+        
+        # Validate each error entry
+        for idx, error_entry in enumerate(errors_list):
+            error_errors = self._validate_error_entry(error_entry, idx)
+            validation_errors.extend(error_errors)
+        
+        return validation_errors
+    
+    def _validate_error_entry(self, error_entry: Any, index: int) -> List[str]:
+        """
+        Validate a single error entry.
+        
+        Args:
+            error_entry: Error dictionary to validate
+            index: Index in the errors list for context
+        
+        Returns:
+            List of validation error messages
+        """
+        errors = []
+        context = f"errors[{index}]"
+        
+        if not isinstance(error_entry, dict):
+            errors.append(f"{context} must be a JSON object (dictionary)")
+            return errors
+        
+        # Check required error fields
+        missing_fields = []
+        for field in self.REQUIRED_ERROR_FIELDS:
+            if field not in error_entry:
+                missing_fields.append(field)
+        
+        if missing_fields:
+            errors.append(
+                f"{context} missing required fields: {', '.join(missing_fields)}"
+            )
+        
+        return errors
+    
+    def convert_to_scan_result(self, data: Dict[str, Any]) -> ScanResult:
+        """
+        Convert uploaded JSON data to a ScanResult object.
+        
+        This method transforms the uploaded scan data into a ScanResult object
+        that is compatible with the existing ReportGenerator service.
+        
+        Args:
+            data: Validated scan data dictionary
+        
+        Returns:
+            ScanResult object compatible with ReportGenerator
+        
+        Requirements:
+            - 5.1: Generate reports in the same format as existing scan tasks
+        
+        Note:
+            This method assumes the data has already been validated using
+            validate_scan_data(). Calling this with invalid data may raise
+            exceptions.
+        
+        Validates:
+            - Property 8: Report generation consistency - converts to same format
+                         used by credential-based scanning
+        """
+        metadata = data.get('metadata', {})
+        resources_data = data.get('resources', {})
+        errors_data = data.get('errors', [])
+        
+        # Create ScanResult with success=True (we have valid data)
+        result = ScanResult(success=True)
+        
+        # Convert resources to ResourceData objects
+        for service, resource_list in resources_data.items():
+            for resource_dict in resource_list:
+                resource = self._convert_resource(resource_dict)
+                result.add_resource(service, resource)
+        
+        # Add errors
+        for error_dict in errors_data:
+            result.add_error(
+                service=error_dict.get('service', 'unknown'),
+                region=error_dict.get('region', 'unknown'),
+                error=error_dict.get('error', 'Unknown error'),
+                details=error_dict.get('details'),
+                error_type=error_dict.get('error_type', 'Unknown'),
+            )
+        
+        # Set metadata
+        result.metadata = {
+            'account_id': metadata.get('account_id', ''),
+            'regions_scanned': metadata.get('regions_scanned', []),
+            'services_scanned': metadata.get('services_scanned', []),
+            'total_resources': sum(len(r) for r in result.resources.values()),
+            'total_errors': len(result.errors),
+            'scan_timestamp': metadata.get('scan_timestamp', ''),
+            'scanner_version': metadata.get('scanner_version', ''),
+            'source': 'upload',  # Mark as uploaded data
+        }
+        
+        logger.info(
+            f"Converted scan data: {result.metadata['total_resources']} resources, "
+            f"{result.metadata['total_errors']} errors"
+        )
+        
+        return result
+    
+    def _convert_resource(self, resource_dict: Dict[str, Any]) -> ResourceData:
+        """
+        Convert a resource dictionary to a ResourceData object.
+        
+        Args:
+            resource_dict: Resource dictionary from uploaded data
+        
+        Returns:
+            ResourceData object
+        """
+        return ResourceData(
+            account_id=resource_dict.get('account_id', ''),
+            region=resource_dict.get('region', ''),
+            service=resource_dict.get('service', ''),
+            resource_type=resource_dict.get('resource_type', ''),
+            resource_id=resource_dict.get('resource_id', ''),
+            name=resource_dict.get('name', ''),
+            attributes=resource_dict.get('attributes', {}),
+        )

+ 216 - 0
backend/app/tasks/scan_tasks.py

@@ -487,6 +487,222 @@ def scan_aws_resources(
         raise
 
 
+@celery_app.task(bind=True, max_retries=3, default_retry_delay=60)
+def process_uploaded_scan(
+    self,
+    task_id: int,
+    scan_data_path: str,
+    project_metadata: Dict[str, Any]
+) -> Dict[str, Any]:
+    """
+    Process uploaded CloudShell scan data and generate report.
+    
+    This task processes JSON scan data uploaded from CloudShell scanner
+    and generates a report using the existing ReportGenerator.
+    
+    Requirements:
+        - 5.1: Generate reports in the same format as existing scan tasks
+        - 5.2: Use account_id from uploaded data as report identifier
+        - 5.3: Update task status to completed when done
+        - 5.5: Record error and update task status to failed on error
+    
+    Args:
+        task_id: Database task ID
+        scan_data_path: Path to the uploaded JSON scan data file
+        project_metadata: Project metadata for report generation
+    
+    Returns:
+        Processing results including report path
+    """
+    import os
+    import json
+    from flask import current_app
+    from app.services.scan_data_processor import ScanDataProcessor
+    
+    try:
+        # Update task status to running
+        update_task_status(task_id, 'running')
+        log_task_message(task_id, 'info', 'Processing uploaded scan data', {
+            'scan_data_path': scan_data_path
+        })
+        
+        # Store Celery task ID
+        task = Task.query.get(task_id)
+        if task:
+            task.celery_task_id = self.request.id
+            db.session.commit()
+        
+        # Load scan data from file
+        if not os.path.exists(scan_data_path):
+            raise FileNotFoundError(f"Scan data file not found: {scan_data_path}")
+        
+        with open(scan_data_path, 'r', encoding='utf-8') as f:
+            scan_data = json.load(f)
+        
+        log_task_message(task_id, 'info', 'Scan data loaded successfully', {
+            'file_size': os.path.getsize(scan_data_path)
+        })
+        update_task_progress(task_id, 20)
+        
+        # Validate and convert scan data
+        processor = ScanDataProcessor()
+        is_valid, validation_errors = processor.validate_scan_data(scan_data)
+        
+        if not is_valid:
+            raise ValueError(f"Invalid scan data: {', '.join(validation_errors)}")
+        
+        log_task_message(task_id, 'info', 'Scan data validation passed')
+        update_task_progress(task_id, 40)
+        
+        # Convert to ScanResult format
+        scan_result = processor.convert_to_scan_result(scan_data)
+        
+        # Convert resources to dict format for report generator
+        all_results = {}
+        for service_key, resources in scan_result.resources.items():
+            all_results[service_key] = []
+            for resource in resources:
+                if hasattr(resource, 'to_dict'):
+                    all_results[service_key].append(resource.to_dict())
+                elif isinstance(resource, dict):
+                    all_results[service_key].append(resource)
+                else:
+                    all_results[service_key].append({
+                        'account_id': getattr(resource, 'account_id', ''),
+                        'region': getattr(resource, 'region', ''),
+                        'service': getattr(resource, 'service', service_key),
+                        'resource_type': getattr(resource, 'resource_type', ''),
+                        'resource_id': getattr(resource, 'resource_id', ''),
+                        'name': getattr(resource, 'name', ''),
+                        'attributes': getattr(resource, 'attributes', {})
+                    })
+        
+        # Get metadata
+        metadata = scan_data.get('metadata', {})
+        regions = metadata.get('regions_scanned', [])
+        
+        log_task_message(task_id, 'info', 'Scan data converted successfully', {
+            'total_services': len(all_results),
+            'total_resources': sum(len(r) for r in all_results.values()),
+            'regions': regions
+        })
+        update_task_progress(task_id, 60)
+        
+        # Generate report
+        log_task_message(task_id, 'info', 'Generating report...')
+        update_task_progress(task_id, 70)
+        
+        report_path = None
+        try:
+            # Check if report already exists for this task (in case of retry)
+            existing_report = Report.query.filter_by(task_id=task_id).first()
+            if existing_report:
+                log_task_message(task_id, 'info', 'Report already exists, skipping generation', {
+                    'file_name': existing_report.file_name
+                })
+                report_path = existing_report.file_path
+            else:
+                # Get reports folder
+                reports_folder = current_app.config.get('REPORTS_FOLDER', 'reports')
+                if not os.path.isabs(reports_folder):
+                    reports_folder = os.path.abspath(reports_folder)
+                os.makedirs(reports_folder, exist_ok=True)
+                
+                # Generate filename and path
+                filename = generate_report_filename(project_metadata)
+                report_path = os.path.join(reports_folder, filename)
+                
+                # Get network diagram path if provided
+                network_diagram_path = project_metadata.get('network_diagram_path')
+                
+                # Generate the report
+                generator = ReportGenerator()
+                result = generator.generate_report(
+                    scan_results=all_results,
+                    project_metadata=project_metadata,
+                    output_path=report_path,
+                    network_diagram_path=network_diagram_path,
+                    regions=regions
+                )
+                
+                # Create report record in database
+                report = Report(
+                    task_id=task_id,
+                    file_name=result['file_name'],
+                    file_path=result['file_path'],
+                    file_size=result['file_size']
+                )
+                db.session.add(report)
+                db.session.commit()
+                
+                log_task_message(task_id, 'info', f'Report generated: {filename}', {
+                    'file_name': filename,
+                    'file_size': result['file_size']
+                })
+            
+        except Exception as e:
+            # Log report generation error with full stack trace
+            db.session.rollback()
+            log_task_error_with_trace(
+                task_id=task_id,
+                error=e,
+                service='report_generation',
+                context={'project_metadata': project_metadata}
+            )
+            report_path = None
+        
+        # Update task status to completed
+        update_task_status(task_id, 'completed', progress=100)
+        
+        total_resources = sum(len(r) for r in all_results.values())
+        total_errors = len(scan_result.errors)
+        
+        log_task_message(task_id, 'info', 'Task completed successfully', {
+            'total_resources': total_resources,
+            'total_errors': total_errors,
+            'report_generated': report_path is not None
+        })
+        
+        return {
+            'status': 'success',
+            'report_path': report_path,
+            'total_resources': total_resources,
+            'total_errors': total_errors
+        }
+        
+    except SoftTimeLimitExceeded as e:
+        log_task_error_with_trace(
+            task_id=task_id,
+            error=e,
+            service='task_execution',
+            context={'error_type': 'timeout'}
+        )
+        update_task_status(task_id, 'failed')
+        raise
+        
+    except Exception as e:
+        log_task_error_with_trace(
+            task_id=task_id,
+            error=e,
+            service='task_execution',
+            context={'retry_count': self.request.retries}
+        )
+        update_task_status(task_id, 'failed')
+        
+        # Retry with exponential backoff
+        retry_count = self.request.retries
+        if retry_count < self.max_retries:
+            countdown = 60 * (2 ** retry_count)
+            log_task_message(task_id, 'warning', f'Retrying task in {countdown} seconds', {
+                'attempt': retry_count + 1,
+                'max_retries': self.max_retries,
+                'countdown': countdown
+            })
+            raise self.retry(exc=e, countdown=countdown)
+        
+        raise
+
+
 @celery_app.task
 def cleanup_old_reports(days: int = 30) -> Dict[str, Any]:
     """

+ 2 - 1
backend/config/settings.py

@@ -20,7 +20,8 @@ class Config:
     # File Storage
     UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', 'uploads')
     REPORTS_FOLDER = os.environ.get('REPORTS_FOLDER', 'reports')
-    MAX_CONTENT_LENGTH = 16 * 1024 * 1024  # 16MB max file size
+    SCAN_DATA_FOLDER = os.environ.get('SCAN_DATA_FOLDER', 'uploads/scan_data')
+    MAX_CONTENT_LENGTH = 50 * 1024 * 1024  # 50MB max file size for CloudShell scan uploads
     
     # Encryption key for sensitive data
     ENCRYPTION_KEY = os.environ.get('ENCRYPTION_KEY', 'encryption-key-change-in-production')

+ 43 - 0
backend/migrations/versions/004_add_cloudshell_scanner_fields.py

@@ -0,0 +1,43 @@
+"""Add CloudShell Scanner fields to tasks table
+
+Revision ID: 004_cloudshell_scanner
+Revises: 003_add_session_token_to_base_role
+Create Date: 2026-01-23
+
+This migration adds the source and scan_data_path fields to the tasks table
+to support the CloudShell Scanner feature.
+
+Requirements:
+    - 4.3: Task model needs source field to distinguish between credential and upload tasks
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '004_cloudshell_scanner'
+down_revision = '003_add_session_token_to_base_role'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    """Add source and scan_data_path columns to tasks table."""
+    # Add source column with default value 'credential'
+    with op.batch_alter_table('tasks', schema=None) as batch_op:
+        batch_op.add_column(
+            sa.Column('source', sa.String(length=20), nullable=True, default='credential')
+        )
+        batch_op.add_column(
+            sa.Column('scan_data_path', sa.String(length=500), nullable=True)
+        )
+    
+    # Update existing rows to have 'credential' as source
+    op.execute("UPDATE tasks SET source = 'credential' WHERE source IS NULL")
+
+
+def downgrade():
+    """Remove source and scan_data_path columns from tasks table."""
+    with op.batch_alter_table('tasks', schema=None) as batch_op:
+        batch_op.drop_column('scan_data_path')
+        batch_op.drop_column('source')

+ 39 - 0
backend/migrations/versions/004_add_task_source_field.py

@@ -0,0 +1,39 @@
+"""Add source and scan_data_path fields to Task model
+
+Revision ID: 004_add_task_source_field
+Revises: 003_add_session_token
+Create Date: 2026-01-24
+
+This migration adds support for CloudShell Scanner upload functionality:
+- source: Indicates whether the task was created via 'credential' scan or 'upload'
+- scan_data_path: Path to the uploaded JSON file for upload-based tasks
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '004_add_task_source_field'
+down_revision = '003_add_session_token'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    """Add source and scan_data_path columns to tasks table"""
+    # Add source column with default value 'credential'
+    op.add_column('tasks',
+                  sa.Column('source', sa.String(20), nullable=True, server_default='credential'))
+    
+    # Add scan_data_path column for storing uploaded JSON file path
+    op.add_column('tasks',
+                  sa.Column('scan_data_path', sa.String(500), nullable=True))
+    
+    # Update existing rows to have 'credential' as source
+    op.execute("UPDATE tasks SET source = 'credential' WHERE source IS NULL")
+
+
+def downgrade():
+    """Remove source and scan_data_path columns from tasks table"""
+    op.drop_column('tasks', 'scan_data_path')
+    op.drop_column('tasks', 'source')

+ 568 - 0
backend/tests/test_report_integration.py

@@ -0,0 +1,568 @@
+"""
+Integration Tests for Report Generation
+
+This module tests the integration between uploaded scan data and report generation,
+ensuring that reports generated from uploaded CloudShell scan data are consistent
+with reports generated from credential-based scanning.
+
+Requirements:
+    - 5.1: Generate reports in the same format as existing scan tasks
+    - 5.2: Use account_id from uploaded data as report identifier
+    - 5.4: Allow users to download generated reports
+"""
+
+import os
+import json
+import tempfile
+import pytest
+from datetime import datetime
+from unittest.mock import patch, MagicMock
+
+from app import create_app, db
+from app.services.scan_data_processor import ScanDataProcessor
+from app.services.report_generator import ReportGenerator, SERVICE_CONFIG, SERVICE_ORDER
+
+
+@pytest.fixture
+def app():
+    """Create application for testing"""
+    app = create_app('testing')
+    
+    with app.app_context():
+        db.create_all()
+        yield app
+        db.drop_all()
+
+
+@pytest.fixture
+def processor():
+    """Create a ScanDataProcessor instance"""
+    return ScanDataProcessor()
+
+
+@pytest.fixture
+def sample_cloudshell_scan_data():
+    """
+    Create sample CloudShell scan data matching the format produced by cloudshell_scanner.py.
+    
+    This fixture provides realistic scan data that would be generated by the CloudShell scanner.
+    """
+    return {
+        "metadata": {
+            "account_id": "123456789012",
+            "scan_timestamp": "2024-01-15T10:30:00Z",
+            "regions_scanned": ["us-east-1", "ap-northeast-1"],
+            "services_scanned": ["vpc", "ec2", "s3", "rds"],
+            "scanner_version": "1.0.0",
+            "total_resources": 5,
+            "total_errors": 0
+        },
+        "resources": {
+            "vpc": [
+                {
+                    "account_id": "123456789012",
+                    "region": "us-east-1",
+                    "service": "vpc",
+                    "resource_type": "VPC",
+                    "resource_id": "vpc-12345678",
+                    "name": "main-vpc",
+                    "attributes": {
+                        "cidr_block": "10.0.0.0/16",
+                        "state": "available",
+                        "is_default": False
+                    }
+                }
+            ],
+            "ec2": [
+                {
+                    "account_id": "123456789012",
+                    "region": "us-east-1",
+                    "service": "ec2",
+                    "resource_type": "Instance",
+                    "resource_id": "i-0123456789abcdef0",
+                    "name": "web-server-1",
+                    "attributes": {
+                        "instance_type": "t3.medium",
+                        "availability_zone": "us-east-1a",
+                        "ami_id": "ami-12345678",
+                        "public_ip": "54.123.45.67",
+                        "private_ip": "10.0.1.100",
+                        "vpc_id": "vpc-12345678",
+                        "subnet_id": "subnet-12345678",
+                        "key_name": "my-key",
+                        "security_groups": ["sg-12345678"],
+                        "ebs_type": "gp3",
+                        "ebs_size": 100,
+                        "encryption": True
+                    }
+                }
+            ],
+            "s3": [
+                {
+                    "account_id": "123456789012",
+                    "region": "us-east-1",
+                    "service": "s3",
+                    "resource_type": "Bucket",
+                    "resource_id": "my-bucket-123",
+                    "name": "my-bucket-123",
+                    "attributes": {
+                        "creation_date": "2024-01-01T00:00:00Z"
+                    }
+                }
+            ],
+            "rds": [
+                {
+                    "account_id": "123456789012",
+                    "region": "us-east-1",
+                    "service": "rds",
+                    "resource_type": "DBInstance",
+                    "resource_id": "mydb",
+                    "name": "mydb",
+                    "attributes": {
+                        "endpoint": "mydb.abc123.us-east-1.rds.amazonaws.com",
+                        "db_name": "production",
+                        "master_username": "admin",
+                        "port": 3306,
+                        "engine": "mysql",
+                        "engine_version": "8.0.35",
+                        "instance_class": "db.t3.medium",
+                        "storage_type": "gp3",
+                        "allocated_storage": 100,
+                        "multi_az": True,
+                        "security_groups": ["sg-db123456"],
+                        "deletion_protection": True,
+                        "performance_insights_enabled": True,
+                        "cloudwatch_logs": ["error", "general", "slowquery"]
+                    }
+                }
+            ]
+        },
+        "errors": []
+    }
+
+
+@pytest.fixture
+def sample_credential_scan_results():
+    """
+    Create sample scan results in the format produced by credential-based scanning.
+    
+    This fixture provides data in the same format that would be produced by
+    the AWSScanner when using credentials.
+    """
+    return {
+        "vpc": [
+            {
+                "account_id": "123456789012",
+                "region": "us-east-1",
+                "service": "vpc",
+                "resource_type": "VPC",
+                "resource_id": "vpc-12345678",
+                "name": "main-vpc",
+                "attributes": {
+                    "cidr_block": "10.0.0.0/16",
+                    "state": "available",
+                    "is_default": False
+                }
+            }
+        ],
+        "ec2": [
+            {
+                "account_id": "123456789012",
+                "region": "us-east-1",
+                "service": "ec2",
+                "resource_type": "Instance",
+                "resource_id": "i-0123456789abcdef0",
+                "name": "web-server-1",
+                "attributes": {
+                    "instance_type": "t3.medium",
+                    "availability_zone": "us-east-1a",
+                    "ami_id": "ami-12345678",
+                    "public_ip": "54.123.45.67",
+                    "private_ip": "10.0.1.100",
+                    "vpc_id": "vpc-12345678",
+                    "subnet_id": "subnet-12345678",
+                    "key_name": "my-key",
+                    "security_groups": ["sg-12345678"],
+                    "ebs_type": "gp3",
+                    "ebs_size": 100,
+                    "encryption": True
+                }
+            }
+        ],
+        "s3": [
+            {
+                "account_id": "123456789012",
+                "region": "us-east-1",
+                "service": "s3",
+                "resource_type": "Bucket",
+                "resource_id": "my-bucket-123",
+                "name": "my-bucket-123",
+                "attributes": {
+                    "creation_date": "2024-01-01T00:00:00Z"
+                }
+            }
+        ],
+        "rds": [
+            {
+                "account_id": "123456789012",
+                "region": "us-east-1",
+                "service": "rds",
+                "resource_type": "DBInstance",
+                "resource_id": "mydb",
+                "name": "mydb",
+                "attributes": {
+                    "endpoint": "mydb.abc123.us-east-1.rds.amazonaws.com",
+                    "db_name": "production",
+                    "master_username": "admin",
+                    "port": 3306,
+                    "engine": "mysql",
+                    "engine_version": "8.0.35",
+                    "instance_class": "db.t3.medium",
+                    "storage_type": "gp3",
+                    "allocated_storage": 100,
+                    "multi_az": True,
+                    "security_groups": ["sg-db123456"],
+                    "deletion_protection": True,
+                    "performance_insights_enabled": True,
+                    "cloudwatch_logs": ["error", "general", "slowquery"]
+                }
+            }
+        ]
+    }
+
+
+@pytest.fixture
+def project_metadata():
+    """Create sample project metadata for report generation"""
+    return {
+        "clientName": "TestClient",
+        "projectName": "TestProject",
+        "bdManager": "BD Manager",
+        "bdManagerEmail": "bd@example.com",
+        "solutionsArchitect": "SA Name",
+        "solutionsArchitectEmail": "sa@example.com",
+        "cloudEngineer": "CE Name",
+        "cloudEngineerEmail": "ce@example.com"
+    }
+
+
+class TestScanDataConversion:
+    """Tests for converting CloudShell scan data to report-compatible format"""
+    
+    def test_convert_cloudshell_data_to_scan_result(self, processor, sample_cloudshell_scan_data):
+        """
+        Test that CloudShell scan data is correctly converted to ScanResult format.
+        
+        Requirements:
+            - 5.1: Generate reports in the same format as existing scan tasks
+        """
+        result = processor.convert_to_scan_result(sample_cloudshell_scan_data)
+        
+        # Verify ScanResult structure
+        assert result.success is True
+        assert 'vpc' in result.resources
+        assert 'ec2' in result.resources
+        assert 's3' in result.resources
+        assert 'rds' in result.resources
+        
+        # Verify resource count
+        assert len(result.resources['vpc']) == 1
+        assert len(result.resources['ec2']) == 1
+        assert len(result.resources['s3']) == 1
+        assert len(result.resources['rds']) == 1
+    
+    def test_converted_data_has_required_fields(self, processor, sample_cloudshell_scan_data):
+        """
+        Test that converted resources have all required fields for report generation.
+        
+        Requirements:
+            - 5.1: Generate reports in the same format as existing scan tasks
+        """
+        result = processor.convert_to_scan_result(sample_cloudshell_scan_data)
+        
+        # Check VPC resource
+        vpc_resource = result.resources['vpc'][0]
+        assert hasattr(vpc_resource, 'account_id')
+        assert hasattr(vpc_resource, 'region')
+        assert hasattr(vpc_resource, 'service')
+        assert hasattr(vpc_resource, 'resource_type')
+        assert hasattr(vpc_resource, 'resource_id')
+        assert hasattr(vpc_resource, 'name')
+        assert hasattr(vpc_resource, 'attributes')
+        
+        # Verify values
+        assert vpc_resource.account_id == "123456789012"
+        assert vpc_resource.region == "us-east-1"
+        assert vpc_resource.name == "main-vpc"
+    
+    def test_metadata_preserved_in_conversion(self, processor, sample_cloudshell_scan_data):
+        """
+        Test that metadata from CloudShell scan is preserved in conversion.
+        
+        Requirements:
+            - 5.2: Use account_id from uploaded data as report identifier
+        """
+        result = processor.convert_to_scan_result(sample_cloudshell_scan_data)
+        
+        assert result.metadata['account_id'] == "123456789012"
+        assert result.metadata['regions_scanned'] == ["us-east-1", "ap-northeast-1"]
+        assert result.metadata['source'] == 'upload'
+
+
+class TestReportFormatConsistency:
+    """Tests for verifying report format consistency between upload and credential scan"""
+    
+    def test_upload_data_produces_same_resource_structure(
+        self, 
+        processor, 
+        sample_cloudshell_scan_data, 
+        sample_credential_scan_results
+    ):
+        """
+        Test that uploaded data produces the same resource structure as credential scan.
+        
+        Requirements:
+            - 5.1: Generate reports in the same format as existing scan tasks
+        """
+        # Convert CloudShell data
+        result = processor.convert_to_scan_result(sample_cloudshell_scan_data)
+        
+        # Convert to dict format (same as credential scan results)
+        converted_results = {}
+        for service_key, resources in result.resources.items():
+            converted_results[service_key] = []
+            for resource in resources:
+                if hasattr(resource, 'to_dict'):
+                    converted_results[service_key].append(resource.to_dict())
+                else:
+                    converted_results[service_key].append(resource)
+        
+        # Verify same services are present
+        assert set(converted_results.keys()) == set(sample_credential_scan_results.keys())
+        
+        # Verify resource structure matches
+        for service_key in converted_results:
+            upload_resources = converted_results[service_key]
+            cred_resources = sample_credential_scan_results[service_key]
+            
+            assert len(upload_resources) == len(cred_resources)
+            
+            for upload_res, cred_res in zip(upload_resources, cred_resources):
+                # Verify all required fields are present
+                assert 'account_id' in upload_res
+                assert 'region' in upload_res
+                assert 'service' in upload_res
+                assert 'resource_type' in upload_res
+                assert 'resource_id' in upload_res
+                assert 'name' in upload_res
+                assert 'attributes' in upload_res
+    
+    def test_all_supported_services_can_be_processed(self, processor):
+        """
+        Test that all services in SERVICE_CONFIG can be processed from uploaded data.
+        
+        Requirements:
+            - 5.1: Generate reports in the same format as existing scan tasks
+        """
+        # Create scan data with all supported services
+        scan_data = {
+            "metadata": {
+                "account_id": "123456789012",
+                "scan_timestamp": "2024-01-15T10:30:00Z",
+                "regions_scanned": ["us-east-1"],
+                "services_scanned": list(SERVICE_CONFIG.keys()),
+            },
+            "resources": {},
+            "errors": []
+        }
+        
+        # Add a sample resource for each service
+        for service_key in SERVICE_CONFIG.keys():
+            scan_data["resources"][service_key] = [
+                {
+                    "account_id": "123456789012",
+                    "region": "us-east-1",
+                    "service": service_key,
+                    "resource_type": "TestResource",
+                    "resource_id": f"{service_key}-123",
+                    "name": f"test-{service_key}",
+                    "attributes": {}
+                }
+            ]
+        
+        # Validate and convert
+        is_valid, errors = processor.validate_scan_data(scan_data)
+        assert is_valid, f"Validation failed: {errors}"
+        
+        result = processor.convert_to_scan_result(scan_data)
+        
+        # Verify all services were converted
+        for service_key in SERVICE_CONFIG.keys():
+            assert service_key in result.resources, f"Service {service_key} not in converted results"
+            assert len(result.resources[service_key]) == 1
+
+
+class TestReportGeneration:
+    """Tests for actual report generation from uploaded data"""
+    
+    def test_report_generator_accepts_converted_data(
+        self, 
+        app, 
+        processor, 
+        sample_cloudshell_scan_data, 
+        project_metadata
+    ):
+        """
+        Test that ReportGenerator can process converted CloudShell data.
+        
+        Requirements:
+            - 5.1: Generate reports in the same format as existing scan tasks
+        """
+        with app.app_context():
+            # Convert CloudShell data
+            result = processor.convert_to_scan_result(sample_cloudshell_scan_data)
+            
+            # Convert to dict format for report generator
+            scan_results = {}
+            for service_key, resources in result.resources.items():
+                scan_results[service_key] = []
+                for resource in resources:
+                    if hasattr(resource, 'to_dict'):
+                        scan_results[service_key].append(resource.to_dict())
+                    else:
+                        scan_results[service_key].append(resource)
+            
+            # Create report generator
+            generator = ReportGenerator()
+            
+            # Test that filter_empty_services works
+            filtered = generator.filter_empty_services(scan_results)
+            assert len(filtered) > 0
+            
+            # Verify services with resources are included
+            assert 'vpc' in filtered
+            assert 'ec2' in filtered
+    
+    def test_report_generation_with_upload_data(
+        self, 
+        app, 
+        processor, 
+        sample_cloudshell_scan_data, 
+        project_metadata
+    ):
+        """
+        Test complete report generation from uploaded CloudShell data.
+        
+        Requirements:
+            - 5.1: Generate reports in the same format as existing scan tasks
+            - 5.4: Allow users to download generated reports
+        """
+        with app.app_context():
+            # Convert CloudShell data
+            result = processor.convert_to_scan_result(sample_cloudshell_scan_data)
+            
+            # Convert to dict format
+            scan_results = {}
+            for service_key, resources in result.resources.items():
+                scan_results[service_key] = []
+                for resource in resources:
+                    if hasattr(resource, 'to_dict'):
+                        scan_results[service_key].append(resource.to_dict())
+                    else:
+                        scan_results[service_key].append(resource)
+            
+            # Check if template exists
+            generator = ReportGenerator()
+            try:
+                template_path = generator._get_default_template_path()
+                template_exists = os.path.exists(template_path)
+            except FileNotFoundError:
+                template_exists = False
+            
+            if not template_exists:
+                pytest.skip("Report template not found, skipping report generation test")
+            
+            # Generate report to temp file
+            with tempfile.NamedTemporaryFile(suffix='.docx', delete=False) as tmp:
+                output_path = tmp.name
+            
+            try:
+                regions = sample_cloudshell_scan_data['metadata']['regions_scanned']
+                report_result = generator.generate_report(
+                    scan_results=scan_results,
+                    project_metadata=project_metadata,
+                    output_path=output_path,
+                    regions=regions
+                )
+                
+                # Verify report was generated
+                assert os.path.exists(output_path)
+                assert report_result['file_size'] > 0
+                assert 'vpc' in report_result['services_included'] or len(report_result['services_included']) > 0
+                
+            finally:
+                # Cleanup
+                if os.path.exists(output_path):
+                    os.remove(output_path)
+
+
+class TestErrorHandling:
+    """Tests for error handling in report generation integration"""
+    
+    def test_empty_resources_handled_gracefully(self, processor):
+        """Test that empty resources are handled gracefully"""
+        scan_data = {
+            "metadata": {
+                "account_id": "123456789012",
+                "scan_timestamp": "2024-01-15T10:30:00Z",
+                "regions_scanned": ["us-east-1"],
+                "services_scanned": ["vpc"],
+            },
+            "resources": {},
+            "errors": []
+        }
+        
+        is_valid, errors = processor.validate_scan_data(scan_data)
+        assert is_valid
+        
+        result = processor.convert_to_scan_result(scan_data)
+        assert result.success is True
+        assert len(result.resources) == 0
+    
+    def test_scan_errors_preserved_in_conversion(self, processor):
+        """Test that scan errors from CloudShell are preserved"""
+        scan_data = {
+            "metadata": {
+                "account_id": "123456789012",
+                "scan_timestamp": "2024-01-15T10:30:00Z",
+                "regions_scanned": ["us-east-1"],
+                "services_scanned": ["vpc", "ec2"],
+            },
+            "resources": {
+                "vpc": [
+                    {
+                        "account_id": "123456789012",
+                        "region": "us-east-1",
+                        "service": "vpc",
+                        "resource_type": "VPC",
+                        "resource_id": "vpc-123",
+                        "name": "test-vpc",
+                        "attributes": {}
+                    }
+                ]
+            },
+            "errors": [
+                {
+                    "service": "ec2",
+                    "region": "us-east-1",
+                    "error": "Access Denied",
+                    "error_type": "AccessDeniedException"
+                }
+            ]
+        }
+        
+        result = processor.convert_to_scan_result(scan_data)
+        
+        assert len(result.errors) == 1
+        assert result.errors[0]['service'] == 'ec2'
+        assert result.errors[0]['error'] == 'Access Denied'

+ 46 - 0
backend/tests/test_scan_data_processor.py

@@ -0,0 +1,46 @@
+import pytest
+from app.services.scan_data_processor import ScanDataProcessor
+from app.scanners.base import ResourceData, ScanResult
+
+@pytest.fixture
+def processor():
+    return ScanDataProcessor()
+
+@pytest.fixture
+def valid_scan_data():
+    return {
+        "metadata": {
+            "account_id": "123456789012",
+            "scan_timestamp": "2024-01-15T10:30:00Z",
+            "regions_scanned": ["us-east-1"],
+            "services_scanned": ["vpc"],
+        },
+        "resources": {
+            "vpc": [{
+                "account_id": "123456789012",
+                "region": "us-east-1",
+                "service": "vpc",
+                "resource_type": "VPC",
+                "resource_id": "vpc-12345678",
+                "name": "main-vpc",
+                "attributes": {}
+            }]
+        },
+        "errors": []
+    }
+
+class TestValidateScanData:
+    def test_valid_scan_data(self, processor, valid_scan_data):
+        is_valid, errors = processor.validate_scan_data(valid_scan_data)
+        assert is_valid is True
+        assert errors == []
+
+    def test_invalid_data_type(self, processor):
+        is_valid, errors = processor.validate_scan_data("not a dict")
+        assert is_valid is False
+
+class TestConvertToScanResult:
+    def test_convert_valid_data(self, processor, valid_scan_data):
+        result = processor.convert_to_scan_result(valid_scan_data)
+        assert isinstance(result, ScanResult)
+        assert result.success is True

+ 3384 - 0
cloudshell_scanner.py

@@ -0,0 +1,3384 @@
+#!/usr/bin/env python3
+"""
+CloudShell Scanner - AWS Resource Scanner for CloudShell Environment
+
+A standalone Python script that scans AWS resources using CloudShell's IAM credentials.
+This script is designed to run in AWS CloudShell without requiring Access Keys.
+
+Requirements:
+    - 1.1: Single-file Python script, only depends on boto3 and Python standard library
+    - 1.2: Automatically uses CloudShell environment's IAM credentials
+    - 1.7: Displays progress information during scanning
+
+Usage:
+    # Scan all regions
+    python cloudshell_scanner.py
+
+    # Scan specific regions
+    python cloudshell_scanner.py --regions us-east-1,ap-northeast-1
+
+    # Specify output file
+    python cloudshell_scanner.py --output my_scan.json
+
+    # Scan specific services
+    python cloudshell_scanner.py --services ec2,vpc,rds
+"""
+
+import argparse
+import json
+import logging
+import sys
+import time
+from datetime import datetime, timezone
+from functools import wraps
+from typing import Any, Callable, Dict, List, Optional, TypeVar
+
+import boto3
+from botocore.exceptions import BotoCoreError, ClientError
+
+# Type variable for generic retry decorator
+T = TypeVar("T")
+
+# Scanner version
+__version__ = "1.0.0"
+
+# Configure logging
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s - %(levelname)s - %(message)s",
+    datefmt="%Y-%m-%d %H:%M:%S",
+)
+logger = logging.getLogger(__name__)
+
+
+# Retryable exceptions for exponential backoff
+RETRYABLE_EXCEPTIONS = (
+    ClientError,
+    BotoCoreError,
+    ConnectionError,
+    TimeoutError,
+)
+
+# Retryable error codes from AWS
+RETRYABLE_ERROR_CODES = {
+    "Throttling",
+    "ThrottlingException",
+    "RequestThrottled",
+    "RequestLimitExceeded",
+    "ProvisionedThroughputExceededException",
+    "ServiceUnavailable",
+    "InternalError",
+    "RequestTimeout",
+    "RequestTimeoutException",
+}
+
+
+def retry_with_exponential_backoff(
+    max_retries: int = 3,
+    base_delay: float = 1.0,
+    max_delay: float = 30.0,
+    exponential_base: float = 2.0,
+) -> Callable:
+    """
+    Decorator that implements retry logic with exponential backoff.
+    
+    This decorator will retry a function call if it raises a retryable exception.
+    The delay between retries increases exponentially.
+    
+    Args:
+        max_retries: Maximum number of retry attempts (default: 3)
+        base_delay: Initial delay in seconds (default: 1.0)
+        max_delay: Maximum delay in seconds (default: 30.0)
+        exponential_base: Base for exponential calculation (default: 2.0)
+    
+    Returns:
+        Decorated function with retry logic
+    
+    Requirements:
+        - 1.8: Record errors and continue scanning other resources
+        - Design: Network timeout - retry 3 times with exponential backoff
+    """
+    def decorator(func: Callable[..., T]) -> Callable[..., T]:
+        @wraps(func)
+        def wrapper(*args, **kwargs) -> T:
+            last_exception = None
+            
+            for attempt in range(max_retries + 1):
+                try:
+                    return func(*args, **kwargs)
+                except RETRYABLE_EXCEPTIONS as e:
+                    last_exception = e
+                    
+                    # Check if it's a retryable error code for ClientError
+                    if isinstance(e, ClientError):
+                        error_code = e.response.get("Error", {}).get("Code", "")
+                        if error_code not in RETRYABLE_ERROR_CODES:
+                            # Not a retryable error, raise immediately
+                            raise
+                    
+                    if attempt < max_retries:
+                        # Calculate delay with exponential backoff
+                        delay = min(
+                            base_delay * (exponential_base ** attempt),
+                            max_delay
+                        )
+                        logger.warning(
+                            f"Attempt {attempt + 1}/{max_retries + 1} failed for "
+                            f"{func.__name__}: {str(e)}. Retrying in {delay:.1f}s..."
+                        )
+                        time.sleep(delay)
+                    else:
+                        logger.error(
+                            f"All {max_retries + 1} attempts failed for "
+                            f"{func.__name__}: {str(e)}"
+                        )
+            
+            # All retries exhausted, raise the last exception
+            if last_exception:
+                raise last_exception
+            
+        return wrapper
+    return decorator
+
+
+def is_retryable_error(exception: Exception) -> bool:
+    """
+    Check if an exception is retryable.
+    
+    Args:
+        exception: The exception to check
+    
+    Returns:
+        True if the exception is retryable, False otherwise
+    """
+    if isinstance(exception, ClientError):
+        error_code = exception.response.get("Error", {}).get("Code", "")
+        return error_code in RETRYABLE_ERROR_CODES
+    return isinstance(exception, RETRYABLE_EXCEPTIONS)
+
+
+class ProgressDisplay:
+    """
+    Progress display utility for showing scan progress.
+    
+    Requirements:
+        - 1.7: Displays progress information during scanning
+    """
+    
+    def __init__(self, total_tasks: int = 0):
+        """
+        Initialize progress display.
+        
+        Args:
+            total_tasks: Total number of tasks to track
+        """
+        self.total_tasks = total_tasks
+        self.completed_tasks = 0
+        self.current_service = ""
+        self.current_region = ""
+    
+    def set_total(self, total: int) -> None:
+        """Set total number of tasks."""
+        self.total_tasks = total
+        self.completed_tasks = 0
+    
+    def update(self, service: str, region: str, status: str = "scanning") -> None:
+        """
+        Update progress display.
+        
+        Args:
+            service: Current service being scanned
+            region: Current region being scanned
+            status: Status message
+        """
+        self.current_service = service
+        self.current_region = region
+        
+        if self.total_tasks > 0:
+            percentage = (self.completed_tasks / self.total_tasks) * 100
+            progress_bar = self._create_progress_bar(percentage)
+            print(
+                f"\r{progress_bar} {percentage:5.1f}% | {status}: {service} in {region}",
+                end="",
+                flush=True,
+            )
+        else:
+            print(f"\r{status}: {service} in {region}", end="", flush=True)
+    
+    def increment(self) -> None:
+        """Increment completed tasks counter."""
+        self.completed_tasks += 1
+    
+    def complete(self, message: str = "Scan completed") -> None:
+        """
+        Mark progress as complete.
+        
+        Args:
+            message: Completion message
+        """
+        if self.total_tasks > 0:
+            progress_bar = self._create_progress_bar(100)
+            print(f"\r{progress_bar} 100.0% | {message}")
+        else:
+            print(f"\r{message}")
+    
+    def _create_progress_bar(self, percentage: float, width: int = 30) -> str:
+        """
+        Create a text-based progress bar.
+        
+        Args:
+            percentage: Completion percentage (0-100)
+            width: Width of the progress bar
+        
+        Returns:
+            Progress bar string
+        """
+        filled = int(width * percentage / 100)
+        bar = "█" * filled + "░" * (width - filled)
+        return f"[{bar}]"
+    
+    def log_error(self, service: str, region: str, error: str) -> None:
+        """
+        Log an error during scanning.
+        
+        Args:
+            service: Service that encountered the error
+            region: Region where the error occurred
+            error: Error message
+        """
+        # Print newline to avoid overwriting progress bar
+        print()
+        logger.warning(f"Error scanning {service} in {region}: {error}")
+
+
+class CloudShellScanner:
+    """
+    CloudShell environment AWS resource scanner.
+    
+    This class provides functionality to scan AWS resources using the IAM credentials
+    automatically available in the CloudShell environment.
+    
+    Requirements:
+        - 1.1: Single-file Python script, only depends on boto3 and Python standard library
+        - 1.2: Automatically uses CloudShell environment's IAM credentials
+        - 1.7: Displays progress information during scanning
+    
+    Attributes:
+        SUPPORTED_SERVICES: List of all supported AWS services
+        GLOBAL_SERVICES: List of global services (not region-specific)
+    """
+    
+    # All supported AWS services (must match AWSScanner.SUPPORTED_SERVICES)
+    SUPPORTED_SERVICES: List[str] = [
+        "vpc", "subnet", "route_table", "internet_gateway", "nat_gateway",
+        "security_group", "vpc_endpoint", "vpc_peering",
+        "customer_gateway", "virtual_private_gateway", "vpn_connection",
+        "ec2", "elastic_ip",
+        "autoscaling", "elb", "target_group",
+        "rds", "elasticache",
+        "eks", "lambda", "s3", "s3_event_notification",
+        "cloudfront", "route53", "acm", "waf",
+        "sns", "cloudwatch", "eventbridge", "cloudtrail", "config",
+    ]
+    
+    # Global services (not region-specific)
+    GLOBAL_SERVICES: List[str] = [
+        "cloudfront", "route53", "waf", "s3", "s3_event_notification", "cloudtrail"
+    ]
+    
+    def __init__(self):
+        """
+        Initialize the CloudShell scanner.
+        
+        Automatically uses CloudShell environment's IAM credentials via boto3's
+        default credential chain.
+        
+        Requirements:
+            - 1.2: Automatically uses CloudShell environment's IAM credentials
+        """
+        self._account_id: Optional[str] = None
+        self._session: Optional[boto3.Session] = None
+        self.progress = ProgressDisplay()
+        
+        # Initialize session using default credentials (CloudShell IAM)
+        try:
+            self._session = boto3.Session()
+            logger.info("Initialized CloudShell scanner with default credentials")
+        except Exception as e:
+            logger.error(f"Failed to initialize boto3 session: {e}")
+            raise
+    
+    def get_account_id(self) -> str:
+        """
+        Get the current AWS account ID.
+        
+        Returns:
+            AWS account ID string
+        
+        Raises:
+            Exception: If unable to retrieve account ID
+        """
+        if self._account_id:
+            return self._account_id
+        
+        try:
+            sts_client = self._session.client("sts")
+            response = sts_client.get_caller_identity()
+            self._account_id = response["Account"]
+            logger.info(f"Retrieved account ID: {self._account_id}")
+            return self._account_id
+        except Exception as e:
+            logger.error(f"Failed to get account ID: {e}")
+            raise
+    
+    def list_regions(self) -> List[str]:
+        """
+        List all available AWS regions.
+        
+        Returns:
+            List of region names
+        
+        Requirements:
+            - 1.4: Scan all available regions when not specified
+        """
+        try:
+            ec2_client = self._session.client("ec2", region_name="us-east-1")
+            response = ec2_client.describe_regions()
+            regions = [region["RegionName"] for region in response["Regions"]]
+            logger.info(f"Found {len(regions)} available regions")
+            return regions
+        except Exception as e:
+            logger.warning(f"Failed to list regions, using defaults: {e}")
+            # Return default regions if API call fails
+            return self._get_default_regions()
+    
+    def _get_default_regions(self) -> List[str]:
+        """
+        Get default AWS regions as fallback.
+        
+        Returns:
+            List of default region names
+        """
+        return [
+            "us-east-1", "us-east-2", "us-west-1", "us-west-2",
+            "eu-west-1", "eu-west-2", "eu-west-3", "eu-central-1",
+            "ap-northeast-1", "ap-northeast-2", "ap-southeast-1", "ap-southeast-2",
+            "ap-south-1", "sa-east-1", "ca-central-1",
+        ]
+    
+    def filter_regions(
+        self,
+        requested_regions: Optional[List[str]] = None,
+    ) -> List[str]:
+        """
+        Filter and validate requested regions against available regions.
+        
+        This method implements region filtering logic:
+        - If no regions specified, returns all available regions
+        - If regions specified, validates them against available regions
+        - Invalid regions are logged and filtered out
+        
+        Args:
+            requested_regions: List of regions requested by user (None = all regions)
+        
+        Returns:
+            List of valid region names to scan
+        
+        Requirements:
+            - 1.3: Scan only specified regions when provided
+            - 1.4: Scan all available regions when not specified
+        """
+        # Get all available regions
+        available_regions = self.list_regions()
+        available_set = set(available_regions)
+        
+        # If no regions specified, return all available regions
+        if requested_regions is None:
+            logger.info(f"No regions specified, will scan all {len(available_regions)} available regions")
+            return available_regions
+        
+        # Validate requested regions
+        valid_regions = []
+        invalid_regions = []
+        
+        for region in requested_regions:
+            # Normalize region name (strip whitespace, lowercase)
+            normalized_region = region.strip().lower()
+            
+            if normalized_region in available_set:
+                valid_regions.append(normalized_region)
+            else:
+                invalid_regions.append(region)
+        
+        # Log invalid regions
+        if invalid_regions:
+            logger.warning(
+                f"Ignoring invalid/unavailable regions: {invalid_regions}. "
+                f"Available regions: {sorted(available_regions)}"
+            )
+        
+        # If no valid regions remain, fall back to all available regions
+        if not valid_regions:
+            logger.warning(
+                "No valid regions specified, falling back to all available regions"
+            )
+            return available_regions
+        
+        logger.info(f"Will scan {len(valid_regions)} specified regions: {valid_regions}")
+        return valid_regions
+    
+    def validate_region(self, region: str) -> bool:
+        """
+        Validate if a region is available.
+        
+        Args:
+            region: Region name to validate
+        
+        Returns:
+            True if region is valid, False otherwise
+        """
+        try:
+            available_regions = self.list_regions()
+            return region.strip().lower() in set(available_regions)
+        except Exception:
+            # If we can't validate, assume it's valid and let the API call fail
+            return True
+    
+    def scan_resources(
+        self,
+        regions: Optional[List[str]] = None,
+        services: Optional[List[str]] = None,
+    ) -> Dict[str, Any]:
+        """
+        Scan AWS resources across specified regions and services.
+        
+        Args:
+            regions: List of regions to scan (None = all available regions)
+            services: List of services to scan (None = all supported services)
+        
+        Returns:
+            Dictionary containing scan results with metadata, resources, and errors
+        
+        Requirements:
+            - 1.3: Scan only specified regions when provided
+            - 1.4: Scan all available regions when not specified
+            - 1.5: Scan all supported service types
+            - 1.7: Display progress information during scanning
+            - 1.8: Record errors and continue scanning other resources
+        """
+        # Get account ID
+        account_id = self.get_account_id()
+        
+        # Filter and validate regions
+        regions_to_scan = self.filter_regions(regions)
+        logger.info(f"Scanning {len(regions_to_scan)} regions")
+        
+        # Determine services to scan
+        services_to_scan = services if services else self.SUPPORTED_SERVICES.copy()
+        logger.info(f"Scanning {len(services_to_scan)} services")
+        
+        # Validate services
+        invalid_services = [s for s in services_to_scan if s not in self.SUPPORTED_SERVICES]
+        if invalid_services:
+            logger.warning(f"Ignoring unsupported services: {invalid_services}")
+            services_to_scan = [s for s in services_to_scan if s in self.SUPPORTED_SERVICES]
+        
+        # Separate global and regional services
+        global_services = [s for s in services_to_scan if s in self.GLOBAL_SERVICES]
+        regional_services = [s for s in services_to_scan if s not in self.GLOBAL_SERVICES]
+        
+        # Calculate total tasks for progress tracking
+        total_tasks = len(global_services) + (len(regional_services) * len(regions_to_scan))
+        self.progress.set_total(total_tasks)
+        
+        # Initialize result structure
+        result: Dict[str, Any] = {
+            "metadata": {
+                "account_id": account_id,
+                "scan_timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
+                "regions_scanned": regions_to_scan,
+                "services_scanned": services_to_scan,
+                "scanner_version": __version__,
+                "total_resources": 0,
+                "total_errors": 0,
+            },
+            "resources": {},
+            "errors": [],
+        }
+        
+        # Scan global services first (only once, not per region)
+        if global_services:
+            logger.info(f"Scanning {len(global_services)} global services")
+            self._scan_global_services(
+                account_id=account_id,
+                services=global_services,
+                result=result,
+            )
+        
+        # Scan regional services
+        if regional_services and regions_to_scan:
+            logger.info(f"Scanning {len(regional_services)} regional services across {len(regions_to_scan)} regions")
+            self._scan_regional_services(
+                account_id=account_id,
+                regions=regions_to_scan,
+                services=regional_services,
+                result=result,
+            )
+        
+        # Update metadata totals
+        result["metadata"]["total_resources"] = sum(
+            len(resources) for resources in result["resources"].values()
+        )
+        result["metadata"]["total_errors"] = len(result["errors"])
+        
+        self.progress.complete(
+            f"Scan completed: {result['metadata']['total_resources']} resources, "
+            f"{result['metadata']['total_errors']} errors"
+        )
+        
+        return result
+    
+    def _call_with_retry(
+        self,
+        func: Callable[..., T],
+        *args,
+        max_retries: int = 3,
+        base_delay: float = 1.0,
+        **kwargs,
+    ) -> T:
+        """
+        Call a function with retry logic and exponential backoff.
+        
+        This method wraps API calls with retry logic for transient failures.
+        
+        Args:
+            func: Function to call
+            *args: Positional arguments for the function
+            max_retries: Maximum number of retry attempts
+            base_delay: Initial delay in seconds
+            **kwargs: Keyword arguments for the function
+        
+        Returns:
+            Result of the function call
+        
+        Raises:
+            Exception: If all retries are exhausted
+        
+        Requirements:
+            - 1.8: Record errors and continue scanning other resources
+            - Design: Network timeout - retry 3 times with exponential backoff
+        """
+        last_exception = None
+        
+        for attempt in range(max_retries + 1):
+            try:
+                return func(*args, **kwargs)
+            except RETRYABLE_EXCEPTIONS as e:
+                last_exception = e
+                
+                # Check if it's a retryable error code for ClientError
+                if isinstance(e, ClientError):
+                    error_code = e.response.get("Error", {}).get("Code", "")
+                    if error_code not in RETRYABLE_ERROR_CODES:
+                        # Not a retryable error, raise immediately
+                        raise
+                
+                if attempt < max_retries:
+                    # Calculate delay with exponential backoff
+                    delay = min(base_delay * (2 ** attempt), 30.0)
+                    logger.warning(
+                        f"Attempt {attempt + 1}/{max_retries + 1} failed: {str(e)}. "
+                        f"Retrying in {delay:.1f}s..."
+                    )
+                    time.sleep(delay)
+                else:
+                    logger.error(f"All {max_retries + 1} attempts failed: {str(e)}")
+        
+        # All retries exhausted, raise the last exception
+        if last_exception:
+            raise last_exception
+    
+    def _scan_global_services(
+        self,
+        account_id: str,
+        services: List[str],
+        result: Dict[str, Any],
+    ) -> None:
+        """
+        Scan global AWS services.
+        
+        Args:
+            account_id: AWS account ID
+            services: List of global services to scan
+            result: Result dictionary to update
+        
+        Requirements:
+            - 1.8: Record errors and continue scanning other resources
+        """
+        for service in services:
+            self.progress.update(service, "global", "Scanning")
+            
+            try:
+                resources = self._scan_service(
+                    account_id=account_id,
+                    region="global",
+                    service=service,
+                )
+                
+                if resources:
+                    if service not in result["resources"]:
+                        result["resources"][service] = []
+                    result["resources"][service].extend(resources)
+                
+            except Exception as e:
+                # Capture detailed error information
+                error_info = self._create_error_info(
+                    service=service,
+                    region="global",
+                    exception=e,
+                )
+                result["errors"].append(error_info)
+                self.progress.log_error(service, "global", str(e))
+            
+            self.progress.increment()
+    
+    def _scan_regional_services(
+        self,
+        account_id: str,
+        regions: List[str],
+        services: List[str],
+        result: Dict[str, Any],
+    ) -> None:
+        """
+        Scan regional AWS services.
+        
+        Args:
+            account_id: AWS account ID
+            regions: List of regions to scan
+            services: List of regional services to scan
+            result: Result dictionary to update
+        
+        Requirements:
+            - 1.8: Record errors and continue scanning other resources
+        """
+        for region in regions:
+            for service in services:
+                self.progress.update(service, region, "Scanning")
+                
+                try:
+                    resources = self._scan_service(
+                        account_id=account_id,
+                        region=region,
+                        service=service,
+                    )
+                    
+                    if resources:
+                        if service not in result["resources"]:
+                            result["resources"][service] = []
+                        result["resources"][service].extend(resources)
+                    
+                except Exception as e:
+                    # Capture detailed error information
+                    error_info = self._create_error_info(
+                        service=service,
+                        region=region,
+                        exception=e,
+                    )
+                    result["errors"].append(error_info)
+                    self.progress.log_error(service, region, str(e))
+                
+                self.progress.increment()
+    
+    def _create_error_info(
+        self,
+        service: str,
+        region: str,
+        exception: Exception,
+    ) -> Dict[str, Any]:
+        """
+        Create a detailed error information dictionary.
+        
+        This method extracts detailed information from exceptions to provide
+        useful error context for debugging and reporting.
+        
+        Args:
+            service: Service that encountered the error
+            region: Region where the error occurred
+            exception: The exception that was raised
+        
+        Returns:
+            Dictionary containing error details
+        
+        Requirements:
+            - 1.8: Record errors and continue scanning other resources
+            - 6.1: Display missing permission information when encountering permission errors
+        """
+        error_info: Dict[str, Any] = {
+            "service": service,
+            "region": region,
+            "error": str(exception),
+            "error_type": type(exception).__name__,
+            "details": None,
+        }
+        
+        # Extract additional details from ClientError
+        if isinstance(exception, ClientError):
+            error_response = exception.response.get("Error", {})
+            error_code = error_response.get("Code", "")
+            error_message = error_response.get("Message", "")
+            
+            error_info["details"] = {
+                "error_code": error_code,
+                "error_message": error_message,
+            }
+            
+            # Check for permission errors and provide helpful information
+            if error_code in ("AccessDenied", "AccessDeniedException", "UnauthorizedAccess"):
+                error_info["details"]["permission_hint"] = (
+                    f"Missing IAM permission for {service} in {region}. "
+                    f"Please ensure your IAM role has the necessary permissions."
+                )
+                logger.warning(
+                    f"Permission denied for {service} in {region}: {error_message}"
+                )
+        
+        # Extract details from BotoCoreError
+        elif isinstance(exception, BotoCoreError):
+            error_info["details"] = {
+                "botocore_error": str(exception),
+            }
+        
+        return error_info
+    
+    def _scan_service(
+        self,
+        account_id: str,
+        region: str,
+        service: str,
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan a single service in a specific region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan (or 'global' for global services)
+            service: Service to scan
+        
+        Returns:
+            List of resource dictionaries
+        
+        Note:
+            This is a placeholder method. Actual service scanning methods
+            will be implemented in subsequent tasks (1.2-1.5).
+        """
+        # Get the scanner method for this service
+        scanner_method = self._get_scanner_method(service)
+        
+        if scanner_method is None:
+            logger.warning(f"No scanner method found for service: {service}")
+            return []
+        
+        # Use us-east-1 for global services
+        actual_region = "us-east-1" if region == "global" else region
+        
+        return scanner_method(account_id, actual_region)
+    
+    def _get_scanner_method(self, service: str) -> Optional[Callable]:
+        """
+        Get the scanner method for a specific service.
+        
+        Args:
+            service: Service name
+        
+        Returns:
+            Scanner method callable or None if not found
+        """
+        scanner_methods: Dict[str, Callable] = {
+            # VPC related services (Task 1.2)
+            "vpc": self._scan_vpcs,
+            "subnet": self._scan_subnets,
+            "route_table": self._scan_route_tables,
+            "internet_gateway": self._scan_internet_gateways,
+            "nat_gateway": self._scan_nat_gateways,
+            "security_group": self._scan_security_groups,
+            "vpc_endpoint": self._scan_vpc_endpoints,
+            "vpc_peering": self._scan_vpc_peering,
+            "customer_gateway": self._scan_customer_gateways,
+            "virtual_private_gateway": self._scan_virtual_private_gateways,
+            "vpn_connection": self._scan_vpn_connections,
+            
+            # EC2 and compute services (Task 1.3)
+            "ec2": self._scan_ec2_instances,
+            "elastic_ip": self._scan_elastic_ips,
+            "autoscaling": self._scan_autoscaling_groups,
+            "elb": self._scan_load_balancers,
+            "target_group": self._scan_target_groups,
+            "lambda": self._scan_lambda_functions,
+            "eks": self._scan_eks_clusters,
+            
+            # Database and storage services (Task 1.4)
+            "rds": self._scan_rds_instances,
+            "elasticache": self._scan_elasticache_clusters,
+            "s3": self._scan_s3_buckets,
+            "s3_event_notification": self._scan_s3_event_notifications,
+            
+            # Global and monitoring services (Task 1.5)
+            "cloudfront": self._scan_cloudfront_distributions,
+            "route53": self._scan_route53_hosted_zones,
+            "acm": self._scan_acm_certificates,
+            "waf": self._scan_waf_web_acls,
+            "sns": self._scan_sns_topics,
+            "cloudwatch": self._scan_cloudwatch_log_groups,
+            "eventbridge": self._scan_eventbridge_rules,
+            "cloudtrail": self._scan_cloudtrail_trails,
+            "config": self._scan_config_recorders,
+        }
+        
+        return scanner_methods.get(service)
+    
+    def export_json(self, result: Dict[str, Any], output_path: str) -> None:
+        """
+        Export scan results to a JSON file.
+        
+        This method serializes the scan result to a JSON file with proper handling
+        of non-serializable types (datetime, bytes, sets, etc.).
+        
+        Args:
+            result: Scan result dictionary containing metadata, resources, and errors
+            output_path: Path to output JSON file
+        
+        Requirements:
+            - 1.6: Export results as JSON file when scan completes
+            - 2.1: Include metadata fields (account_id, scan_timestamp, regions_scanned, services_scanned)
+            - 2.2: Include resources field organized by service type
+            - 2.3: Include errors field with scan error information
+            - 2.4: Use JSON format encoding for serialization
+        
+        Raises:
+            IOError: If unable to write to the output file
+            TypeError: If result contains non-serializable types that cannot be converted
+        """
+        try:
+            # Validate the result structure before export
+            self._validate_scan_data_structure(result)
+            
+            # Serialize with custom encoder for non-standard types
+            json_str = json.dumps(
+                result,
+                indent=2,
+                ensure_ascii=False,
+                default=self._json_serializer,
+                sort_keys=False,
+            )
+            
+            # Write to file
+            with open(output_path, "w", encoding="utf-8") as f:
+                f.write(json_str)
+            
+            logger.info(f"Scan results exported to: {output_path}")
+            logger.info(
+                f"Export summary: {result['metadata']['total_resources']} resources, "
+                f"{result['metadata']['total_errors']} errors"
+            )
+        except (IOError, OSError) as e:
+            logger.error(f"Failed to write to {output_path}: {e}")
+            raise
+        except (TypeError, ValueError) as e:
+            logger.error(f"Failed to serialize scan results: {e}")
+            raise
+    
+    def _json_serializer(self, obj: Any) -> Any:
+        """
+        Custom JSON serializer for non-standard types.
+        
+        Handles datetime, date, bytes, sets, and other non-JSON-serializable types.
+        
+        Args:
+            obj: Object to serialize
+        
+        Returns:
+            JSON-serializable representation of the object
+        
+        Requirements:
+            - 2.4: Use JSON format encoding (handle non-serializable types gracefully)
+        """
+        # Handle datetime objects - convert to ISO 8601 format
+        if isinstance(obj, datetime):
+            # Ensure UTC timezone and proper ISO 8601 format
+            if obj.tzinfo is None:
+                obj = obj.replace(tzinfo=timezone.utc)
+            return obj.isoformat().replace("+00:00", "Z")
+        
+        # Handle date objects
+        if hasattr(obj, 'isoformat'):
+            return obj.isoformat()
+        
+        # Handle bytes
+        if isinstance(obj, bytes):
+            return obj.decode('utf-8', errors='replace')
+        
+        # Handle sets
+        if isinstance(obj, set):
+            return list(obj)
+        
+        # Handle frozensets
+        if isinstance(obj, frozenset):
+            return list(obj)
+        
+        # Handle objects with __dict__
+        if hasattr(obj, '__dict__'):
+            return obj.__dict__
+        
+        # Fallback to string representation
+        return str(obj)
+    
+    def _validate_scan_data_structure(self, data: Dict[str, Any]) -> None:
+        """
+        Validate that the scan data structure matches the expected format.
+        
+        This method ensures the data structure conforms to the ScanData interface
+        defined in the design document.
+        
+        Args:
+            data: Scan data dictionary to validate
+        
+        Raises:
+            ValueError: If required fields are missing or have incorrect types
+        
+        Requirements:
+            - 2.1: Metadata fields (account_id, scan_timestamp, regions_scanned, services_scanned)
+            - 2.2: Resources field organized by service type
+            - 2.3: Errors field with error information
+        """
+        # Check top-level structure
+        required_top_level = ["metadata", "resources", "errors"]
+        for field in required_top_level:
+            if field not in data:
+                raise ValueError(f"Missing required top-level field: {field}")
+        
+        # Check metadata fields
+        metadata = data.get("metadata", {})
+        required_metadata = [
+            "account_id",
+            "scan_timestamp",
+            "regions_scanned",
+            "services_scanned",
+            "scanner_version",
+            "total_resources",
+            "total_errors",
+        ]
+        
+        missing_metadata = [f for f in required_metadata if f not in metadata]
+        if missing_metadata:
+            raise ValueError(f"Missing required metadata fields: {missing_metadata}")
+        
+        # Validate metadata field types
+        if not isinstance(metadata.get("account_id"), str):
+            raise ValueError("metadata.account_id must be a string")
+        if not isinstance(metadata.get("scan_timestamp"), str):
+            raise ValueError("metadata.scan_timestamp must be a string")
+        if not isinstance(metadata.get("regions_scanned"), list):
+            raise ValueError("metadata.regions_scanned must be a list")
+        if not isinstance(metadata.get("services_scanned"), list):
+            raise ValueError("metadata.services_scanned must be a list")
+        if not isinstance(metadata.get("scanner_version"), str):
+            raise ValueError("metadata.scanner_version must be a string")
+        if not isinstance(metadata.get("total_resources"), int):
+            raise ValueError("metadata.total_resources must be an integer")
+        if not isinstance(metadata.get("total_errors"), int):
+            raise ValueError("metadata.total_errors must be an integer")
+        
+        # Validate resources structure
+        resources = data.get("resources", {})
+        if not isinstance(resources, dict):
+            raise ValueError("resources must be a dictionary")
+        
+        # Validate errors structure
+        errors = data.get("errors", [])
+        if not isinstance(errors, list):
+            raise ValueError("errors must be a list")
+    
+    @staticmethod
+    def create_scan_data(
+        account_id: str,
+        regions_scanned: List[str],
+        services_scanned: List[str],
+        resources: Dict[str, List[Dict[str, Any]]],
+        errors: List[Dict[str, Any]],
+        scan_timestamp: Optional[str] = None,
+    ) -> Dict[str, Any]:
+        """
+        Create a properly structured ScanData dictionary.
+        
+        This is a factory method to create scan data with the correct structure
+        as defined in the design document.
+        
+        Args:
+            account_id: AWS account ID
+            regions_scanned: List of regions that were scanned
+            services_scanned: List of services that were scanned
+            resources: Dictionary of resources organized by service type
+            errors: List of error dictionaries
+            scan_timestamp: Optional ISO 8601 timestamp (defaults to current time)
+        
+        Returns:
+            Properly structured ScanData dictionary
+        
+        Requirements:
+            - 2.1: Include metadata fields
+            - 2.2: Include resources field organized by service type
+            - 2.3: Include errors field
+        """
+        if scan_timestamp is None:
+            scan_timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+        
+        # Calculate totals
+        total_resources = sum(len(res_list) for res_list in resources.values())
+        total_errors = len(errors)
+        
+        return {
+            "metadata": {
+                "account_id": account_id,
+                "scan_timestamp": scan_timestamp,
+                "regions_scanned": regions_scanned,
+                "services_scanned": services_scanned,
+                "scanner_version": __version__,
+                "total_resources": total_resources,
+                "total_errors": total_errors,
+            },
+            "resources": resources,
+            "errors": errors,
+        }
+    
+    @staticmethod
+    def load_scan_data(file_path: str) -> Dict[str, Any]:
+        """
+        Load scan data from a JSON file.
+        
+        This method reads and parses a JSON file containing scan data,
+        validating its structure.
+        
+        Args:
+            file_path: Path to the JSON file to load
+        
+        Returns:
+            Parsed scan data dictionary
+        
+        Raises:
+            FileNotFoundError: If the file does not exist
+            json.JSONDecodeError: If the file contains invalid JSON
+            ValueError: If the JSON structure is invalid
+        
+        Requirements:
+            - 2.5: Round-trip consistency (load what was exported)
+        """
+        try:
+            with open(file_path, "r", encoding="utf-8") as f:
+                data = json.load(f)
+            
+            # Create a temporary scanner instance to validate
+            # We use a class method approach to avoid needing AWS credentials
+            CloudShellScanner._validate_scan_data_structure_static(data)
+            
+            logger.info(f"Loaded scan data from: {file_path}")
+            return data
+        except FileNotFoundError:
+            logger.error(f"File not found: {file_path}")
+            raise
+        except json.JSONDecodeError as e:
+            logger.error(f"Invalid JSON in {file_path}: {e}")
+            raise
+    
+    @staticmethod
+    def _validate_scan_data_structure_static(data: Dict[str, Any]) -> None:
+        """
+        Static version of _validate_scan_data_structure for use without instance.
+        
+        Args:
+            data: Scan data dictionary to validate
+        
+        Raises:
+            ValueError: If required fields are missing or have incorrect types
+        """
+        # Check top-level structure
+        required_top_level = ["metadata", "resources", "errors"]
+        for field in required_top_level:
+            if field not in data:
+                raise ValueError(f"Missing required top-level field: {field}")
+        
+        # Check metadata fields
+        metadata = data.get("metadata", {})
+        required_metadata = [
+            "account_id",
+            "scan_timestamp",
+            "regions_scanned",
+            "services_scanned",
+            "scanner_version",
+            "total_resources",
+            "total_errors",
+        ]
+        
+        missing_metadata = [f for f in required_metadata if f not in metadata]
+        if missing_metadata:
+            raise ValueError(f"Missing required metadata fields: {missing_metadata}")
+    
+    # Helper method to get resource name from tags
+    def _get_name_from_tags(
+        self, tags: Optional[List[Dict[str, str]]], default: str = ""
+    ) -> str:
+        """
+        Extract Name tag value from tags list.
+        
+        Args:
+            tags: List of tag dictionaries with 'Key' and 'Value'
+            default: Default value if Name tag not found
+        
+        Returns:
+            Name tag value or default
+        """
+        if not tags:
+            return default
+        for tag in tags:
+            if tag.get("Key") == "Name":
+                return tag.get("Value", default)
+        return default
+
+    # =========================================================================
+    # VPC Related Service Scanners (Task 1.2)
+    # =========================================================================
+
+    def _scan_vpcs(self, account_id: str, region: str) -> List[Dict[str, Any]]:
+        """
+        Scan VPCs in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of VPC resource dictionaries
+        
+        Attributes: Region, Name, ID, CIDR
+        """
+        resources = []
+        ec2_client = self._session.client("ec2", region_name=region)
+        
+        paginator = ec2_client.get_paginator("describe_vpcs")
+        for page in paginator.paginate():
+            for vpc in page.get("Vpcs", []):
+                name = self._get_name_from_tags(vpc.get("Tags", []), vpc["VpcId"])
+                
+                resources.append({
+                    "account_id": account_id,
+                    "region": region,
+                    "service": "vpc",
+                    "resource_type": "VPC",
+                    "resource_id": vpc["VpcId"],
+                    "name": name,
+                    "attributes": {
+                        "Region": region,
+                        "Name": name,
+                        "ID": vpc["VpcId"],
+                        "CIDR": vpc.get("CidrBlock", ""),
+                    },
+                })
+        
+        return resources
+
+    def _scan_subnets(self, account_id: str, region: str) -> List[Dict[str, Any]]:
+        """
+        Scan Subnets in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of Subnet resource dictionaries
+        
+        Attributes: Name, ID, AZ, CIDR
+        """
+        resources = []
+        ec2_client = self._session.client("ec2", region_name=region)
+        
+        paginator = ec2_client.get_paginator("describe_subnets")
+        for page in paginator.paginate():
+            for subnet in page.get("Subnets", []):
+                name = self._get_name_from_tags(
+                    subnet.get("Tags", []), subnet["SubnetId"]
+                )
+                
+                resources.append({
+                    "account_id": account_id,
+                    "region": region,
+                    "service": "subnet",
+                    "resource_type": "Subnet",
+                    "resource_id": subnet["SubnetId"],
+                    "name": name,
+                    "attributes": {
+                        "Name": name,
+                        "ID": subnet["SubnetId"],
+                        "AZ": subnet.get("AvailabilityZone", ""),
+                        "CIDR": subnet.get("CidrBlock", ""),
+                    },
+                })
+        
+        return resources
+
+    def _scan_route_tables(self, account_id: str, region: str) -> List[Dict[str, Any]]:
+        """
+        Scan Route Tables in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of Route Table resource dictionaries
+        
+        Attributes: Name, ID, Subnet Associations
+        """
+        resources = []
+        ec2_client = self._session.client("ec2", region_name=region)
+        
+        paginator = ec2_client.get_paginator("describe_route_tables")
+        for page in paginator.paginate():
+            for rt in page.get("RouteTables", []):
+                name = self._get_name_from_tags(
+                    rt.get("Tags", []), rt["RouteTableId"]
+                )
+                
+                # Get subnet associations
+                associations = []
+                for assoc in rt.get("Associations", []):
+                    if assoc.get("SubnetId"):
+                        associations.append(assoc["SubnetId"])
+                
+                resources.append({
+                    "account_id": account_id,
+                    "region": region,
+                    "service": "route_table",
+                    "resource_type": "Route Table",
+                    "resource_id": rt["RouteTableId"],
+                    "name": name,
+                    "attributes": {
+                        "Name": name,
+                        "ID": rt["RouteTableId"],
+                        "Subnet Associations": ", ".join(associations) if associations else "None",
+                    },
+                })
+        
+        return resources
+
+    def _scan_internet_gateways(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan Internet Gateways in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of Internet Gateway resource dictionaries
+        
+        Attributes: Name, ID
+        """
+        resources = []
+        ec2_client = self._session.client("ec2", region_name=region)
+        
+        paginator = ec2_client.get_paginator("describe_internet_gateways")
+        for page in paginator.paginate():
+            for igw in page.get("InternetGateways", []):
+                igw_id = igw["InternetGatewayId"]
+                name = self._get_name_from_tags(igw.get("Tags", []), igw_id)
+                
+                resources.append({
+                    "account_id": account_id,
+                    "region": region,
+                    "service": "internet_gateway",
+                    "resource_type": "Internet Gateway",
+                    "resource_id": igw_id,
+                    "name": name,
+                    "attributes": {
+                        "Name": name,
+                        "ID": igw_id,
+                    },
+                })
+        
+        return resources
+
+    def _scan_nat_gateways(self, account_id: str, region: str) -> List[Dict[str, Any]]:
+        """
+        Scan NAT Gateways in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of NAT Gateway resource dictionaries
+        
+        Attributes: Name, ID, Public IP, Private IP
+        """
+        resources = []
+        ec2_client = self._session.client("ec2", region_name=region)
+        
+        paginator = ec2_client.get_paginator("describe_nat_gateways")
+        for page in paginator.paginate():
+            for nat in page.get("NatGateways", []):
+                # Skip deleted NAT gateways
+                if nat.get("State") == "deleted":
+                    continue
+                
+                name = self._get_name_from_tags(
+                    nat.get("Tags", []), nat["NatGatewayId"]
+                )
+                
+                # Get IP addresses from addresses
+                public_ip = ""
+                private_ip = ""
+                for addr in nat.get("NatGatewayAddresses", []):
+                    if addr.get("PublicIp"):
+                        public_ip = addr["PublicIp"]
+                    if addr.get("PrivateIp"):
+                        private_ip = addr["PrivateIp"]
+                
+                resources.append({
+                    "account_id": account_id,
+                    "region": region,
+                    "service": "nat_gateway",
+                    "resource_type": "NAT Gateway",
+                    "resource_id": nat["NatGatewayId"],
+                    "name": name,
+                    "attributes": {
+                        "Name": name,
+                        "ID": nat["NatGatewayId"],
+                        "Public IP": public_ip,
+                        "Private IP": private_ip,
+                    },
+                })
+        
+        return resources
+
+    def _scan_security_groups(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan Security Groups in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of Security Group resource dictionaries
+        
+        Attributes: Name, ID, Protocol, Port range, Source
+        Note: Creates one entry per inbound rule
+        """
+        resources = []
+        ec2_client = self._session.client("ec2", region_name=region)
+        
+        paginator = ec2_client.get_paginator("describe_security_groups")
+        for page in paginator.paginate():
+            for sg in page.get("SecurityGroups", []):
+                sg_name = sg.get("GroupName", sg["GroupId"])
+                
+                # Process inbound rules
+                for rule in sg.get("IpPermissions", []):
+                    protocol = rule.get("IpProtocol", "-1")
+                    if protocol == "-1":
+                        protocol = "All"
+                    
+                    # Get port range
+                    from_port = rule.get("FromPort", "All")
+                    to_port = rule.get("ToPort", "All")
+                    if from_port == to_port:
+                        port_range = str(from_port) if from_port != "All" else "All"
+                    else:
+                        port_range = f"{from_port}-{to_port}"
+                    
+                    # Get sources
+                    sources = []
+                    for ip_range in rule.get("IpRanges", []):
+                        sources.append(ip_range.get("CidrIp", ""))
+                    for ip_range in rule.get("Ipv6Ranges", []):
+                        sources.append(ip_range.get("CidrIpv6", ""))
+                    for group in rule.get("UserIdGroupPairs", []):
+                        sources.append(group.get("GroupId", ""))
+                    
+                    source = ", ".join(sources) if sources else "N/A"
+                    
+                    resources.append({
+                        "account_id": account_id,
+                        "region": region,
+                        "service": "security_group",
+                        "resource_type": "Security Group",
+                        "resource_id": sg["GroupId"],
+                        "name": sg_name,
+                        "attributes": {
+                            "Name": sg_name,
+                            "ID": sg["GroupId"],
+                            "Protocol": protocol,
+                            "Port range": port_range,
+                            "Source": source,
+                        },
+                    })
+                
+                # If no inbound rules, still add the security group
+                if not sg.get("IpPermissions"):
+                    resources.append({
+                        "account_id": account_id,
+                        "region": region,
+                        "service": "security_group",
+                        "resource_type": "Security Group",
+                        "resource_id": sg["GroupId"],
+                        "name": sg_name,
+                        "attributes": {
+                            "Name": sg_name,
+                            "ID": sg["GroupId"],
+                            "Protocol": "N/A",
+                            "Port range": "N/A",
+                            "Source": "N/A",
+                        },
+                    })
+        
+        return resources
+
+    def _scan_vpc_endpoints(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan VPC Endpoints in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of VPC Endpoint resource dictionaries
+        
+        Attributes: Name, ID, VPC, Service Name, Type
+        """
+        resources = []
+        ec2_client = self._session.client("ec2", region_name=region)
+        
+        paginator = ec2_client.get_paginator("describe_vpc_endpoints")
+        for page in paginator.paginate():
+            for endpoint in page.get("VpcEndpoints", []):
+                name = self._get_name_from_tags(
+                    endpoint.get("Tags", []), endpoint["VpcEndpointId"]
+                )
+                
+                resources.append({
+                    "account_id": account_id,
+                    "region": region,
+                    "service": "vpc_endpoint",
+                    "resource_type": "Endpoint",
+                    "resource_id": endpoint["VpcEndpointId"],
+                    "name": name,
+                    "attributes": {
+                        "Name": name,
+                        "ID": endpoint["VpcEndpointId"],
+                        "VPC": endpoint.get("VpcId", ""),
+                        "Service Name": endpoint.get("ServiceName", ""),
+                        "Type": endpoint.get("VpcEndpointType", ""),
+                    },
+                })
+        
+        return resources
+
+    def _scan_vpc_peering(self, account_id: str, region: str) -> List[Dict[str, Any]]:
+        """
+        Scan VPC Peering Connections in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of VPC Peering resource dictionaries
+        
+        Attributes: Name, Peering Connection ID, Requester VPC, Accepter VPC
+        """
+        resources = []
+        ec2_client = self._session.client("ec2", region_name=region)
+        
+        paginator = ec2_client.get_paginator("describe_vpc_peering_connections")
+        for page in paginator.paginate():
+            for peering in page.get("VpcPeeringConnections", []):
+                # Skip deleted/rejected peerings
+                status = peering.get("Status", {}).get("Code", "")
+                if status in ["deleted", "rejected", "failed"]:
+                    continue
+                
+                name = self._get_name_from_tags(
+                    peering.get("Tags", []), peering["VpcPeeringConnectionId"]
+                )
+                
+                requester_vpc = peering.get("RequesterVpcInfo", {}).get("VpcId", "")
+                accepter_vpc = peering.get("AccepterVpcInfo", {}).get("VpcId", "")
+                
+                resources.append({
+                    "account_id": account_id,
+                    "region": region,
+                    "service": "vpc_peering",
+                    "resource_type": "VPC Peering",
+                    "resource_id": peering["VpcPeeringConnectionId"],
+                    "name": name,
+                    "attributes": {
+                        "Name": name,
+                        "Peering Connection ID": peering["VpcPeeringConnectionId"],
+                        "Requester VPC": requester_vpc,
+                        "Accepter VPC": accepter_vpc,
+                    },
+                })
+        
+        return resources
+
+    def _scan_customer_gateways(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan Customer Gateways in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of Customer Gateway resource dictionaries
+        
+        Attributes: Name, Customer Gateway ID, IP Address
+        """
+        resources = []
+        ec2_client = self._session.client("ec2", region_name=region)
+        
+        response = ec2_client.describe_customer_gateways()
+        for cgw in response.get("CustomerGateways", []):
+            # Skip deleted gateways
+            if cgw.get("State") == "deleted":
+                continue
+            
+            name = self._get_name_from_tags(
+                cgw.get("Tags", []), cgw["CustomerGatewayId"]
+            )
+            
+            resources.append({
+                "account_id": account_id,
+                "region": region,
+                "service": "customer_gateway",
+                "resource_type": "Customer Gateway",
+                "resource_id": cgw["CustomerGatewayId"],
+                "name": name,
+                "attributes": {
+                    "Name": name,
+                    "Customer Gateway ID": cgw["CustomerGatewayId"],
+                    "IP Address": cgw.get("IpAddress", ""),
+                },
+            })
+        
+        return resources
+
+    def _scan_virtual_private_gateways(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan Virtual Private Gateways in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of Virtual Private Gateway resource dictionaries
+        
+        Attributes: Name, Virtual Private Gateway ID, VPC
+        """
+        resources = []
+        ec2_client = self._session.client("ec2", region_name=region)
+        
+        response = ec2_client.describe_vpn_gateways()
+        for vgw in response.get("VpnGateways", []):
+            # Skip deleted gateways
+            if vgw.get("State") == "deleted":
+                continue
+            
+            name = self._get_name_from_tags(
+                vgw.get("Tags", []), vgw["VpnGatewayId"]
+            )
+            
+            # Get attached VPC
+            vpc_id = ""
+            for attachment in vgw.get("VpcAttachments", []):
+                if attachment.get("State") == "attached":
+                    vpc_id = attachment.get("VpcId", "")
+                    break
+            
+            resources.append({
+                "account_id": account_id,
+                "region": region,
+                "service": "virtual_private_gateway",
+                "resource_type": "Virtual Private Gateway",
+                "resource_id": vgw["VpnGatewayId"],
+                "name": name,
+                "attributes": {
+                    "Name": name,
+                    "Virtual Private Gateway ID": vgw["VpnGatewayId"],
+                    "VPC": vpc_id,
+                },
+            })
+        
+        return resources
+
+    def _scan_vpn_connections(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan VPN Connections in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of VPN Connection resource dictionaries
+        
+        Attributes: Name, VPN ID, Routes
+        """
+        resources = []
+        ec2_client = self._session.client("ec2", region_name=region)
+        
+        response = ec2_client.describe_vpn_connections()
+        for vpn in response.get("VpnConnections", []):
+            # Skip deleted connections
+            if vpn.get("State") == "deleted":
+                continue
+            
+            name = self._get_name_from_tags(
+                vpn.get("Tags", []), vpn["VpnConnectionId"]
+            )
+            
+            # Get routes
+            routes = []
+            for route in vpn.get("Routes", []):
+                if route.get("DestinationCidrBlock"):
+                    routes.append(route["DestinationCidrBlock"])
+            
+            resources.append({
+                "account_id": account_id,
+                "region": region,
+                "service": "vpn_connection",
+                "resource_type": "VPN Connection",
+                "resource_id": vpn["VpnConnectionId"],
+                "name": name,
+                "attributes": {
+                    "Name": name,
+                    "VPN ID": vpn["VpnConnectionId"],
+                    "Routes": ", ".join(routes) if routes else "N/A",
+                },
+            })
+        
+        return resources
+
+    # =========================================================================
+    # EC2 and Compute Service Scanners (Task 1.3)
+    # =========================================================================
+
+    def _scan_ec2_instances(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan EC2 Instances in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of EC2 Instance resource dictionaries
+        
+        Attributes: Name, Instance ID, Instance Type, AZ, AMI,
+                   Public IP, Public DNS, Private IP, VPC ID, Subnet ID,
+                   Key, Security Groups, EBS Type, EBS Size, Encryption
+        """
+        resources = []
+        ec2_client = self._session.client("ec2", region_name=region)
+        
+        paginator = ec2_client.get_paginator("describe_instances")
+        for page in paginator.paginate():
+            for reservation in page.get("Reservations", []):
+                for instance in reservation.get("Instances", []):
+                    # Skip terminated instances
+                    state = instance.get("State", {}).get("Name", "")
+                    if state == "terminated":
+                        continue
+                    
+                    name = self._get_name_from_tags(
+                        instance.get("Tags", []), instance["InstanceId"]
+                    )
+                    
+                    # Get security groups
+                    security_groups = []
+                    for sg in instance.get("SecurityGroups", []):
+                        security_groups.append(
+                            sg.get("GroupName", sg.get("GroupId", ""))
+                        )
+                    
+                    # Get EBS volume info
+                    ebs_type = ""
+                    ebs_size = ""
+                    ebs_encrypted = ""
+                    
+                    for block_device in instance.get("BlockDeviceMappings", []):
+                        ebs = block_device.get("Ebs", {})
+                        if ebs.get("VolumeId"):
+                            # Get volume details
+                            try:
+                                vol_response = ec2_client.describe_volumes(
+                                    VolumeIds=[ebs["VolumeId"]]
+                                )
+                                if vol_response.get("Volumes"):
+                                    volume = vol_response["Volumes"][0]
+                                    ebs_type = volume.get("VolumeType", "")
+                                    ebs_size = f"{volume.get('Size', '')} GB"
+                                    ebs_encrypted = (
+                                        "Yes" if volume.get("Encrypted") else "No"
+                                    )
+                            except Exception as e:
+                                logger.warning(
+                                    f"Failed to get volume details: {str(e)}"
+                                )
+                            break  # Only get first volume for simplicity
+                    
+                    resources.append({
+                        "account_id": account_id,
+                        "region": region,
+                        "service": "ec2",
+                        "resource_type": "Instance",
+                        "resource_id": instance["InstanceId"],
+                        "name": name,
+                        "attributes": {
+                            "Name": name,
+                            "Instance ID": instance["InstanceId"],
+                            "Instance Type": instance.get("InstanceType", ""),
+                            "AZ": instance.get("Placement", {}).get(
+                                "AvailabilityZone", ""
+                            ),
+                            "AMI": instance.get("ImageId", ""),
+                            "Public IP": instance.get("PublicIpAddress", ""),
+                            "Public DNS": instance.get("PublicDnsName", ""),
+                            "Private IP": instance.get("PrivateIpAddress", ""),
+                            "VPC ID": instance.get("VpcId", ""),
+                            "Subnet ID": instance.get("SubnetId", ""),
+                            "Key": instance.get("KeyName", ""),
+                            "Security Groups": ", ".join(security_groups),
+                            "EBS Type": ebs_type,
+                            "EBS Size": ebs_size,
+                            "Encryption": ebs_encrypted,
+                            "Other Requirement": "",
+                        },
+                    })
+        
+        return resources
+
+    def _scan_elastic_ips(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan Elastic IPs in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of Elastic IP resource dictionaries
+        
+        Attributes: Name, Elastic IP
+        """
+        resources = []
+        ec2_client = self._session.client("ec2", region_name=region)
+        
+        response = ec2_client.describe_addresses()
+        for eip in response.get("Addresses", []):
+            public_ip = eip.get("PublicIp", "")
+            name = self._get_name_from_tags(
+                eip.get("Tags", []),
+                public_ip or eip.get("AllocationId", ""),
+            )
+            
+            resources.append({
+                "account_id": account_id,
+                "region": region,
+                "service": "elastic_ip",
+                "resource_type": "Elastic IP",
+                "resource_id": eip.get("AllocationId", public_ip),
+                "name": name,
+                "attributes": {
+                    "Name": name,
+                    "Elastic IP": public_ip,
+                },
+            })
+        
+        return resources
+
+    def _scan_autoscaling_groups(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan Auto Scaling Groups in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of Auto Scaling Group resource dictionaries
+        
+        Attributes: Name, Launch Template, AMI, Instance type, Key, Target Groups,
+                   Desired, Min, Max, Scaling Policy
+        """
+        resources = []
+        asg_client = self._session.client("autoscaling", region_name=region)
+        ec2_client = self._session.client("ec2", region_name=region)
+        
+        paginator = asg_client.get_paginator("describe_auto_scaling_groups")
+        for page in paginator.paginate():
+            for asg in page.get("AutoScalingGroups", []):
+                name = asg.get("AutoScalingGroupName", "")
+                
+                # Get Launch Template info
+                launch_template_name = ""
+                ami = ""
+                instance_type = ""
+                key_name = ""
+                
+                # Check for Launch Template
+                lt = asg.get("LaunchTemplate")
+                if lt:
+                    launch_template_name = lt.get(
+                        "LaunchTemplateName", lt.get("LaunchTemplateId", "")
+                    )
+                    # Get Launch Template details
+                    try:
+                        lt_response = ec2_client.describe_launch_template_versions(
+                            LaunchTemplateId=lt.get("LaunchTemplateId", ""),
+                            Versions=[lt.get("Version", "$Latest")],
+                        )
+                        if lt_response.get("LaunchTemplateVersions"):
+                            lt_data = lt_response["LaunchTemplateVersions"][0].get(
+                                "LaunchTemplateData", {}
+                            )
+                            ami = lt_data.get("ImageId", "")
+                            instance_type = lt_data.get("InstanceType", "")
+                            key_name = lt_data.get("KeyName", "")
+                    except Exception as e:
+                        logger.warning(
+                            f"Failed to get launch template details: {str(e)}"
+                        )
+                
+                # Check for Mixed Instances Policy
+                mip = asg.get("MixedInstancesPolicy")
+                if mip:
+                    lt_spec = mip.get("LaunchTemplate", {}).get(
+                        "LaunchTemplateSpecification", {}
+                    )
+                    if lt_spec:
+                        launch_template_name = lt_spec.get(
+                            "LaunchTemplateName", lt_spec.get("LaunchTemplateId", "")
+                        )
+                
+                # Check for Launch Configuration (legacy)
+                lc_name = asg.get("LaunchConfigurationName")
+                if lc_name and not launch_template_name:
+                    launch_template_name = f"LC: {lc_name}"
+                    try:
+                        lc_response = asg_client.describe_launch_configurations(
+                            LaunchConfigurationNames=[lc_name]
+                        )
+                        if lc_response.get("LaunchConfigurations"):
+                            lc = lc_response["LaunchConfigurations"][0]
+                            ami = lc.get("ImageId", "")
+                            instance_type = lc.get("InstanceType", "")
+                            key_name = lc.get("KeyName", "")
+                    except Exception as e:
+                        logger.warning(
+                            f"Failed to get launch configuration details: {str(e)}"
+                        )
+                
+                # Get Target Groups
+                target_groups = []
+                for tg_arn in asg.get("TargetGroupARNs", []):
+                    # Extract target group name from ARN
+                    tg_name = tg_arn.split("/")[-2] if "/" in tg_arn else tg_arn
+                    target_groups.append(tg_name)
+                
+                # Get Scaling Policies
+                scaling_policies = []
+                try:
+                    policy_response = asg_client.describe_policies(
+                        AutoScalingGroupName=name
+                    )
+                    for policy in policy_response.get("ScalingPolicies", []):
+                        scaling_policies.append(policy.get("PolicyName", ""))
+                except Exception as e:
+                    logger.warning(f"Failed to get scaling policies: {str(e)}")
+                
+                resources.append({
+                    "account_id": account_id,
+                    "region": region,
+                    "service": "autoscaling",
+                    "resource_type": "Auto Scaling Group",
+                    "resource_id": asg.get("AutoScalingGroupARN", name),
+                    "name": name,
+                    "attributes": {
+                        "Name": name,
+                        "Launch Template": launch_template_name,
+                        "AMI": ami,
+                        "Instance type": instance_type,
+                        "Key": key_name,
+                        "Target Groups": (
+                            ", ".join(target_groups) if target_groups else "N/A"
+                        ),
+                        "Desired": str(asg.get("DesiredCapacity", 0)),
+                        "Min": str(asg.get("MinSize", 0)),
+                        "Max": str(asg.get("MaxSize", 0)),
+                        "Scaling Policy": (
+                            ", ".join(scaling_policies) if scaling_policies else "N/A"
+                        ),
+                    },
+                })
+        
+        return resources
+
+    def _scan_load_balancers(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan Load Balancers (ALB, NLB, CLB) in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of Load Balancer resource dictionaries
+        
+        Attributes: Name, Type, DNS, Scheme, VPC, Availability Zones, Subnet,
+                   Security Groups
+        """
+        resources = []
+        
+        # Scan ALB/NLB using elbv2
+        elbv2_client = self._session.client("elbv2", region_name=region)
+        
+        try:
+            paginator = elbv2_client.get_paginator("describe_load_balancers")
+            for page in paginator.paginate():
+                for lb in page.get("LoadBalancers", []):
+                    name = lb.get("LoadBalancerName", "")
+                    lb_type = lb.get("Type", "application")
+                    
+                    # Get availability zones and subnets
+                    azs = []
+                    subnets = []
+                    for az_info in lb.get("AvailabilityZones", []):
+                        azs.append(az_info.get("ZoneName", ""))
+                        if az_info.get("SubnetId"):
+                            subnets.append(az_info["SubnetId"])
+                    
+                    # Get security groups (only for ALB)
+                    security_groups = lb.get("SecurityGroups", [])
+                    
+                    resources.append({
+                        "account_id": account_id,
+                        "region": region,
+                        "service": "elb",
+                        "resource_type": "Load Balancer",
+                        "resource_id": lb.get("LoadBalancerArn", name),
+                        "name": name,
+                        "attributes": {
+                            "Name": name,
+                            "Type": lb_type.upper(),
+                            "DNS": lb.get("DNSName", ""),
+                            "Scheme": lb.get("Scheme", ""),
+                            "VPC": lb.get("VpcId", ""),
+                            "Availability Zones": ", ".join(azs),
+                            "Subnet": ", ".join(subnets),
+                            "Security Groups": (
+                                ", ".join(security_groups)
+                                if security_groups
+                                else "N/A"
+                            ),
+                        },
+                    })
+        except Exception as e:
+            logger.warning(f"Failed to scan ALB/NLB: {str(e)}")
+        
+        # Scan Classic Load Balancers
+        elb_client = self._session.client("elb", region_name=region)
+        
+        try:
+            paginator = elb_client.get_paginator("describe_load_balancers")
+            for page in paginator.paginate():
+                for lb in page.get("LoadBalancerDescriptions", []):
+                    name = lb.get("LoadBalancerName", "")
+                    
+                    resources.append({
+                        "account_id": account_id,
+                        "region": region,
+                        "service": "elb",
+                        "resource_type": "Load Balancer",
+                        "resource_id": name,
+                        "name": name,
+                        "attributes": {
+                            "Name": name,
+                            "Type": "CLASSIC",
+                            "DNS": lb.get("DNSName", ""),
+                            "Scheme": lb.get("Scheme", ""),
+                            "VPC": lb.get("VPCId", ""),
+                            "Availability Zones": ", ".join(
+                                lb.get("AvailabilityZones", [])
+                            ),
+                            "Subnet": ", ".join(lb.get("Subnets", [])),
+                            "Security Groups": ", ".join(
+                                lb.get("SecurityGroups", [])
+                            ),
+                        },
+                    })
+        except Exception as e:
+            logger.warning(f"Failed to scan Classic ELB: {str(e)}")
+        
+        return resources
+
+    def _scan_target_groups(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan Target Groups in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of Target Group resource dictionaries
+        
+        Attributes: Load Balancer, TG Name, Port, Protocol, Registered Instances,
+                   Health Check Path
+        """
+        resources = []
+        elbv2_client = self._session.client("elbv2", region_name=region)
+        
+        try:
+            paginator = elbv2_client.get_paginator("describe_target_groups")
+            for page in paginator.paginate():
+                for tg in page.get("TargetGroups", []):
+                    name = tg.get("TargetGroupName", "")
+                    tg_arn = tg.get("TargetGroupArn", "")
+                    
+                    # Get associated load balancers
+                    lb_arns = tg.get("LoadBalancerArns", [])
+                    lb_names = []
+                    for lb_arn in lb_arns:
+                        # Extract LB name from ARN
+                        lb_name = lb_arn.split("/")[-2] if "/" in lb_arn else lb_arn
+                        lb_names.append(lb_name)
+                    
+                    # Get registered targets
+                    registered_instances = []
+                    try:
+                        targets_response = elbv2_client.describe_target_health(
+                            TargetGroupArn=tg_arn
+                        )
+                        for target in targets_response.get(
+                            "TargetHealthDescriptions", []
+                        ):
+                            target_id = target.get("Target", {}).get("Id", "")
+                            if target_id:
+                                registered_instances.append(target_id)
+                    except Exception as e:
+                        logger.warning(f"Failed to get target health: {str(e)}")
+                    
+                    resources.append({
+                        "account_id": account_id,
+                        "region": region,
+                        "service": "target_group",
+                        "resource_type": "Target Group",
+                        "resource_id": tg_arn,
+                        "name": name,
+                        "attributes": {
+                            "Load Balancer": (
+                                ", ".join(lb_names) if lb_names else "N/A"
+                            ),
+                            "TG Name": name,
+                            "Port": str(tg.get("Port", "")),
+                            "Protocol": tg.get("Protocol", ""),
+                            "Registered Instances": (
+                                ", ".join(registered_instances)
+                                if registered_instances
+                                else "None"
+                            ),
+                            "Health Check Path": tg.get("HealthCheckPath", "N/A"),
+                        },
+                    })
+        except Exception as e:
+            logger.warning(f"Failed to scan target groups: {str(e)}")
+        
+        return resources
+
+    def _scan_lambda_functions(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan Lambda Functions in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of Lambda Function resource dictionaries
+        
+        Attributes: Function Name, Runtime, Memory (MB), Timeout (s), Last Modified
+        """
+        resources = []
+        lambda_client = self._session.client("lambda", region_name=region)
+        
+        try:
+            paginator = lambda_client.get_paginator("list_functions")
+            for page in paginator.paginate():
+                for func in page.get("Functions", []):
+                    func_name = func.get("FunctionName", "")
+                    
+                    resources.append({
+                        "account_id": account_id,
+                        "region": region,
+                        "service": "lambda",
+                        "resource_type": "Function",
+                        "resource_id": func.get("FunctionArn", func_name),
+                        "name": func_name,
+                        "attributes": {
+                            "Function Name": func_name,
+                            "Runtime": func.get("Runtime", "N/A"),
+                            "Memory (MB)": str(func.get("MemorySize", "")),
+                            "Timeout (s)": str(func.get("Timeout", "")),
+                            "Last Modified": func.get("LastModified", ""),
+                        },
+                    })
+        except Exception as e:
+            logger.warning(f"Failed to scan Lambda functions: {str(e)}")
+        
+        return resources
+
+    def _scan_eks_clusters(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan EKS Clusters in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of EKS Cluster resource dictionaries
+        
+        Attributes: Cluster Name, Version, Status, Endpoint, VPC ID
+        """
+        resources = []
+        eks_client = self._session.client("eks", region_name=region)
+        
+        try:
+            # List clusters
+            paginator = eks_client.get_paginator("list_clusters")
+            cluster_names = []
+            for page in paginator.paginate():
+                cluster_names.extend(page.get("clusters", []))
+            
+            # Get details for each cluster
+            for cluster_name in cluster_names:
+                try:
+                    response = eks_client.describe_cluster(name=cluster_name)
+                    cluster = response.get("cluster", {})
+                    
+                    resources.append({
+                        "account_id": account_id,
+                        "region": region,
+                        "service": "eks",
+                        "resource_type": "Cluster",
+                        "resource_id": cluster.get("arn", cluster_name),
+                        "name": cluster_name,
+                        "attributes": {
+                            "Cluster Name": cluster_name,
+                            "Version": cluster.get("version", ""),
+                            "Status": cluster.get("status", ""),
+                            "Endpoint": cluster.get("endpoint", ""),
+                            "VPC ID": cluster.get("resourcesVpcConfig", {}).get(
+                                "vpcId", ""
+                            ),
+                        },
+                    })
+                except Exception as e:
+                    logger.warning(
+                        f"Failed to describe EKS cluster {cluster_name}: {str(e)}"
+                    )
+        except Exception as e:
+            logger.warning(f"Failed to list EKS clusters: {str(e)}")
+        
+        return resources
+
+    # =========================================================================
+    # Database and Storage Service Scanners (Task 1.4)
+    # =========================================================================
+
+    def _scan_rds_instances(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan RDS DB Instances in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of RDS DB Instance resource dictionaries
+        
+        Attributes (vertical layout - one table per instance):
+            Region, Endpoint, DB instance ID, DB name, Master Username, Port,
+            DB Engine, DB Version, Instance Type, Storage type, Storage, Multi-AZ,
+            Security Group, Deletion Protection, Performance Insights Enabled, CloudWatch Logs
+        """
+        resources = []
+        rds_client = self._session.client("rds", region_name=region)
+        
+        try:
+            paginator = rds_client.get_paginator("describe_db_instances")
+            for page in paginator.paginate():
+                for db in page.get("DBInstances", []):
+                    db_id = db.get("DBInstanceIdentifier", "")
+                    
+                    # Get security groups
+                    security_groups = []
+                    for sg in db.get("VpcSecurityGroups", []):
+                        security_groups.append(sg.get("VpcSecurityGroupId", ""))
+                    
+                    # Get CloudWatch logs exports
+                    cw_logs = db.get("EnabledCloudwatchLogsExports", [])
+                    
+                    # Get endpoint
+                    endpoint = db.get("Endpoint", {})
+                    endpoint_address = endpoint.get("Address", "")
+                    port = endpoint.get("Port", "")
+                    
+                    resources.append({
+                        "account_id": account_id,
+                        "region": region,
+                        "service": "rds",
+                        "resource_type": "DB Instance",
+                        "resource_id": db.get("DBInstanceArn", db_id),
+                        "name": db_id,
+                        "attributes": {
+                            "Region": region,
+                            "Endpoint": endpoint_address,
+                            "DB instance ID": db_id,
+                            "DB name": db.get("DBName", ""),
+                            "Master Username": db.get("MasterUsername", ""),
+                            "Port": str(port),
+                            "DB Engine": db.get("Engine", ""),
+                            "DB Version": db.get("EngineVersion", ""),
+                            "Instance Type": db.get("DBInstanceClass", ""),
+                            "Storage type": db.get("StorageType", ""),
+                            "Storage": f"{db.get('AllocatedStorage', '')} GB",
+                            "Multi-AZ": "Yes" if db.get("MultiAZ") else "No",
+                            "Security Group": ", ".join(security_groups),
+                            "Deletion Protection": (
+                                "Yes" if db.get("DeletionProtection") else "No"
+                            ),
+                            "Performance Insights Enabled": (
+                                "Yes" if db.get("PerformanceInsightsEnabled") else "No"
+                            ),
+                            "CloudWatch Logs": (
+                                ", ".join(cw_logs) if cw_logs else "N/A"
+                            ),
+                        },
+                    })
+        except Exception as e:
+            logger.warning(f"Failed to scan RDS instances: {str(e)}")
+        
+        return resources
+
+    def _scan_elasticache_clusters(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan ElastiCache Clusters in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of ElastiCache Cluster resource dictionaries
+        
+        Attributes (vertical layout - one table per cluster):
+            Cluster ID, Engine, Engine Version, Node Type, Num Nodes, Status
+        """
+        resources = []
+        elasticache_client = self._session.client("elasticache", region_name=region)
+        
+        # Scan cache clusters (Redis/Memcached)
+        try:
+            paginator = elasticache_client.get_paginator("describe_cache_clusters")
+            for page in paginator.paginate(ShowCacheNodeInfo=True):
+                for cluster in page.get("CacheClusters", []):
+                    cluster_id = cluster.get("CacheClusterId", "")
+                    
+                    resources.append({
+                        "account_id": account_id,
+                        "region": region,
+                        "service": "elasticache",
+                        "resource_type": "Cache Cluster",
+                        "resource_id": cluster.get("ARN", cluster_id),
+                        "name": cluster_id,
+                        "attributes": {
+                            "Cluster ID": cluster_id,
+                            "Engine": cluster.get("Engine", ""),
+                            "Engine Version": cluster.get("EngineVersion", ""),
+                            "Node Type": cluster.get("CacheNodeType", ""),
+                            "Num Nodes": str(cluster.get("NumCacheNodes", 0)),
+                            "Status": cluster.get("CacheClusterStatus", ""),
+                        },
+                    })
+        except Exception as e:
+            logger.warning(f"Failed to scan ElastiCache clusters: {str(e)}")
+        
+        # Also scan replication groups (Redis cluster mode)
+        try:
+            paginator = elasticache_client.get_paginator("describe_replication_groups")
+            for page in paginator.paginate():
+                for rg in page.get("ReplicationGroups", []):
+                    rg_id = rg.get("ReplicationGroupId", "")
+                    
+                    # Count nodes
+                    num_nodes = 0
+                    for node_group in rg.get("NodeGroups", []):
+                        num_nodes += len(node_group.get("NodeGroupMembers", []))
+                    
+                    # Get node type from member clusters
+                    node_type = ""
+                    member_clusters = rg.get("MemberClusters", [])
+                    if member_clusters:
+                        try:
+                            cluster_response = elasticache_client.describe_cache_clusters(
+                                CacheClusterId=member_clusters[0]
+                            )
+                            if cluster_response.get("CacheClusters"):
+                                node_type = cluster_response["CacheClusters"][0].get(
+                                    "CacheNodeType", ""
+                                )
+                        except Exception:
+                            pass
+                    
+                    resources.append({
+                        "account_id": account_id,
+                        "region": region,
+                        "service": "elasticache",
+                        "resource_type": "Cache Cluster",
+                        "resource_id": rg.get("ARN", rg_id),
+                        "name": rg_id,
+                        "attributes": {
+                            "Cluster ID": rg_id,
+                            "Engine": "redis",
+                            "Engine Version": "",
+                            "Node Type": node_type,
+                            "Num Nodes": str(num_nodes),
+                            "Status": rg.get("Status", ""),
+                        },
+                    })
+        except Exception as e:
+            logger.warning(f"Failed to scan ElastiCache replication groups: {str(e)}")
+        
+        return resources
+
+    def _scan_s3_buckets(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan S3 Buckets (global service, scanned once from us-east-1).
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan (should be us-east-1 for global service)
+        
+        Returns:
+            List of S3 Bucket resource dictionaries
+        
+        Attributes (horizontal layout): Region, Bucket Name
+        """
+        resources = []
+        s3_client = self._session.client("s3", region_name=region)
+        
+        try:
+            response = s3_client.list_buckets()
+            for bucket in response.get("Buckets", []):
+                bucket_name = bucket.get("Name", "")
+                
+                # Get bucket location
+                try:
+                    location_response = s3_client.get_bucket_location(
+                        Bucket=bucket_name
+                    )
+                    bucket_region = (
+                        location_response.get("LocationConstraint") or "us-east-1"
+                    )
+                except Exception:
+                    bucket_region = "unknown"
+                
+                resources.append({
+                    "account_id": account_id,
+                    "region": "global",
+                    "service": "s3",
+                    "resource_type": "Bucket",
+                    "resource_id": bucket_name,
+                    "name": bucket_name,
+                    "attributes": {
+                        "Region": bucket_region,
+                        "Bucket Name": bucket_name,
+                    },
+                })
+        except Exception as e:
+            logger.warning(f"Failed to scan S3 buckets: {str(e)}")
+        
+        return resources
+
+    def _scan_s3_event_notifications(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan S3 Event Notifications (global service, scanned once from us-east-1).
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan (should be us-east-1 for global service)
+        
+        Returns:
+            List of S3 Event Notification resource dictionaries
+        
+        Attributes (vertical layout):
+            Bucket, Name, Event Type, Destination type, Destination
+        """
+        resources = []
+        s3_client = self._session.client("s3", region_name=region)
+        
+        try:
+            # First get all buckets
+            buckets_response = s3_client.list_buckets()
+            
+            for bucket in buckets_response.get("Buckets", []):
+                bucket_name = bucket.get("Name", "")
+                
+                try:
+                    # Get notification configuration
+                    notif_response = s3_client.get_bucket_notification_configuration(
+                        Bucket=bucket_name
+                    )
+                    
+                    # Process Lambda function configurations
+                    for config in notif_response.get(
+                        "LambdaFunctionConfigurations", []
+                    ):
+                        config_id = config.get("Id", "Lambda")
+                        events = config.get("Events", [])
+                        lambda_arn = config.get("LambdaFunctionArn", "")
+                        
+                        resources.append({
+                            "account_id": account_id,
+                            "region": "global",
+                            "service": "s3_event_notification",
+                            "resource_type": "S3 event notification",
+                            "resource_id": f"{bucket_name}/{config_id}",
+                            "name": config_id,
+                            "attributes": {
+                                "Bucket": bucket_name,
+                                "Name": config_id,
+                                "Event Type": ", ".join(events),
+                                "Destination type": "Lambda",
+                                "Destination": (
+                                    lambda_arn.split(":")[-1] if lambda_arn else ""
+                                ),
+                            },
+                        })
+                    
+                    # Process SQS queue configurations
+                    for config in notif_response.get("QueueConfigurations", []):
+                        config_id = config.get("Id", "SQS")
+                        events = config.get("Events", [])
+                        queue_arn = config.get("QueueArn", "")
+                        
+                        resources.append({
+                            "account_id": account_id,
+                            "region": "global",
+                            "service": "s3_event_notification",
+                            "resource_type": "S3 event notification",
+                            "resource_id": f"{bucket_name}/{config_id}",
+                            "name": config_id,
+                            "attributes": {
+                                "Bucket": bucket_name,
+                                "Name": config_id,
+                                "Event Type": ", ".join(events),
+                                "Destination type": "SQS",
+                                "Destination": (
+                                    queue_arn.split(":")[-1] if queue_arn else ""
+                                ),
+                            },
+                        })
+                    
+                    # Process SNS topic configurations
+                    for config in notif_response.get("TopicConfigurations", []):
+                        config_id = config.get("Id", "SNS")
+                        events = config.get("Events", [])
+                        topic_arn = config.get("TopicArn", "")
+                        
+                        resources.append({
+                            "account_id": account_id,
+                            "region": "global",
+                            "service": "s3_event_notification",
+                            "resource_type": "S3 event notification",
+                            "resource_id": f"{bucket_name}/{config_id}",
+                            "name": config_id,
+                            "attributes": {
+                                "Bucket": bucket_name,
+                                "Name": config_id,
+                                "Event Type": ", ".join(events),
+                                "Destination type": "SNS",
+                                "Destination": (
+                                    topic_arn.split(":")[-1] if topic_arn else ""
+                                ),
+                            },
+                        })
+                        
+                except Exception as e:
+                    # Skip buckets we can't access
+                    logger.debug(
+                        f"Failed to get notifications for bucket {bucket_name}: "
+                        f"{str(e)}"
+                    )
+                    
+        except Exception as e:
+            logger.warning(f"Failed to scan S3 event notifications: {str(e)}")
+        
+        return resources
+
+    # =========================================================================
+    # Global and Monitoring Service Scanners (Task 1.5)
+    # =========================================================================
+
+    def _scan_cloudfront_distributions(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan CloudFront Distributions (global service).
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan (should be us-east-1 for global service)
+        
+        Returns:
+            List of CloudFront Distribution resource dictionaries
+        
+        Attributes (vertical layout - one table per distribution):
+            CloudFront ID, Domain Name, CNAME, Origin Domain Name,
+            Origin Protocol Policy, Viewer Protocol Policy,
+            Allowed HTTP Methods, Cached HTTP Methods
+        """
+        resources = []
+        # CloudFront is a global service, always use us-east-1
+        cf_client = self._session.client("cloudfront", region_name="us-east-1")
+        
+        try:
+            paginator = cf_client.get_paginator("list_distributions")
+            for page in paginator.paginate():
+                distribution_list = page.get("DistributionList", {})
+                for dist in distribution_list.get("Items", []):
+                    dist_id = dist.get("Id", "")
+                    
+                    # Get aliases (CNAMEs)
+                    aliases = dist.get("Aliases", {}).get("Items", [])
+                    
+                    # Get origin info
+                    origins = dist.get("Origins", {}).get("Items", [])
+                    origin_domain = ""
+                    origin_protocol = ""
+                    if origins:
+                        origin = origins[0]
+                        origin_domain = origin.get("DomainName", "")
+                        custom_origin = origin.get("CustomOriginConfig", {})
+                        if custom_origin:
+                            origin_protocol = custom_origin.get(
+                                "OriginProtocolPolicy", ""
+                            )
+                        else:
+                            origin_protocol = "S3"
+                    
+                    # Get default cache behavior
+                    default_behavior = dist.get("DefaultCacheBehavior", {})
+                    viewer_protocol = default_behavior.get(
+                        "ViewerProtocolPolicy", ""
+                    )
+                    allowed_methods = default_behavior.get(
+                        "AllowedMethods", {}
+                    ).get("Items", [])
+                    cached_methods = default_behavior.get(
+                        "AllowedMethods", {}
+                    ).get("CachedMethods", {}).get("Items", [])
+                    
+                    resources.append({
+                        "account_id": account_id,
+                        "region": "global",
+                        "service": "cloudfront",
+                        "resource_type": "Distribution",
+                        "resource_id": dist.get("ARN", dist_id),
+                        "name": dist_id,
+                        "attributes": {
+                            "CloudFront ID": dist_id,
+                            "Domain Name": dist.get("DomainName", ""),
+                            "CNAME": ", ".join(aliases) if aliases else "N/A",
+                            "Origin Domain Name": origin_domain,
+                            "Origin Protocol Policy": origin_protocol,
+                            "Viewer Protocol Policy": viewer_protocol,
+                            "Allowed HTTP Methods": ", ".join(allowed_methods),
+                            "Cached HTTP Methods": ", ".join(cached_methods),
+                        },
+                    })
+        except Exception as e:
+            logger.warning(f"Failed to scan CloudFront distributions: {str(e)}")
+        
+        return resources
+
+    def _scan_route53_hosted_zones(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan Route 53 Hosted Zones (global service).
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan (should be us-east-1 for global service)
+        
+        Returns:
+            List of Route 53 Hosted Zone resource dictionaries
+        
+        Attributes (horizontal layout):
+            Zone ID, Name, Type, Record Count
+        """
+        resources = []
+        # Route 53 is a global service
+        route53_client = self._session.client("route53", region_name="us-east-1")
+        
+        try:
+            paginator = route53_client.get_paginator("list_hosted_zones")
+            for page in paginator.paginate():
+                for zone in page.get("HostedZones", []):
+                    zone_id = zone.get("Id", "").replace("/hostedzone/", "")
+                    zone_name = zone.get("Name", "")
+                    
+                    # Determine zone type
+                    zone_type = (
+                        "Private"
+                        if zone.get("Config", {}).get("PrivateZone")
+                        else "Public"
+                    )
+                    
+                    resources.append({
+                        "account_id": account_id,
+                        "region": "global",
+                        "service": "route53",
+                        "resource_type": "Hosted Zone",
+                        "resource_id": zone_id,
+                        "name": zone_name,
+                        "attributes": {
+                            "Zone ID": zone_id,
+                            "Name": zone_name,
+                            "Type": zone_type,
+                            "Record Count": str(
+                                zone.get("ResourceRecordSetCount", 0)
+                            ),
+                        },
+                    })
+        except Exception as e:
+            logger.warning(f"Failed to scan Route 53 hosted zones: {str(e)}")
+        
+        return resources
+
+    def _scan_acm_certificates(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan ACM Certificates (regional service).
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of ACM Certificate resource dictionaries
+        
+        Attributes (horizontal layout): Domain name, Additional names
+        """
+        resources = []
+        # ACM is a regional service
+        acm_client = self._session.client("acm", region_name=region)
+        
+        try:
+            paginator = acm_client.get_paginator("list_certificates")
+            for page in paginator.paginate():
+                for cert in page.get("CertificateSummaryList", []):
+                    domain_name = cert.get("DomainName", "")
+                    cert_arn = cert.get("CertificateArn", "")
+                    
+                    # Get additional names (Subject Alternative Names)
+                    additional_names = ""
+                    try:
+                        cert_detail = acm_client.describe_certificate(
+                            CertificateArn=cert_arn
+                        )
+                        sans = cert_detail.get("Certificate", {}).get(
+                            "SubjectAlternativeNames", []
+                        )
+                        # Filter out the main domain name from SANs
+                        additional = [san for san in sans if san != domain_name]
+                        additional_names = ", ".join(additional) if additional else ""
+                    except Exception:
+                        pass
+                    
+                    resources.append({
+                        "account_id": account_id,
+                        "region": region,
+                        "service": "acm",
+                        "resource_type": "Certificate",
+                        "resource_id": cert_arn,
+                        "name": domain_name,
+                        "attributes": {
+                            "Domain name": domain_name,
+                            "Additional names": additional_names,
+                        },
+                    })
+        except Exception as e:
+            logger.warning(f"Failed to scan ACM certificates in {region}: {str(e)}")
+        
+        return resources
+
+    def _scan_waf_web_acls(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan WAF Web ACLs (global service for CloudFront).
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan (should be us-east-1 for global service)
+        
+        Returns:
+            List of WAF Web ACL resource dictionaries
+        
+        Attributes (horizontal layout):
+            WebACL Name, Scope, Rules Count, Associated Resources
+        """
+        resources = []
+        
+        # Scan WAFv2 global (CloudFront) Web ACLs
+        wafv2_client = self._session.client("wafv2", region_name="us-east-1")
+        
+        try:
+            # List CloudFront Web ACLs (CLOUDFRONT scope)
+            response = wafv2_client.list_web_acls(Scope="CLOUDFRONT")
+            
+            for acl in response.get("WebACLs", []):
+                acl_name = acl.get("Name", "")
+                acl_id = acl.get("Id", "")
+                acl_arn = acl.get("ARN", "")
+                
+                # Get Web ACL details for rules count
+                rules_count = 0
+                associated_resources = []
+                
+                try:
+                    acl_response = wafv2_client.get_web_acl(
+                        Name=acl_name,
+                        Scope="CLOUDFRONT",
+                        Id=acl_id,
+                    )
+                    web_acl = acl_response.get("WebACL", {})
+                    rules_count = len(web_acl.get("Rules", []))
+                    
+                    # Get associated resources
+                    resources_response = wafv2_client.list_resources_for_web_acl(
+                        WebACLArn=acl_arn
+                    )
+                    for resource_arn in resources_response.get("ResourceArns", []):
+                        # Extract resource name from ARN
+                        resource_name = resource_arn.split("/")[-1]
+                        associated_resources.append(resource_name)
+                except Exception as e:
+                    logger.debug(f"Failed to get WAF ACL details: {str(e)}")
+                
+                resources.append({
+                    "account_id": account_id,
+                    "region": "global",
+                    "service": "waf",
+                    "resource_type": "Web ACL",
+                    "resource_id": acl_arn,
+                    "name": acl_name,
+                    "attributes": {
+                        "WebACL Name": acl_name,
+                        "Scope": "CLOUDFRONT",
+                        "Rules Count": str(rules_count),
+                        "Associated Resources": (
+                            ", ".join(associated_resources)
+                            if associated_resources
+                            else "None"
+                        ),
+                    },
+                })
+        except Exception as e:
+            logger.warning(f"Failed to scan WAFv2 Web ACLs: {str(e)}")
+        
+        # Also scan regional WAF Web ACLs
+        try:
+            response = wafv2_client.list_web_acls(Scope="REGIONAL")
+            
+            for acl in response.get("WebACLs", []):
+                acl_name = acl.get("Name", "")
+                acl_id = acl.get("Id", "")
+                acl_arn = acl.get("ARN", "")
+                
+                rules_count = 0
+                associated_resources = []
+                
+                try:
+                    acl_response = wafv2_client.get_web_acl(
+                        Name=acl_name,
+                        Scope="REGIONAL",
+                        Id=acl_id,
+                    )
+                    web_acl = acl_response.get("WebACL", {})
+                    rules_count = len(web_acl.get("Rules", []))
+                    
+                    resources_response = wafv2_client.list_resources_for_web_acl(
+                        WebACLArn=acl_arn
+                    )
+                    for resource_arn in resources_response.get("ResourceArns", []):
+                        resource_name = resource_arn.split("/")[-1]
+                        associated_resources.append(resource_name)
+                except Exception as e:
+                    logger.debug(f"Failed to get WAF ACL details: {str(e)}")
+                
+                resources.append({
+                    "account_id": account_id,
+                    "region": "global",
+                    "service": "waf",
+                    "resource_type": "Web ACL",
+                    "resource_id": acl_arn,
+                    "name": acl_name,
+                    "attributes": {
+                        "WebACL Name": acl_name,
+                        "Scope": "REGIONAL",
+                        "Rules Count": str(rules_count),
+                        "Associated Resources": (
+                            ", ".join(associated_resources)
+                            if associated_resources
+                            else "None"
+                        ),
+                    },
+                })
+        except Exception as e:
+            logger.warning(f"Failed to scan regional WAFv2 Web ACLs: {str(e)}")
+        
+        return resources
+
+    def _scan_sns_topics(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan SNS Topics in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of SNS Topic resource dictionaries
+        
+        Attributes (horizontal layout):
+            Topic Name, Topic Display Name, Subscription Protocol, Subscription Endpoint
+        """
+        resources = []
+        sns_client = self._session.client("sns", region_name=region)
+        
+        try:
+            paginator = sns_client.get_paginator("list_topics")
+            for page in paginator.paginate():
+                for topic in page.get("Topics", []):
+                    topic_arn = topic.get("TopicArn", "")
+                    topic_name = topic_arn.split(":")[-1] if topic_arn else ""
+                    
+                    # Get topic attributes
+                    display_name = ""
+                    try:
+                        attrs_response = sns_client.get_topic_attributes(
+                            TopicArn=topic_arn
+                        )
+                        attrs = attrs_response.get("Attributes", {})
+                        display_name = attrs.get("DisplayName", "")
+                    except Exception as e:
+                        logger.debug(f"Failed to get topic attributes: {str(e)}")
+                    
+                    # Get subscriptions
+                    subscriptions = []
+                    try:
+                        sub_paginator = sns_client.get_paginator(
+                            "list_subscriptions_by_topic"
+                        )
+                        for sub_page in sub_paginator.paginate(TopicArn=topic_arn):
+                            for sub in sub_page.get("Subscriptions", []):
+                                protocol = sub.get("Protocol", "")
+                                endpoint = sub.get("Endpoint", "")
+                                subscriptions.append({
+                                    "protocol": protocol,
+                                    "endpoint": endpoint,
+                                })
+                    except Exception as e:
+                        logger.debug(f"Failed to get subscriptions: {str(e)}")
+                    
+                    # Create one entry per subscription, or one entry if no subscriptions
+                    if subscriptions:
+                        for sub in subscriptions:
+                            resources.append({
+                                "account_id": account_id,
+                                "region": region,
+                                "service": "sns",
+                                "resource_type": "Topic",
+                                "resource_id": topic_arn,
+                                "name": topic_name,
+                                "attributes": {
+                                    "Topic Name": topic_name,
+                                    "Topic Display Name": display_name,
+                                    "Subscription Protocol": sub["protocol"],
+                                    "Subscription Endpoint": sub["endpoint"],
+                                },
+                            })
+                    else:
+                        resources.append({
+                            "account_id": account_id,
+                            "region": region,
+                            "service": "sns",
+                            "resource_type": "Topic",
+                            "resource_id": topic_arn,
+                            "name": topic_name,
+                            "attributes": {
+                                "Topic Name": topic_name,
+                                "Topic Display Name": display_name,
+                                "Subscription Protocol": "N/A",
+                                "Subscription Endpoint": "N/A",
+                            },
+                        })
+        except Exception as e:
+            logger.warning(f"Failed to scan SNS topics: {str(e)}")
+        
+        return resources
+
+    def _scan_cloudwatch_log_groups(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan CloudWatch Log Groups in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of CloudWatch Log Group resource dictionaries
+        
+        Attributes (horizontal layout):
+            Log Group Name, Retention Days, Stored Bytes, KMS Encryption
+        """
+        resources = []
+        logs_client = self._session.client("logs", region_name=region)
+        
+        try:
+            paginator = logs_client.get_paginator("describe_log_groups")
+            for page in paginator.paginate():
+                for log_group in page.get("logGroups", []):
+                    log_group_name = log_group.get("logGroupName", "")
+                    
+                    # Get retention in days
+                    retention = log_group.get("retentionInDays")
+                    retention_str = str(retention) if retention else "Never Expire"
+                    
+                    # Get stored bytes
+                    stored_bytes = log_group.get("storedBytes", 0)
+                    stored_str = (
+                        f"{stored_bytes / (1024*1024):.2f} MB"
+                        if stored_bytes
+                        else "0 MB"
+                    )
+                    
+                    # Check KMS encryption
+                    kms_key = log_group.get("kmsKeyId", "")
+                    kms_encrypted = "Yes" if kms_key else "No"
+                    
+                    resources.append({
+                        "account_id": account_id,
+                        "region": region,
+                        "service": "cloudwatch",
+                        "resource_type": "Log Group",
+                        "resource_id": log_group.get("arn", log_group_name),
+                        "name": log_group_name,
+                        "attributes": {
+                            "Log Group Name": log_group_name,
+                            "Retention Days": retention_str,
+                            "Stored Bytes": stored_str,
+                            "KMS Encryption": kms_encrypted,
+                        },
+                    })
+        except Exception as e:
+            logger.warning(f"Failed to scan CloudWatch log groups: {str(e)}")
+        
+        return resources
+
+    def _scan_eventbridge_rules(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan EventBridge Rules in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of EventBridge Rule resource dictionaries
+        
+        Attributes (horizontal layout):
+            Name, Description, Event Bus, State
+        """
+        resources = []
+        events_client = self._session.client("events", region_name=region)
+        
+        try:
+            # List event buses first
+            buses_response = events_client.list_event_buses()
+            event_buses = [
+                bus.get("Name", "default")
+                for bus in buses_response.get("EventBuses", [])
+            ]
+            
+            # If no buses found, use default
+            if not event_buses:
+                event_buses = ["default"]
+            
+            for bus_name in event_buses:
+                try:
+                    paginator = events_client.get_paginator("list_rules")
+                    for page in paginator.paginate(EventBusName=bus_name):
+                        for rule in page.get("Rules", []):
+                            rule_name = rule.get("Name", "")
+                            
+                            resources.append({
+                                "account_id": account_id,
+                                "region": region,
+                                "service": "eventbridge",
+                                "resource_type": "Rule",
+                                "resource_id": rule.get("Arn", rule_name),
+                                "name": rule_name,
+                                "attributes": {
+                                    "Name": rule_name,
+                                    "Description": rule.get("Description", ""),
+                                    "Event Bus": bus_name,
+                                    "State": rule.get("State", ""),
+                                },
+                            })
+                except Exception as e:
+                    logger.debug(
+                        f"Failed to list rules for bus {bus_name}: {str(e)}"
+                    )
+        except Exception as e:
+            logger.warning(f"Failed to scan EventBridge rules: {str(e)}")
+        
+        return resources
+
+    def _scan_cloudtrail_trails(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan CloudTrail Trails (global service).
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan (should be us-east-1 for global service)
+        
+        Returns:
+            List of CloudTrail Trail resource dictionaries
+        
+        Attributes (horizontal layout):
+            Name, Multi-Region Trail, Log File Validation, KMS Encryption
+        """
+        resources = []
+        cloudtrail_client = self._session.client(
+            "cloudtrail", region_name="us-east-1"
+        )
+        
+        try:
+            response = cloudtrail_client.describe_trails()
+            for trail in response.get("trailList", []):
+                trail_name = trail.get("Name", "")
+                
+                # Get multi-region status
+                is_multi_region = trail.get("IsMultiRegionTrail", False)
+                
+                resources.append({
+                    "account_id": account_id,
+                    "region": "global",
+                    "service": "cloudtrail",
+                    "resource_type": "Trail",
+                    "resource_id": trail.get("TrailARN", trail_name),
+                    "name": trail_name,
+                    "attributes": {
+                        "Name": trail_name,
+                        "Multi-Region Trail": "Yes" if is_multi_region else "No",
+                        "Log File Validation": (
+                            "Yes" if trail.get("LogFileValidationEnabled") else "No"
+                        ),
+                        "KMS Encryption": (
+                            "Yes" if trail.get("KmsKeyId") else "No"
+                        ),
+                    },
+                })
+        except Exception as e:
+            logger.warning(f"Failed to scan CloudTrail trails: {str(e)}")
+        
+        return resources
+
+    def _scan_config_recorders(
+        self, account_id: str, region: str
+    ) -> List[Dict[str, Any]]:
+        """
+        Scan AWS Config Recorders in the specified region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+        
+        Returns:
+            List of AWS Config Recorder resource dictionaries
+        
+        Attributes (horizontal layout):
+            Name, Regional Resources, Global Resources, Retention period
+        """
+        resources = []
+        config_client = self._session.client("config", region_name=region)
+        
+        try:
+            response = config_client.describe_configuration_recorders()
+            for recorder in response.get("ConfigurationRecorders", []):
+                recorder_name = recorder.get("name", "")
+                
+                # Get recording group settings
+                recording_group = recorder.get("recordingGroup", {})
+                all_supported = recording_group.get("allSupported", False)
+                include_global = recording_group.get(
+                    "includeGlobalResourceTypes", False
+                )
+                
+                # Get retention period
+                retention_period = "N/A"
+                try:
+                    retention_response = (
+                        config_client.describe_retention_configurations()
+                    )
+                    for retention in retention_response.get(
+                        "RetentionConfigurations", []
+                    ):
+                        retention_period = (
+                            f"{retention.get('RetentionPeriodInDays', 'N/A')} days"
+                        )
+                        break
+                except Exception:
+                    pass
+                
+                resources.append({
+                    "account_id": account_id,
+                    "region": region,
+                    "service": "config",
+                    "resource_type": "Config",
+                    "resource_id": recorder_name,
+                    "name": recorder_name,
+                    "attributes": {
+                        "Name": recorder_name,
+                        "Regional Resources": "Yes" if all_supported else "No",
+                        "Global Resources": "Yes" if include_global else "No",
+                        "Retention period": retention_period,
+                    },
+                })
+        except Exception as e:
+            logger.warning(f"Failed to scan Config recorders: {str(e)}")
+        
+        return resources
+
+
+def parse_arguments() -> argparse.Namespace:
+    """
+    Parse command-line arguments.
+    
+    Returns:
+        Parsed arguments namespace
+    """
+    parser = argparse.ArgumentParser(
+        description="CloudShell Scanner - AWS Resource Scanner for CloudShell Environment",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+Examples:
+    # Scan all regions and services
+    python cloudshell_scanner.py
+
+    # Scan specific regions
+    python cloudshell_scanner.py --regions us-east-1,ap-northeast-1
+
+    # Specify output file
+    python cloudshell_scanner.py --output my_scan.json
+
+    # Scan specific services
+    python cloudshell_scanner.py --services ec2,vpc,rds
+
+    # Combine options
+    python cloudshell_scanner.py --regions us-east-1 --services ec2,vpc --output scan.json
+        """,
+    )
+    
+    parser.add_argument(
+        "--regions",
+        type=str,
+        default=None,
+        help="Comma-separated list of AWS regions to scan (default: all regions)",
+    )
+    
+    parser.add_argument(
+        "--output",
+        type=str,
+        default="scan_result.json",
+        help="Output JSON file path (default: scan_result.json)",
+    )
+    
+    parser.add_argument(
+        "--services",
+        type=str,
+        default=None,
+        help="Comma-separated list of services to scan (default: all services)",
+    )
+    
+    parser.add_argument(
+        "--version",
+        action="version",
+        version=f"CloudShell Scanner v{__version__}",
+    )
+    
+    parser.add_argument(
+        "--verbose",
+        "-v",
+        action="store_true",
+        help="Enable verbose logging",
+    )
+    
+    parser.add_argument(
+        "--list-services",
+        action="store_true",
+        help="List all supported services and exit",
+    )
+    
+    return parser.parse_args()
+
+
+def main() -> int:
+    """
+    Main entry point for the CloudShell Scanner.
+    
+    Returns:
+        Exit code (0 for success, non-zero for failure)
+    """
+    args = parse_arguments()
+    
+    # Set logging level
+    if args.verbose:
+        logging.getLogger().setLevel(logging.DEBUG)
+        logger.debug("Verbose logging enabled")
+    
+    # List services and exit if requested
+    if args.list_services:
+        print("Supported services:")
+        for service in CloudShellScanner.SUPPORTED_SERVICES:
+            global_marker = " (global)" if service in CloudShellScanner.GLOBAL_SERVICES else ""
+            print(f"  - {service}{global_marker}")
+        return 0
+    
+    # Parse regions
+    regions: Optional[List[str]] = None
+    if args.regions:
+        regions = [r.strip() for r in args.regions.split(",")]
+        logger.info(f"Regions specified: {regions}")
+    
+    # Parse services
+    services: Optional[List[str]] = None
+    if args.services:
+        services = [s.strip() for s in args.services.split(",")]
+        logger.info(f"Services specified: {services}")
+    
+    try:
+        # Initialize scanner
+        print(f"CloudShell Scanner v{__version__}")
+        print("=" * 50)
+        
+        scanner = CloudShellScanner()
+        
+        # Get account info
+        account_id = scanner.get_account_id()
+        print(f"AWS Account: {account_id}")
+        print("=" * 50)
+        
+        # Run scan
+        result = scanner.scan_resources(regions=regions, services=services)
+        
+        # Export results
+        scanner.export_json(result, args.output)
+        
+        # Print summary
+        print("\n" + "=" * 50)
+        print("Scan Summary:")
+        print(f"  Account ID: {result['metadata']['account_id']}")
+        print(f"  Regions scanned: {len(result['metadata']['regions_scanned'])}")
+        print(f"  Services scanned: {len(result['metadata']['services_scanned'])}")
+        print(f"  Total resources: {result['metadata']['total_resources']}")
+        print(f"  Total errors: {result['metadata']['total_errors']}")
+        print(f"  Output file: {args.output}")
+        print("=" * 50)
+        
+        return 0
+        
+    except KeyboardInterrupt:
+        print("\n\nScan interrupted by user")
+        return 130
+    except Exception as e:
+        logger.error(f"Scan failed: {e}")
+        if args.verbose:
+            import traceback
+            traceback.print_exc()
+        return 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 238 - 0
frontend/src/components/Upload/JsonUploader.tsx

@@ -0,0 +1,238 @@
+/**
+ * JsonUploader Component - File upload with drag-and-drop for CloudShell scan JSON
+ * Requirements: 3.2, 3.5
+ */
+import { useState, useCallback } from 'react';
+import { Upload, message, Alert, Typography, Space } from 'antd';
+import { InboxOutlined, FileTextOutlined, CheckCircleOutlined } from '@ant-design/icons';
+import type { UploadFile, RcFile } from 'antd/es/upload/interface';
+import { validateScanData, type ScanData, type ValidationResult } from '../../utils/scanDataValidator';
+
+const { Dragger } = Upload;
+const { Text, Paragraph } = Typography;
+
+const MAX_FILE_SIZE_MB = 50;
+const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
+
+export interface JsonUploaderProps {
+  onUploadSuccess: (data: ScanData, file: File) => void;
+  onUploadError: (error: string) => void;
+  onClear?: () => void;
+  maxFileSize?: number;
+  disabled?: boolean;
+}
+
+interface UploadState {
+  file: File | null;
+  data: ScanData | null;
+  validationResult: ValidationResult | null;
+}
+
+export default function JsonUploader({
+  onUploadSuccess,
+  onUploadError,
+  onClear,
+  maxFileSize = MAX_FILE_SIZE_BYTES,
+  disabled = false,
+}: JsonUploaderProps) {
+  const [fileList, setFileList] = useState<UploadFile[]>([]);
+  const [uploadState, setUploadState] = useState<UploadState>({
+    file: null,
+    data: null,
+    validationResult: null,
+  });
+  const [loading, setLoading] = useState(false);
+
+  const resetState = useCallback(() => {
+    setFileList([]);
+    setUploadState({ file: null, data: null, validationResult: null });
+    onClear?.();
+  }, [onClear]);
+
+  const validateAndProcessFile = useCallback(async (file: RcFile): Promise<boolean> => {
+    setLoading(true);
+    
+    try {
+      // Check file size
+      if (file.size > maxFileSize) {
+        const errorMsg = `File size exceeds ${MAX_FILE_SIZE_MB}MB limit`;
+        message.error(errorMsg);
+        onUploadError(errorMsg);
+        setLoading(false);
+        return false;
+      }
+
+      // Check file extension
+      if (!file.name.toLowerCase().endsWith('.json')) {
+        const errorMsg = 'Please upload a JSON file';
+        message.error(errorMsg);
+        onUploadError(errorMsg);
+        setLoading(false);
+        return false;
+      }
+
+      // Read and parse file
+      const text = await file.text();
+      let parsedData: unknown;
+      
+      try {
+        parsedData = JSON.parse(text);
+      } catch {
+        const errorMsg = 'Invalid JSON format: Unable to parse file';
+        message.error(errorMsg);
+        onUploadError(errorMsg);
+        setUploadState({
+          file: file,
+          data: null,
+          validationResult: {
+            isValid: false,
+            errors: [errorMsg],
+            missingFields: [],
+          },
+        });
+        setLoading(false);
+        return false;
+      }
+
+      // Validate structure
+      const validationResult = validateScanData(parsedData);
+      
+      if (!validationResult.isValid) {
+        const errorMsg = validationResult.missingFields.length > 0
+          ? `Missing required fields: ${validationResult.missingFields.join(', ')}`
+          : validationResult.errors[0] || 'Invalid scan data structure';
+        
+        message.error(errorMsg);
+        onUploadError(errorMsg);
+        setUploadState({
+          file: file,
+          data: null,
+          validationResult,
+        });
+        setLoading(false);
+        return false;
+      }
+
+      // Success
+      const scanData = parsedData as ScanData;
+      setUploadState({
+        file: file,
+        data: scanData,
+        validationResult,
+      });
+      
+      message.success('JSON file validated successfully');
+      onUploadSuccess(scanData, file);
+      setLoading(false);
+      return false; // Prevent default upload behavior
+      
+    } catch (error) {
+      const errorMsg = error instanceof Error ? error.message : 'Failed to process file';
+      message.error(errorMsg);
+      onUploadError(errorMsg);
+      setLoading(false);
+      return false;
+    }
+  }, [maxFileSize, onUploadError, onUploadSuccess]);
+
+  const handleRemove = useCallback(() => {
+    resetState();
+    return true;
+  }, [resetState]);
+
+  const renderValidationErrors = () => {
+    const { validationResult } = uploadState;
+    if (!validationResult || validationResult.isValid) return null;
+
+    return (
+      <Alert
+        type="error"
+        showIcon
+        message="Validation Failed"
+        description={
+          <Space direction="vertical" size={4}>
+            {validationResult.missingFields.length > 0 && (
+              <div>
+                <Text strong>Missing fields:</Text>
+                <ul style={{ margin: '4px 0', paddingLeft: 20 }}>
+                  {validationResult.missingFields.map((field, i) => (
+                    <li key={i}><Text code>{field}</Text></li>
+                  ))}
+                </ul>
+              </div>
+            )}
+            {validationResult.errors.length > 0 && (
+              <div>
+                <Text strong>Errors:</Text>
+                <ul style={{ margin: '4px 0', paddingLeft: 20 }}>
+                  {validationResult.errors.slice(0, 5).map((error, i) => (
+                    <li key={i}><Text type="danger">{error}</Text></li>
+                  ))}
+                  {validationResult.errors.length > 5 && (
+                    <li><Text type="secondary">...and {validationResult.errors.length - 5} more errors</Text></li>
+                  )}
+                </ul>
+              </div>
+            )}
+          </Space>
+        }
+        style={{ marginTop: 16 }}
+      />
+    );
+  };
+
+  const renderSuccessInfo = () => {
+    const { data } = uploadState;
+    if (!data) return null;
+
+    return (
+      <Alert
+        type="success"
+        showIcon
+        icon={<CheckCircleOutlined />}
+        message="File Validated Successfully"
+        description={
+          <Space direction="vertical" size={4}>
+            <Text>Account ID: <Text code>{data.metadata.account_id}</Text></Text>
+            <Text>Scan Time: {new Date(data.metadata.scan_timestamp).toLocaleString()}</Text>
+            <Text>Regions: {data.metadata.regions_scanned.join(', ')}</Text>
+            <Text>Total Resources: {data.metadata.total_resources}</Text>
+          </Space>
+        }
+        style={{ marginTop: 16 }}
+      />
+    );
+  };
+
+  return (
+    <div>
+      <Dragger
+        accept=".json"
+        multiple={false}
+        fileList={fileList}
+        beforeUpload={validateAndProcessFile}
+        onRemove={handleRemove}
+        onChange={({ fileList: newFileList }) => setFileList(newFileList.slice(-1))}
+        disabled={disabled || loading}
+        showUploadList={{
+          showRemoveIcon: true,
+          showPreviewIcon: false,
+        }}
+      >
+        <p className="ant-upload-drag-icon">
+          {uploadState.data ? <FileTextOutlined style={{ color: '#52c41a' }} /> : <InboxOutlined />}
+        </p>
+        <p className="ant-upload-text">
+          Click or drag JSON file to this area to upload
+        </p>
+        <Paragraph type="secondary" style={{ margin: 0 }}>
+          Upload the scan result JSON file generated by CloudShell Scanner.
+          Maximum file size: {MAX_FILE_SIZE_MB}MB
+        </Paragraph>
+      </Dragger>
+
+      {renderValidationErrors()}
+      {renderSuccessInfo()}
+    </div>
+  );
+}

+ 2 - 0
frontend/src/components/Upload/index.ts

@@ -0,0 +1,2 @@
+export { default as JsonUploader } from './JsonUploader';
+export type { JsonUploaderProps } from './JsonUploader';

+ 192 - 54
frontend/src/pages/Tasks.tsx

@@ -18,7 +18,8 @@ import {
   Card,
   Descriptions,
   List,
-  Alert
+  Alert,
+  Radio
 } from 'antd';
 import { 
   PlusOutlined, 
@@ -31,7 +32,9 @@ import {
   CheckCircleOutlined,
   CloseCircleOutlined,
   SyncOutlined,
-  ClockCircleOutlined
+  ClockCircleOutlined,
+  CloudUploadOutlined,
+  KeyOutlined
 } from '@ant-design/icons';
 import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
 import type { UploadFile } from 'antd/es/upload/interface';
@@ -40,10 +43,15 @@ import { taskService } from '../services/tasks';
 import { credentialService } from '../services/credentials';
 import { usePagination } from '../hooks/usePagination';
 import { formatDateTime } from '../utils';
+import { JsonUploader } from '../components/Upload';
+import type { ScanData } from '../utils/scanDataValidator';
 
 const { Title, Text } = Typography;
 const { Option } = Select;
 
+// Data source types
+type DataSource = 'credential' | 'upload';
+
 // AWS Regions list
 const AWS_REGIONS = [
   { value: 'us-east-1', label: 'US East (N. Virginia)' },
@@ -94,11 +102,17 @@ export default function Tasks() {
   const [logsLoading, setLogsLoading] = useState(false);
   const [fileList, setFileList] = useState<UploadFile[]>([]);
   
+  // New state for data source selection
+  const [dataSource, setDataSource] = useState<DataSource>('credential');
+  const [uploadedScanData, setUploadedScanData] = useState<ScanData | null>(null);
+  const [uploadedScanFile, setUploadedScanFile] = useState<File | null>(null);
+  
   const pagination = usePagination(10);
   const [form] = Form.useForm();
   const refreshIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
   const detailRefreshIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
 
+
   // Fetch tasks with specific page/pageSize
   const fetchTasksWithParams = useCallback(async (page: number, pageSize: number, status?: string) => {
     try {
@@ -176,7 +190,6 @@ export default function Tasks() {
   // Handle table pagination change
   const handleTableChange = async (paginationConfig: TablePaginationConfig) => {
     const newPage = paginationConfig.current ?? pagination.page;
-    // Only use new pageSize if it actually changed (user clicked size changer)
     const newPageSize = paginationConfig.pageSize ?? pagination.pageSize;
     
     pagination.setPage(newPage);
@@ -184,7 +197,6 @@ export default function Tasks() {
       pagination.setPageSize(newPageSize);
     }
     
-    // Directly fetch with new params
     await fetchTasksWithParams(newPage, newPageSize, statusFilter);
   };
 
@@ -200,11 +212,34 @@ export default function Tasks() {
     fetchCredentials();
     setCreateModalVisible(true);
     setFileList([]);
+    setDataSource('credential');
+    setUploadedScanData(null);
+    setUploadedScanFile(null);
     form.resetFields();
   };
 
-  // Handle create task
-  const handleCreateTask = async (values: Record<string, unknown>) => {
+  // Handle JSON upload success
+  const handleUploadSuccess = (data: ScanData, file: File) => {
+    setUploadedScanData(data);
+    setUploadedScanFile(file);
+  };
+
+  // Handle JSON upload error
+  const handleUploadError = (error: string) => {
+    console.error('Upload error:', error);
+    setUploadedScanData(null);
+    setUploadedScanFile(null);
+  };
+
+  // Handle JSON upload clear
+  const handleUploadClear = () => {
+    setUploadedScanData(null);
+    setUploadedScanFile(null);
+  };
+
+
+  // Handle create task (credential mode)
+  const handleCreateTaskCredential = async (values: Record<string, unknown>) => {
     try {
       setCreating(true);
       
@@ -242,16 +277,63 @@ export default function Tasks() {
     }
   };
 
+  // Handle create task (upload mode)
+  const handleCreateTaskUpload = async (values: Record<string, unknown>) => {
+    if (!uploadedScanFile) {
+      message.error('Please upload a scan data JSON file');
+      return;
+    }
+
+    try {
+      setCreating(true);
+      
+      await taskService.uploadScan({
+        scanData: uploadedScanFile,
+        projectMetadata: {
+          clientName: values.clientName as string,
+          projectName: values.projectName as string,
+          bdManager: (values.bdManager as string) || '',
+          bdManagerEmail: (values.bdManagerEmail as string) || '',
+          solutionsArchitect: (values.solutionsArchitect as string) || '',
+          solutionsArchitectEmail: (values.solutionsArchitectEmail as string) || '',
+          cloudEngineer: (values.cloudEngineer as string) || '',
+          cloudEngineerEmail: (values.cloudEngineerEmail as string) || '',
+        },
+        networkDiagram: fileList.length > 0 && fileList[0].originFileObj ? fileList[0].originFileObj : undefined,
+      });
+      
+      message.success('Task created successfully');
+      setCreateModalVisible(false);
+      form.resetFields();
+      setFileList([]);
+      setUploadedScanData(null);
+      setUploadedScanFile(null);
+      fetchTasks();
+    } catch (error: unknown) {
+      const err = error as { response?: { data?: { error?: { message?: string } } } };
+      message.error(err.response?.data?.error?.message || 'Failed to create task');
+    } finally {
+      setCreating(false);
+    }
+  };
+
+  // Handle create task (dispatch based on data source)
+  const handleCreateTask = async (values: Record<string, unknown>) => {
+    if (dataSource === 'credential') {
+      await handleCreateTaskCredential(values);
+    } else {
+      await handleCreateTaskUpload(values);
+    }
+  };
+
   // Refresh task detail
   const refreshTaskDetail = useCallback(async (taskId: number) => {
     try {
       const detail = await taskService.getTaskDetail(taskId);
       setSelectedTask(detail);
-      // Also refresh logs
       const logsResponse = await taskService.getTaskLogs(taskId, { page: 1, pageSize: 50 });
       setTaskLogs(logsResponse.data);
     } catch (error) {
-      // Silently fail for auto-refresh
       console.error('Failed to refresh task details:', error);
     }
   }, []);
@@ -273,7 +355,7 @@ export default function Tasks() {
     if (detailModalVisible && selectedTask && (selectedTask.status === 'running' || selectedTask.status === 'pending')) {
       detailRefreshIntervalRef.current = setInterval(() => {
         refreshTaskDetail(selectedTask.id);
-      }, 3000); // Refresh every 3 seconds
+      }, 3000);
     }
     
     return () => {
@@ -299,6 +381,7 @@ export default function Tasks() {
     }
   };
 
+
   // Table columns
   const columns: ColumnsType<ScanTask> = [
     { 
@@ -380,7 +463,7 @@ export default function Tasks() {
     },
   ];
 
-  // Upload props
+  // Upload props for network diagram
   const uploadProps = {
     beforeUpload: (file: File) => {
       const isImage = file.type.startsWith('image/');
@@ -393,15 +476,16 @@ export default function Tasks() {
         message.error('Image must be smaller than 5MB!');
         return Upload.LIST_IGNORE;
       }
-      return false; // Prevent auto upload
+      return false;
     },
     fileList,
     onChange: ({ fileList: newFileList }: { fileList: UploadFile[] }) => {
-      setFileList(newFileList.slice(-1)); // Only keep the last file
+      setFileList(newFileList.slice(-1));
     },
     maxCount: 1,
   };
 
+
   return (
     <div>
       <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
@@ -472,50 +556,98 @@ export default function Tasks() {
           layout="vertical"
           onFinish={handleCreateTask}
         >
-          <Form.Item
-            name="name"
-            label="Task Name"
-            rules={[{ required: true, message: 'Please enter task name' }]}
-          >
-            <Input placeholder="Enter task name" />
-          </Form.Item>
-
-          <Form.Item
-            name="credentialIds"
-            label="AWS Accounts"
-            rules={[{ required: true, message: 'Please select at least one AWS account' }]}
-          >
-            <Select
-              mode="multiple"
-              placeholder="Select AWS accounts"
-              loading={credentialsLoading}
-              optionFilterProp="children"
+          {/* Data Source Selection */}
+          <Form.Item label="Data Source" required>
+            <Radio.Group 
+              value={dataSource} 
+              onChange={(e) => {
+                setDataSource(e.target.value);
+                setUploadedScanData(null);
+                setUploadedScanFile(null);
+              }}
+              optionType="button"
+              buttonStyle="solid"
             >
-              {credentials.map((cred: AWSCredential) => (
-                <Option key={cred.id} value={cred.id}>
-                  {cred.name} ({cred.account_id})
-                </Option>
-              ))}
-            </Select>
+              <Radio.Button value="credential">
+                <Space>
+                  <KeyOutlined />
+                  Use Credentials
+                </Space>
+              </Radio.Button>
+              <Radio.Button value="upload">
+                <Space>
+                  <CloudUploadOutlined />
+                  Upload JSON
+                </Space>
+              </Radio.Button>
+            </Radio.Group>
           </Form.Item>
 
-          <Form.Item
-            name="regions"
-            label="AWS Regions"
-            rules={[{ required: true, message: 'Please select at least one region' }]}
-          >
-            <Select
-              mode="multiple"
-              placeholder="Select AWS regions"
-              optionFilterProp="children"
-            >
-              {AWS_REGIONS.map(region => (
-                <Option key={region.value} value={region.value}>
-                  {region.label} ({region.value})
-                </Option>
-              ))}
-            </Select>
-          </Form.Item>
+          {dataSource === 'credential' ? (
+            <>
+              {/* Credential Mode Fields */}
+              <Form.Item
+                name="name"
+                label="Task Name"
+                rules={[{ required: true, message: 'Please enter task name' }]}
+              >
+                <Input placeholder="Enter task name" />
+              </Form.Item>
+
+              <Form.Item
+                name="credentialIds"
+                label="AWS Accounts"
+                rules={[{ required: true, message: 'Please select at least one AWS account' }]}
+              >
+                <Select
+                  mode="multiple"
+                  placeholder="Select AWS accounts"
+                  loading={credentialsLoading}
+                  optionFilterProp="children"
+                >
+                  {credentials.map((cred: AWSCredential) => (
+                    <Option key={cred.id} value={cred.id}>
+                      {cred.name} ({cred.account_id})
+                    </Option>
+                  ))}
+                </Select>
+              </Form.Item>
+
+              <Form.Item
+                name="regions"
+                label="AWS Regions"
+                rules={[{ required: true, message: 'Please select at least one region' }]}
+              >
+                <Select
+                  mode="multiple"
+                  placeholder="Select AWS regions"
+                  optionFilterProp="children"
+                >
+                  {AWS_REGIONS.map(region => (
+                    <Option key={region.value} value={region.value}>
+                      {region.label} ({region.value})
+                    </Option>
+                  ))}
+                </Select>
+              </Form.Item>
+            </>
+          ) : (
+            <>
+              {/* Upload Mode Fields */}
+              <Form.Item
+                label="Scan Data JSON"
+                required
+                help={uploadedScanData ? `Account: ${uploadedScanData.metadata.account_id}` : 'Upload the JSON file generated by CloudShell Scanner'}
+              >
+                <JsonUploader
+                  onUploadSuccess={handleUploadSuccess}
+                  onUploadError={handleUploadError}
+                  onClear={handleUploadClear}
+                />
+              </Form.Item>
+            </>
+          )}
+
 
           <Title level={5}>Project Metadata</Title>
 
@@ -606,7 +738,12 @@ export default function Tasks() {
               <Button onClick={() => setCreateModalVisible(false)}>
                 Cancel
               </Button>
-              <Button type="primary" htmlType="submit" loading={creating}>
+              <Button 
+                type="primary" 
+                htmlType="submit" 
+                loading={creating}
+                disabled={dataSource === 'upload' && !uploadedScanFile}
+              >
                 Create Task
               </Button>
             </Space>
@@ -614,6 +751,7 @@ export default function Tasks() {
         </Form>
       </Modal>
 
+
       {/* Task Detail Modal */}
       <Modal
         title={

+ 36 - 0
frontend/src/services/tasks.ts

@@ -7,6 +7,27 @@ interface CreateTaskResponse {
   celery_task_id: string;
 }
 
+interface UploadScanResponse {
+  message: string;
+  task: ScanTask;
+  celery_task_id: string;
+}
+
+export interface UploadScanRequest {
+  scanData: File;
+  projectMetadata: {
+    clientName: string;
+    projectName: string;
+    bdManager?: string;
+    bdManagerEmail?: string;
+    solutionsArchitect?: string;
+    solutionsArchitectEmail?: string;
+    cloudEngineer?: string;
+    cloudEngineerEmail?: string;
+  };
+  networkDiagram?: File;
+}
+
 export const taskService = {
   getTasks: async (params?: { page?: number; pageSize?: number; status?: string }): Promise<PaginatedResponse<ScanTask>> => {
     const response = await api.get<PaginatedResponse<ScanTask>>('/tasks', { params });
@@ -27,6 +48,21 @@ export const taskService = {
     return response.data;
   },
 
+  uploadScan: async (data: UploadScanRequest): Promise<UploadScanResponse> => {
+    const formData = new FormData();
+    formData.append('scan_data', data.scanData);
+    formData.append('project_metadata', JSON.stringify(data.projectMetadata));
+    if (data.networkDiagram) {
+      formData.append('network_diagram', data.networkDiagram);
+    }
+    const response = await api.post<UploadScanResponse>('/tasks/upload-scan', formData, {
+      headers: {
+        'Content-Type': 'multipart/form-data',
+      },
+    });
+    return response.data;
+  },
+
   getTaskDetail: async (id: number): Promise<ScanTask> => {
     const response = await api.get<ScanTask>('/tasks/detail', { params: { id } });
     return response.data;

+ 1 - 0
frontend/src/utils/index.ts

@@ -1 +1,2 @@
 export * from './date';
+export * from './scanDataValidator';

+ 262 - 0
frontend/src/utils/scanDataValidator.ts

@@ -0,0 +1,262 @@
+/**
+ * ScanData Validator - Validates JSON structure for CloudShell scan data
+ * Requirements: 3.3, 3.4
+ */
+
+// ScanData type definitions based on design document
+export interface ScanDataMetadata {
+  account_id: string;
+  scan_timestamp: string;
+  regions_scanned: string[];
+  services_scanned: string[];
+  scanner_version: string;
+  total_resources: number;
+  total_errors: number;
+}
+
+export interface ResourceData {
+  account_id: string;
+  region: string;
+  service: string;
+  resource_type: string;
+  resource_id: string;
+  name: string;
+  attributes: Record<string, unknown>;
+}
+
+export interface ErrorData {
+  service: string;
+  region: string;
+  error: string;
+  error_type: string;
+  details: Record<string, unknown> | null;
+}
+
+export interface ScanData {
+  metadata: ScanDataMetadata;
+  resources: Record<string, ResourceData[]>;
+  errors: ErrorData[];
+}
+
+export interface ValidationResult {
+  isValid: boolean;
+  errors: string[];
+  missingFields: string[];
+}
+
+const REQUIRED_METADATA_FIELDS = [
+  'account_id',
+  'scan_timestamp',
+  'regions_scanned',
+  'services_scanned',
+  'scanner_version',
+  'total_resources',
+  'total_errors',
+] as const;
+
+const REQUIRED_RESOURCE_FIELDS = [
+  'account_id',
+  'region',
+  'service',
+  'resource_type',
+  'resource_id',
+  'name',
+  'attributes',
+] as const;
+
+const REQUIRED_ERROR_FIELDS = [
+  'service',
+  'region',
+  'error',
+  'error_type',
+] as const;
+
+function isObject(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function validateMetadata(metadata: unknown, missingFields: string[]): string[] {
+  const errors: string[] = [];
+
+  if (!isObject(metadata)) {
+    errors.push('metadata must be an object');
+    missingFields.push('metadata');
+    return errors;
+  }
+
+  for (const field of REQUIRED_METADATA_FIELDS) {
+    if (!(field in metadata)) {
+      missingFields.push(`metadata.${field}`);
+    }
+  }
+
+  if ('account_id' in metadata && typeof metadata.account_id !== 'string') {
+    errors.push('metadata.account_id must be a string');
+  }
+
+  if ('scan_timestamp' in metadata && typeof metadata.scan_timestamp !== 'string') {
+    errors.push('metadata.scan_timestamp must be a string');
+  }
+
+  if ('regions_scanned' in metadata && !Array.isArray(metadata.regions_scanned)) {
+    errors.push('metadata.regions_scanned must be an array');
+  }
+
+  if ('services_scanned' in metadata && !Array.isArray(metadata.services_scanned)) {
+    errors.push('metadata.services_scanned must be an array');
+  }
+
+  if ('scanner_version' in metadata && typeof metadata.scanner_version !== 'string') {
+    errors.push('metadata.scanner_version must be a string');
+  }
+
+  if ('total_resources' in metadata && typeof metadata.total_resources !== 'number') {
+    errors.push('metadata.total_resources must be a number');
+  }
+
+  if ('total_errors' in metadata && typeof metadata.total_errors !== 'number') {
+    errors.push('metadata.total_errors must be a number');
+  }
+
+  return errors;
+}
+
+function validateResource(resource: unknown, index: number, service: string): string[] {
+  const errors: string[] = [];
+  const prefix = `resources.${service}[${index}]`;
+
+  if (!isObject(resource)) {
+    errors.push(`${prefix} must be an object`);
+    return errors;
+  }
+
+  for (const field of REQUIRED_RESOURCE_FIELDS) {
+    if (!(field in resource)) {
+      errors.push(`${prefix}.${field} is missing`);
+    }
+  }
+
+  return errors;
+}
+
+function validateResources(resources: unknown, missingFields: string[]): string[] {
+  const errors: string[] = [];
+
+  if (!isObject(resources)) {
+    errors.push('resources must be an object');
+    missingFields.push('resources');
+    return errors;
+  }
+
+  for (const [service, resourceList] of Object.entries(resources)) {
+    if (!Array.isArray(resourceList)) {
+      errors.push(`resources.${service} must be an array`);
+      continue;
+    }
+
+    // Only validate first few resources to avoid excessive errors
+    const maxToValidate = Math.min(resourceList.length, 3);
+    for (let i = 0; i < maxToValidate; i++) {
+      errors.push(...validateResource(resourceList[i], i, service));
+    }
+  }
+
+  return errors;
+}
+
+function validateErrorItem(errorItem: unknown, index: number): string[] {
+  const errors: string[] = [];
+  const prefix = `errors[${index}]`;
+
+  if (!isObject(errorItem)) {
+    errors.push(`${prefix} must be an object`);
+    return errors;
+  }
+
+  for (const field of REQUIRED_ERROR_FIELDS) {
+    if (!(field in errorItem)) {
+      errors.push(`${prefix}.${field} is missing`);
+    }
+  }
+
+  return errors;
+}
+
+function validateErrors(errorsArray: unknown, missingFields: string[]): string[] {
+  const errors: string[] = [];
+
+  if (!Array.isArray(errorsArray)) {
+    errors.push('errors must be an array');
+    missingFields.push('errors');
+    return errors;
+  }
+
+  // Only validate first few errors to avoid excessive output
+  const maxToValidate = Math.min(errorsArray.length, 3);
+  for (let i = 0; i < maxToValidate; i++) {
+    errors.push(...validateErrorItem(errorsArray[i], i));
+  }
+
+  return errors;
+}
+
+/**
+ * Validates that the provided data conforms to the ScanData structure
+ * @param data - The data to validate (should be parsed JSON)
+ * @returns ValidationResult with isValid flag, errors, and missing fields
+ */
+export function validateScanData(data: unknown): ValidationResult {
+  const errors: string[] = [];
+  const missingFields: string[] = [];
+
+  if (!isObject(data)) {
+    return {
+      isValid: false,
+      errors: ['Data must be a JSON object'],
+      missingFields: ['metadata', 'resources', 'errors'],
+    };
+  }
+
+  // Check top-level required fields
+  if (!('metadata' in data)) {
+    missingFields.push('metadata');
+  } else {
+    errors.push(...validateMetadata(data.metadata, missingFields));
+  }
+
+  if (!('resources' in data)) {
+    missingFields.push('resources');
+  } else {
+    errors.push(...validateResources(data.resources, missingFields));
+  }
+
+  if (!('errors' in data)) {
+    missingFields.push('errors');
+  } else {
+    errors.push(...validateErrors(data.errors, missingFields));
+  }
+
+  return {
+    isValid: errors.length === 0 && missingFields.length === 0,
+    errors,
+    missingFields,
+  };
+}
+
+/**
+ * Parses JSON string and validates it as ScanData
+ * @param jsonString - The JSON string to parse and validate
+ * @returns ValidationResult with parsing and validation errors
+ */
+export function parseAndValidateScanData(jsonString: string): ValidationResult {
+  try {
+    const data = JSON.parse(jsonString);
+    return validateScanData(data);
+  } catch (e) {
+    return {
+      isValid: false,
+      errors: [`Invalid JSON format: ${e instanceof Error ? e.message : 'Unknown error'}`],
+      missingFields: [],
+    };
+  }
+}

+ 1309 - 0
test_cloudshell_scanner.py

@@ -0,0 +1,1309 @@
+#!/usr/bin/env python3
+"""
+Unit tests for CloudShell Scanner - Region Filtering and Error Handling
+
+Tests for Task 1.6:
+- Region list retrieval and filtering logic
+- Error capture and continue scanning logic
+- Retry mechanism with exponential backoff
+
+Requirements tested:
+- 1.3: Scan only specified regions when provided
+- 1.4: Scan all available regions when not specified
+- 1.8: Record errors and continue scanning other resources
+"""
+
+import time
+import unittest
+from unittest.mock import MagicMock, patch, PropertyMock
+
+import pytest
+from botocore.exceptions import ClientError, BotoCoreError
+
+# Import the module under test
+from cloudshell_scanner import (
+    CloudShellScanner,
+    retry_with_exponential_backoff,
+    is_retryable_error,
+    RETRYABLE_ERROR_CODES,
+    RETRYABLE_EXCEPTIONS,
+)
+
+
+class TestRetryWithExponentialBackoff(unittest.TestCase):
+    """Tests for the retry_with_exponential_backoff decorator."""
+    
+    def test_successful_call_no_retry(self):
+        """Test that successful calls don't trigger retries."""
+        call_count = 0
+        
+        @retry_with_exponential_backoff(max_retries=3, base_delay=0.01)
+        def successful_func():
+            nonlocal call_count
+            call_count += 1
+            return "success"
+        
+        result = successful_func()
+        
+        self.assertEqual(result, "success")
+        self.assertEqual(call_count, 1)
+    
+    def test_retry_on_throttling_error(self):
+        """Test that throttling errors trigger retries."""
+        call_count = 0
+        
+        @retry_with_exponential_backoff(max_retries=2, base_delay=0.01)
+        def throttled_func():
+            nonlocal call_count
+            call_count += 1
+            if call_count < 3:
+                error_response = {
+                    "Error": {
+                        "Code": "Throttling",
+                        "Message": "Rate exceeded"
+                    }
+                }
+                raise ClientError(error_response, "TestOperation")
+            return "success"
+        
+        result = throttled_func()
+        
+        self.assertEqual(result, "success")
+        self.assertEqual(call_count, 3)
+    
+    def test_no_retry_on_non_retryable_error(self):
+        """Test that non-retryable errors are raised immediately."""
+        call_count = 0
+        
+        @retry_with_exponential_backoff(max_retries=3, base_delay=0.01)
+        def access_denied_func():
+            nonlocal call_count
+            call_count += 1
+            error_response = {
+                "Error": {
+                    "Code": "AccessDenied",
+                    "Message": "Access Denied"
+                }
+            }
+            raise ClientError(error_response, "TestOperation")
+        
+        with self.assertRaises(ClientError):
+            access_denied_func()
+        
+        # Should only be called once since AccessDenied is not retryable
+        self.assertEqual(call_count, 1)
+    
+    def test_max_retries_exhausted(self):
+        """Test that exception is raised after max retries."""
+        call_count = 0
+        
+        @retry_with_exponential_backoff(max_retries=2, base_delay=0.01)
+        def always_fails():
+            nonlocal call_count
+            call_count += 1
+            error_response = {
+                "Error": {
+                    "Code": "ServiceUnavailable",
+                    "Message": "Service unavailable"
+                }
+            }
+            raise ClientError(error_response, "TestOperation")
+        
+        with self.assertRaises(ClientError):
+            always_fails()
+        
+        # Should be called max_retries + 1 times
+        self.assertEqual(call_count, 3)
+    
+    def test_exponential_backoff_timing(self):
+        """Test that delays increase exponentially."""
+        call_times = []
+        
+        @retry_with_exponential_backoff(max_retries=2, base_delay=0.1, exponential_base=2.0)
+        def timed_func():
+            call_times.append(time.time())
+            if len(call_times) < 3:
+                error_response = {
+                    "Error": {
+                        "Code": "Throttling",
+                        "Message": "Rate exceeded"
+                    }
+                }
+                raise ClientError(error_response, "TestOperation")
+            return "success"
+        
+        timed_func()
+        
+        # Check that delays are approximately exponential
+        # First delay should be ~0.1s, second should be ~0.2s
+        if len(call_times) >= 2:
+            first_delay = call_times[1] - call_times[0]
+            self.assertGreater(first_delay, 0.05)  # At least half the base delay
+        
+        if len(call_times) >= 3:
+            second_delay = call_times[2] - call_times[1]
+            self.assertGreater(second_delay, first_delay * 0.8)  # Second delay should be larger
+
+
+class TestIsRetryableError(unittest.TestCase):
+    """Tests for the is_retryable_error function."""
+    
+    def test_throttling_is_retryable(self):
+        """Test that throttling errors are retryable."""
+        error_response = {
+            "Error": {
+                "Code": "Throttling",
+                "Message": "Rate exceeded"
+            }
+        }
+        error = ClientError(error_response, "TestOperation")
+        self.assertTrue(is_retryable_error(error))
+    
+    def test_service_unavailable_is_retryable(self):
+        """Test that service unavailable errors are retryable."""
+        error_response = {
+            "Error": {
+                "Code": "ServiceUnavailable",
+                "Message": "Service unavailable"
+            }
+        }
+        error = ClientError(error_response, "TestOperation")
+        self.assertTrue(is_retryable_error(error))
+    
+    def test_access_denied_not_retryable(self):
+        """Test that access denied errors are not retryable."""
+        error_response = {
+            "Error": {
+                "Code": "AccessDenied",
+                "Message": "Access Denied"
+            }
+        }
+        error = ClientError(error_response, "TestOperation")
+        self.assertFalse(is_retryable_error(error))
+    
+    def test_connection_error_is_retryable(self):
+        """Test that connection errors are retryable."""
+        error = ConnectionError("Connection refused")
+        self.assertTrue(is_retryable_error(error))
+    
+    def test_timeout_error_is_retryable(self):
+        """Test that timeout errors are retryable."""
+        error = TimeoutError("Request timed out")
+        self.assertTrue(is_retryable_error(error))
+
+
+class TestRegionFiltering(unittest.TestCase):
+    """Tests for region filtering functionality."""
+    
+    @patch('cloudshell_scanner.boto3.Session')
+    def setUp(self, mock_session):
+        """Set up test fixtures."""
+        # Mock the boto3 session
+        self.mock_session = MagicMock()
+        mock_session.return_value = self.mock_session
+        
+        # Mock STS client for get_account_id
+        self.mock_sts = MagicMock()
+        self.mock_sts.get_caller_identity.return_value = {"Account": "123456789012"}
+        
+        # Mock EC2 client for list_regions
+        self.mock_ec2 = MagicMock()
+        self.mock_ec2.describe_regions.return_value = {
+            "Regions": [
+                {"RegionName": "us-east-1"},
+                {"RegionName": "us-west-2"},
+                {"RegionName": "eu-west-1"},
+                {"RegionName": "ap-northeast-1"},
+            ]
+        }
+        
+        def get_client(service, **kwargs):
+            if service == "sts":
+                return self.mock_sts
+            elif service == "ec2":
+                return self.mock_ec2
+            return MagicMock()
+        
+        self.mock_session.client.side_effect = get_client
+        
+        self.scanner = CloudShellScanner()
+    
+    def test_list_regions_returns_available_regions(self):
+        """Test that list_regions returns available regions from AWS."""
+        regions = self.scanner.list_regions()
+        
+        self.assertEqual(len(regions), 4)
+        self.assertIn("us-east-1", regions)
+        self.assertIn("us-west-2", regions)
+        self.assertIn("eu-west-1", regions)
+        self.assertIn("ap-northeast-1", regions)
+    
+    def test_list_regions_fallback_on_error(self):
+        """Test that list_regions falls back to defaults on error."""
+        self.mock_ec2.describe_regions.side_effect = Exception("API Error")
+        
+        regions = self.scanner.list_regions()
+        
+        # Should return default regions
+        self.assertIn("us-east-1", regions)
+        self.assertIn("us-west-2", regions)
+        self.assertGreater(len(regions), 0)
+    
+    def test_filter_regions_with_valid_regions(self):
+        """Test filtering with valid regions."""
+        # Validates: Requirements 1.3
+        requested = ["us-east-1", "us-west-2"]
+        
+        filtered = self.scanner.filter_regions(requested)
+        
+        self.assertEqual(len(filtered), 2)
+        self.assertIn("us-east-1", filtered)
+        self.assertIn("us-west-2", filtered)
+    
+    def test_filter_regions_with_invalid_regions(self):
+        """Test filtering removes invalid regions."""
+        # Validates: Requirements 1.3
+        requested = ["us-east-1", "invalid-region", "us-west-2"]
+        
+        filtered = self.scanner.filter_regions(requested)
+        
+        self.assertEqual(len(filtered), 2)
+        self.assertIn("us-east-1", filtered)
+        self.assertIn("us-west-2", filtered)
+        self.assertNotIn("invalid-region", filtered)
+    
+    def test_filter_regions_none_returns_all(self):
+        """Test that None returns all available regions."""
+        # Validates: Requirements 1.4
+        filtered = self.scanner.filter_regions(None)
+        
+        self.assertEqual(len(filtered), 4)
+    
+    def test_filter_regions_all_invalid_falls_back(self):
+        """Test that all invalid regions falls back to all available."""
+        requested = ["invalid-1", "invalid-2"]
+        
+        filtered = self.scanner.filter_regions(requested)
+        
+        # Should fall back to all available regions
+        self.assertEqual(len(filtered), 4)
+    
+    def test_filter_regions_normalizes_input(self):
+        """Test that region names are normalized (whitespace, case)."""
+        requested = ["  US-EAST-1  ", "us-west-2"]
+        
+        filtered = self.scanner.filter_regions(requested)
+        
+        self.assertEqual(len(filtered), 2)
+        self.assertIn("us-east-1", filtered)
+        self.assertIn("us-west-2", filtered)
+    
+    def test_validate_region_valid(self):
+        """Test validate_region with valid region."""
+        self.assertTrue(self.scanner.validate_region("us-east-1"))
+    
+    def test_validate_region_invalid(self):
+        """Test validate_region with invalid region."""
+        self.assertFalse(self.scanner.validate_region("invalid-region"))
+
+
+class TestErrorHandling(unittest.TestCase):
+    """Tests for error handling functionality."""
+    
+    @patch('cloudshell_scanner.boto3.Session')
+    def setUp(self, mock_session):
+        """Set up test fixtures."""
+        self.mock_session = MagicMock()
+        mock_session.return_value = self.mock_session
+        
+        # Mock STS client
+        self.mock_sts = MagicMock()
+        self.mock_sts.get_caller_identity.return_value = {"Account": "123456789012"}
+        
+        # Mock EC2 client
+        self.mock_ec2 = MagicMock()
+        self.mock_ec2.describe_regions.return_value = {
+            "Regions": [{"RegionName": "us-east-1"}]
+        }
+        
+        def get_client(service, **kwargs):
+            if service == "sts":
+                return self.mock_sts
+            elif service == "ec2":
+                return self.mock_ec2
+            return MagicMock()
+        
+        self.mock_session.client.side_effect = get_client
+        
+        self.scanner = CloudShellScanner()
+    
+    def test_create_error_info_client_error(self):
+        """Test error info creation for ClientError."""
+        error_response = {
+            "Error": {
+                "Code": "AccessDenied",
+                "Message": "User is not authorized"
+            }
+        }
+        exception = ClientError(error_response, "DescribeInstances")
+        
+        error_info = self.scanner._create_error_info(
+            service="ec2",
+            region="us-east-1",
+            exception=exception,
+        )
+        
+        self.assertEqual(error_info["service"], "ec2")
+        self.assertEqual(error_info["region"], "us-east-1")
+        self.assertEqual(error_info["error_type"], "ClientError")
+        self.assertIsNotNone(error_info["details"])
+        self.assertEqual(error_info["details"]["error_code"], "AccessDenied")
+        self.assertIn("permission_hint", error_info["details"])
+    
+    def test_create_error_info_generic_exception(self):
+        """Test error info creation for generic exceptions."""
+        exception = ValueError("Invalid value")
+        
+        error_info = self.scanner._create_error_info(
+            service="vpc",
+            region="eu-west-1",
+            exception=exception,
+        )
+        
+        self.assertEqual(error_info["service"], "vpc")
+        self.assertEqual(error_info["region"], "eu-west-1")
+        self.assertEqual(error_info["error_type"], "ValueError")
+        self.assertIn("Invalid value", error_info["error"])
+    
+    def test_scan_continues_after_error(self):
+        """Test that scanning continues after encountering an error."""
+        # Validates: Requirements 1.8
+        
+        # Mock _scan_service to fail for one service but succeed for another
+        call_count = {"vpc": 0, "ec2": 0}
+        
+        def mock_scan_service(account_id, region, service):
+            call_count[service] = call_count.get(service, 0) + 1
+            if service == "vpc":
+                raise Exception("VPC scan failed")
+            return [{"resource_id": "i-123", "service": service}]
+        
+        self.scanner._scan_service = mock_scan_service
+        
+        # Scan with both services
+        result = self.scanner.scan_resources(
+            regions=["us-east-1"],
+            services=["vpc", "ec2"],
+        )
+        
+        # Both services should have been attempted
+        self.assertEqual(call_count["vpc"], 1)
+        self.assertEqual(call_count["ec2"], 1)
+        
+        # Should have one error and one successful resource
+        self.assertEqual(len(result["errors"]), 1)
+        self.assertEqual(result["errors"][0]["service"], "vpc")
+        self.assertIn("ec2", result["resources"])
+    
+    def test_error_info_includes_region(self):
+        """Test that error info includes the correct region."""
+        # Validates: Requirements 1.8
+        
+        def mock_scan_service(account_id, region, service):
+            raise Exception(f"Error in {region}")
+        
+        self.scanner._scan_service = mock_scan_service
+        
+        result = self.scanner.scan_resources(
+            regions=["us-east-1"],
+            services=["vpc"],
+        )
+        
+        self.assertEqual(len(result["errors"]), 1)
+        self.assertEqual(result["errors"][0]["region"], "us-east-1")
+
+
+class TestCallWithRetry(unittest.TestCase):
+    """Tests for the _call_with_retry method."""
+    
+    @patch('cloudshell_scanner.boto3.Session')
+    def setUp(self, mock_session):
+        """Set up test fixtures."""
+        self.mock_session = MagicMock()
+        mock_session.return_value = self.mock_session
+        
+        self.mock_sts = MagicMock()
+        self.mock_sts.get_caller_identity.return_value = {"Account": "123456789012"}
+        
+        self.mock_session.client.return_value = self.mock_sts
+        
+        self.scanner = CloudShellScanner()
+    
+    def test_call_with_retry_success(self):
+        """Test successful call without retries."""
+        def success_func():
+            return "result"
+        
+        result = self.scanner._call_with_retry(success_func, max_retries=3, base_delay=0.01)
+        
+        self.assertEqual(result, "result")
+    
+    def test_call_with_retry_eventual_success(self):
+        """Test call that succeeds after retries."""
+        call_count = 0
+        
+        def eventual_success():
+            nonlocal call_count
+            call_count += 1
+            if call_count < 3:
+                error_response = {
+                    "Error": {
+                        "Code": "Throttling",
+                        "Message": "Rate exceeded"
+                    }
+                }
+                raise ClientError(error_response, "TestOperation")
+            return "success"
+        
+        result = self.scanner._call_with_retry(
+            eventual_success,
+            max_retries=3,
+            base_delay=0.01,
+        )
+        
+        self.assertEqual(result, "success")
+        self.assertEqual(call_count, 3)
+    
+    def test_call_with_retry_exhausted(self):
+        """Test call that exhausts all retries."""
+        def always_fails():
+            error_response = {
+                "Error": {
+                    "Code": "ServiceUnavailable",
+                    "Message": "Service unavailable"
+                }
+            }
+            raise ClientError(error_response, "TestOperation")
+        
+        with self.assertRaises(ClientError):
+            self.scanner._call_with_retry(
+                always_fails,
+                max_retries=2,
+                base_delay=0.01,
+            )
+
+
+class TestScanResourcesIntegration(unittest.TestCase):
+    """Integration tests for scan_resources with region filtering."""
+    
+    @patch('cloudshell_scanner.boto3.Session')
+    def setUp(self, mock_session):
+        """Set up test fixtures."""
+        self.mock_session = MagicMock()
+        mock_session.return_value = self.mock_session
+        
+        self.mock_sts = MagicMock()
+        self.mock_sts.get_caller_identity.return_value = {"Account": "123456789012"}
+        
+        self.mock_ec2 = MagicMock()
+        self.mock_ec2.describe_regions.return_value = {
+            "Regions": [
+                {"RegionName": "us-east-1"},
+                {"RegionName": "us-west-2"},
+                {"RegionName": "eu-west-1"},
+            ]
+        }
+        
+        def get_client(service, **kwargs):
+            if service == "sts":
+                return self.mock_sts
+            elif service == "ec2":
+                return self.mock_ec2
+            return MagicMock()
+        
+        self.mock_session.client.side_effect = get_client
+        
+        self.scanner = CloudShellScanner()
+    
+    def test_scan_resources_with_specified_regions(self):
+        """Test scan_resources only scans specified regions."""
+        # Validates: Requirements 1.3
+        
+        scanned_regions = set()
+        
+        def mock_scan_service(account_id, region, service):
+            if region != "global":
+                scanned_regions.add(region)
+            return []
+        
+        self.scanner._scan_service = mock_scan_service
+        
+        result = self.scanner.scan_resources(
+            regions=["us-east-1", "us-west-2"],
+            services=["vpc"],
+        )
+        
+        # Only specified regions should be scanned
+        self.assertEqual(scanned_regions, {"us-east-1", "us-west-2"})
+        self.assertEqual(set(result["metadata"]["regions_scanned"]), {"us-east-1", "us-west-2"})
+    
+    def test_scan_resources_with_no_regions_scans_all(self):
+        """Test scan_resources scans all regions when none specified."""
+        # Validates: Requirements 1.4
+        
+        scanned_regions = set()
+        
+        def mock_scan_service(account_id, region, service):
+            if region != "global":
+                scanned_regions.add(region)
+            return []
+        
+        self.scanner._scan_service = mock_scan_service
+        
+        result = self.scanner.scan_resources(
+            regions=None,
+            services=["vpc"],
+        )
+        
+        # All available regions should be scanned
+        self.assertEqual(scanned_regions, {"us-east-1", "us-west-2", "eu-west-1"})
+    
+    def test_scan_resources_filters_invalid_regions(self):
+        """Test scan_resources filters out invalid regions."""
+        scanned_regions = set()
+        
+        def mock_scan_service(account_id, region, service):
+            if region != "global":
+                scanned_regions.add(region)
+            return []
+        
+        self.scanner._scan_service = mock_scan_service
+        
+        result = self.scanner.scan_resources(
+            regions=["us-east-1", "invalid-region", "us-west-2"],
+            services=["vpc"],
+        )
+        
+        # Invalid region should be filtered out
+        self.assertNotIn("invalid-region", scanned_regions)
+        self.assertEqual(scanned_regions, {"us-east-1", "us-west-2"})
+
+
+if __name__ == "__main__":
+    unittest.main()
+
+
+# =========================================================================
+# JSON Export Tests (Task 1.7)
+# =========================================================================
+
+import json
+import os
+import tempfile
+from datetime import datetime, timezone, date
+
+
+class TestJsonExport(unittest.TestCase):
+    """Tests for JSON export functionality (Task 1.7)."""
+    
+    @patch('cloudshell_scanner.boto3.Session')
+    def setUp(self, mock_session):
+        """Set up test fixtures."""
+        self.mock_session = MagicMock()
+        mock_session.return_value = self.mock_session
+        
+        self.mock_sts = MagicMock()
+        self.mock_sts.get_caller_identity.return_value = {"Account": "123456789012"}
+        
+        self.mock_session.client.return_value = self.mock_sts
+        
+        self.scanner = CloudShellScanner()
+        
+        # Create a temporary directory for test files
+        self.temp_dir = tempfile.mkdtemp()
+    
+    def tearDown(self):
+        """Clean up temporary files."""
+        import shutil
+        shutil.rmtree(self.temp_dir, ignore_errors=True)
+    
+    def _create_valid_scan_data(self) -> dict:
+        """Create a valid scan data structure for testing."""
+        return {
+            "metadata": {
+                "account_id": "123456789012",
+                "scan_timestamp": "2024-01-15T10:30:00Z",
+                "regions_scanned": ["us-east-1", "us-west-2"],
+                "services_scanned": ["vpc", "ec2"],
+                "scanner_version": "1.0.0",
+                "total_resources": 5,
+                "total_errors": 1,
+            },
+            "resources": {
+                "vpc": [
+                    {
+                        "account_id": "123456789012",
+                        "region": "us-east-1",
+                        "service": "vpc",
+                        "resource_type": "VPC",
+                        "resource_id": "vpc-12345",
+                        "name": "main-vpc",
+                        "attributes": {"CIDR": "10.0.0.0/16"},
+                    }
+                ],
+                "ec2": [
+                    {
+                        "account_id": "123456789012",
+                        "region": "us-east-1",
+                        "service": "ec2",
+                        "resource_type": "Instance",
+                        "resource_id": "i-12345",
+                        "name": "web-server",
+                        "attributes": {"InstanceType": "t3.micro"},
+                    }
+                ],
+            },
+            "errors": [
+                {
+                    "service": "rds",
+                    "region": "us-west-2",
+                    "error": "Access denied",
+                    "error_type": "ClientError",
+                    "details": {"error_code": "AccessDenied"},
+                }
+            ],
+        }
+    
+    def test_export_json_creates_file(self):
+        """Test that export_json creates a JSON file."""
+        # Validates: Requirements 1.6
+        scan_data = self._create_valid_scan_data()
+        output_path = os.path.join(self.temp_dir, "test_output.json")
+        
+        self.scanner.export_json(scan_data, output_path)
+        
+        self.assertTrue(os.path.exists(output_path))
+    
+    def test_export_json_valid_json_format(self):
+        """Test that exported file contains valid JSON."""
+        # Validates: Requirements 2.4
+        scan_data = self._create_valid_scan_data()
+        output_path = os.path.join(self.temp_dir, "test_output.json")
+        
+        self.scanner.export_json(scan_data, output_path)
+        
+        with open(output_path, "r", encoding="utf-8") as f:
+            loaded_data = json.load(f)
+        
+        self.assertIsInstance(loaded_data, dict)
+    
+    def test_export_json_contains_metadata(self):
+        """Test that exported JSON contains all required metadata fields."""
+        # Validates: Requirements 2.1
+        scan_data = self._create_valid_scan_data()
+        output_path = os.path.join(self.temp_dir, "test_output.json")
+        
+        self.scanner.export_json(scan_data, output_path)
+        
+        with open(output_path, "r", encoding="utf-8") as f:
+            loaded_data = json.load(f)
+        
+        metadata = loaded_data["metadata"]
+        self.assertIn("account_id", metadata)
+        self.assertIn("scan_timestamp", metadata)
+        self.assertIn("regions_scanned", metadata)
+        self.assertIn("services_scanned", metadata)
+        self.assertIn("scanner_version", metadata)
+        self.assertIn("total_resources", metadata)
+        self.assertIn("total_errors", metadata)
+    
+    def test_export_json_contains_resources(self):
+        """Test that exported JSON contains resources organized by service."""
+        # Validates: Requirements 2.2
+        scan_data = self._create_valid_scan_data()
+        output_path = os.path.join(self.temp_dir, "test_output.json")
+        
+        self.scanner.export_json(scan_data, output_path)
+        
+        with open(output_path, "r", encoding="utf-8") as f:
+            loaded_data = json.load(f)
+        
+        self.assertIn("resources", loaded_data)
+        self.assertIsInstance(loaded_data["resources"], dict)
+        self.assertIn("vpc", loaded_data["resources"])
+        self.assertIn("ec2", loaded_data["resources"])
+    
+    def test_export_json_contains_errors(self):
+        """Test that exported JSON contains errors field."""
+        # Validates: Requirements 2.3
+        scan_data = self._create_valid_scan_data()
+        output_path = os.path.join(self.temp_dir, "test_output.json")
+        
+        self.scanner.export_json(scan_data, output_path)
+        
+        with open(output_path, "r", encoding="utf-8") as f:
+            loaded_data = json.load(f)
+        
+        self.assertIn("errors", loaded_data)
+        self.assertIsInstance(loaded_data["errors"], list)
+        self.assertEqual(len(loaded_data["errors"]), 1)
+    
+    def test_export_json_preserves_data_integrity(self):
+        """Test that exported data matches original data."""
+        # Validates: Requirements 2.4, 2.5 (round-trip consistency)
+        scan_data = self._create_valid_scan_data()
+        output_path = os.path.join(self.temp_dir, "test_output.json")
+        
+        self.scanner.export_json(scan_data, output_path)
+        
+        with open(output_path, "r", encoding="utf-8") as f:
+            loaded_data = json.load(f)
+        
+        # Check metadata values
+        self.assertEqual(loaded_data["metadata"]["account_id"], "123456789012")
+        self.assertEqual(loaded_data["metadata"]["regions_scanned"], ["us-east-1", "us-west-2"])
+        self.assertEqual(loaded_data["metadata"]["services_scanned"], ["vpc", "ec2"])
+        
+        # Check resources
+        self.assertEqual(len(loaded_data["resources"]["vpc"]), 1)
+        self.assertEqual(loaded_data["resources"]["vpc"][0]["resource_id"], "vpc-12345")
+    
+    def test_export_json_handles_unicode(self):
+        """Test that export handles Unicode characters correctly."""
+        scan_data = self._create_valid_scan_data()
+        scan_data["resources"]["vpc"][0]["name"] = "测试VPC-日本語"
+        output_path = os.path.join(self.temp_dir, "test_unicode.json")
+        
+        self.scanner.export_json(scan_data, output_path)
+        
+        with open(output_path, "r", encoding="utf-8") as f:
+            loaded_data = json.load(f)
+        
+        self.assertEqual(loaded_data["resources"]["vpc"][0]["name"], "测试VPC-日本語")
+    
+    def test_export_json_raises_on_invalid_path(self):
+        """Test that export raises error for invalid file path."""
+        scan_data = self._create_valid_scan_data()
+        invalid_path = "/nonexistent/directory/output.json"
+        
+        with self.assertRaises((IOError, OSError)):
+            self.scanner.export_json(scan_data, invalid_path)
+
+
+class TestJsonSerializer(unittest.TestCase):
+    """Tests for the custom JSON serializer."""
+    
+    @patch('cloudshell_scanner.boto3.Session')
+    def setUp(self, mock_session):
+        """Set up test fixtures."""
+        self.mock_session = MagicMock()
+        mock_session.return_value = self.mock_session
+        
+        self.mock_sts = MagicMock()
+        self.mock_sts.get_caller_identity.return_value = {"Account": "123456789012"}
+        
+        self.mock_session.client.return_value = self.mock_sts
+        
+        self.scanner = CloudShellScanner()
+    
+    def test_serializer_handles_datetime(self):
+        """Test that serializer converts datetime to ISO 8601 format."""
+        dt = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
+        
+        result = self.scanner._json_serializer(dt)
+        
+        self.assertEqual(result, "2024-01-15T10:30:00Z")
+    
+    def test_serializer_handles_naive_datetime(self):
+        """Test that serializer handles naive datetime (no timezone)."""
+        dt = datetime(2024, 1, 15, 10, 30, 0)
+        
+        result = self.scanner._json_serializer(dt)
+        
+        # Should add UTC timezone
+        self.assertIn("2024-01-15T10:30:00", result)
+    
+    def test_serializer_handles_date(self):
+        """Test that serializer converts date to ISO format."""
+        d = date(2024, 1, 15)
+        
+        result = self.scanner._json_serializer(d)
+        
+        self.assertEqual(result, "2024-01-15")
+    
+    def test_serializer_handles_bytes(self):
+        """Test that serializer converts bytes to string."""
+        b = b"test bytes"
+        
+        result = self.scanner._json_serializer(b)
+        
+        self.assertEqual(result, "test bytes")
+    
+    def test_serializer_handles_set(self):
+        """Test that serializer converts set to list."""
+        s = {"a", "b", "c"}
+        
+        result = self.scanner._json_serializer(s)
+        
+        self.assertIsInstance(result, list)
+        self.assertEqual(set(result), {"a", "b", "c"})
+    
+    def test_serializer_handles_frozenset(self):
+        """Test that serializer converts frozenset to list."""
+        fs = frozenset(["x", "y", "z"])
+        
+        result = self.scanner._json_serializer(fs)
+        
+        self.assertIsInstance(result, list)
+        self.assertEqual(set(result), {"x", "y", "z"})
+    
+    def test_serializer_fallback_to_string(self):
+        """Test that serializer falls back to string for unknown types."""
+        class CustomObject:
+            __slots__ = []  # No __dict__ attribute
+            
+            def __str__(self):
+                return "custom_object_str"
+        
+        obj = CustomObject()
+        
+        result = self.scanner._json_serializer(obj)
+        
+        self.assertEqual(result, "custom_object_str")
+    
+    def test_serializer_handles_object_with_dict(self):
+        """Test that serializer converts objects with __dict__ to dict."""
+        class DataObject:
+            def __init__(self):
+                self.name = "test"
+                self.value = 42
+        
+        obj = DataObject()
+        
+        result = self.scanner._json_serializer(obj)
+        
+        self.assertIsInstance(result, dict)
+        self.assertEqual(result["name"], "test")
+        self.assertEqual(result["value"], 42)
+
+
+class TestValidateScanDataStructure(unittest.TestCase):
+    """Tests for scan data structure validation."""
+    
+    @patch('cloudshell_scanner.boto3.Session')
+    def setUp(self, mock_session):
+        """Set up test fixtures."""
+        self.mock_session = MagicMock()
+        mock_session.return_value = self.mock_session
+        
+        self.mock_sts = MagicMock()
+        self.mock_sts.get_caller_identity.return_value = {"Account": "123456789012"}
+        
+        self.mock_session.client.return_value = self.mock_sts
+        
+        self.scanner = CloudShellScanner()
+    
+    def _create_valid_scan_data(self) -> dict:
+        """Create a valid scan data structure."""
+        return {
+            "metadata": {
+                "account_id": "123456789012",
+                "scan_timestamp": "2024-01-15T10:30:00Z",
+                "regions_scanned": ["us-east-1"],
+                "services_scanned": ["vpc"],
+                "scanner_version": "1.0.0",
+                "total_resources": 0,
+                "total_errors": 0,
+            },
+            "resources": {},
+            "errors": [],
+        }
+    
+    def test_valid_structure_passes(self):
+        """Test that valid structure passes validation."""
+        data = self._create_valid_scan_data()
+        
+        # Should not raise
+        self.scanner._validate_scan_data_structure(data)
+    
+    def test_missing_metadata_raises(self):
+        """Test that missing metadata field raises ValueError."""
+        data = self._create_valid_scan_data()
+        del data["metadata"]
+        
+        with self.assertRaises(ValueError) as context:
+            self.scanner._validate_scan_data_structure(data)
+        
+        self.assertIn("metadata", str(context.exception))
+    
+    def test_missing_resources_raises(self):
+        """Test that missing resources field raises ValueError."""
+        data = self._create_valid_scan_data()
+        del data["resources"]
+        
+        with self.assertRaises(ValueError) as context:
+            self.scanner._validate_scan_data_structure(data)
+        
+        self.assertIn("resources", str(context.exception))
+    
+    def test_missing_errors_raises(self):
+        """Test that missing errors field raises ValueError."""
+        data = self._create_valid_scan_data()
+        del data["errors"]
+        
+        with self.assertRaises(ValueError) as context:
+            self.scanner._validate_scan_data_structure(data)
+        
+        self.assertIn("errors", str(context.exception))
+    
+    def test_missing_metadata_field_raises(self):
+        """Test that missing metadata sub-field raises ValueError."""
+        # Validates: Requirements 2.1
+        data = self._create_valid_scan_data()
+        del data["metadata"]["account_id"]
+        
+        with self.assertRaises(ValueError) as context:
+            self.scanner._validate_scan_data_structure(data)
+        
+        self.assertIn("account_id", str(context.exception))
+    
+    def test_invalid_account_id_type_raises(self):
+        """Test that non-string account_id raises ValueError."""
+        data = self._create_valid_scan_data()
+        data["metadata"]["account_id"] = 123456789012  # Should be string
+        
+        with self.assertRaises(ValueError) as context:
+            self.scanner._validate_scan_data_structure(data)
+        
+        self.assertIn("account_id", str(context.exception))
+    
+    def test_invalid_regions_type_raises(self):
+        """Test that non-list regions_scanned raises ValueError."""
+        data = self._create_valid_scan_data()
+        data["metadata"]["regions_scanned"] = "us-east-1"  # Should be list
+        
+        with self.assertRaises(ValueError) as context:
+            self.scanner._validate_scan_data_structure(data)
+        
+        self.assertIn("regions_scanned", str(context.exception))
+    
+    def test_invalid_resources_type_raises(self):
+        """Test that non-dict resources raises ValueError."""
+        data = self._create_valid_scan_data()
+        data["resources"] = []  # Should be dict
+        
+        with self.assertRaises(ValueError) as context:
+            self.scanner._validate_scan_data_structure(data)
+        
+        self.assertIn("resources", str(context.exception))
+    
+    def test_invalid_errors_type_raises(self):
+        """Test that non-list errors raises ValueError."""
+        data = self._create_valid_scan_data()
+        data["errors"] = {}  # Should be list
+        
+        with self.assertRaises(ValueError) as context:
+            self.scanner._validate_scan_data_structure(data)
+        
+        self.assertIn("errors", str(context.exception))
+
+
+class TestCreateScanData(unittest.TestCase):
+    """Tests for the create_scan_data factory method."""
+    
+    def test_create_scan_data_structure(self):
+        """Test that create_scan_data creates correct structure."""
+        # Validates: Requirements 2.1, 2.2, 2.3
+        resources = {
+            "vpc": [{"resource_id": "vpc-123"}],
+            "ec2": [{"resource_id": "i-123"}, {"resource_id": "i-456"}],
+        }
+        errors = [{"service": "rds", "error": "Access denied"}]
+        
+        result = CloudShellScanner.create_scan_data(
+            account_id="123456789012",
+            regions_scanned=["us-east-1", "us-west-2"],
+            services_scanned=["vpc", "ec2", "rds"],
+            resources=resources,
+            errors=errors,
+        )
+        
+        # Check structure
+        self.assertIn("metadata", result)
+        self.assertIn("resources", result)
+        self.assertIn("errors", result)
+        
+        # Check metadata
+        self.assertEqual(result["metadata"]["account_id"], "123456789012")
+        self.assertEqual(result["metadata"]["regions_scanned"], ["us-east-1", "us-west-2"])
+        self.assertEqual(result["metadata"]["services_scanned"], ["vpc", "ec2", "rds"])
+        self.assertEqual(result["metadata"]["total_resources"], 3)
+        self.assertEqual(result["metadata"]["total_errors"], 1)
+    
+    def test_create_scan_data_with_custom_timestamp(self):
+        """Test that create_scan_data accepts custom timestamp."""
+        custom_timestamp = "2024-01-15T10:30:00Z"
+        
+        result = CloudShellScanner.create_scan_data(
+            account_id="123456789012",
+            regions_scanned=["us-east-1"],
+            services_scanned=["vpc"],
+            resources={},
+            errors=[],
+            scan_timestamp=custom_timestamp,
+        )
+        
+        self.assertEqual(result["metadata"]["scan_timestamp"], custom_timestamp)
+    
+    def test_create_scan_data_auto_timestamp(self):
+        """Test that create_scan_data generates timestamp if not provided."""
+        result = CloudShellScanner.create_scan_data(
+            account_id="123456789012",
+            regions_scanned=["us-east-1"],
+            services_scanned=["vpc"],
+            resources={},
+            errors=[],
+        )
+        
+        # Should have a timestamp in ISO 8601 format
+        timestamp = result["metadata"]["scan_timestamp"]
+        self.assertIsInstance(timestamp, str)
+        self.assertIn("T", timestamp)
+        self.assertTrue(timestamp.endswith("Z"))
+    
+    def test_create_scan_data_includes_version(self):
+        """Test that create_scan_data includes scanner version."""
+        result = CloudShellScanner.create_scan_data(
+            account_id="123456789012",
+            regions_scanned=["us-east-1"],
+            services_scanned=["vpc"],
+            resources={},
+            errors=[],
+        )
+        
+        self.assertIn("scanner_version", result["metadata"])
+        self.assertIsInstance(result["metadata"]["scanner_version"], str)
+
+
+class TestLoadScanData(unittest.TestCase):
+    """Tests for the load_scan_data method."""
+    
+    def setUp(self):
+        """Set up test fixtures."""
+        self.temp_dir = tempfile.mkdtemp()
+    
+    def tearDown(self):
+        """Clean up temporary files."""
+        import shutil
+        shutil.rmtree(self.temp_dir, ignore_errors=True)
+    
+    def _create_valid_scan_data(self) -> dict:
+        """Create a valid scan data structure."""
+        return {
+            "metadata": {
+                "account_id": "123456789012",
+                "scan_timestamp": "2024-01-15T10:30:00Z",
+                "regions_scanned": ["us-east-1"],
+                "services_scanned": ["vpc"],
+                "scanner_version": "1.0.0",
+                "total_resources": 0,
+                "total_errors": 0,
+            },
+            "resources": {},
+            "errors": [],
+        }
+    
+    def test_load_scan_data_success(self):
+        """Test loading valid scan data from file."""
+        # Validates: Requirements 2.5 (round-trip consistency)
+        data = self._create_valid_scan_data()
+        file_path = os.path.join(self.temp_dir, "test_load.json")
+        
+        with open(file_path, "w", encoding="utf-8") as f:
+            json.dump(data, f)
+        
+        loaded = CloudShellScanner.load_scan_data(file_path)
+        
+        self.assertEqual(loaded["metadata"]["account_id"], "123456789012")
+    
+    def test_load_scan_data_file_not_found(self):
+        """Test that loading non-existent file raises FileNotFoundError."""
+        with self.assertRaises(FileNotFoundError):
+            CloudShellScanner.load_scan_data("/nonexistent/file.json")
+    
+    def test_load_scan_data_invalid_json(self):
+        """Test that loading invalid JSON raises JSONDecodeError."""
+        file_path = os.path.join(self.temp_dir, "invalid.json")
+        
+        with open(file_path, "w") as f:
+            f.write("not valid json {{{")
+        
+        with self.assertRaises(json.JSONDecodeError):
+            CloudShellScanner.load_scan_data(file_path)
+    
+    def test_load_scan_data_invalid_structure(self):
+        """Test that loading JSON with invalid structure raises ValueError."""
+        file_path = os.path.join(self.temp_dir, "invalid_structure.json")
+        
+        with open(file_path, "w") as f:
+            json.dump({"invalid": "structure"}, f)
+        
+        with self.assertRaises(ValueError):
+            CloudShellScanner.load_scan_data(file_path)
+
+
+class TestJsonRoundTrip(unittest.TestCase):
+    """Tests for JSON round-trip consistency (Property 1)."""
+    
+    @patch('cloudshell_scanner.boto3.Session')
+    def setUp(self, mock_session):
+        """Set up test fixtures."""
+        self.mock_session = MagicMock()
+        mock_session.return_value = self.mock_session
+        
+        self.mock_sts = MagicMock()
+        self.mock_sts.get_caller_identity.return_value = {"Account": "123456789012"}
+        
+        self.mock_session.client.return_value = self.mock_sts
+        
+        self.scanner = CloudShellScanner()
+        self.temp_dir = tempfile.mkdtemp()
+    
+    def tearDown(self):
+        """Clean up temporary files."""
+        import shutil
+        shutil.rmtree(self.temp_dir, ignore_errors=True)
+    
+    def test_round_trip_preserves_data(self):
+        """Test that export and load preserves all data."""
+        # Validates: Requirements 2.4, 2.5 (Property 1: JSON round-trip consistency)
+        original_data = {
+            "metadata": {
+                "account_id": "123456789012",
+                "scan_timestamp": "2024-01-15T10:30:00Z",
+                "regions_scanned": ["us-east-1", "us-west-2", "eu-west-1"],
+                "services_scanned": ["vpc", "ec2", "rds", "s3"],
+                "scanner_version": "1.0.0",
+                "total_resources": 10,
+                "total_errors": 2,
+            },
+            "resources": {
+                "vpc": [
+                    {
+                        "account_id": "123456789012",
+                        "region": "us-east-1",
+                        "service": "vpc",
+                        "resource_type": "VPC",
+                        "resource_id": "vpc-12345",
+                        "name": "main-vpc",
+                        "attributes": {
+                            "CIDR": "10.0.0.0/16",
+                            "IsDefault": False,
+                            "Tags": [{"Key": "Name", "Value": "main-vpc"}],
+                        },
+                    }
+                ],
+                "ec2": [
+                    {
+                        "account_id": "123456789012",
+                        "region": "us-east-1",
+                        "service": "ec2",
+                        "resource_type": "Instance",
+                        "resource_id": "i-12345",
+                        "name": "web-server",
+                        "attributes": {
+                            "InstanceType": "t3.micro",
+                            "State": "running",
+                        },
+                    }
+                ],
+            },
+            "errors": [
+                {
+                    "service": "rds",
+                    "region": "us-west-2",
+                    "error": "Access denied",
+                    "error_type": "ClientError",
+                    "details": {
+                        "error_code": "AccessDenied",
+                        "error_message": "User is not authorized",
+                    },
+                }
+            ],
+        }
+        
+        file_path = os.path.join(self.temp_dir, "round_trip.json")
+        
+        # Export
+        self.scanner.export_json(original_data, file_path)
+        
+        # Load
+        loaded_data = CloudShellScanner.load_scan_data(file_path)
+        
+        # Verify all data is preserved
+        self.assertEqual(loaded_data["metadata"], original_data["metadata"])
+        self.assertEqual(loaded_data["resources"], original_data["resources"])
+        self.assertEqual(loaded_data["errors"], original_data["errors"])
+    
+    def test_round_trip_with_empty_resources(self):
+        """Test round-trip with empty resources."""
+        original_data = {
+            "metadata": {
+                "account_id": "123456789012",
+                "scan_timestamp": "2024-01-15T10:30:00Z",
+                "regions_scanned": [],
+                "services_scanned": [],
+                "scanner_version": "1.0.0",
+                "total_resources": 0,
+                "total_errors": 0,
+            },
+            "resources": {},
+            "errors": [],
+        }
+        
+        file_path = os.path.join(self.temp_dir, "empty_round_trip.json")
+        
+        self.scanner.export_json(original_data, file_path)
+        loaded_data = CloudShellScanner.load_scan_data(file_path)
+        
+        self.assertEqual(loaded_data, original_data)
+    
+    def test_round_trip_with_special_characters(self):
+        """Test round-trip with special characters in data."""
+        original_data = {
+            "metadata": {
+                "account_id": "123456789012",
+                "scan_timestamp": "2024-01-15T10:30:00Z",
+                "regions_scanned": ["us-east-1"],
+                "services_scanned": ["vpc"],
+                "scanner_version": "1.0.0",
+                "total_resources": 1,
+                "total_errors": 0,
+            },
+            "resources": {
+                "vpc": [
+                    {
+                        "account_id": "123456789012",
+                        "region": "us-east-1",
+                        "service": "vpc",
+                        "resource_type": "VPC",
+                        "resource_id": "vpc-12345",
+                        "name": "测试VPC-日本語-émoji-🚀",
+                        "attributes": {
+                            "Description": "Special chars: <>&\"'",
+                        },
+                    }
+                ],
+            },
+            "errors": [],
+        }
+        
+        file_path = os.path.join(self.temp_dir, "special_chars.json")
+        
+        self.scanner.export_json(original_data, file_path)
+        loaded_data = CloudShellScanner.load_scan_data(file_path)
+        
+        self.assertEqual(
+            loaded_data["resources"]["vpc"][0]["name"],
+            "测试VPC-日本語-émoji-🚀"
+        )