Spaces:
No application file
No application file
FORGE-X: Upload source (e5130a28-api.zip)
Browse files- README.md +91 -6
- e5130a28/Dockerfile +15 -0
- e5130a28/README.md +89 -0
- e5130a28/auth.py +53 -0
- e5130a28/docker-compose.yml +10 -0
- e5130a28/main.py +184 -0
- e5130a28/models.py +47 -0
- e5130a28/requirements.txt +9 -0
- e5130a28/tests/__init__.py +0 -0
- e5130a28/tests/test_main.py +96 -0
README.md
CHANGED
|
@@ -1,13 +1,98 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: gray
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
|
|
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: "GitHub Actions & CI/CD Compliance and Risk Scoring API"
|
| 3 |
+
emoji: ⚡
|
| 4 |
colorFrom: gray
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 4.44.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
tags:
|
| 12 |
+
- github
|
| 13 |
+
- ci-cd
|
| 14 |
+
- automation
|
| 15 |
+
- devops
|
| 16 |
+
- actions
|
| 17 |
+
- data
|
| 18 |
+
- validation
|
| 19 |
+
- enrichment
|
| 20 |
+
- document
|
| 21 |
+
- generation
|
| 22 |
---
|
| 23 |
+
# GitHub Actions & CI/CD Compliance and Risk Scoring API
|
| 24 |
|
| 25 |
+
Stop guessing whether your GitHub Actions workflows are compliant — our API scores every action for risk and compliance in real time, so you can catch issues before they become breaches.
|
| 26 |
+
|
| 27 |
+
This REST API automates the tedious process of auditing CI/CD pipelines by scoring each workflow run against customizable compliance policies and risk factors. It integrates directly into your existing monitoring stack, giving you a continuous compliance score without manual reviews.
|
| 28 |
+
|
| 29 |
+
## What's Included
|
| 30 |
+
|
| 31 |
+
- Real-time compliance scoring for every GitHub Actions workflow run
|
| 32 |
+
- Risk scoring based on action origins, permissions, and secrets exposure
|
| 33 |
+
- Custom compliance policies (SOC2, HIPAA, internal standards) mapped to scoring rules
|
| 34 |
+
- Historical trend analysis to track compliance improvements over time
|
| 35 |
+
- Webhook and polling support for seamless integration with your observability tools
|
| 36 |
+
|
| 37 |
+
## Who Is This For
|
| 38 |
+
|
| 39 |
+
- DevOps engineers who need to enforce compliance across hundreds of repositories
|
| 40 |
+
- Security teams automating CI/CD risk assessments at scale
|
| 41 |
+
- Compliance officers requiring auditable, programmatic evidence of pipeline controls
|
| 42 |
+
- Platform engineers building internal developer portals with compliance dashboards
|
| 43 |
+
|
| 44 |
+
## How It Works
|
| 45 |
+
|
| 46 |
+
After purchasing, you'll receive a unique API key. Send a POST request with your workflow run data (or use our GitHub App to auto-forward events) and receive a JSON response with compliance and risk scores. Integrate the API into your CI/CD pipeline to gate deployments based on score thresholds.
|
| 47 |
+
|
| 48 |
+
## Frequently Asked Questions
|
| 49 |
+
|
| 50 |
+
**How does the API determine compliance and risk scores?**
|
| 51 |
+
We analyze action metadata, workflow configurations, and runtime behavior against a rule engine you can customize via policy definitions. Scores are calculated per run and aggregated over time.
|
| 52 |
+
|
| 53 |
+
**Can I use this API with self-hosted runners or enterprise accounts?**
|
| 54 |
+
Yes, the API is endpoint-agnostic. As long as you provide the necessary workflow data, it works with any GitHub plan, including GitHub Enterprise.
|
| 55 |
+
|
| 56 |
+
**What data does the API process? Do you store my workflow details?**
|
| 57 |
+
We process only the metadata you send (action names, versions, permissions, etc.). We do not store source code or secrets. Scores and policies are stored temporarily for trend analysis unless you opt for data deletion.
|
| 58 |
+
|
| 59 |
+
**How is pricing structured? Are there usage limits?**
|
| 60 |
+
The listed price is for 10,000 API calls per month. Higher tiers are available for larger volumes — contact us after purchase for upgrades.
|
| 61 |
+
|
| 62 |
+
**Can I integrate this with my existing monitoring tools (Datadog, Splunk, etc.)?**
|
| 63 |
+
Absolutely. The API returns structured JSON that can be forwarded to any observability platform via webhooks or custom integrations. We also provide sample scripts for common tools.
|
| 64 |
+
|
| 65 |
+
## What You Get
|
| 66 |
+
|
| 67 |
+
- Instant digital download
|
| 68 |
+
- Complete REST API with full documentation
|
| 69 |
+
- Free updates for life — pay once, own forever
|
| 70 |
+
- Setup guide and usage instructions
|
| 71 |
+
|
| 72 |
+
**Start scoring your GitHub Actions compliance and risk today — get the API and secure your CI/CD pipelines in minutes.**
|
| 73 |
+
|
| 74 |
+
## 🚀 Usage
|
| 75 |
+
|
| 76 |
+
1. Click **Use in Spaces** above to run the demo directly
|
| 77 |
+
2. Or clone the repository and run locally:
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
git clone https://huggingface.co/spaces/WealthFromAI/github-actions-cicd-compliance-and-risk-scoring-api-e513
|
| 81 |
+
cd github-actions-cicd-compliance-and-risk-scoring-api-e513
|
| 82 |
+
pip install -r requirements.txt
|
| 83 |
+
python app.py
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
## 💰 Pricing
|
| 87 |
+
|
| 88 |
+
- **Demo**: Free on Hugging Face Spaces
|
| 89 |
+
- **Full Source Code**: $42.23
|
| 90 |
+
- Available on [Gumroad](https://gumroad.com) and [Whop](https://whop.com)
|
| 91 |
+
|
| 92 |
+
## 📄 License
|
| 93 |
+
|
| 94 |
+
MIT License — free to use, modify, and distribute.
|
| 95 |
+
|
| 96 |
+
---
|
| 97 |
+
|
| 98 |
+
*Built with [FORGE-X](https://github.com/WealthFromAI) — automated digital product engine*
|
e5130a28/Dockerfile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
EXPOSE 8000
|
| 11 |
+
|
| 12 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
| 13 |
+
CMD python -c "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"
|
| 14 |
+
|
| 15 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
|
e5130a28/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# GitHub Actions & CI/CD Compliance and Risk Scoring API
|
| 2 |
+
|
| 3 |
+
> Automated compliance checks, risk assessments, and regulatory validation for GitHub Actions & CI/CD. Stay compliant without expensive consultants.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
- Full REST API
|
| 8 |
+
|
| 9 |
+
## Quick Start
|
| 10 |
+
|
| 11 |
+
```bash
|
| 12 |
+
# 1. Install dependencies
|
| 13 |
+
pip install -r requirements.txt
|
| 14 |
+
|
| 15 |
+
# 2. Configure environment
|
| 16 |
+
cp .env.example .env
|
| 17 |
+
# Edit .env with your settings
|
| 18 |
+
|
| 19 |
+
# 3. Run locally
|
| 20 |
+
uvicorn main:app --reload --port 8000
|
| 21 |
+
|
| 22 |
+
# 4. View interactive docs
|
| 23 |
+
open http://localhost:8000/docs
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
## Docker Deployment
|
| 27 |
+
|
| 28 |
+
```bash
|
| 29 |
+
# Build and run
|
| 30 |
+
docker compose up -d
|
| 31 |
+
|
| 32 |
+
# Check health
|
| 33 |
+
curl http://localhost:8000/health
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
## Authentication
|
| 37 |
+
|
| 38 |
+
Get a token first:
|
| 39 |
+
```bash
|
| 40 |
+
curl -X POST "http://localhost:8000/auth/token?username=admin&password=admin123"
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
Use the token in subsequent requests:
|
| 44 |
+
```bash
|
| 45 |
+
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8000/items
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
## API Endpoints
|
| 49 |
+
|
| 50 |
+
| Method | Path | Description |
|
| 51 |
+
|--------|------|-------------|
|
| 52 |
+
| GET | `/health` | System health |
|
| 53 |
+
| POST | `/auth/token` | Get JWT token |
|
| 54 |
+
| GET | `/items` | List all items |
|
| 55 |
+
| POST | `/items` | Create item |
|
| 56 |
+
| GET | `/items/{id}` | Get item |
|
| 57 |
+
| PATCH | `/items/{id}` | Update item |
|
| 58 |
+
| DELETE | `/items/{id}` | Delete item |
|
| 59 |
+
| GET | `/stats` | API statistics |
|
| 60 |
+
|
| 61 |
+
Full interactive docs: `http://localhost:8000/docs`
|
| 62 |
+
|
| 63 |
+
## Rate Limits
|
| 64 |
+
|
| 65 |
+
| Endpoint | Limit |
|
| 66 |
+
|----------|-------|
|
| 67 |
+
| `/auth/token` | 10/minute |
|
| 68 |
+
| `GET /items` | 60/minute |
|
| 69 |
+
| `POST /items` | 30/minute |
|
| 70 |
+
| `DELETE /items` | 20/minute |
|
| 71 |
+
|
| 72 |
+
## Running Tests
|
| 73 |
+
|
| 74 |
+
```bash
|
| 75 |
+
pip install pytest httpx
|
| 76 |
+
pytest tests/ -v
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
## Production Notes
|
| 80 |
+
|
| 81 |
+
- Change `SECRET_KEY` in `.env` before deploying
|
| 82 |
+
- Replace in-memory `_db` with a real database
|
| 83 |
+
- Add proper user management to `auth.py`
|
| 84 |
+
- Configure `ALLOWED_ORIGINS` for CORS
|
| 85 |
+
- Use Nginx/Traefik as reverse proxy
|
| 86 |
+
|
| 87 |
+
## License
|
| 88 |
+
|
| 89 |
+
MIT
|
e5130a28/auth.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Simple JWT auth — swap for OAuth2/API keys in production.
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
from datetime import datetime, timedelta, timezone
|
| 6 |
+
from typing import Optional
|
| 7 |
+
from fastapi import Depends, HTTPException, status
|
| 8 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 9 |
+
from jose import JWTError, jwt
|
| 10 |
+
from passlib.context import CryptContext
|
| 11 |
+
|
| 12 |
+
SECRET_KEY = os.getenv("SECRET_KEY", "changeme-in-production-please")
|
| 13 |
+
ALGORITHM = os.getenv("ALGORITHM", "HS256")
|
| 14 |
+
EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
|
| 15 |
+
|
| 16 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 17 |
+
security = HTTPBearer()
|
| 18 |
+
|
| 19 |
+
# Demo users — replace with real DB lookup
|
| 20 |
+
USERS: dict[str, str] = {
|
| 21 |
+
"admin": pwd_context.hash("admin123"),
|
| 22 |
+
"demo": pwd_context.hash("demo123"),
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def verify_password(username: str, password: str) -> bool:
|
| 27 |
+
hashed = USERS.get(username)
|
| 28 |
+
if not hashed:
|
| 29 |
+
return False
|
| 30 |
+
return pwd_context.verify(password, hashed)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def create_access_token(data: dict) -> str:
|
| 34 |
+
to_encode = data.copy()
|
| 35 |
+
expire = datetime.now(timezone.utc) + timedelta(minutes=EXPIRE_MINUTES)
|
| 36 |
+
to_encode["exp"] = expire
|
| 37 |
+
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
|
| 41 |
+
token = credentials.credentials
|
| 42 |
+
try:
|
| 43 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 44 |
+
username: Optional[str] = payload.get("sub")
|
| 45 |
+
if not username:
|
| 46 |
+
raise HTTPException(status_code=401, detail="Invalid token payload")
|
| 47 |
+
return username
|
| 48 |
+
except JWTError:
|
| 49 |
+
raise HTTPException(
|
| 50 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 51 |
+
detail="Invalid or expired token",
|
| 52 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 53 |
+
)
|
e5130a28/docker-compose.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
services:
|
| 3 |
+
api:
|
| 4 |
+
build: .
|
| 5 |
+
ports:
|
| 6 |
+
- "8000:8000"
|
| 7 |
+
env_file: .env
|
| 8 |
+
restart: unless-stopped
|
| 9 |
+
volumes:
|
| 10 |
+
- ./data:/app/data
|
e5130a28/main.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GitHub Actions & CI/CD Compliance and Risk Scoring API — REST API Service
|
| 3 |
+
Automated compliance checks, risk assessments, and regulatory validation for GitHub Actions & CI/CD. Stay compliant without expensive consultants.
|
| 4 |
+
|
| 5 |
+
Features:
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
import os
|
| 9 |
+
from contextlib import asynccontextmanager
|
| 10 |
+
from typing import Optional, List
|
| 11 |
+
from datetime import datetime, timezone
|
| 12 |
+
|
| 13 |
+
from fastapi import FastAPI, Depends, HTTPException, Request, status
|
| 14 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 15 |
+
from fastapi.responses import JSONResponse
|
| 16 |
+
from slowapi import Limiter
|
| 17 |
+
from slowapi.util import get_remote_address
|
| 18 |
+
from slowapi.errors import RateLimitExceeded
|
| 19 |
+
from dotenv import load_dotenv
|
| 20 |
+
|
| 21 |
+
from models import ItemCreate, ItemUpdate, ItemResponse, HealthResponse
|
| 22 |
+
from auth import get_current_user, create_access_token, verify_password
|
| 23 |
+
|
| 24 |
+
load_dotenv()
|
| 25 |
+
|
| 26 |
+
limiter = Limiter(key_func=get_remote_address)
|
| 27 |
+
_db: dict = {} # In-memory store — swap for real DB in production
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@asynccontextmanager
|
| 31 |
+
async def lifespan(app: FastAPI):
|
| 32 |
+
print(f"{app.title} starting up...")
|
| 33 |
+
yield
|
| 34 |
+
print(f"{app.title} shutting down...")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
app = FastAPI(
|
| 38 |
+
title="GitHub Actions & CI/CD Compliance and Risk Scoring API",
|
| 39 |
+
description="Automated compliance checks, risk assessments, and regulatory validation for GitHub Actions & CI/CD. Stay compliant without expensive consultants.",
|
| 40 |
+
version="1.0.0",
|
| 41 |
+
lifespan=lifespan,
|
| 42 |
+
docs_url="/docs",
|
| 43 |
+
redoc_url="/redoc",
|
| 44 |
+
openapi_url="/openapi.json",
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
app.state.limiter = limiter
|
| 48 |
+
|
| 49 |
+
app.add_middleware(
|
| 50 |
+
CORSMiddleware,
|
| 51 |
+
allow_origins=os.getenv("ALLOWED_ORIGINS", "*").split(","),
|
| 52 |
+
allow_credentials=True,
|
| 53 |
+
allow_methods=["*"],
|
| 54 |
+
allow_headers=["*"],
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@app.exception_handler(RateLimitExceeded)
|
| 59 |
+
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
| 60 |
+
return JSONResponse(
|
| 61 |
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
| 62 |
+
content={"error": "Rate limit exceeded", "detail": str(exc)},
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@app.exception_handler(Exception)
|
| 67 |
+
async def global_exception_handler(request: Request, exc: Exception):
|
| 68 |
+
return JSONResponse(
|
| 69 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 70 |
+
content={"error": "Internal server error", "detail": str(exc)},
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ── Health ────────────────────────────────────────────────────────────────────
|
| 75 |
+
|
| 76 |
+
@app.get("/health", response_model=HealthResponse, tags=["System"])
|
| 77 |
+
async def health():
|
| 78 |
+
return {
|
| 79 |
+
"status": "healthy",
|
| 80 |
+
"service": "GitHub Actions & CI/CD Compliance and Risk Scoring API",
|
| 81 |
+
"version": "1.0.0",
|
| 82 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 83 |
+
"items_count": len(_db),
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ── Auth ──────────────────────────────────────────────────────────────────────
|
| 88 |
+
|
| 89 |
+
@app.post("/auth/token", tags=["Auth"], summary="Get API token")
|
| 90 |
+
@limiter.limit("10/minute")
|
| 91 |
+
async def login(request: Request, username: str, password: str):
|
| 92 |
+
if not verify_password(username, password):
|
| 93 |
+
raise HTTPException(status_code=401, detail="Invalid credentials")
|
| 94 |
+
token = create_access_token({"sub": username})
|
| 95 |
+
return {"access_token": token, "token_type": "bearer"}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ── CRUD ──────────────────────────────────────────────────────────────────────
|
| 99 |
+
|
| 100 |
+
@app.get("/items", response_model=List[ItemResponse], tags=["Items"])
|
| 101 |
+
@limiter.limit("60/minute")
|
| 102 |
+
async def list_items(
|
| 103 |
+
request: Request,
|
| 104 |
+
skip: int = 0,
|
| 105 |
+
limit: int = 50,
|
| 106 |
+
search: Optional[str] = None,
|
| 107 |
+
current_user: str = Depends(get_current_user),
|
| 108 |
+
):
|
| 109 |
+
items = list(_db.values())
|
| 110 |
+
if search:
|
| 111 |
+
items = [i for i in items if search.lower() in i.get("name", "").lower()]
|
| 112 |
+
return items[skip : skip + limit]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@app.post("/items", response_model=ItemResponse, status_code=201, tags=["Items"])
|
| 116 |
+
@limiter.limit("30/minute")
|
| 117 |
+
async def create_item(
|
| 118 |
+
request: Request,
|
| 119 |
+
item: ItemCreate,
|
| 120 |
+
current_user: str = Depends(get_current_user),
|
| 121 |
+
):
|
| 122 |
+
item_id = f"item_{len(_db) + 1:06d}"
|
| 123 |
+
record = {
|
| 124 |
+
"id": item_id,
|
| 125 |
+
**item.model_dump(),
|
| 126 |
+
"created_by": current_user,
|
| 127 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 128 |
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
| 129 |
+
}
|
| 130 |
+
_db[item_id] = record
|
| 131 |
+
return record
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@app.get("/items/{item_id}", response_model=ItemResponse, tags=["Items"])
|
| 135 |
+
@limiter.limit("120/minute")
|
| 136 |
+
async def get_item(
|
| 137 |
+
request: Request,
|
| 138 |
+
item_id: str,
|
| 139 |
+
current_user: str = Depends(get_current_user),
|
| 140 |
+
):
|
| 141 |
+
if item_id not in _db:
|
| 142 |
+
raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
|
| 143 |
+
return _db[item_id]
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@app.patch("/items/{item_id}", response_model=ItemResponse, tags=["Items"])
|
| 147 |
+
@limiter.limit("30/minute")
|
| 148 |
+
async def update_item(
|
| 149 |
+
request: Request,
|
| 150 |
+
item_id: str,
|
| 151 |
+
item: ItemUpdate,
|
| 152 |
+
current_user: str = Depends(get_current_user),
|
| 153 |
+
):
|
| 154 |
+
if item_id not in _db:
|
| 155 |
+
raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
|
| 156 |
+
record = _db[item_id]
|
| 157 |
+
updates = item.model_dump(exclude_unset=True)
|
| 158 |
+
record.update({**updates, "updated_at": datetime.now(timezone.utc).isoformat()})
|
| 159 |
+
_db[item_id] = record
|
| 160 |
+
return record
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@app.delete("/items/{item_id}", status_code=204, tags=["Items"])
|
| 164 |
+
@limiter.limit("20/minute")
|
| 165 |
+
async def delete_item(
|
| 166 |
+
request: Request,
|
| 167 |
+
item_id: str,
|
| 168 |
+
current_user: str = Depends(get_current_user),
|
| 169 |
+
):
|
| 170 |
+
if item_id not in _db:
|
| 171 |
+
raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
|
| 172 |
+
del _db[item_id]
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# ── Stats ─────────────────────────────────────────────────────────────────────
|
| 176 |
+
|
| 177 |
+
@app.get("/stats", tags=["System"])
|
| 178 |
+
@limiter.limit("30/minute")
|
| 179 |
+
async def stats(request: Request, current_user: str = Depends(get_current_user)):
|
| 180 |
+
return {
|
| 181 |
+
"total_items": len(_db),
|
| 182 |
+
"service": "GitHub Actions & CI/CD Compliance and Risk Scoring API",
|
| 183 |
+
"niche": "github_actions",
|
| 184 |
+
}
|
e5130a28/models.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class ItemCreate(BaseModel):
|
| 7 |
+
name: str = Field(..., min_length=1, max_length=200, description="Item name")
|
| 8 |
+
description: Optional[str] = Field(None, max_length=2000)
|
| 9 |
+
category: Optional[str] = Field(None, max_length=100)
|
| 10 |
+
tags: list[str] = Field(default_factory=list)
|
| 11 |
+
metadata: dict = Field(default_factory=dict)
|
| 12 |
+
|
| 13 |
+
model_config = {"json_schema_extra": {"example": {
|
| 14 |
+
"name": "Sample Item",
|
| 15 |
+
"description": "A sample github_actions item",
|
| 16 |
+
"category": "default",
|
| 17 |
+
"tags": ["github_actions"],
|
| 18 |
+
"metadata": {},
|
| 19 |
+
}}}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ItemUpdate(BaseModel):
|
| 23 |
+
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
| 24 |
+
description: Optional[str] = Field(None, max_length=2000)
|
| 25 |
+
category: Optional[str] = None
|
| 26 |
+
tags: Optional[list[str]] = None
|
| 27 |
+
metadata: Optional[dict] = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ItemResponse(BaseModel):
|
| 31 |
+
id: str
|
| 32 |
+
name: str
|
| 33 |
+
description: Optional[str]
|
| 34 |
+
category: Optional[str]
|
| 35 |
+
tags: list[str]
|
| 36 |
+
metadata: dict
|
| 37 |
+
created_by: str
|
| 38 |
+
created_at: str
|
| 39 |
+
updated_at: str
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class HealthResponse(BaseModel):
|
| 43 |
+
status: str
|
| 44 |
+
service: str
|
| 45 |
+
version: str
|
| 46 |
+
timestamp: str
|
| 47 |
+
items_count: int
|
e5130a28/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.110.0
|
| 2 |
+
uvicorn[standard]>=0.27.0
|
| 3 |
+
pydantic>=2.0.0
|
| 4 |
+
python-jose[cryptography]>=3.3.0
|
| 5 |
+
passlib[bcrypt]>=1.7.4
|
| 6 |
+
python-multipart>=0.0.9
|
| 7 |
+
slowapi>=0.1.9
|
| 8 |
+
python-dotenv>=1.0.0
|
| 9 |
+
httpx>=0.27.0
|
e5130a28/tests/__init__.py
ADDED
|
File without changes
|
e5130a28/tests/test_main.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for GitHub Actions & CI/CD Compliance and Risk Scoring API API.
|
| 3 |
+
"""
|
| 4 |
+
import pytest
|
| 5 |
+
from fastapi.testclient import TestClient
|
| 6 |
+
from main import app
|
| 7 |
+
|
| 8 |
+
client = TestClient(app)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def test_health():
|
| 12 |
+
r = client.get("/health")
|
| 13 |
+
assert r.status_code == 200
|
| 14 |
+
data = r.json()
|
| 15 |
+
assert data["status"] == "healthy"
|
| 16 |
+
assert "version" in data
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_unauthorized():
|
| 20 |
+
r = client.get("/items")
|
| 21 |
+
assert r.status_code == 403
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_login():
|
| 25 |
+
r = client.post("/auth/token", params={"username": "admin", "password": "admin123"})
|
| 26 |
+
assert r.status_code == 200
|
| 27 |
+
assert "access_token" in r.json()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _auth_headers():
|
| 31 |
+
r = client.post("/auth/token", params={"username": "admin", "password": "admin123"})
|
| 32 |
+
token = r.json()["access_token"]
|
| 33 |
+
return {"Authorization": f"Bearer {token}"}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_create_item():
|
| 37 |
+
headers = _auth_headers()
|
| 38 |
+
payload = {"name": "Test Item", "description": "A test", "tags": ["test"]}
|
| 39 |
+
r = client.post("/items", json=payload, headers=headers)
|
| 40 |
+
assert r.status_code == 201
|
| 41 |
+
data = r.json()
|
| 42 |
+
assert data["name"] == "Test Item"
|
| 43 |
+
assert "id" in data
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_list_items():
|
| 47 |
+
headers = _auth_headers()
|
| 48 |
+
r = client.get("/items", headers=headers)
|
| 49 |
+
assert r.status_code == 200
|
| 50 |
+
assert isinstance(r.json(), list)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_get_item():
|
| 54 |
+
headers = _auth_headers()
|
| 55 |
+
# Create
|
| 56 |
+
payload = {"name": "Get Test", "tags": []}
|
| 57 |
+
r = client.post("/items", json=payload, headers=headers)
|
| 58 |
+
item_id = r.json()["id"]
|
| 59 |
+
# Get
|
| 60 |
+
r = client.get(f"/items/{item_id}", headers=headers)
|
| 61 |
+
assert r.status_code == 200
|
| 62 |
+
assert r.json()["id"] == item_id
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_update_item():
|
| 66 |
+
headers = _auth_headers()
|
| 67 |
+
payload = {"name": "Update Test", "tags": []}
|
| 68 |
+
r = client.post("/items", json=payload, headers=headers)
|
| 69 |
+
item_id = r.json()["id"]
|
| 70 |
+
r = client.patch(f"/items/{item_id}", json={"name": "Updated"}, headers=headers)
|
| 71 |
+
assert r.status_code == 200
|
| 72 |
+
assert r.json()["name"] == "Updated"
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_delete_item():
|
| 76 |
+
headers = _auth_headers()
|
| 77 |
+
payload = {"name": "Delete Test", "tags": []}
|
| 78 |
+
r = client.post("/items", json=payload, headers=headers)
|
| 79 |
+
item_id = r.json()["id"]
|
| 80 |
+
r = client.delete(f"/items/{item_id}", headers=headers)
|
| 81 |
+
assert r.status_code == 204
|
| 82 |
+
r = client.get(f"/items/{item_id}", headers=headers)
|
| 83 |
+
assert r.status_code == 404
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_stats():
|
| 87 |
+
headers = _auth_headers()
|
| 88 |
+
r = client.get("/stats", headers=headers)
|
| 89 |
+
assert r.status_code == 200
|
| 90 |
+
assert "total_items" in r.json()
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_openapi_docs():
|
| 94 |
+
r = client.get("/openapi.json")
|
| 95 |
+
assert r.status_code == 200
|
| 96 |
+
assert "openapi" in r.json()
|