Skip to content
Merged

Dev #50

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
de371f4
chore: add skeleton files and requirements
Vishnu2707 Apr 25, 2026
dd24ce0
fix: remove embedded git repo
Vishnu2707 Apr 25, 2026
e872074
Core Structure Created
Vishnu2707 Apr 25, 2026
ee77377
feat: build complete core — scanner engine, 10 rules, API, playbooks,…
Vishnu2707 Apr 25, 2026
053be03
docs: replace ASCII architecture with interactive Mermaid diagram
Vishnu2707 Apr 25, 2026
b31ecb7
feat: Sentinel integration — ingest.py, 4 KQL rules, setup guide (#12)
TFT444 May 2, 2026
d545744
fix: add AZ-STOR-003 compliance mappings, correct NIST control to PR.…
Vishnu2707 May 4, 2026
6c0c58e
docs: add real-world breach scenarios for all 10 starter rules (#15)
TFT444 May 4, 2026
e4382cd
feat: add AZ-KV-002 key vault public access rule and remediation play…
parthrohit22 May 4, 2026
7593ba0
Merge branch 'main' into dev
Vishnu2707 May 4, 2026
0ec2290
Merge remote-tracking branch 'origin/main' into dev
Vishnu2707 May 4, 2026
e8fed83
docs: update README with rule count, roadmap progress and contributors
Vishnu2707 May 4, 2026
35312d4
feat: add network security rules AZ-NET-003 to AZ-NET-010 (#16)
TFT444 May 4, 2026
aee88b2
Merge remote-tracking branch 'origin/main' into dev
Vishnu2707 May 4, 2026
2badbce
Feat/az stor 003 (#21)
ritiksah141 May 5, 2026
1e7a81f
docs: add SOC 2 Type II compliance framework mapping (#33)
TFT444 May 8, 2026
f409b67
Refactor/azure client network methods (#22)
TFT444 May 9, 2026
bb47779
feat: add CI pipeline with 6 automated checks (#34)
ritiksah141 May 9, 2026
0d99e2d
Merge branch 'main' into dev
Vishnu2707 May 9, 2026
46096a6
Merge remote-tracking branch 'origin/main' into dev
Vishnu2707 May 9, 2026
9e5d355
docs: update .github/ISSUE_TEMPLATE/new_rule.md to reflect current co…
Vishnu2707 May 9, 2026
2a5655e
docs: update .github/PULL_REQUEST_TEMPLATE.md to reflect current code…
Vishnu2707 May 9, 2026
57f25a6
docs: update CONTRIBUTING.md to reflect current codebase state
Vishnu2707 May 9, 2026
309deca
docs: update README.md to reflect current codebase state
Vishnu2707 May 9, 2026
693b20c
docs: update compliance/frameworks/iso27001.json to reflect current c…
Vishnu2707 May 9, 2026
c292efc
docs: update compliance/frameworks/nist_csf.json to reflect current c…
Vishnu2707 May 9, 2026
034b9d5
docs: update docs/adding-a-rule.md to reflect current codebase state
Vishnu2707 May 9, 2026
936a7d6
docs: update docs/architecture.md to reflect current codebase state
Vishnu2707 May 9, 2026
3cd0f00
docs: update docs/az-stor-003-test-plan.md to reflect current codebas…
Vishnu2707 May 9, 2026
17c29f4
docs: update docs/azure-setup.md to reflect current codebase state
Vishnu2707 May 9, 2026
6275396
docs: update docs/ci-pipeline.md to reflect current codebase state
Vishnu2707 May 9, 2026
ab16a16
docs: update docs/sentinel-setup.md to reflect current codebase state
Vishnu2707 May 9, 2026
1cd89dd
docs: update sentinel/TEST_PLAN.md to reflect current codebase state
Vishnu2707 May 9, 2026
a2fed2e
docs: update docs/api-reference.md to reflect current codebase state
Vishnu2707 May 9, 2026
98894bc
docs: update docs/rules-reference.md to reflect current codebase state
Vishnu2707 May 9, 2026
fdae7e7
Merge remote-tracking branch 'origin/dev' into dev
Vishnu2707 May 9, 2026
85bbb7f
docs: update README.md for professional open source style
Vishnu2707 May 9, 2026
0643eaf
docs: update CONTRIBUTING.md for professional open source style
Vishnu2707 May 9, 2026
5ebcdd9
docs: update docs/adding-a-rule.md for professional open source style
Vishnu2707 May 9, 2026
eb88659
Merge branch 'main' into dev
Vishnu2707 May 9, 2026
2d230dd
docs: update deployment guide to use Render instead of Azure App Service
Vishnu2707 May 9, 2026
bac6146
Merge remote-tracking branch 'origin/dev' into dev
Vishnu2707 May 9, 2026
d4384fe
feat: add rule AZ-STOR-004 storage account diagnostic logging check (…
SHAURYAKSHARMA24 May 13, 2026
826396a
feat: add rule AZ-IDN-003 Adds scanner rule AZ-IDN-003 detecting Entr…
TFT444 May 13, 2026
cd47b68
feat: add rule AZ-CMP-002 — VM disk not protected by CMK or ADE (#47)
TFT444 May 13, 2026
1efe1f3
Feat/api deployment (#46)
ritiksah141 May 13, 2026
ba6c70c
feat: AZ-NET-011 Network Watcher not enabled in all regions (#42)
emon22-ts May 13, 2026
e7c3487
feat: add AZ-DB-003 PostgreSQL Flexible Server SSL enforcement rule a…
emon22-ts May 16, 2026
024e635
Merge branch 'main' into dev
Vishnu2707 May 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,13 @@ jobs:
run: |
echo "=== Checking Python syntax on scanner/rules/ ==="
FAIL=0
for f in scanner/rules/az_*.py; do
shopt -s nullglob
files=(scanner/rules/az_*.py)
if [ ${#files[@]} -eq 0 ]; then
echo "ERROR: No rule files found matching scanner/rules/az_*.py"
exit 1
fi
for f in "${files[@]}"; do
if ! python -m py_compile "$f" 2>&1; then
echo "SYNTAX ERROR: $f"
FAIL=1
Expand Down Expand Up @@ -137,7 +143,7 @@ jobs:
grep -v "\.env" | \
grep -v "os\.environ" | \
grep -v "os\.getenv" | \
grep -v "#" | \
grep -vE '^\s*#' | \
grep -v "example" | \
grep -v "placeholder" || true)

Expand All @@ -161,7 +167,13 @@ jobs:
run: |
echo "=== Checking playbooks exist and are valid bash ==="
FAIL=0
for rule_file in scanner/rules/az_*.py; do
shopt -s nullglob
files=(scanner/rules/az_*.py)
if [ ${#files[@]} -eq 0 ]; then
echo "ERROR: No rule files found matching scanner/rules/az_*.py"
exit 1
fi
for rule_file in "${files[@]}"; do
filename=$(basename "$rule_file" .py)
playbook="playbooks/cli/fix_${filename}.sh"

Expand Down Expand Up @@ -287,7 +299,8 @@ jobs:
continue
fpath = os.path.join(framework_dir, fname)
try:
data = json.load(open(fpath))
with open(fpath) as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
continue

Expand Down
109 changes: 109 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
name: Deploy API to Render

on:
push:
branches:
- dev
- main
workflow_dispatch: # allows manual trigger from GitHub UI

jobs:
deploy:
name: Deploy to Render
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"

# ── Dependency caching ─────────────────────────────────────────────
- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt

# ── Secret check (Determines if smoke tests should run) ───────────
- name: Check for JWT_SECRET
id: check_config
run: |
if [ -n "${{ secrets.JWT_SECRET }}" ]; then
echo "is_configured=true" >> $GITHUB_OUTPUT
else
echo "is_configured=false" >> $GITHUB_OUTPUT
fi

# ── Wait for Render auto-deployment ────────────────────────────────
# Render handles the actual physical deployment when you push.
# We just pause the Action to let Render's servers finish building.
- name: Wait for app to initialise
run: |
echo "Waiting 120 seconds for Render to build and start the app..."
sleep 120

# ── Health gate ────────────────────────────────────────────────────
- name: Health gate check
id: health_gate
env:
# Use secret URL if provided, otherwise fallback to default
API_URL: ${{ secrets.API_URL || 'https://openshield-api.onrender.com' }}
run: |
MAX_RETRIES=5
RETRY_DELAY=15
URL="${API_URL}/health"

echo "Pinging health gate at: $URL"
for i in $(seq 1 $MAX_RETRIES); do
echo "Health check attempt $i of $MAX_RETRIES..."
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$URL" --max-time 30) || true

if [ "$HTTP_STATUS" -eq 200 ]; then
echo "Health check passed (HTTP $HTTP_STATUS)"
exit 0
fi

echo "Got HTTP $HTTP_STATUS — retrying in ${RETRY_DELAY}s..."
sleep $RETRY_DELAY
done

echo "HEALTH GATE FAILED after $MAX_RETRIES attempts"
echo "Note: If you haven't set up Render for this fork, this is expected."
# Only allow failure on feature branches; fail on main/dev
if [[ "${{ github.ref }}" == "refs/heads/main" || "${{ github.ref }}" == "refs/heads/dev" ]]; then
echo "ERROR: Health check failed on protected branch. Deployment verification required."
exit 1
else
echo "Allowing health check failure on feature branch (infra may not be set up)"
exit 0
fi

# ── Smoke tests ────────────────────────────────────────────────────
- name: Run smoke tests against live deployment
if: steps.check_config.outputs.is_configured == 'true' || github.event_name == 'workflow_dispatch'
env:
API_URL: ${{ secrets.API_URL || 'https://openshield-api.onrender.com' }}
JWT_SECRET: ${{ secrets.JWT_SECRET || 'change-me-in-production' }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
RUN_REAL_SCAN: "true"
run: |
if [[ "${{ github.ref }}" == "refs/heads/main" && -z "${{ secrets.JWT_SECRET }}" ]]; then
echo "ERROR: Cannot run smoke tests on main branch without JWT_SECRET configured"
exit 1
fi
echo "Running smoke tests against: $API_URL"
python tests/smoke_test.py
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@ flowchart TD
I -->|alerts| A
```

## Live API

The OpenShield API is deployed to the Render free tier and is accessible at:

**`https://openshield-api.onrender.com`**

> **Note:** As this is hosted on the Render free tier, the service may spin down after 15 minutes of inactivity. The first request after a spin-down can take 30-60 seconds to complete.

> [!IMPORTANT]
> **Security Requirement:** For absolute security, any production deployment **must** override the default `JWT_SECRET` with a strong, unique value in the environment variables.

---

## Tech Stack

| Layer | Technology | Cost |
Expand Down
55 changes: 51 additions & 4 deletions api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from flask import Flask, g, jsonify, request
from flask_cors import CORS

from api.models.finding import DatabaseManager

load_dotenv()

logging.basicConfig(
Expand All @@ -28,14 +30,49 @@ def create_app() -> Flask:
- JWT authentication middleware on all non-public routes
- Blueprints for findings, scans, score, and compliance
- JSON error handlers for 400, 401, 403, 404, and 500
- Global database connection teardown
"""
app = Flask(__name__)
app.config["JWT_SECRET"] = os.environ.get("JWT_SECRET", "change-me-in-production")

# ------------------------------------------------------------------ #
# Configuration & Security #
# ------------------------------------------------------------------ #
jwt_key = os.environ.get("JWT_SECRET")
if not jwt_key:
logger.warning(
"!!! SECURITY WARNING: JWT_SECRET NOT SET. USING INSECURE DEFAULT !!! "
"For production deployments, you MUST set a strong, unique JWT_SECRET."
)
jwt_key = "change-me-in-production"
app.config["JWT_SECRET"] = jwt_key

# ------------------------------------------------------------------ #
# CORS #
# ------------------------------------------------------------------ #
CORS(app, resources={r"/api/*": {"origins": "*"}})
allowed_origins_raw = os.environ.get("ALLOWED_ORIGINS", "*")
if allowed_origins_raw == "*":
logger.warning(
"!!! SECURITY WARNING: ALLOWED_ORIGINS NOT SET. DEFAULTING TO '*' !!! "
"For production deployments, set this to your specific frontend domain(s)."
)
allowed_origins = allowed_origins_raw.split(",")
CORS(app, resources={r"/api/*": {"origins": allowed_origins}})

# ------------------------------------------------------------------ #
# Database Management #
# ------------------------------------------------------------------ #

@app.teardown_appcontext
def close_db(error):
"""Ensure the database connection is closed after the request."""
db = g.pop("db_conn", None)
if db is not None:
try:
if hasattr(db, "conn") and db.conn is not None:
db.conn.close()
logger.debug("Database connection closed gracefully")
except Exception as exc:
logger.error("Error closing database connection: %s", exc)

# ------------------------------------------------------------------ #
# JWT middleware #
Expand Down Expand Up @@ -82,9 +119,18 @@ def verify_jwt() -> None:
app.register_blueprint(compliance_bp)

# ------------------------------------------------------------------ #
# Health check (public) #
# Routes (public) #
# ------------------------------------------------------------------ #

@app.get("/")
def index():
return jsonify({
"message": "Welcome to the OpenShield REST API",
"version": "1.0.0",
"docs": "/docs",
"status": "online"
})

@app.get("/health")
def health():
return jsonify({"status": "ok"})
Expand Down Expand Up @@ -118,8 +164,9 @@ def internal_error(exc):
return app


application = create_app()

if __name__ == "__main__":
application = create_app()
application.run(
host="0.0.0.0",
port=int(os.environ.get("PORT", 5000)),
Expand Down
14 changes: 12 additions & 2 deletions api/models/finding.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,16 @@ def __init__(self, dsn: Optional[str] = None) -> None:
# ------------------------------------------------------------------ #

def connect(self) -> None:
"""Open a persistent database connection."""
"""Open a persistent database connection and set the search path."""
self.conn = psycopg2.connect(self.dsn)
self.conn.autocommit = True # Set to True for schema management
with self.conn.cursor() as cur:
# Ensure the openshield schema exists and is preferred in the search path.
# This avoids 'permission denied for schema public' in restricted environments.
cur.execute("CREATE SCHEMA IF NOT EXISTS openshield;")
cur.execute("SET search_path TO openshield, public;")
self.conn.autocommit = False
logger.info("Database connection established")
logger.info("Database connection established (schema: openshield)")

def _get_conn(self) -> Any:
if self.conn is None or self.conn.closed:
Expand All @@ -94,6 +100,10 @@ def _get_conn(self) -> Any:
# Schema #
# ------------------------------------------------------------------ #

def init_db(self) -> None:
"""Alias for create_tables to match startup script expectations."""
self.create_tables()

def create_tables(self) -> None:
"""Create the findings, scans, and rules tables if they do not exist."""
conn = self._get_conn()
Expand Down
43 changes: 25 additions & 18 deletions api/routes/compliance.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,46 @@
"""Compliance routes: framework-specific posture breakdown."""

import logging
import os
from flask import Blueprint, jsonify
from flask import Blueprint, g, jsonify

from api.models.finding import DatabaseManager

compliance_bp = Blueprint("compliance", __name__)
logger = logging.getLogger(__name__)

SUPPORTED_FRAMEWORKS = ("cis", "nist", "iso27001", "soc2")


def _get_db() -> DatabaseManager:
db = DatabaseManager(os.environ["DATABASE_URL"])
db.connect()
return db
if "db_conn" not in g:
g.db_conn = DatabaseManager(os.environ["DATABASE_URL"])
g.db_conn.connect()
return g.db_conn


@compliance_bp.get("/api/compliance/<framework>")
def get_compliance(framework: str):
"""Return pass/fail compliance breakdown for a framework.

Supported frameworks: cis, nist, iso27001, soc2
Supported frameworks: cis, nist, iso27001, soc2

Returns control-level pass/fail status mapped to current open findings.
"""
if framework.lower() not in SUPPORTED_FRAMEWORKS:
return jsonify({
"error": f"Unknown framework '{framework}'",
"supported": list(SUPPORTED_FRAMEWORKS),
}), 400

db = _get_db()
result = db.get_compliance_score(framework.lower())

if "error" in result:
return jsonify(result), 500

return jsonify(result)
try:
if framework.lower() not in SUPPORTED_FRAMEWORKS:
return jsonify({
"error": f"Unknown framework '{framework}'",
"supported": list(SUPPORTED_FRAMEWORKS),
}), 400

db = _get_db()
result = db.get_compliance_score(framework.lower())

if "error" in result:
return jsonify(result), 500

return jsonify(result)
except Exception as exc:
logger.error("Failed to retrieve compliance score for %s: %s", framework, exc)
return jsonify({"error": "Compliance calculation failed", "detail": str(exc)}), 500
Loading
Loading