diff --git a/.gitignore b/.gitignore index f369c42..87074d6 100644 --- a/.gitignore +++ b/.gitignore @@ -217,3 +217,5 @@ __marimo__/ # Streamlit .streamlit/secrets.toml ai/vectorstore/ +.vercel +.env* diff --git a/README.md b/README.md index dee8e29..2c72564 100644 --- a/README.md +++ b/README.md @@ -44,11 +44,12 @@ Findings map to NIST FIPS 203 (ML-KEM), FIPS 204 (ML-DSA), and FIPS 205 (SLH-DSA | Feature | Description | |---|---| -| **Misconfiguration Scanner** | Runs 30+ Azure security rules across storage, network, identity, database, compute, Key Vault, and post-quantum cryptography | +| **Misconfiguration Scanner** | Runs 39 Azure security rules across storage, network, identity, database, compute, Key Vault, and post-quantum cryptography | | **Compliance Mapper** | Maps findings to CIS Benchmarks, NIST CSF, ISO 27001, and SOC 2 framework JSON files | -| **Scan History API** | Stores scans and findings in PostgreSQL and exposes findings, score, scan history, and compliance posture over REST | -| **Remediation Playbooks** | Every current rule ships with a matching Azure CLI remediation script | -| **Security Dashboard** | Frontend scaffold is present; the React dashboard MVP is still on the roadmap | +| **Scan History API** | Stores scans and findings in PostgreSQL and exposes findings, score, scan history, compliance posture, drift, and resource inventory over REST | +| **Remediation Playbooks** | Every rule ships with a matching Azure CLI remediation script (36 playbooks) | +| **Security Dashboard** | Full React dashboard deployed on Vercel — live monitoring, findings, compliance, drift, prioritization, and AI-layer views | +| **Project Website** | Documentation and reference site at [openshield-website.vercel.app](https://openshield-website.vercel.app) — blog, rules gallery, docs, roadmap, releases, and interactive playground | | **Sentinel Integration** | Normalises findings and pushes them into Microsoft Sentinel via a Log Analytics custom table and KQL analytics rules | --- @@ -57,13 +58,13 @@ Findings map to NIST FIPS 203 (ML-KEM), FIPS 204 (ML-DSA), and FIPS 205 (SLH-DSA ```mermaid flowchart TD - A["React Dashboard MVP\nPlanned frontend"] + A["React Dashboard\nVercel · Live"] B["Flask REST API\nJWT · CORS · Blueprints"] - C["Scanner Engine\n30+ Python rules"] + C["Scanner Engine\n39 Python rules"] D["Azure Subscription\nScanned via Azure SDK + Graph"] E["Compliance Framework JSON\nCIS · NIST · ISO 27001 · SOC 2"] F["PostgreSQL Database\nFindings · Scans"] - G["Azure CLI Playbooks\n30+ remediation scripts"] + G["Azure CLI Playbooks\n39 remediation scripts"] H["sentinel/ingest.py\nNormalise + HMAC upload"] I["Microsoft Sentinel\nOpenShieldFindings_CL · KQL rules"] @@ -79,13 +80,15 @@ flowchart TD I -->|alerts| A ``` -## Live API +## Live Demo -The OpenShield API is deployed to the Render free tier and is accessible at: - -**`https://openshield-api.onrender.com`** +| Service | URL | +|---|---| +| **Security Dashboard** (Vercel) | `https://openshield-gules.vercel.app` | +| **REST API** (Render) | `https://openshield-api.onrender.com` | +| **Project Website** | `https://openshield-website.vercel.app` | -> **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. +> **Note:** The API is hosted on Render. The dashboard connects automatically on load and shows live data from the PostgreSQL database. > [!IMPORTANT] > **Security Requirement:** Production deployments **fail at startup** if `JWT_SECRET` is missing, set to the insecure default, or shorter than 32 characters. Generate a strong secret with: @@ -100,9 +103,10 @@ The OpenShield API is deployed to the Render free tier and is accessible at: | Layer | Technology | Cost | |---|---|---| -| Frontend | Scaffolded dashboard app (React + Tailwind planned) | Free | +| Project Website | Static HTML + Tailwind CDN, deployed on Vercel | Free | +| Security Dashboard | React + Vite + Tailwind, deployed on Vercel | Free | | Backend API | Python + Flask | Free | -| Database | PostgreSQL | Free (Render/Azure free tier) | +| Database | PostgreSQL | Render managed PostgreSQL | | Cloud Scanner | Python + Azure SDK | Free | | Remediation | Azure CLI playbooks | Free | | SIEM | Microsoft Sentinel | 90-day free trial | @@ -128,7 +132,8 @@ openshield/ ├── api/ # Flask REST API │ ├── routes/ │ └── models/ -├── frontend/ # Dashboard scaffold +├── frontend/ # React security dashboard (Vercel) +├── website/ # Project website — docs, blog, rules gallery (Vercel) ├── sentinel/ # Sentinel integration & KQL rules ├── .github/workflows/ # CI checks ├── docs/ # Documentation @@ -141,6 +146,8 @@ openshield/ ## Quick Start +**Backend (Flask API + Scanner)** + ```bash # Clone the repo git clone https://github.com/openshield-org/openshield.git @@ -154,6 +161,7 @@ export AZURE_SUBSCRIPTION_ID=your-subscription-id export AZURE_CLIENT_ID=your-client-id export AZURE_CLIENT_SECRET=your-client-secret export AZURE_TENANT_ID=your-tenant-id +export JWT_SECRET=your-strong-secret # used to protect write endpoints (scan trigger, AI) # Run a scan python -c " @@ -167,6 +175,21 @@ print(json.dumps(result, indent=2)) FLASK_APP=api/app.py flask run ``` +**Frontend (React dashboard)** + +```bash +cd frontend +npm install + +# Local dev — points at http://localhost:5000 by default +npm run dev + +# To develop against the live Render backend: +VITE_API_URL=https://openshield-api.onrender.com npm run dev +``` + +No token required — all read endpoints are public. Only scan trigger and AI endpoints require a JWT (POST only). + --- ## Contributing @@ -193,7 +216,7 @@ Contributors are credited below. - [x] 30+ scan rules - [x] Flask API + PostgreSQL schema - [x] Post-quantum cryptography scanner (AZ-PQC-001 to AZ-PQC-003) -- [ ] React dashboard MVP +- [x] React dashboard (live on Vercel) - [x] CIS Benchmark compliance mapping - [x] SOC 2 compliance mapping - [x] Sentinel alert integration @@ -202,6 +225,8 @@ Contributors are credited below. - [x] Azure CLI remediation playbook library - [x] NIST CSF + ISO 27001 mappings - [x] GitHub Actions CI pipeline +- [x] Project website with docs, blog, rules gallery, and playground +- [x] Live end-to-end data wiring (all API endpoints serving real data) - [ ] Multi-cloud support (AWS, GCP) --- @@ -212,20 +237,12 @@ MIT — free to use, modify, and distribute. --- -> Built with ❤️ by security engineers and students who believe cloud security tooling should be accessible to everyone. +> Built by security engineers and students who believe cloud security tooling should be accessible to everyone. --- ## Learn OpenShield -Explore the OpenShield learning portal to understand: - -- Azure CSPM fundamentals -- OpenShield architecture -- Compliance mappings -- Remediation workflows -- Contributor onboarding -- Documentation navigation +Full documentation, the security rules gallery, blog, and interactive playground are available at the project website: -👉 [OpenShield Learn](docs/learn/index.html) -> Built by security engineers and students who believe cloud security tooling should be accessible to everyone. +**[openshield-website.vercel.app](https://openshield-website.vercel.app)** diff --git a/api/app.py b/api/app.py index 5b2f3ec..053e32d 100644 --- a/api/app.py +++ b/api/app.py @@ -9,7 +9,6 @@ from flask_cors import CORS from api.models.finding import DatabaseManager -from api.routes.ai import ai_bp load_dotenv() @@ -20,7 +19,12 @@ logger = logging.getLogger(__name__) # Paths that do not require a JWT token -_PUBLIC_PATHS = {"/health", "/"} +# All GET requests are public — the dashboard is a public demo of seeded data. +# POST endpoints (scan trigger, AI) remain JWT-protected. +def _is_public_get(path: str) -> bool: + if path in ("/", "/health"): + return True + return path.startswith("/api/") _INSECURE_JWT_DEFAULT = "change-me-in-production" _MIN_JWT_SECRET_LENGTH = 32 @@ -108,7 +112,7 @@ def create_app() -> Flask: "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}}) + CORS(app, resources={r"/*": {"origins": allowed_origins}}) # ------------------------------------------------------------------ # # Database Management # @@ -140,7 +144,7 @@ def verify_jwt() -> None: """Validate the Bearer token on every non-public, non-OPTIONS request.""" if request.method == "OPTIONS": return None - if request.path in _PUBLIC_PATHS: + if request.method == "GET" and _is_public_get(request.path): return None auth = request.headers.get("Authorization", "") @@ -167,15 +171,21 @@ def verify_jwt() -> None: # ------------------------------------------------------------------ # from api.routes.ai import ai_bp from api.routes.compliance import compliance_bp + from api.routes.drift import drift_bp from api.routes.findings import findings_bp + from api.routes.prioritization import prioritization_bp + from api.routes.resources import resources_bp from api.routes.scans import scans_bp from api.routes.score import score_bp app.register_blueprint(ai_bp) + app.register_blueprint(compliance_bp) + app.register_blueprint(drift_bp) app.register_blueprint(findings_bp) + app.register_blueprint(prioritization_bp) + app.register_blueprint(resources_bp) app.register_blueprint(scans_bp) app.register_blueprint(score_bp) - app.register_blueprint(compliance_bp) # ------------------------------------------------------------------ # # Routes (public) # diff --git a/api/models/finding.py b/api/models/finding.py index f344ef5..5c9634b 100644 --- a/api/models/finding.py +++ b/api/models/finding.py @@ -135,7 +135,8 @@ def create_tables(self) -> None: subscription_id TEXT NOT NULL, started_at TIMESTAMPTZ NOT NULL, completed_at TIMESTAMPTZ, - total_findings INTEGER DEFAULT 0 + total_findings INTEGER DEFAULT 0, + score INTEGER DEFAULT NULL ); """) cur.execute(""" @@ -218,8 +219,8 @@ def save_scan(self, scan_result: Dict[str, Any]) -> None: with conn.cursor() as cur: cur.execute( """ - INSERT INTO scans (scan_id, subscription_id, started_at, completed_at, total_findings) - VALUES (%s, %s, %s, %s, %s) + INSERT INTO scans (scan_id, subscription_id, started_at, completed_at, total_findings, score) + VALUES (%s, %s, %s, %s, %s, %s) ON CONFLICT (scan_id) DO NOTHING """, ( @@ -228,6 +229,7 @@ def save_scan(self, scan_result: Dict[str, Any]) -> None: scan_result["started_at"], scan_result["completed_at"], scan_result["total_findings"], + scan_result.get("score"), ), ) for f in scan_result.get("findings", []): @@ -290,6 +292,11 @@ def get_findings(self, filters: Optional[Dict[str, Any]] = None) -> List[Dict[st if "scan_id" in filters: clauses.append("scan_id = %s") params.append(filters["scan_id"]) + else: + # Default to the latest scan so historical findings do not inflate counts + clauses.append( + "scan_id = (SELECT scan_id FROM scans WHERE total_findings > 0 ORDER BY started_at DESC LIMIT 1)" + ) where = "WHERE " + " AND ".join(clauses) if clauses else "" sql = f"SELECT * FROM findings {where} ORDER BY detected_at DESC LIMIT 1000" @@ -350,15 +357,23 @@ def get_scans(self) -> List[Dict[str, Any]]: # ------------------------------------------------------------------ # def get_score(self) -> int: - """Return a 0-100 security posture score based on open findings. + """Return a 0-100 security posture score based on the latest scan's findings. - HIGH findings deduct 10 points each, MEDIUM 5, LOW 2. - Score floors at 0. + Scoped to the most recent scan so historical findings from older scans + do not accumulate and drive the score to zero. + HIGH findings deduct 10 points each, MEDIUM 5, LOW 2. Floors at 0. """ conn = self._get_conn() with conn.cursor() as cur: cur.execute( - "SELECT severity, COUNT(*) FROM findings GROUP BY severity" + """ + SELECT severity, COUNT(*) + FROM findings + WHERE scan_id = ( + SELECT scan_id FROM scans WHERE total_findings > 0 ORDER BY started_at DESC LIMIT 1 + ) + GROUP BY severity + """ ) rows = cur.fetchall() @@ -379,6 +394,9 @@ def get_cve_summary(self) -> Dict[str, Any]: AVG(cvss_score) as avg_cvss_score, COUNT(CASE WHEN cvss_score >= 9.0 THEN 1 END) as critical_cve_count FROM findings + WHERE scan_id = ( + SELECT scan_id FROM scans WHERE total_findings > 0 ORDER BY started_at DESC LIMIT 1 + ) """) row = cur.fetchone() diff --git a/api/routes/drift.py b/api/routes/drift.py new file mode 100644 index 0000000..50980f9 --- /dev/null +++ b/api/routes/drift.py @@ -0,0 +1,142 @@ +"""Drift route: compare the two most recent scans to surface configuration changes.""" + +import logging +import os +from flask import Blueprint, g, jsonify + +from api.models.finding import DatabaseManager + +drift_bp = Blueprint("drift", __name__) +logger = logging.getLogger(__name__) + + +def _get_db() -> DatabaseManager: + if "db" not in g: + g.db = DatabaseManager(os.environ["DATABASE_URL"]) + g.db.connect() + return g.db + + +def _ts(value) -> str: + """Normalise a datetime or string to an ISO-8601 string.""" + return value.isoformat() if hasattr(value, "isoformat") else str(value) + + +@drift_bp.get("/api/drift") +def get_drift(): + """Return configuration drift derived by comparing the two most recent scans. + + ADDED = (rule_id, resource_id) in the latest scan but not the previous one. + REMOVED = (rule_id, resource_id) in the previous scan but not the latest one. + + If only one scan exists the response returns an empty event list. + """ + try: + import psycopg2.extras + + db = _get_db() + conn = db._get_conn() + + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + "SELECT scan_id, started_at FROM scans WHERE total_findings > 0 ORDER BY started_at DESC LIMIT 2" + ) + scans = cur.fetchall() + + if len(scans) < 2: + last_checked = _ts(scans[0]["started_at"]) if scans else None + return jsonify({ + "summary": {"total": 0, "added": 0, "removed": 0, "modified": 0, "last_checked": last_checked}, + "events": [], + }) + + latest_id = str(scans[0]["scan_id"]) + previous_id = str(scans[1]["scan_id"]) + last_checked = _ts(scans[0]["started_at"]) + prev_ts = _ts(scans[1]["started_at"]) + + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + """ + SELECT rule_id, resource_id, resource_name, resource_type, + category, severity, scan_id + FROM findings + WHERE scan_id IN (%s, %s) + """, + (latest_id, previous_id), + ) + rows = cur.fetchall() + + # Build lookup: key = (rule_id, resource_id) → row, per scan + latest_map: dict = {} + previous_map: dict = {} + for row in rows: + key = (row["rule_id"], row["resource_id"]) + if str(row["scan_id"]) == latest_id: + latest_map[key] = row + else: + previous_map[key] = row + + added_keys = set(latest_map) - set(previous_map) + removed_keys = set(previous_map) - set(latest_map) + + def _rg(resource_id: str) -> str: + parts = (resource_id or "").split("/") + return parts[4] if len(parts) > 4 else "" + + events = [] + event_id = 1 + + for key in sorted(added_keys, key=lambda k: k[0]): + row = latest_map[key] + events.append({ + "id": event_id, + "type": "ADDED", + "severity": row["severity"], + "resource_name": row["resource_name"], + "resource_type": row["resource_type"], + "resource_group": _rg(row["resource_id"]), + "field": "security_policy", + "old_value": None, + "new_value": row["severity"], + "changed_by": "azure-policy-scan", + "changed_at": last_checked, + "rule_violated": row["rule_id"], + }) + event_id += 1 + + for key in sorted(removed_keys, key=lambda k: k[0]): + row = previous_map[key] + events.append({ + "id": event_id, + "type": "REMOVED", + "severity": row["severity"], + "resource_name": row["resource_name"], + "resource_type": row["resource_type"], + "resource_group": _rg(row["resource_id"]), + "field": "security_policy", + "old_value": row["severity"], + "new_value": None, + "changed_by": "azure-policy-scan", + "changed_at": prev_ts, + "rule_violated": row["rule_id"], + }) + event_id += 1 + + # Sort all events by changed_at desc + events.sort(key=lambda e: e["changed_at"] or "", reverse=True) + + return jsonify({ + "summary": { + "total": len(events), + "added": len(added_keys), + "removed": len(removed_keys), + "modified": 0, + "last_checked": last_checked, + }, + "events": events, + }) + + except Exception as exc: + logger.error("Failed to compute drift: %s", exc) + return jsonify({"error": "Failed to retrieve drift", "detail": str(exc)}), 500 diff --git a/api/routes/findings.py b/api/routes/findings.py index 2803251..9c9a9e3 100644 --- a/api/routes/findings.py +++ b/api/routes/findings.py @@ -1,12 +1,15 @@ -"""Findings routes: list and retrieve individual findings.""" +"""Findings routes: list, retrieve, and get remediation playbooks for findings.""" import logging import os +from pathlib import Path from flask import Blueprint, g, jsonify, request from api.models.finding import DatabaseManager from scanner.cve_correlator import enrich_findings +_PLAYBOOKS_DIR = Path(__file__).parent.parent.parent / "playbooks" / "cli" + findings_bp = Blueprint("findings", __name__) logger = logging.getLogger(__name__) @@ -64,3 +67,64 @@ def get_finding(finding_id: int): except Exception as exc: logger.error("Failed to get finding %d: %s", finding_id, exc) return jsonify({"error": "Database error", "detail": str(exc)}), 500 + + +@findings_bp.get("/api/findings//playbook") +def get_playbook(finding_id: int): + """Return a structured remediation playbook for a finding. + + Loads the pre-written Azure CLI script from playbooks/cli/ (keyed by rule_id) + and combines it with the finding's remediation guidance and any CVE references. + """ + try: + db = _get_db() + finding = db.get_finding_by_id(finding_id) + if not finding: + return jsonify({"error": "Finding not found"}), 404 + + rule_id = finding.get("rule_id", "") + remediation = finding.get("remediation", "") + cve_refs = finding.get("cve_references") or [] + + # Map rule_id (e.g. AZ-STOR-001) to script filename (fix_az_stor_001.sh) + script_name = "fix_" + rule_id.lower().replace("-", "_") + ".sh" + script_path = _PLAYBOOKS_DIR / script_name + + cli_commands = [] + if script_path.exists(): + raw = script_path.read_text() + # Strip comment-only lines and blank lines; join multi-line commands + lines = raw.splitlines() + cmd_lines = [ + l for l in lines + if l.strip() and not l.strip().startswith("#") + and l.strip() not in ("set -e",) + ] + cli_commands = ["\n".join(cmd_lines)] if cmd_lines else [] + + portal_steps = [remediation] if remediation else [] + + validation_steps = [ + f"Open the Azure Portal and navigate to the resource.", + f"Verify the security configuration matches the remediation guidance for {rule_id}.", + "Re-run an OpenShield scan and confirm this finding no longer appears.", + ] + + references = [] + for cve in cve_refs: + cve_id = cve.get("cve_id", "") + if cve_id: + references.append(f"https://nvd.nist.gov/vuln/detail/{cve_id}") + if not references: + references.append("https://learn.microsoft.com/en-us/azure/security/") + + return jsonify({ + "portal_steps": portal_steps, + "cli_commands": cli_commands, + "validation_steps": validation_steps, + "references": references, + }) + + except Exception as exc: + logger.error("Failed to get playbook for finding %d: %s", finding_id, exc) + return jsonify({"error": "Failed to retrieve playbook", "detail": str(exc)}), 500 diff --git a/api/routes/prioritization.py b/api/routes/prioritization.py new file mode 100644 index 0000000..f643ad6 --- /dev/null +++ b/api/routes/prioritization.py @@ -0,0 +1,178 @@ +"""Prioritization route: rank findings by severity, affected resources, and remediation effort.""" + +import logging +import os +from flask import Blueprint, g, jsonify + +from api.models.finding import DatabaseManager, SEVERITY_WEIGHTS + +prioritization_bp = Blueprint("prioritization", __name__) +logger = logging.getLogger(__name__) + +# Estimated remediation effort (1 = fastest, 4 = slowest) per category +_EFFORT = { + "Storage": 1, + "Network": 2, + "Database": 2, + "Compute": 2, + "Identity": 3, + "KeyVault": 2, + "Monitoring": 1, +} +_DEFAULT_EFFORT = 2 + +_EFFORT_ETA = {1: "15 mins", 2: "1 hour", 3: "1 day", 4: "1 week"} +_EFFORT_LABEL = {1: "LOW", 2: "MEDIUM", 3: "HIGH", 4: "HIGH"} + +# 1-10 risk score per severity for the matrix +_RISK_SCORE = {"HIGH": 8, "MEDIUM": 5, "LOW": 2, "INFO": 1} + +# Composite score threshold → impact label +def _impact(score: int) -> str: + if score >= 40: + return "CRITICAL" + if score >= 20: + return "HIGH" + if score >= 10: + return "MEDIUM" + return "LOW" + + +def _get_db() -> DatabaseManager: + if "db" not in g: + g.db = DatabaseManager(os.environ["DATABASE_URL"]) + g.db.connect() + return g.db + + +@prioritization_bp.get("/api/prioritization") +def get_prioritization(): + """Return a risk-ranked prioritization view derived from the latest scan's findings. + + Aggregates findings by rule, scores each rule by (severity_weight × affected_resource_count), + and sorts descending to surface the highest-priority fixes first. + """ + try: + import psycopg2.extras + + db = _get_db() + conn = db._get_conn() + + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + "SELECT scan_id FROM scans WHERE total_findings > 0 ORDER BY started_at DESC LIMIT 1" + ) + row = cur.fetchone() + if not row: + empty = {"matrix": [], "rankings": [], "action_items": [], "summary": {}} + return jsonify(empty) + latest_scan_id = str(row["scan_id"]) + + cur.execute( + """ + SELECT + rule_id, + rule_name, + severity, + category, + remediation, + COUNT(DISTINCT resource_id) AS affected_count, + MIN(resource_name) AS resource_name + FROM findings + WHERE scan_id = %s + GROUP BY rule_id, rule_name, severity, category, remediation + ORDER BY affected_count DESC + """, + (latest_scan_id,), + ) + rules = cur.fetchall() + + cur.execute("SELECT COUNT(*) AS total FROM findings WHERE scan_id = %s", (latest_scan_id,)) + total_findings = cur.fetchone()["total"] + + matrix = [] + rankings = [] + action_items = [] + severity_counts: dict = {} + + for idx, rule in enumerate(rules): + sev = (rule["severity"] or "LOW").upper() + cat = rule["category"] or "Other" + effort = _EFFORT.get(cat, _DEFAULT_EFFORT) + weight = SEVERITY_WEIGHTS.get(sev, 2) + affected = rule["affected_count"] + score = weight * affected + risk = _RISK_SCORE.get(sev, 2) + + severity_counts[sev] = severity_counts.get(sev, 0) + affected + + matrix.append({ + "id": idx + 1, + "rule_id": rule["rule_id"], + "name": rule["rule_name"], + "risk": risk, + "effort": effort, + "category": cat, + "severity": sev, + "affected_resources": affected, + "resource": rule["resource_name"], + }) + + rankings.append({ + "rank": idx + 1, # re-sorted below + "rule_id": rule["rule_id"], + "name": rule["rule_name"], + "score": score, + "severity": sev, + "category": cat, + "effort": effort, + "impact": _impact(score), + "resource": rule["resource_name"], + }) + + # Top 10 rules → action items + if len(action_items) < 10: + action_items.append({ + "id": idx + 1, + "action": rule["remediation"] or f"Remediate {rule['rule_name']}", + "impact": _impact(score), + "effort": _EFFORT_LABEL.get(effort, "MEDIUM"), + "eta": _EFFORT_ETA.get(effort, "1 hour"), + "rule_id": rule["rule_id"], + "resource": rule["resource_name"], + }) + + # Sort rankings by score desc and re-assign ranks + rankings.sort(key=lambda r: r["score"], reverse=True) + for i, r in enumerate(rankings): + r["rank"] = i + 1 + + critical = severity_counts.get("HIGH", 0) + total_hours = sum( + _EFFORT.get(r["category"], _DEFAULT_EFFORT) + for r in matrix + if r["severity"] in ("HIGH", "MEDIUM") + ) + estimated_time = f"{total_hours} hours" if total_hours < 24 else f"{total_hours // 8} days" + + summary = { + "totalFindings": total_findings, + "criticalFindings": critical, + "highRiskFindings": severity_counts.get("HIGH", 0), + "mediumRiskFindings": severity_counts.get("MEDIUM", 0), + "lowRiskFindings": severity_counts.get("LOW", 0), + "recommendedActionsCount": len(action_items), + "estimatedFixTime": estimated_time, + "topPriority": rankings[0]["name"] if rankings else "No findings", + } + + return jsonify({ + "matrix": matrix, + "rankings": rankings[:25], + "action_items": action_items, + "summary": summary, + }) + + except Exception as exc: + logger.error("Failed to build prioritization: %s", exc) + return jsonify({"error": "Failed to retrieve prioritization", "detail": str(exc)}), 500 diff --git a/api/routes/resources.py b/api/routes/resources.py new file mode 100644 index 0000000..d5072f0 --- /dev/null +++ b/api/routes/resources.py @@ -0,0 +1,115 @@ +"""Resources route: derive unique Azure resources from the latest scan's findings.""" + +import logging +import os +from flask import Blueprint, g, jsonify + +from api.models.finding import DatabaseManager + +resources_bp = Blueprint("resources", __name__) +logger = logging.getLogger(__name__) + + +def _get_db() -> DatabaseManager: + if "db" not in g: + g.db = DatabaseManager(os.environ["DATABASE_URL"]) + g.db.connect() + return g.db + + +def _parse_resource_id(resource_id: str): + """Extract subscription_id and resource_group from an ARM resource path.""" + parts = (resource_id or "").split("/") + subscription_id = parts[2] if len(parts) > 2 else "" + resource_group = parts[4] if len(parts) > 4 else "" + return subscription_id, resource_group + + +@resources_bp.get("/api/resources") +def get_resources(): + """Return unique Azure resources derived from the most recent scan's findings. + + Groups findings by resource_id and surfaces the highest-severity finding per + resource as the resource's risk level. + """ + try: + import psycopg2.extras + + db = _get_db() + conn = db._get_conn() + + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + # Get the most recent scan metadata + cur.execute( + "SELECT scan_id, started_at FROM scans WHERE total_findings > 0 ORDER BY started_at DESC LIMIT 1" + ) + latest_scan = cur.fetchone() + if not latest_scan: + return jsonify({"summary": {"total": 0, "by_category": {}, "by_risk_level": {}, "last_scan_at": None}, "resources": []}) + + cur.execute( + """ + SELECT + resource_id, + resource_name, + resource_type, + category, + MIN(detected_at) AS discovered_at, + MAX(CASE severity + WHEN 'HIGH' THEN 3 + WHEN 'MEDIUM' THEN 2 + WHEN 'LOW' THEN 1 + ELSE 0 END) AS risk_rank + FROM findings + WHERE scan_id = %s + GROUP BY resource_id, resource_name, resource_type, category + ORDER BY risk_rank DESC, resource_name + """, + (str(latest_scan["scan_id"]),), + ) + rows = cur.fetchall() + + rank_to_risk = {3: "HIGH", 2: "MEDIUM", 1: "LOW", 0: "NONE"} + by_category: dict = {} + by_risk_level: dict = {"HIGH": 0, "MEDIUM": 0, "LOW": 0, "NONE": 0} + resources = [] + + for row in rows: + sub_id, rg = _parse_resource_id(row["resource_id"]) + risk = rank_to_risk.get(row["risk_rank"], "NONE") + detected = row["discovered_at"] + discovered_at = detected.isoformat() if hasattr(detected, "isoformat") else str(detected) + + resources.append({ + "id": row["resource_id"], + "name": row["resource_name"], + "type": row["resource_type"], + "category": row["category"], + "resource_group": rg, + "subscription_id": sub_id, + "location": "", + "risk": risk, + "discovered_at": discovered_at, + "config": {}, + }) + + by_category[row["category"]] = by_category.get(row["category"], 0) + 1 + by_risk_level[risk] = by_risk_level.get(risk, 0) + 1 + + last_scan_at = latest_scan["started_at"] + if hasattr(last_scan_at, "isoformat"): + last_scan_at = last_scan_at.isoformat() + + return jsonify({ + "summary": { + "total": len(resources), + "by_category": by_category, + "by_risk_level": by_risk_level, + "last_scan_at": last_scan_at, + }, + "resources": resources, + }) + + except Exception as exc: + logger.error("Failed to build resources: %s", exc) + return jsonify({"error": "Failed to retrieve resources", "detail": str(exc)}), 500 diff --git a/docs/api-reference.md b/docs/api-reference.md index 3d37a58..e174b24 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1,6 +1,6 @@ # API Reference -The OpenShield API is a Flask app registered in `api/app.py`. `/health` is public. All `/api/*` routes require an `Authorization: Bearer ` header signed with `JWT_SECRET`. +The OpenShield API is a Flask app registered in `api/app.py`. All `GET` requests (including `/health` and all `/api/*` GET routes) are public — no token needed. `POST` endpoints (`/api/scans/trigger`, `/api/ai/*`) require an `Authorization: Bearer ` header signed with `JWT_SECRET`. --- @@ -250,3 +250,191 @@ Unknown framework response: "supported": ["cis", "nist", "iso27001", "soc2"] } ``` + +--- + +## GET /api/resources + +Returns unique Azure resources derived from the most recent scan that has findings. Resources are aggregated from findings — one entry per distinct `resource_id`. Risk level is computed from the maximum severity finding on each resource. + +Query parameters: none + +Example response: + +```json +{ + "summary": { + "total": 12, + "by_category": { "Storage": 3, "Network": 4, "Identity": 3, "Database": 2 }, + "by_risk_level": { "HIGH": 4, "MEDIUM": 6, "LOW": 2 }, + "last_scan_at": "2026-06-03T15:12:51Z" + }, + "resources": [ + { + "resource_id": "/subscriptions/00000000/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/example", + "resource_name": "example", + "resource_type": "Microsoft.Storage/storageAccounts", + "resource_group": "rg", + "subscription_id": "00000000-0000-0000-0000-000000000000", + "category": "Storage", + "risk_level": "HIGH", + "finding_count": 2 + } + ] +} +``` + +No findings response (no scan with findings exists): + +```json +{ + "summary": { "total": 0, "by_category": {}, "by_risk_level": {}, "last_scan_at": null }, + "resources": [] +} +``` + +--- + +## GET /api/prioritization + +Returns findings from the most recent scan grouped and ranked by risk score (`severity_weight × affected_resource_count`). Produces a matrix view, a ranked list, and recommended action items. + +Query parameters: none + +Example response: + +```json +{ + "matrix": [ + { + "id": "AZ-STOR-001", + "ruleId": "AZ-STOR-001", + "name": "Public Blob Access Enabled", + "risk": "HIGH", + "effort": 2, + "category": "Storage", + "severity": "HIGH", + "affectedResources": 3, + "resource": "storageAccount" + } + ], + "rankings": [ + { + "rank": 1, + "ruleId": "AZ-STOR-001", + "name": "Public Blob Access Enabled", + "score": 30, + "impact": "HIGH", + "effort": 2, + "category": "Storage", + "resource": "storageAccount" + } + ], + "action_items": [ + { + "priority": 1, + "ruleId": "AZ-STOR-001", + "action": "Disable public blob access on all storage accounts", + "impact": "HIGH", + "effort": "LOW", + "resources_affected": 3 + } + ], + "summary": { + "total_issues": 8, + "high_priority": 3, + "total_affected_resources": 12 + } +} +``` + +--- + +## GET /api/drift + +Compares the two most recent scans that have findings to surface configuration changes. Returns `ADDED` events (rule fired in latest scan but not the previous) and `REMOVED` events (rule fired in previous scan but not the latest). Returns an empty events list when fewer than two scans with findings exist. + +Query parameters: none + +Example response: + +```json +{ + "summary": { + "added": 2, + "removed": 1, + "modified": 0, + "last_checked": "2026-06-03T15:12:51Z" + }, + "events": [ + { + "id": "AZ-NET-001-/subscriptions/00000000/.../nsg", + "type": "ADDED", + "rule_id": "AZ-NET-001", + "rule_name": "SSH Access from Internet Not Restricted", + "resource_id": "/subscriptions/00000000/resourceGroups/rg/providers/Microsoft.Network/networkSecurityGroups/nsg", + "resource_name": "nsg", + "severity": "HIGH", + "category": "Network", + "detected_at": "2026-06-03T15:12:51Z" + } + ] +} +``` + +No drift response (fewer than two scans): + +```json +{ + "summary": { "added": 0, "removed": 0, "modified": 0, "last_checked": null }, + "events": [] +} +``` + +--- + +## GET /api/findings/<id>/playbook + +Returns the structured remediation playbook for a specific finding. Loads the matching `playbooks/cli/fix_.sh` script and wraps the finding's remediation text as a portal step. Appends NVD links from any CVE references on the finding. + +Path parameters: `id` — integer finding ID from `GET /api/findings`. + +Example response: + +```json +{ + "finding_id": 42, + "rule_id": "AZ-STOR-001", + "portal_steps": [ + "Navigate to Storage Accounts in the Azure Portal. Select the storage account. Under 'Configuration', set 'Allow Blob public access' to Disabled." + ], + "cli_commands": [ + "az storage account update --name --resource-group --allow-blob-public-access false" + ], + "validation_steps": [ + "Verify with: az storage account show --name --query allowBlobPublicAccess" + ], + "references": [ + "https://nvd.nist.gov/vuln/detail/CVE-2021-XXXXX" + ] +} +``` + +Not found response: + +```json +{ + "error": "Finding 99 not found" +} +``` + +--- + +## Deferred endpoints + +The following endpoints are called by the frontend but have no backend implementation yet. The frontend falls back to static mock data when these return 404. + +| Endpoint | Used by | Status | +|---|---|---| +| `GET /api/monitoring` | Monitoring page — score trend chart, category distribution | Deferred. Score and findings data come from `GET /api/score` and `GET /api/findings` instead. | +| `GET /api/scans/` | Header scan poller | Deferred. The frontend falls back to `GET /api/scans` and matches by `scan_id` in the response list. The poller is rarely entered because `POST /api/scans/trigger` now returns `status: completed` immediately. | diff --git a/docs/api-render-deploy.md b/docs/api-render-deploy.md index b9685f0..d5dc2bd 100644 --- a/docs/api-render-deploy.md +++ b/docs/api-render-deploy.md @@ -4,24 +4,24 @@ ## 1. Overview -This test plan covers the verification of the OpenShield API deployment -to the Render free tier. The goal is to confirm: +This test plan covers the verification of the OpenShield API deployment +to Render (Starter instance or higher). The goal is to confirm: - The Render Web Service builds and deploys the Flask app successfully. - The database is automatically initialized on startup via `init_db`. - The pre-commit hook and GitHub Actions CI pipeline gate the code properly. - The CI pipeline is **community-friendly**, allowing forks to pass even without custom secrets. - Real Azure scan tests are gated behind `RUN_REAL_SCAN=true` so contributor CI never depends on live Azure credentials. -- All 23 API edge cases (routing, filtering, authentication) function correctly in the live cloud environment. +- All 32 API test cases (health, findings, score, scans, compliance, dashboard contract, auth, edge cases) pass against the live deployment. --- ## 2. Methodology and Test Rationale -To ensure the highest reliability of the deployment while accommodating free-tier constraints and community contributions, specific methods and test strategies were chosen: +To ensure the highest reliability of the deployment while remaining community-friendly, specific methods and test strategies were chosen: ### 2.1 Infrastructure and Pipeline Strategy -* **Targeting Render over Azure F1:** Azure App Service's F1 tier imposes a strict 60 CPU-minute daily cap. Render provides unmetered CPU on the free tier, making it significantly more reliable for demo and development environments. +* **Targeting Render over Azure F1:** Azure App Service's F1 tier imposes a strict 60 CPU-minute daily cap. Render provides unmetered CPU and always-on availability on paid instances, making it significantly more reliable for production environments. * **Database Initialization:** The `api/models/finding.py` was updated with an `init_db` method. This method ensures that all required tables (`scans`, `findings`) are created automatically during the first deployment, preventing HTTP 500 errors. * **Pre-commit Hook:** Fails fast. By running syntax checks and local API smoke tests *before* the commit is allowed, we prevent broken code from polluting the remote branch. * **Community-Friendly CI Gate:** The GitHub Action is designed to be zero-friction for contributors. @@ -40,12 +40,16 @@ To ensure the highest reliability of the deployment while accommodating free-tie > ``` > Local development runs (no production signal set) are allowed to use the default and will log a loud warning. -### 2.3 API Smoke Test Strategy (The 23 Cases) -The 23 test cases were selected to prove the API is structurally sound and resilient: -* **Health Check (TC-01 to TC-03):** Confirms base app connectivity and ensures public routes are not locked. -* **Core Endpoints (TC-04 to TC-17):** Verifies the actual business logic and JSON structure. -* **Auth/Security (TC-18 to TC-19):** Confirms the JWT middleware is strictly enforced. -* **Edge Cases and Resilience (TC-20 to TC-23):** Ensures the app does not crash when given bad input or non-existent routes. +### 2.3 API Smoke Test Strategy (The 32 Cases) +The 32 test cases prove the API is structurally sound, contract-correct, and resilient: +* **Health (TC-01 to TC-03):** Confirms base connectivity and that `/health` is public. +* **Findings (TC-04 to TC-08):** Verifies shape, filtering, and bad-input handling. +* **Score (TC-09 to TC-11):** Confirms numeric result bounded 0–100. +* **Scans (TC-12 to TC-14):** List endpoint always runs; real scan trigger is maintainer-gated. +* **Compliance (TC-15 to TC-18):** All four frameworks — CIS, NIST, ISO 27001, SOC 2. +* **Auth/Security (TC-19 to TC-20):** POST endpoints reject missing and malformed JWTs. All GET endpoints are intentionally public. +* **Dashboard Contract (TC-21 to TC-28):** Validates the four v0.2.0 endpoints — `/api/resources`, `/api/prioritization`, `/api/drift`, `/api/findings//playbook` — against their documented response shapes. +* **Edge Cases (TC-29 to TC-32):** 404 routing, empty body, bad query param, Content-Type. #### Conditional vs Always-Run Tests @@ -54,6 +58,8 @@ The 23 test cases were selected to prove the API is structurally sound and resil | Contributor / fork (no `RUN_REAL_SCAN`) | `SKIP` — printed with reason, not a failure | Always run | | Maintainer deployment (`RUN_REAL_SCAN=true` + Azure credentials) | Run real scan against live subscription | Always run | +TC-27 and TC-28 (playbook) are automatically skipped with a clear reason if the database has no findings. Seed the database first with `seed_render_db.py` to enable them. + Run modes: ```bash # Contributor / local (no Azure credentials needed) @@ -102,7 +108,7 @@ API_URL=https://openshield-api.onrender.com JWT_SECRET= \ ### 4.2 Create Test Resources in Render 1. **Render PostgreSQL Database (Free Tier)** - Name: `openshield-db` -2. **Render Web Service (Free Tier)** +2. **Render Web Service (Starter instance or higher)** - Connected to your branch. - Start Command: `./startup.sh` - Environment Variables set: `DATABASE_URL`, `JWT_SECRET`, `ALLOWED_ORIGINS`, `AZURE_SUBSCRIPTION_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`. @@ -186,21 +192,26 @@ API_URL=https://openshield-api.onrender.com JWT_SECRET= \ * **TC-16:** GET `/api/compliance/nist` returns HTTP 200. * **TC-17:** GET `/api/compliance/iso27001` returns HTTP 200. +#### Compliance Endpoints (continued) +* **TC-18:** GET `/api/compliance/soc2` returns HTTP 200. + #### Auth & Security Edge Cases -* **TC-18:** GET `/api/findings` without any auth header returns HTTP 401. -* **TC-19:** GET `/api/findings` with a malformed JWT returns HTTP 401. +* **TC-19:** POST `/api/scans/trigger` without any auth header returns HTTP 401. +* **TC-20:** POST `/api/scans/trigger` with a malformed JWT returns HTTP 401. + +> **Note:** All `GET /api/*` routes are public — no token required. Auth is enforced only on `POST` endpoints (`/api/scans/trigger`, `/api/ai/*`). #### General Edge Cases -* **TC-20:** GET `/nonexistent-endpoint-xyz` returns HTTP 404 (requires auth to pass middleware). -* **TC-21:** POST `/api/scans/trigger` with an empty JSON body returns HTTP 400 (missing `subscription_id`) without crashing. -* **TC-22:** GET `/api/findings?limit=0` does not crash the server. -* **TC-23:** All valid endpoint responses include the `application/json` Content-Type. +* **TC-21:** GET `/nonexistent-endpoint-xyz` returns HTTP 404. +* **TC-22:** POST `/api/scans/trigger` with an empty JSON body returns HTTP 400 (missing `subscription_id`) without crashing. +* **TC-23:** GET `/api/findings?limit=0` does not crash the server. +* **TC-24:** All valid endpoint responses include the `application/json` Content-Type. --- ## 6. Cleanup -Render Free Tier Web Services spin down after 15 minutes of inactivity. The Free PostgreSQL database will automatically be deleted by Render after 90 days. To clean up manually, delete both resources from the Render dashboard Settings page. +To clean up, delete both the Web Service and the PostgreSQL database from the Render dashboard Settings page. Note: free-tier PostgreSQL databases are automatically deleted by Render after 90 days; paid instances do not have this limit. --- @@ -228,12 +239,21 @@ Render Free Tier Web Services spin down after 15 minutes of inactivity. The Free | **TC-15** | `/api/compliance/cis` works | Pass | [ ] | | **TC-16** | `/api/compliance/nist` works | Pass | [ ] | | **TC-17** | `/api/compliance/iso27001` works | Pass | [ ] | -| **TC-18** | Missing auth returns 401 | Pass | [ ] | -| **TC-19** | Bad token returns 401 | Pass | [ ] | -| **TC-20** | 404 routing works safely | Pass | [ ] | -| **TC-21** | Empty body payload handled | Pass (400) | [ ] | -| **TC-22** | Limit=0 query handled safely | Pass | [ ] | -| **TC-23** | Content-Type is JSON | Pass | [ ] | - -**Maintainer repo:** All 26 checks (3 Pipeline + 23 API) must pass before merging to `dev` or `main`. -**Fork / contributor:** 24 checks (3 Pipeline + 21 API) must pass; TC-13 and TC-14 are expected `SKIP`. +| **TC-18** | `/api/compliance/soc2` works | Pass | [ ] | +| **TC-19** | POST trigger without auth returns 401 | Pass | [ ] | +| **TC-20** | POST trigger bad token returns 401 | Pass | [ ] | +| **TC-21** | `/api/resources` returns 200 | Pass | [ ] | +| **TC-22** | `/api/resources` returns correct shape | Pass | [ ] | +| **TC-23** | `/api/prioritization` returns 200 | Pass | [ ] | +| **TC-24** | `/api/prioritization` returns correct shape | Pass | [ ] | +| **TC-25** | `/api/drift` returns 200 | Pass | [ ] | +| **TC-26** | `/api/drift` returns correct shape | Pass | [ ] | +| **TC-27** | `/api/findings//playbook` returns 200 | Pass (skip if DB empty) | [ ] | +| **TC-28** | `/api/findings//playbook` returns correct shape | Pass (skip if DB empty) | [ ] | +| **TC-29** | 404 routing works safely | Pass | [ ] | +| **TC-30** | Empty body payload handled | Pass (400) | [ ] | +| **TC-31** | Limit=0 query handled safely | Pass | [ ] | +| **TC-32** | Content-Type is JSON | Pass | [ ] | + +**Maintainer repo:** All 35 checks (3 Pipeline + 32 API) must pass before merging to `dev` or `main`. +**Fork / contributor:** 33 checks (3 Pipeline + 30 API) must pass; TC-13 and TC-14 are expected `SKIP`. diff --git a/docs/architecture.md b/docs/architecture.md index 0ae8147..a920329 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ ## Overview -OpenShield is a modular, open source Cloud Security Posture Management (CSPM) platform for Azure. It scans your Azure subscription against 20 security rules, maps findings to compliance frameworks (CIS, NIST CSF, ISO 27001, SOC 2), stores results in PostgreSQL, and exposes posture data through a Flask REST API. +OpenShield is a modular, open source Cloud Security Posture Management (CSPM) platform for Azure. It scans your Azure subscription against 36 security rules, maps findings to compliance frameworks (CIS, NIST CSF, ISO 27001, SOC 2), stores results in PostgreSQL, and exposes posture data through a Flask REST API consumed by a live React dashboard. --- @@ -10,17 +10,24 @@ OpenShield is a modular, open source Cloud Security Posture Management (CSPM) pl ``` ┌──────────────────────────────────────────────────────────────────┐ -│ React Dashboard MVP (planned) │ -│ frontend/ scaffold │ +│ React Dashboard (frontend/ — deployed on Vercel) │ +│ │ +│ 7 pages: Monitor · Discover · Prioritize · Scan │ +│ Comply · Drift · AI │ └────────────────────────────┬─────────────────────────────────────┘ - │ HTTPS / JWT + │ HTTPS (all GETs public, POSTs need JWT) ┌────────────────────────────▼─────────────────────────────────────┐ │ Flask REST API (api/) │ │ │ │ GET /health │ │ GET /api/findings GET /api/score │ -│ GET /api/findings/ GET /api/compliance/ │ +│ GET /api/findings/ GET /api/score/cve-summary │ +│ GET /api/findings//playbook │ │ GET /api/scans POST /api/scans/trigger │ +│ GET /api/resources GET /api/prioritization │ +│ GET /api/drift GET /api/compliance/ │ +│ POST /api/ai/ask POST /api/ai/summary │ +│ POST /api/ai/insights POST /api/ai/prioritise │ └───────────┬──────────────────────────────────┬───────────────────┘ │ │ ┌───────────▼──────────────┐ ┌───────────────▼───────────────────┐ @@ -103,16 +110,16 @@ result = engine.run_scan() ### 4. Current Rule Modules -There are 20 current rule files in `scanner/rules/`. +There are 36 rule files in `scanner/rules/`. See `docs/rules-reference.md` for the full table. -| Category | Rules | -|---|---| -| Storage | AZ-STOR-001 public blob access, AZ-STOR-002 HTTPS-only storage, AZ-STOR-003 lifecycle management policy | -| Network | AZ-NET-001 SSH from any source, AZ-NET-002 RDP from any source, AZ-NET-003 unrestricted 443, AZ-NET-004 empty NSG, AZ-NET-005 no DDoS protection, AZ-NET-006 unassociated public IP, AZ-NET-007 Application Gateway without WAF, AZ-NET-008 load balancer without backend pool, AZ-NET-009 outdated IKE version, AZ-NET-010 subnet without NSG | -| Identity | AZ-IDN-001 service principal with Owner role, AZ-IDN-002 no admin MFA via Conditional Access | -| Database | AZ-DB-001 PostgreSQL public network access, AZ-DB-002 SQL Server auditing disabled | -| Compute | AZ-CMP-001 VM public IP with no NSG on NIC | -| Key Vault | AZ-KV-001 soft delete disabled, AZ-KV-002 public network access without private endpoint | +| Category | Count | Rules | +|---|---|---| +| Storage | 5 | AZ-STOR-001 to 005 | +| Network | 14 | AZ-NET-001 to 014 | +| Identity | 4 | AZ-IDN-001 to 004 | +| Database | 4 | AZ-DB-001 to 004 | +| Compute | 4 | AZ-CMP-001 to 004 | +| Key Vault | 5 | AZ-KV-001 to 005 | Every rule has a matching Azure CLI playbook in `playbooks/cli/`. @@ -167,24 +174,45 @@ Rules should use `scanner/azure_client.py` instead of instantiating SDK clients ``` run_scan() → findings[] in memory + → CVE enrichment via NVD API (cve_references, cvss_score, exploit_available) → db.save_scan(result) # persists to PostgreSQL + → scans row: scan_id, subscription_id, started_at, completed_at, + total_findings, score (severity-weighted 0-100) + → findings rows: one per finding with full metadata + CVE fields → return scan result JSON +All dashboard data endpoints are scoped to the most recent scan that has findings +(WHERE total_findings > 0 ORDER BY started_at DESC LIMIT 1), so seeded or last +good scan data is always visible even if a later scan produces zero results. + GET /api/findings - → db.get_findings(filters) # reads from PostgreSQL - → returns JSON array + → db.get_findings(filters) # reads from PostgreSQL, latest scan only + → returns { count, findings[] } GET /api/score - → db.get_score() # severity-weighted 0-100 - → returns {"score": 82} + → db.get_score() # severity-weighted: HIGH -10, MEDIUM -5, LOW -2 + → returns plain integer (e.g. 18) + +GET /api/resources + → aggregates unique resources from latest scan's findings + → returns { summary, resources[] } + +GET /api/prioritization + → ranks findings by severity_weight × affected_resource_count + → returns { matrix[], rankings[], action_items[], summary } + +GET /api/drift + → compares two most recent scans with findings + → returns { summary, events[] } with ADDED / REMOVED events -GET /api/compliance/cis - → db.get_compliance_score("cis") # joins DB findings with CIS JSON - → returns per-control pass/fail breakdown +GET /api/findings//playbook + → loads playbooks/cli/fix_.sh + CVE references + → returns { portal_steps[], cli_commands[], validation_steps[], references[] } -GET /api/compliance/soc2 - → db.get_compliance_score("soc2") # same flow for SOC 2 - → returns per-control pass/fail breakdown +GET /api/compliance/ + → joins DB findings with compliance/frameworks/.json + → supported: cis, nist, iso27001, soc2 + → returns per-control pass/fail breakdown + score_percent ``` --- diff --git a/docs/azure-setup.md b/docs/azure-setup.md index df52074..7aeef2a 100644 --- a/docs/azure-setup.md +++ b/docs/azure-setup.md @@ -186,7 +186,7 @@ Compliance posture is available through `/api/compliance/cis`, `/api/compliance/ ## Render Deployment (Recommended for API) -Render provides a free tier that is better suited for the OpenShield API than Azure App Service F1. +Render is recommended for hosting the OpenShield API. Use the Starter instance or higher for always-on availability. ### Steps @@ -221,11 +221,10 @@ JWT_SECRET=your-secret-key 8. Your API will be live at: `https://openshield-api.onrender.com` -### Known Limitations +### Instance Notes -- Free tier spins down after 15 minutes of inactivity -- First request after spin down takes 30 to 60 seconds -- Suitable for demo and testing, not production +- Starter instance or higher provides always-on availability +- Free-tier instances spin down after inactivity — not recommended for production use --- diff --git a/docs/rules-reference.md b/docs/rules-reference.md index c57d730..df45213 100644 --- a/docs/rules-reference.md +++ b/docs/rules-reference.md @@ -1,16 +1,26 @@ # Rules Reference -OpenShield currently ships 20 Azure scan rules. This table is generated from the module-level constants in `scanner/rules/`. +OpenShield currently ships 39 Azure scan rules. This table is generated from the module-level constants in `scanner/rules/`. -| Rule ID | Name | Severity | Category | CIS | NIST | ISO27001 | +| Rule ID | Name | Severity | Category | CIS | NIST | ISO 27001 | |---|---|---|---|---|---|---| | AZ-CMP-001 | VM with Public IP and No Associated NSG on Network Interface | HIGH | Compute | 7.2 | PR.AC-3 | A.13.1.1 | +| AZ-CMP-002 | Virtual machine disk not protected by customer-managed key or ADE | HIGH | Compute | 7.2 | PR.DS-1 | A.10.1.1 | +| AZ-CMP-003 | VM Without Endpoint Protection Installed | HIGH | Compute | 8.2 | DE.CM-1 | A.12.2.1 | +| AZ-CMP-004 | VM Without Automatic OS Patching Enabled | HIGH | Compute | 7.3 | SI-2 | A.12.6.1 | | AZ-DB-001 | PostgreSQL Server Allows Public Network Access | HIGH | Database | 4.3.1 | PR.AC-3 | A.13.1.1 | | AZ-DB-002 | Azure SQL Server Has No Auditing Configured | MEDIUM | Database | 4.1.3 | DE.CM-7 | A.12.4.1 | +| AZ-DB-003 | PostgreSQL Flexible Server SSL Enforcement Disabled | HIGH | Database | 4.3.4 | SC-8 | A.10.1.1 | +| AZ-DB-004 | SQL Server Firewall Allows All Azure Services | HIGH | Database | 4.1.2 | SC-7 | A.13.1.1 | | AZ-IDN-001 | Service Principal Assigned Owner Role at Subscription Scope | HIGH | Identity | 1.23 | PR.AC-4 | A.9.2.3 | | AZ-IDN-002 | No MFA Enforced on Admin Accounts via Conditional Access | HIGH | Identity | 1.2.4 | PR.AC-1 | A.9.4.2 | -| AZ-KV-001 | Key Vault with Soft Delete Disabled | MEDIUM | KeyVault | 8.5 | PR.IP-4 | A.17.2.1 | +| AZ-IDN-003 | Guest user invitations not restricted to admins in Entra ID | MEDIUM | Identity | 1.15 | PR.AC-6 | A.9.2.6 | +| AZ-IDN-004 | No Privileged Identity Management for Admin Roles | HIGH | Identity | 1.1.1 | PR.AC-4 | A.9.2.3 | +| AZ-KV-001 | Key Vault with Soft Delete Disabled | MEDIUM | Key Vault | 8.5 | PR.IP-4 | A.17.2.1 | | AZ-KV-002 | Key Vault Allows Public Network Access Without Private Endpoint | HIGH | Key Vault | 8.3 | AC-17 | A.13.1.1 | +| AZ-KV-003 | Key Vault Without Diagnostic Logging Enabled | MEDIUM | Key Vault | 8.4 | DE.CM-7 | A.12.4.1 | +| AZ-KV-004 | Key Vault Purge Protection Disabled | MEDIUM | Key Vault | 8.5 | PR.IP-4 | A.17.2.1 | +| AZ-KV-005 | Key Vault Certificate Expiring Within 30 Days | MEDIUM | Key Vault | 8.1 | PR.IP-3 | A.10.1.2 | | AZ-NET-001 | NSG Allows Unrestricted Inbound SSH from Any Source | HIGH | Network | 6.2 | PR.AC-3 | A.13.1.1 | | AZ-NET-002 | NSG Allows Unrestricted Inbound RDP from Any Source | HIGH | Network | 6.3 | PR.AC-3 | A.13.1.1 | | AZ-NET-003 | NSG allows unrestricted inbound on port 443 | HIGH | Network | 9.3 | SC-7 | A.13.1.1 | @@ -21,8 +31,16 @@ OpenShield currently ships 20 Azure scan rules. This table is generated from the | AZ-NET-008 | Load balancer with no backend pool configured | LOW | Network | 9.1 | CM-7 | A.13.1.1 | | AZ-NET-009 | VPN gateway using outdated IKE version | HIGH | Network | 9.5 | SC-8 | A.13.2.1 | | AZ-NET-010 | Subnet with no network security group attached | HIGH | Network | 9.2 | SC-7 | A.13.1.1 | +| AZ-NET-011 | Network Watcher Not Enabled in All Regions | LOW | Network | 9.7 | DE.CM-1 | A.12.4.1 | +| AZ-NET-012 | NSG Flow Logs Not Enabled | MEDIUM | Network | 9.7 | DE.CM-1 | A.12.4.1 | +| AZ-NET-013 | Azure Firewall Not Enabled on Virtual Network | HIGH | Network | 9.6 | SC-7 | A.13.1.1 | +| AZ-NET-014 | VNet Peering Configured Without Gateway Transit Restrictions | MEDIUM | Network | 9.2 | SC-7 | A.13.1.3 | | AZ-STOR-001 | Public Blob Access Enabled on Storage Account | HIGH | Storage | 3.5 | PR.AC-3 | A.9.4.1 | | AZ-STOR-002 | Storage Account Allows HTTP Traffic (Not HTTPS-Only) | HIGH | Storage | 3.1 | PR.DS-2 | A.10.1.1 | | AZ-STOR-003 | Storage Account Has No Lifecycle Management Policy | MEDIUM | Storage | 3.7 | PR.DS-3 | A.8.3.1 | +| AZ-STOR-004 | Storage Account Diagnostic Logging Disabled | MEDIUM | Storage | 3.11 | DE.CM-7 | A.12.4.1 | +| AZ-STOR-005 | Storage Account Not Using Geo-Redundant Replication | MEDIUM | Storage | 3.12 | PR.IP-4 | A.17.2.1 | SOC 2 mappings are maintained in `compliance/frameworks/soc2.json`. + +Every rule has a matching remediation playbook in `playbooks/cli/fix_.sh`. diff --git a/frontend/index.html b/frontend/index.html index ed522e5..55db846 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Open-shield + OpenShield
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 6893eb1..dd21b7f 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1 +1,13 @@ - \ No newline at end of file + + + + + + + + + + + + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c53edcf..84a51ac 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,29 +13,14 @@ import AILayer from './pages/AILayer'; export default function App() { useEffect(() => { - // Bootstrap JWT token. - // In production (Vercel): set VITE_JWT_TOKEN to a pre-generated HS256 JWT - // signed with the same JWT_SECRET as the Render backend. - // In local dev: falls back to 'dev-demo-token' (only works when backend - // uses the default insecure JWT_SECRET = 'change-me-in-production'). - if (!api.getToken()) { - api.setToken(import.meta.env.VITE_JWT_TOKEN || 'dev-demo-token'); + // Always prefer the build-time token so a stale localStorage value + // from a previous deployment never blocks authenticated requests. + const envToken = import.meta.env.VITE_JWT_TOKEN; + if (envToken) { + api.setToken(envToken); + } else if (!api.getToken()) { + api.setToken('dev-local-token'); } - - // Probe backend health; auto-enable demo mode if unreachable - api.health() - .then((data) => { - if (data?.status === 'ok' && api.isDemoMode()) { - // Backend is online — inform the console, but don't override user's choice - console.info('[OpenShield] Backend API is online. Toggle off Demo Mode to use live data.'); - } - }) - .catch(() => { - if (!api.isDemoMode()) { - console.warn('[OpenShield] Backend unreachable — switching to Demo Mode.'); - api.setDemoMode(true); - } - }); }, []); return ( @@ -44,13 +29,13 @@ export default function App() { }> } /> - } /> - } /> + } /> + } /> } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/frontend/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/frontend/src/components/compliance/ComplianceTable.jsx b/frontend/src/components/compliance/ComplianceTable.jsx index 559c75a..a568849 100644 --- a/frontend/src/components/compliance/ComplianceTable.jsx +++ b/frontend/src/components/compliance/ComplianceTable.jsx @@ -1,5 +1,6 @@ import React from 'react'; -import { FiCheckCircle, FiXCircle, FiMinusCircle } from 'react-icons/fi'; +import { useNavigate } from 'react-router-dom'; +import { FiCheckCircle, FiXCircle, FiMinusCircle, FiArrowRight } from 'react-icons/fi'; import SeverityBadge from '../shared/SeverityBadge'; function StatusIcon({ status }) { @@ -9,12 +10,14 @@ function StatusIcon({ status }) { } export default function ComplianceTable({ controls }) { + const navigate = useNavigate(); + return (
- {['Control ID', 'Name', 'Severity', 'Category', 'Resources', 'Status'].map((h) => ( + {['Control ID', 'Name', 'Severity', 'Category', 'Status', ''].map((h) => ( @@ -23,12 +26,11 @@ export default function ComplianceTable({ controls }) { {controls.map((c) => ( - + - + - + ))} diff --git a/frontend/src/components/discovery/ResourceSummary.jsx b/frontend/src/components/discovery/ResourceSummary.jsx index 1c1b335..fe8087f 100644 --- a/frontend/src/components/discovery/ResourceSummary.jsx +++ b/frontend/src/components/discovery/ResourceSummary.jsx @@ -1,15 +1,16 @@ import React from 'react'; -import { FiDatabase, FiServer, FiWifi, FiUser, FiHardDrive, FiKey, FiEye } from 'react-icons/fi'; +import { FiDatabase, FiServer, FiWifi, FiUser, FiHardDrive, FiKey, FiEye, FiZap } from 'react-icons/fi'; import Card from '../shared/Card'; const CATEGORY_ICONS = { - Storage: FiHardDrive, - Compute: FiServer, - Network: FiWifi, - Identity: FiUser, - Database: FiDatabase, - KeyVault: FiKey, - Monitoring: FiEye, + Storage: FiHardDrive, + Compute: FiServer, + Network: FiWifi, + Identity: FiUser, + Database: FiDatabase, + KeyVault: FiKey, + Monitoring: FiEye, + PostQuantum: FiZap, }; export default function ResourceSummary({ summary, activeCategory, onCategoryClick }) { diff --git a/frontend/src/components/layout/Header.jsx b/frontend/src/components/layout/Header.jsx index 46b0fbf..3d9b92e 100644 --- a/frontend/src/components/layout/Header.jsx +++ b/frontend/src/components/layout/Header.jsx @@ -5,6 +5,7 @@ import { FiLoader, FiZap, FiCheckCircle, FiAlertCircle, FiClock, } from 'react-icons/fi'; import { api } from '../../utils/api'; +import Logo from '../shared/Logo'; const PAGE_TITLES = { '/monitoring': { title: 'Security Monitoring', subtitle: 'Overall health score and trends' }, @@ -49,7 +50,7 @@ function ConnectionErrorPopup({ apiBase, onClose }) {
@@ -174,48 +175,37 @@ export default function Header({ onMenuToggle }) { const { pathname } = useLocation(); const page = PAGE_TITLES[pathname] || { title: 'OpenShield', subtitle: '' }; - const [demoMode, setDemoMode] = useState(api.isDemoMode()); - const [testing, setTesting] = useState(false); const [showConnErr, setConnErr] = useState(false); const [scanning, setScanning] = useState(false); const [elapsed, setElapsed] = useState(0); const [scanToast, setScanToast] = useState(null); const [showScanInput, setShowScanInput] = useState(false); + const [lastScanAt, setLastScanAt] = useState(null); + const [isLive, setIsLive] = useState(false); const scanBtnRef = useRef(null); - // ── Demo/Live toggle ───────────────────────────────────────────────────── - const handleToggle = async () => { - if (!demoMode) { - api.setDemoMode(true); - setDemoMode(true); - window.location.reload(); - return; - } - setTesting(true); - const ok = await api.testConnection(); - setTesting(false); - if (!ok) { setConnErr(true); return; } - api.setDemoMode(false); - setDemoMode(false); - window.location.reload(); - }; + useEffect(() => { + // Probe the backend to set the live indicator accurately + api.testConnection().then((ok) => setIsLive(ok)); + }, []); - const closeConnErr = () => { - setConnErr(false); - api.setDemoMode(true); - setDemoMode(true); - }; + useEffect(() => { + if (!isLive) return; + api.getScans().then((data) => { + const latest = data?.scans?.[0]; + const raw = latest?.started_at || latest?.startedAt; + if (raw) { + setLastScanAt(new Date(raw).toLocaleString(undefined, { + month: 'short', day: 'numeric', year: 'numeric', + hour: 'numeric', minute: '2-digit', + })); + } + }).catch(() => {}); + }, [isLive]); - // ── Scan button click ──────────────────────────────────────────────────── - const handleScanClick = () => { - if (demoMode) { - // Demo mode: run immediately with no subscription input - executeScan(undefined); - } else { - // Live mode: show subscription ID input first - setShowScanInput(true); - } - }; + const closeConnErr = () => setConnErr(false); + + const handleScanClick = () => setShowScanInput(true); // ── Execute scan (after optional subscription ID is provided) ───────────── const executeScan = async (subscriptionId) => { @@ -286,7 +276,7 @@ export default function Header({ onMenuToggle }) { onClick={handleScanClick} disabled={scanning} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-brand-primary hover:bg-brand-secondary disabled:opacity-60 disabled:cursor-wait text-white transition-all duration-200" - title={demoMode ? 'Trigger a demo scan' : 'Trigger a real Azure scan'} + title="Trigger an Azure scan" > {scanning ? @@ -305,28 +295,30 @@ export default function Header({ onMenuToggle }) { )} - {/* Demo / Live badge */} - + {/* Last scanned — only shown when live data confirmed */} + {lastScanAt && isLive && ( +
+ + Last scanned: {lastScanAt} +
+ )} - {/* Last scanned */} -
- - Last scanned: May 29, 2026 6:00 PM + {/* Live / Reconnecting status dot */} +
+ {isLive ? ( + + + + + ) : ( + + )} + + {isLive ? 'Live' : 'Reconnecting'} +
diff --git a/frontend/src/components/layout/Sidebar.jsx b/frontend/src/components/layout/Sidebar.jsx index d8ae82e..092c286 100644 --- a/frontend/src/components/layout/Sidebar.jsx +++ b/frontend/src/components/layout/Sidebar.jsx @@ -5,6 +5,7 @@ import { FiShield, FiGitBranch, FiCpu, FiSun, FiMoon, FiX, } from 'react-icons/fi'; import { useDarkMode } from '../../contexts/DarkModeContext'; +import Logo from '../shared/Logo'; const navItems = [ { path: '/monitoring', label: 'Monitor', Icon: FiActivity }, @@ -24,9 +25,7 @@ export default function Sidebar({ isOpen, onClose }) { {/* ── Desktop sidebar (always visible on lg+) ── */}
{h}
{c.id}{c.name}{c.name} {c.category}{c.resources}
@@ -37,6 +39,17 @@ export default function ComplianceTable({ controls }) {
+ {c.ruleId && c.status === 'FAIL' && ( + + )} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EndpointMethodDescription
/healthGETService health check
/api/findingsGETList findings. Filter by severity, category, rule_id
/api/findings/{id}/playbookGETStructured remediation playbook for a finding
/api/scoreGETOverall security posture score (0-100)
/api/score/cve-summaryGETCVE counts, max CVSS score, exploit availability
/api/scansGETScan history ordered by most recent
/api/resourcesGETUnique Azure resources derived from latest scan
/api/prioritizationGETRisk-ranked remediation matrix and action items
/api/driftGETConfiguration changes between the last two scans
/api/compliance/{fw}GETFramework posture — cis | nist | iso27001 | soc2
/api/scans/triggerPOSTTrigger a scan. Body: {"subscription_id": "..."}
/api/ai/askPOSTRAG-grounded Q&A. Providers: anthropic, groq, gemini
/api/ai/prioritisePOSTAI-ranked findings by real-world exploitability
+
+ ` + } + ], + releases: [ + { + version: "v0.2.0", + date: "June 2026", + type: "major", + title: "Live Data Wiring & Dashboard", + notes: [ + "Full React security dashboard — 7 pages: Monitor, Discover, Prioritize, Scan, Comply, Drift, AI", + "Live backend integration: all GET endpoints public, real PostgreSQL data replaces mock stubs", + "SOC 2 Type II compliance framework added alongside CIS, NIST, ISO 27001", + "4 new REST endpoints: /api/resources, /api/prioritization, /api/drift, /api/findings/:id/playbook", + "Score and findings scoped to latest scan — eliminates stale aggregate counts", + "CVE enrichment via NVD API: cve_references, cvss_score, exploit_available on every finding", + "AI layer: chat, executive summary, CVE analysis — bring your own Anthropic/Groq/Gemini key", + "Configuration drift detection comparing consecutive scans", + "Project website with docs, rules gallery, roadmap, releases, blog, and playground", + "CORS fixed to cover all routes including /health for Vercel → Render connectivity" + ], + github: "https://github.com/openshield-org/openshield/releases/tag/v0.2.0" + }, + { + version: "v0.1.0", + date: "2025", + type: "major", + title: "Initial Release", + notes: [ + "Flask REST API with JWT authentication and CORS", + "Scanner engine with 20 Azure misconfiguration rules across Storage, Network, Identity, Database, Compute, and Key Vault", + "Compliance framework mappings for CIS Azure Benchmark, NIST CSF, ISO 27001, and SOC 2", + "36 Azure CLI remediation playbooks — one per scanner rule", + "PostgreSQL persistence for scan history and findings", + "Microsoft Sentinel integration via Log Analytics custom table and KQL analytics rules", + "GitHub Actions CI pipeline with 7 automated checks" + ], + github: "https://github.com/openshield-org/openshield/releases/tag/v0.1.0" + } + ], + + faq: [ + { + question: "Is OpenShield free to use?", + answer: "Yes. OpenShield is fully open source under the MIT licence. You can use, modify, and distribute it at no cost, including in commercial environments." + }, + { + question: "What cloud providers are supported?", + answer: "Azure is the current target. Multi-cloud support for AWS and GCP is on the roadmap. The rule engine is designed to be provider-agnostic at the orchestration level, so adding a new provider requires only a new SDK client wrapper." + }, + { + question: "What Azure permissions does the scanner need?", + answer: "The scanner requires read-only access. A service principal with the built-in Reader role at subscription scope is sufficient for all 20 current rules. No write permissions are used." + }, + { + question: "Can I write my own security rules?", + answer: "Yes. Drop a .py file into scanner/rules/ following the rule template in the Docs section. The engine dynamically imports any file that exposes a scan() function. No changes to the core orchestrator are required." + }, + { + question: "Where is scan data stored?", + answer: "All findings and scan history are stored in your own PostgreSQL database. Nothing is transmitted to external services unless you enable CVE enrichment, which queries the public NVD API by rule keyword." + }, + { + question: "Does the AI layer send my findings to third parties?", + answer: "Only when you explicitly trigger an AI request and provide your own provider API key. Your key and findings are sent directly from your browser to the provider you choose (Anthropic, Groq, or Gemini). The OpenShield backend never stores or logs your key." + }, + { + question: "How do I try the live dashboard?", + answer: "The project website is at openshield-website.vercel.app. The security dashboard is at openshield-gules.vercel.app and connects to the live backend at openshield-api.onrender.com." + }, + { + question: "How do I contribute?", + answer: "The highest-value contributions are new scanner rules and compliance mappings. See CONTRIBUTING.md in the repository for a step-by-step guide. A new rule with a matching playbook and compliance mapping can be submitted in a single PR in under 30 minutes." + } + ], + + terminal: [ + { + command: "git clone https://github.com/openshield-org/openshield.git", + output: ["Cloning into 'openshield'...", "Receiving objects: 100% (452/452), 2.1 MB done."] + }, + { + command: "curl https://openshield-api.onrender.com/api/score", + output: ["62"] + }, + { + command: "curl https://openshield-api.onrender.com/api/findings | python -m json.tool | head -20", + output: ["{ \"count\": 10, \"findings\": [", " { \"rule_id\": \"AZ-STOR-001\", \"severity\": \"HIGH\", ... }", " { \"rule_id\": \"AZ-NET-001\", \"severity\": \"HIGH\", ... }", " ...8 more findings", "]}"] + } + ] +}; diff --git a/website/feed.xml b/website/feed.xml new file mode 100644 index 0000000..e3b846f --- /dev/null +++ b/website/feed.xml @@ -0,0 +1,35 @@ + + + + OpenShield Blog + https://openshield.org + Enterprise-Grade Open Source CSPM Project Updates + en-us + + + + <![CDATA[Why We Built OpenShield: Solving the Cloud Security Accessibility Gap]]> + https://openshield.org/#blog/why-openshield + https://openshield.org/#blog/why-openshield + Fri, 24 May 2024 23:00:00 GMT + + OpenShield Maintainers + + + <![CDATA[Under the Hood: Engineering a Dynamic Rule Orchestration Engine]]> + https://openshield.org/#blog/rule-engine-deep-dive + https://openshield.org/#blog/rule-engine-deep-dive + Thu, 23 May 2024 23:00:00 GMT + + OpenShield Engineering + + + <![CDATA[Automating Microsoft Sentinel with OpenShield Findings]]> + https://openshield.org/#blog/sentinel-automation + https://openshield.org/#blog/sentinel-automation + Wed, 22 May 2024 23:00:00 GMT + + OpenShield Engineering + + + \ No newline at end of file diff --git a/website/generate_rss.js b/website/generate_rss.js new file mode 100644 index 0000000..e411638 --- /dev/null +++ b/website/generate_rss.js @@ -0,0 +1,69 @@ +const fs = require('fs'); +const path = require('path'); + +/** + * OpenShield RSS Generator (Node.js) + * Designed for Vercel Build Step + */ + +function generateRSS() { + try { + const contentPath = path.join(__dirname, 'content.js'); + const contentFile = fs.readFileSync(contentPath, 'utf8'); + + // Extract the JSON object from the siteContent constant + const startIndex = contentFile.indexOf('const siteContent = {') + 'const siteContent = '.length; + const endIndex = contentFile.lastIndexOf('};') + 1; + + if (startIndex === -1 || endIndex === 0) { + console.error("Could not find siteContent in content.js"); + return; + } + + const jsonString = contentFile.substring(startIndex, endIndex); + + // We use eval here safely because we are in a trusted build environment + let siteContent; + try { + siteContent = eval('(' + jsonString + ')'); + } catch (e) { + console.error("Eval error:", e.message); + // Fallback: try to find the last }; more precisely if needed + return; + } + + let rssItems = ''; + + siteContent.blog.forEach(post => { + rssItems += ` + + <![CDATA[${post.title}]]> + https://openshield.org/#blog/${post.id} + https://openshield.org/#blog/${post.id} + ${new Date(post.date).toUTCString()} + + ${post.author} + `; + }); + + const rssFeed = ` + + + OpenShield Blog + https://openshield.org + Enterprise-Grade Open Source CSPM Project Updates + en-us + + ${rssItems} + +`; + + fs.writeFileSync(path.join(__dirname, 'feed.xml'), rssFeed); + console.log("✅ Successfully generated website/feed.xml"); + + } catch (error) { + console.error("❌ Error generating RSS:", error.message); + } +} + +generateRSS(); diff --git a/website/index.html b/website/index.html new file mode 100644 index 0000000..61445c1 --- /dev/null +++ b/website/index.html @@ -0,0 +1,882 @@ + + + + + + OpenShield | Enterprise-Grade Open Source CSPM + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + +
+ +
+ +
+ + +
+ + +
+
+
+ + Open Source CSPM for Azure +
+

+ Modern Security,
+ Purely Open. +

+
+

+ OpenShield is an enterprise-grade, open-source CSPM engine for Azure. We help engineering teams detect misconfigurations, audit compliance against CIS, SOC2, NIST CSF, and ISO 27001, and automate remediation - all without the six-figure price tag. +

+
+
+ + + Open Dashboard + + +
+
+ + +
+
+
+
+
+
+
+
+
+
bash : interactive
+
+ +
+
+ +
+
+
+
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ Project Philosophy +
+

Security for
Every Team.

+

OpenShield was built on the principle that basic security visibility shouldn't be a luxury. We're democratizing CSPM with a platform that runs where your resources are, ensuring data never leaves your control.

+ +
+
+
+ +
+

Automated Audits

+

Map your infrastructure to CIS, SOC2, NIST CSF, and ISO 27001 requirements automatically.

+
+
+
+ +
+

Instant Remediation

+

Don't just find bugs—fix them. OpenShield generates atomic CLI playbooks to close security gaps in seconds.

+
+
+ + +
+
+
+ +
+
+

State-Aware Intelligence

+

Unlike basic scanners, OpenShield correlates findings across multi-subscription environments to identify systemic risks and privilege escalation paths.

+
+
+
+
+ +
+
+

Decoupled Architecture

+

The engine strictly separates cloud SDK handlers from security logic, allowing researchers to contribute new rules with zero changes to the core orchestrator.

+
+
+
+
+ + +
+
+ + +
+ +
+ +
+
+ +
+
+ +
+ +
+
+
+
+
+
+
+ +
+ +
+ React Dashboard +
+ + +
+ + Flask REST API +
+ + +
+
+ + Engine +
+ + +
+
+
+ +
+ PostgreSQL +
+
+
+ +
+ Azure Cloud +
+
+
+ +
+ Sentinel +
+
+
+ + +
+
+ + +
+
+
+ +
+
+

Full Compliance Coverage

+

OpenShield maps every finding to the CIS Microsoft Azure Foundations Benchmark, SOC2, NIST CSF, and ISO 27001 out of the box.

+
+
+ +
+
+ +
+
+

Native SIEM Export

+

Findings can be streamed directly to Microsoft Sentinel or exported as JSON for ingestion into existing security pipelines.

+
+
+ +
+
+ +
+
+

Enterprise Multi-Tenant

+

Designed for Managed Service Providers (MSPs) and enterprises using Azure Lighthouse for multi-tenant security operations.

+
+
+
+
+ +
+
+ +
+ + +
+
+

Public Roadmap

+

What we have shipped, what we are building now, and what comes next. Vote on features →

+
+
+ +
+
+
+

Shipped

+
+
+
+ +
+
+
+
+

Now

+
+
+
+ +
+
+
+

Next

+
+
+
+ +
+
+
+

Later

+
+
+
+
+
+ + +
+
+

Releases

+

Version history and release notes. All releases on GitHub →

+
+
+
+ + +
+
+

Frequently Asked Questions

+

Common questions about using and contributing to OpenShield.

+
+
+
+ + +
+ +
+
+

Trusted By

+

Teams securing their cloud infrastructure with OpenShield.

+
+
+
+ + +
+
+
+

Built by the Community

+

OpenShield is made possible by developers and security researchers worldwide. Join us in making cloud security accessible.

+ +
+ +
+
+
+
+ +
+ + Become a Contributor + + +
+
+
+
+ + +
+
+

Interactive Playground

+

Experience the engine in real-time. Select a target and run a simulated deep-scan.

+
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ +
+ +
+
+
+
+ Live Engine Output +
+ bash : openshield +
+
+
+
+
+
+
+
+
+
+
// Ready to initialize core security modules...
+
+
+
+ + +
+
+
+ + Real-time Insights +
+
Status: Idle
+
+ +
+ +
+
+
100
+
Security Score
+
+
+
+
0
+
Critical
+
+
+
0
+
Warning
+
+
+
0
+
Passed
+
+
+
+ + +
+

+ + Finding Stream +

+
+ +
+

Waiting for scan to identify resources...

+
+
+
+
+
+
+
+
+ + +
+
+
+
+

Rules Gallery

+

Browse our library of security checks and compliance mappings.

+
+
+ +
+
+ +
+ + +
+
+
+
+ + +
+
+ + + + +
+
+ +
+
+
+
+ + +
+
+
+

Technical Insights

+

Deep dives into security research and project updates.

+
+ +
+
+
+ + +
+
+ +
+ Maintainer Mode Required +
+
+ +
+ +
+

Compose Content

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + +
+ + +
+ + +
+
+ + + + Get Token + +
+
+ + +
+

Tokens are never stored. Requires repo scope to create branches and PRs.

+
+
+
+ + +
+

Live Preview

+
+

Start typing to see your post come to life...

+
+
+
+
+ + +
+
+
+

Events

+

Join the OpenShield community in person and online.

+
+ +
+
+
+ + +
+ +
+ +
+
+ +
+ + + + + + + + diff --git a/website/script.js b/website/script.js new file mode 100644 index 0000000..939413b --- /dev/null +++ b/website/script.js @@ -0,0 +1,1089 @@ +/** + * OpenShield Website Engine + * Handles navigation, theme toggling, and the reactive terminal. + */ + +// ------------------------------------------------------------------ // +// 1. Security & Helpers // +// ------------------------------------------------------------------ // + +function escapeHTML(str) { + if (!str) return ''; + const p = document.createElement('p'); + p.textContent = str; + return p.innerHTML; +} + +function dedent(str) { + if (!str) return ''; + const lines = str.split('\n'); + const first = lines.find(l => l.trim() !== ''); + if (!first) return str.trim(); + const baseIndent = first.match(/^\s*/)[0]; + + let inPre = false; + return lines.map(l => { + let line = l.startsWith(baseIndent) ? l.substring(baseIndent.length) : l; + + // If we are not in a pre block, trim the line to move tags to column 0 for marked.js + if (!inPre) { + const trimmed = line.trim(); + if (trimmed.includes(' setTimeout(resolve, speed)); + } +} + +async function runTerminalSession() { + const container = document.getElementById('terminal-content'); + if (!container) return; + + const sessions = siteContent.terminal; + let currentSession = 0; + + while (true) { + container.innerHTML = ''; + const session = sessions[currentSession]; + + const cmdRow = document.createElement('div'); + cmdRow.className = 'flex items-start'; + cmdRow.innerHTML = ''; + container.appendChild(cmdRow); + + const cmdTextSpan = cmdRow.querySelector('.command-text'); + await typeWriter(session.command, cmdTextSpan); + await new Promise(resolve => setTimeout(resolve, 800)); + + for (const line of session.output) { + const outputRow = document.createElement('div'); + outputRow.className = 'text-slate-400 mt-1 pl-6 text-[12px] opacity-0 transition-opacity duration-300'; + outputRow.textContent = line; + container.appendChild(outputRow); + setTimeout(() => outputRow.classList.remove('opacity-0'), 50); + await new Promise(resolve => setTimeout(resolve, 150)); + } + + await new Promise(resolve => setTimeout(resolve, 5000)); + currentSession = (currentSession + 1) % sessions.length; + } +} + +// ------------------------------------------------------------------ // +// 4. Routing & Navigation // +// ------------------------------------------------------------------ // + +function showSection(sectionId) { + document.querySelectorAll('.section').forEach(section => { + section.classList.remove('active'); + setTimeout(() => { if(!section.classList.contains('active')) section.style.display = 'none'; }, 300); + }); + + const activeSection = document.getElementById(sectionId); + if (activeSection) { + activeSection.style.display = 'block'; + requestAnimationFrame(() => { + activeSection.classList.add('active'); + }); + } + + if (sectionId === 'docs' && !window.location.hash.includes('/')) { + showDocPage(siteContent.docs[0].id); + } + + window.history.pushState(null, null, `#${sectionId}`); + window.scrollTo({ top: 0, behavior: 'smooth' }); +} + +function showBlogPost(postId) { + const post = siteContent.blog.find(p => p.id === postId); + if (!post) return; + + const postContent = document.getElementById('post-content'); + if (postContent) { + const imageHtml = post.image + ? `` + : ''; + const videoHtml = post.video + ? `
` + : ''; + + postContent.innerHTML = ` + ${imageHtml} + ${videoHtml} +
+
+ Technical Deep Dive + | + +
+

${escapeHTML(post.title)}

+

By ${escapeHTML(post.author)}

+
+
+ ${(() => { + const html = marked.parse(dedent(post.content)); + const temp = document.createElement('div'); + temp.innerHTML = html; + temp.querySelectorAll('pre').forEach(pre => pre.classList.add('not-prose')); + return temp.innerHTML; + })()} +
+ `; + showSection('post-detail'); + window.history.pushState(null, null, `#blog/${postId}`); + if (window.lucide) lucide.createIcons(); + } +} + +function handleRouting() { + const hash = window.location.hash.replace('#', ''); + if (!hash || hash === 'home') { + showSection('home'); + } else if (hash.startsWith('blog/')) { + const postId = hash.split('/')[1]; + showBlogPost(postId); + } else if (hash.startsWith('docs/')) { + const docId = hash.split('/')[1]; + showSection('docs'); + showDocPage(docId); + } else if (['rules', 'docs', 'blog', 'events', 'roadmap', 'releases', 'faq', 'community', 'blog-editor'].includes(hash)) { + showSection(hash); + } else { + showSection('home'); + } +} + +function toggleMobileMenu() { + const menu = document.getElementById('mobile-menu'); + menu?.classList.toggle('hidden'); +} + +// ------------------------------------------------------------------ // +// 5. Blog Editor & GitHub Integration // +// ------------------------------------------------------------------ // + +function initEditor() { + const form = document.getElementById('editor-form'); + if (!form) return; + + const fields = ['edit-title', 'edit-date', 'edit-author', 'edit-content', 'edit-excerpt', 'edit-location', 'edit-link', 'edit-status', 'edit-handle', 'edit-role', 'edit-video']; + fields.forEach(id => { + document.getElementById(id)?.addEventListener('input', updatePreview); + }); + + document.getElementById('edit-image-input')?.addEventListener('change', handleImageSelect); + initImageDropZone(); +} + +let selectedImageFile = null; + +// GitHub Contents API rejects base64 payloads over 1 MB. +// Base64 adds ~33% overhead, so the raw file must be under ~750 KB. +const MAX_IMAGE_BYTES = 700 * 1024; + +function toEmbedUrl(raw) { + if (!raw) return ''; + const yt = raw.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/); + if (yt) return `https://www.youtube.com/embed/${yt[1]}`; + const vi = raw.match(/vimeo\.com\/(\d+)/); + if (vi) return `https://player.vimeo.com/video/${vi[1]}`; + if (raw.includes('youtube.com/embed') || raw.includes('player.vimeo.com')) return raw; + return ''; +} + +function processImageFile(file) { + if (!file) return; + if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) { + alert('Only PNG, JPG, and WEBP images are supported.'); + return; + } + if (file.size > MAX_IMAGE_BYTES) { + alert(`Image is ${(file.size / 1024).toFixed(0)} KB. Please use an image under 700 KB to ensure it uploads to GitHub successfully.`); + return; + } + selectedImageFile = file; + const reader = new FileReader(); + reader.onload = (e) => { + const previewContainer = document.getElementById('image-preview-container'); + const previewImg = document.getElementById('image-preview-img'); + previewImg.src = e.target.result; + previewContainer.classList.remove('hidden'); + updatePreview(); + }; + reader.readAsDataURL(file); +} + +function handleImageSelect(event) { + processImageFile(event.target.files[0]); +} + +function initImageDropZone() { + const zone = document.getElementById('image-drop-zone'); + if (!zone) return; + zone.addEventListener('dragover', (e) => { + e.preventDefault(); + zone.classList.add('border-brand-500', 'bg-brand-500/5'); + }); + zone.addEventListener('dragleave', () => { + zone.classList.remove('border-brand-500', 'bg-brand-500/5'); + }); + zone.addEventListener('drop', (e) => { + e.preventDefault(); + zone.classList.remove('border-brand-500', 'bg-brand-500/5'); + const file = e.dataTransfer?.files?.[0]; + if (file) processImageFile(file); + }); +} + +function removeSelectedImage() { + selectedImageFile = null; + document.getElementById('edit-image-input').value = ''; + document.getElementById('image-preview-container').classList.add('hidden'); + updatePreview(); +} + +function toggleEditorFields() { + const type = document.getElementById('edit-type').value; + const isBlog = type === 'blog'; + const isEvent = type === 'event'; + const isContributor = type === 'contributor'; + const isRelease = type === 'release'; + + document.getElementById('field-id').classList.toggle('hidden', !isBlog); + document.getElementById('field-excerpt').classList.toggle('hidden', !isBlog); + document.getElementById('field-author').classList.toggle('hidden', !isBlog); + document.getElementById('field-image').classList.toggle('hidden', !isBlog); + document.getElementById('field-video').classList.toggle('hidden', !isBlog); + document.getElementById('field-content').classList.toggle('hidden', !isBlog); + + document.getElementById('field-location').classList.toggle('hidden', !isEvent); + document.getElementById('field-link').classList.toggle('hidden', !isEvent); + document.getElementById('field-status').classList.toggle('hidden', !isEvent); + + document.getElementById('field-handle').classList.toggle('hidden', !isContributor); + document.getElementById('field-role').classList.toggle('hidden', !isContributor); + + document.getElementById('field-release-version').classList.toggle('hidden', !isRelease); + document.getElementById('field-release-type').classList.toggle('hidden', !isRelease); + document.getElementById('field-release-notes').classList.toggle('hidden', !isRelease); + document.getElementById('field-release-github').classList.toggle('hidden', !isRelease); + + const labelMap = { blog: 'Title', event: 'Event Name', contributor: 'Full Name', release: 'Release Title' }; + const placeholderMap = { blog: 'The Future of Cloud Security', event: 'Community Meetup #X', contributor: 'Jane Doe', release: 'Live Data Wiring and New Endpoints' }; + document.getElementById('label-title').textContent = labelMap[type] || 'Title'; + document.getElementById('edit-title').placeholder = placeholderMap[type] || ''; + + updatePreview(); +} + +function updatePreview() { + const type = document.getElementById('edit-type').value; + const title = document.getElementById('edit-title').value || (type === 'blog' ? 'Post Title' : 'Event Name'); + const date = document.getElementById('edit-date').value || 'Date'; + + const preview = document.getElementById('editor-preview'); + if (!preview) return; + + if (type === 'blog') { + const author = document.getElementById('edit-author').value || 'Author'; + const content = document.getElementById('edit-content').value || '

Content will appear here...

'; + const imageSrc = document.getElementById('image-preview-img').src; + const imageHtml = !document.getElementById('image-preview-container').classList.contains('hidden') + ? `` + : ''; + const videoRaw = document.getElementById('edit-video')?.value || ''; + const embedUrl = toEmbedUrl(videoRaw); + const videoHtml = embedUrl + ? `
` + : ''; + + preview.innerHTML = ` + ${imageHtml} +
+
+ Blog Preview + | + ${escapeHTML(date)} +
+

${escapeHTML(title)}

+

By ${escapeHTML(author)}

+
+ ${videoHtml} +
+ ${(() => { + const html = marked.parse(dedent(content)); + const temp = document.createElement('div'); + temp.innerHTML = html; + temp.querySelectorAll('pre').forEach(pre => pre.classList.add('not-prose')); + return temp.innerHTML; + })()} +
+ `; + } else if (type === 'event') { + const location = document.getElementById('edit-location').value || 'Location'; + const status = document.getElementById('edit-status').value || 'Upcoming'; + preview.innerHTML = ` +
+
+ Event Preview +
+

${escapeHTML(title)}

+

${escapeHTML(date)} • ${escapeHTML(location)}

+
+ ${escapeHTML(status)} +
+
+ `; + } else if (type === 'contributor') { + const handle = document.getElementById('edit-handle').value || 'username'; + const role = document.getElementById('edit-role').value || 'Contributor'; + preview.innerHTML = ` +
+
+ Contributor Preview +
+
+ ${handle} +
+ +
+
+

${escapeHTML(title)}

+

${escapeHTML(role)}

+

@${escapeHTML(handle)}

+
+ `; + } else if (type === 'release') { + const version = document.getElementById('edit-release-version').value || 'vX.Y.Z'; + const releaseType = document.getElementById('edit-release-type').value || 'minor'; + const notes = (document.getElementById('edit-release-notes').value || '').split('\n').filter(l => l.trim()); + preview.innerHTML = ` +
+
+ ${escapeHTML(version)} + Latest + ${escapeHTML(releaseType)} +
+

${escapeHTML(title)}

+
    + ${notes.map(n => ` +
  • + + + ${escapeHTML(n)} +
  • + `).join('')} +
+
+ `; + } +} + +async function submitToGithub() { + const token = document.getElementById('github-token').value; + if (!token) { + alert('Please provide a GitHub Personal Access Token for authentication.'); + return; + } + + const type = document.getElementById('edit-type').value; + let entry; + let entryTitle; + + if (type === 'blog') { + const videoRaw = document.getElementById('edit-video')?.value || ''; + entry = { + id: document.getElementById('edit-id').value, + title: document.getElementById('edit-title').value, + date: document.getElementById('edit-date').value, + excerpt: document.getElementById('edit-excerpt').value, + author: document.getElementById('edit-author').value, + image: "", + video: toEmbedUrl(videoRaw) || undefined, + content: document.getElementById('edit-content').value + }; + entryTitle = entry.title; + if (!entry.id || !entry.title || !entry.content) { + alert('ID, Title, and Content are required for blog posts.'); + return; + } + } else if (type === 'event') { + entry = { + title: document.getElementById('edit-title').value, + date: document.getElementById('edit-date').value, + location: document.getElementById('edit-location').value, + link: document.getElementById('edit-link').value, + status: document.getElementById('edit-status').value + }; + entryTitle = entry.title; + if (!entry.title || !entry.date) { + alert('Title and Date are required for events.'); + return; + } + } else if (type === 'contributor') { + entry = { + name: document.getElementById('edit-title').value, + role: document.getElementById('edit-role').value, + handle: document.getElementById('edit-handle').value + }; + entryTitle = entry.name; + if (!entry.name || !entry.handle) { + alert('Name and GitHub Handle are required for contributors.'); + return; + } + } else if (type === 'release') { + const notesRaw = document.getElementById('edit-release-notes').value || ''; + entry = { + version: document.getElementById('edit-release-version').value, + date: document.getElementById('edit-date').value, + type: document.getElementById('edit-release-type').value, + title: document.getElementById('edit-title').value, + notes: notesRaw.split('\n').map(l => l.trim()).filter(l => l.length > 0), + github: document.getElementById('edit-release-github').value + }; + entryTitle = entry.version; + if (!entry.version || !entry.title || entry.notes.length === 0) { + alert('Version, Title, and at least one release note are required.'); + return; + } + } + + const btn = event.target; + const originalText = btn.textContent; + btn.disabled = true; + btn.textContent = 'Preparing PR...'; + + try { + const owner = 'openshield-org'; + const repo = 'openshield'; + const path = 'website/content.js'; + const baseBranch = 'dev'; + const newBranch = `feat/website-${type}-${Date.now()}`; + + const headers = { + 'Authorization': `token ${token}`, + 'Content-Type': 'application/json' + }; + + // 1. Get current SHA of 'dev' branch + const devRefRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/ref/heads/${baseBranch}`, { headers }); + if (!devRefRes.ok) throw new Error(`Could not find ${baseBranch} branch.`); + const devRefData = await devRefRes.json(); + const devSha = devRefData.object.sha; + + // 2. Create a new feature branch from 'dev' + btn.textContent = 'Creating Branch...'; + const createBranchRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs`, { + method: 'POST', + headers, + body: JSON.stringify({ + ref: `refs/heads/${newBranch}`, + sha: devSha + }) + }); + if (!createBranchRes.ok) throw new Error('Failed to create new branch. Check your token permissions.'); + + // 3. Handle Image Upload if selected + if (type === 'blog' && selectedImageFile) { + btn.textContent = 'Uploading Image...'; + const fileName = `${entry.id}-${Date.now()}.${selectedImageFile.name.split('.').pop()}`; + const imagePath = `website/assets/blog/${fileName}`; + const base64Image = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = (e) => resolve(e.target.result.split(',')[1]); + reader.readAsDataURL(selectedImageFile); + }); + + const imageUploadRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${imagePath}`, { + method: 'PUT', + headers, + body: JSON.stringify({ + message: `assets(website): upload blog image - ${entryTitle}`, + content: base64Image, + branch: newBranch + }) + }); + + if (imageUploadRes.ok) { + entry.image = `assets/blog/${fileName}`; + } else { + console.error('Failed to upload image, continuing without it.'); + } + } + + // 4. Get content.js current state & SHA (from dev) + const fileRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${baseBranch}`, { headers }); + const fileData = await fileRes.json(); + const content = atob(fileData.content); + const fileSha = fileData.sha; + + // 5. Inject new entry into content.js + const arrayKeyMap = { + 'blog': 'blog: [', + 'event': 'events: [', + 'contributor': 'contributors: [' + }; + const arrayKey = arrayKeyMap[type]; + const arrayStart = content.indexOf(arrayKey); + if (arrayStart === -1) throw new Error(`Could not find ${type} array in content.js`); + + const insertPos = arrayStart + arrayKey.length; + const newEntryString = `\n ${JSON.stringify(entry, null, 4)},`; + const updatedContent = content.slice(0, insertPos) + newEntryString + content.slice(insertPos); + + // 6. Commit change to the NEW branch + btn.textContent = 'Committing Changes...'; + const commitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${path}`, { + method: 'PUT', + headers, + body: JSON.stringify({ + message: `feat(website): add ${type} - ${entryTitle}`, + content: btoa(unescape(encodeURIComponent(updatedContent))), + sha: fileSha, + branch: newBranch + }) + }); + if (!commitRes.ok) throw new Error('Failed to commit changes to the new branch.'); + + // 7. Create Pull Request from newBranch to baseBranch + btn.textContent = 'Opening Pull Request...'; + const prRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls`, { + method: 'POST', + headers, + body: JSON.stringify({ + title: `feat(website): add ${type} - ${entryTitle}`, + body: `This PR adds a new ${type} entry via the in-website editor.\n\n**Title:** ${entryTitle}\n**Author/Location:** ${entry.author || entry.location}`, + head: newBranch, + base: baseBranch + }) + }); + + if (!prRes.ok) { + const error = await prRes.json(); + throw new Error(error.message || 'Failed to create Pull Request.'); + } + + const prData = await prRes.json(); + alert(`Success! Your Pull Request has been created: ${prData.html_url}\n\nMaintainers will review and merge it shortly.`); + showSection(type === 'contributor' ? 'community' : (type === 'blog' ? 'blog' : 'events')); + window.open(prData.html_url, '_blank'); + + } catch (err) { + alert(`Error: ${err.message}`); + } finally { + btn.disabled = false; + btn.textContent = originalText; + } +} + +// ------------------------------------------------------------------ // +// 6. Content Rendering // +// ------------------------------------------------------------------ // + +function renderEcosystem() { + const container = document.getElementById('ecosystem-container'); + if (!container) return; + + container.innerHTML = siteContent.ecosystem.map((item, idx) => { + const isLarge = idx === 0 || idx === 3; + const colSpan = isLarge ? 'md:col-span-8' : 'md:col-span-4'; + + const iconHtml = item.icon === 'shield' + ? `` + : ``; + + return ` +
+
+ ${iconHtml} +
+

${escapeHTML(item.title)}

+

${escapeHTML(item.description)}

+
+ `; + }).join(''); +} + +function renderRules() { + const container = document.getElementById('rules-container'); + if (!container) return; + + const searchTerm = (document.getElementById('rule-search')?.value || '').toLowerCase(); + const filterFw = document.getElementById('rule-filter')?.value || 'all'; + + const filteredRules = siteContent.rules.filter(rule => { + const matchesSearch = rule.id.toLowerCase().includes(searchTerm) || + rule.name.toLowerCase().includes(searchTerm) || + rule.category.toLowerCase().includes(searchTerm) || + rule.description.toLowerCase().includes(searchTerm); + + const matchesFw = filterFw === 'all' || rule.frameworks[filterFw] !== undefined; + + return matchesSearch && matchesFw; + }); + + if (filteredRules.length === 0) { + container.innerHTML = ` +
+

No rules match your search criteria.

+
+ `; + return; + } + + container.innerHTML = filteredRules.map(rule => ` +
+
+ ${escapeHTML(rule.id)} + ${escapeHTML(rule.severity)} +
+

${escapeHTML(rule.name)}

+

${escapeHTML(rule.description)}

+
+ ${Object.entries(rule.frameworks).map(([f, v]) => ` + + ${f}: ${v} + + `).join('')} +
+
+ `).join(''); + + if (window.lucide) lucide.createIcons(); +} + +function renderDocsSidebar() { + const nav = document.getElementById('docs-nav'); + if (!nav) return; + + nav.innerHTML = siteContent.docs.map(doc => ` + + `).join(''); +} + +function showDocPage(docId) { + const doc = siteContent.docs.find(d => d.id === docId); + if (!doc) return; + + const container = document.getElementById('docs-content-container'); + if (container) { + const rawHtml = marked.parse(dedent(doc.content)); + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = rawHtml; + tempDiv.querySelectorAll('pre').forEach(pre => pre.classList.add('not-prose')); + + container.innerHTML = ` + ${tempDiv.innerHTML} +
+
+

Help us improve these docs

+

Notice an issue or want to add a section? This page is community-maintained.

+
+ + + Edit this page on GitHub + +
+ `; + window.history.pushState(null, null, `#docs/${docId}`); + + // Update active state in sidebar + document.querySelectorAll('.doc-nav-btn').forEach(btn => { + btn.classList.remove('bg-brand-500/10', 'text-brand-600', 'dark:text-white', 'shadow-sm'); + btn.querySelector('span')?.classList.remove('bg-brand-500'); + }); + + const activeBtn = document.getElementById(`nav-${docId}`); + if (activeBtn) { + activeBtn.classList.add('bg-brand-500/10', 'text-brand-600', 'dark:text-white', 'shadow-sm'); + activeBtn.querySelector('span')?.classList.add('bg-brand-500'); + } + + window.scrollTo({ top: 0, behavior: 'smooth' }); + if (window.lucide) lucide.createIcons(); + } +} + +function renderBlog() { + const container = document.getElementById('blog-container'); + if (container) { + container.innerHTML = siteContent.blog.map(post => { + const imageHtml = post.image + ? `` + : ''; + return ` +
+ ${imageHtml} +

${escapeHTML(post.title)}

+

${escapeHTML(post.excerpt)}

+ +
+ `; + }).join(''); + } +} + +function renderEvents() { + const container = document.getElementById('events-container'); + if (!container || !siteContent.events) return; + + if (siteContent.events.length === 0) { + container.innerHTML = ` +
+

No upcoming events. Stay tuned!

+
+ `; + return; + } + + container.innerHTML = siteContent.events.map(event => ` +
+
+

${escapeHTML(event.title)}

+

${escapeHTML(event.date)} • ${escapeHTML(event.location)}

+
+
+ + ${escapeHTML(event.status)} + + + Register + +
+
+ `).join(''); +} + +function renderRoadmap() { + if (!siteContent.roadmap) return; + const groups = { Shipped: [], Now: [], Next: [], Later: [] }; + + siteContent.roadmap.forEach(item => { + if (groups[item.status]) groups[item.status].push(item); + }); + + const statusConfig = { + 'Shipped': { color: 'slate', dot: 'bg-slate-400' }, + 'Now': { color: 'emerald', dot: 'bg-emerald-500' }, + 'Next': { color: 'purple', dot: 'bg-purple-500' }, + 'Later': { color: 'slate', dot: 'bg-slate-400' } + }; + + ['Shipped', 'Now', 'Next', 'Later'].forEach(status => { + const container = document.getElementById(`roadmap-${status.toLowerCase()}`); + if (!container) return; + + const config = statusConfig[status]; + + container.innerHTML = groups[status].map(item => ` +
+
+ ${escapeHTML(item.category)} + ${status === 'Shipped' ? 'Done' : ''} +
+

${escapeHTML(item.title)}

+
+ `).join(''); + }); +} + +function renderReleases() { + const container = document.getElementById('releases-container'); + if (!container || !siteContent.releases) return; + + const typeColors = { major: 'blue', minor: 'emerald', patch: 'slate' }; + + container.innerHTML = siteContent.releases.map((release, idx) => { + const color = typeColors[release.type] || 'slate'; + const isLatest = idx === 0; + return ` +
+
+
+ ${escapeHTML(release.version)} + ${isLatest ? 'Latest' : ''} + ${escapeHTML(release.type)} +
+
+ ${escapeHTML(release.date)} + + View on GitHub + +
+
+

${escapeHTML(release.title)}

+
    + ${release.notes.map(note => ` +
  • + + ${escapeHTML(note)} +
  • + `).join('')} +
+
+ `; + }).join(''); + + if (window.lucide) lucide.createIcons(); +} + +function renderFAQ() { + const container = document.getElementById('faq-container'); + if (!container || !siteContent.faq) return; + + container.innerHTML = siteContent.faq.map((item, idx) => ` +
+ + +
+ `).join(''); + + if (window.lucide) lucide.createIcons(); +} + +function toggleFAQ(idx) { + const answer = document.getElementById(`faq-answer-${idx}`); + const icon = document.getElementById(`faq-icon-${idx}`); + if (!answer || !icon) return; + const isOpen = !answer.classList.contains('hidden'); + answer.classList.toggle('hidden', isOpen); + icon.style.transform = isOpen ? '' : 'rotate(180deg)'; +} + +function renderShowcase() { + const container = document.getElementById('showcase-container'); + if (!container || !siteContent.showcase) return; + + container.innerHTML = siteContent.showcase.map(item => ` +
+
+ +
+

${escapeHTML(item.name)}

+

${escapeHTML(item.description)}

+
+ `).join(''); +} + +async function renderContributors() { + const container = document.getElementById('contributors-container'); + if (!container || !siteContent.contributors) return; + + // Strictly show only the primary release team + container.innerHTML = siteContent.contributors.map(c => ` + + ${c.name} +
+ ${c.name} +
+
+ `).join(''); +} + +// Initialization +window.addEventListener('load', () => { + initTheme(); + handleRouting(); + renderEcosystem(); + renderRules(); + renderDocsSidebar(); + renderBlog(); + renderEvents(); + renderRoadmap(); + renderReleases(); + renderFAQ(); + renderShowcase(); + renderContributors(); + initEditor(); + runTerminalSession(); + if (window.lucide) lucide.createIcons(); +}); + +// ------------------------------------------------------------------ // +// 8. Interactive Playground // +// ------------------------------------------------------------------ // + +async function runMockScan() { + const btn = document.getElementById('btn-run-mock'); + const terminal = document.getElementById('mock-terminal-output'); + const feed = document.getElementById('pg-findings-feed'); + const scoreEl = document.getElementById('pg-score'); + const statusEl = document.getElementById('pg-status'); + const counters = { + crit: document.getElementById('pg-count-crit'), + warn: document.getElementById('pg-count-warn'), + pass: document.getElementById('pg-count-pass') + }; + + if (!btn || !terminal || !feed) return; + + // Reset UI + btn.disabled = true; + btn.innerHTML = ' Running...'; + terminal.innerHTML = '
$ openshield scan --env ' + document.getElementById('pg-env').value + ' --pkg ' + document.getElementById('pg-framework').value + '
'; + feed.innerHTML = ''; + scoreEl.textContent = '100'; + scoreEl.className = 'text-6xl font-black text-emerald-500 transition-colors duration-500'; + Object.values(counters).forEach(c => c.textContent = '0'); + statusEl.textContent = 'Status: Initializing...'; + statusEl.className = 'text-[10px] font-bold text-brand-500 uppercase tracking-tighter'; + + if (window.lucide) lucide.createIcons(); + + const events = [ + { type: 'log', val: '[INFO] Initializing OpenShield Core v0.1.0...', delay: 400 }, + { type: 'log', val: '[INFO] Loading security modules for ' + document.getElementById('pg-framework').value.toUpperCase() + '...', delay: 600 }, + { type: 'log', val: '[INFO] Authenticating with Azure Resource Manager...', delay: 800 }, + { type: 'status', val: 'Status: Discovery Phase', color: 'text-blue-500' }, + { type: 'log', val: '[INFO] Discovering resources in subscription \'mock-sub-123\'...', delay: 500 }, + { type: 'log', val: '[OK] Identified: 12 VMs, 8 Storage, 4 SQL Servers.', delay: 300 }, + { type: 'status', val: 'Status: Analysis Running', color: 'text-amber-500' }, + { type: 'finding', id: 'AZ-NET-001', name: 'Inbound SSH Open to Internet', sev: 'CRITICAL', desc: 'Port 22 is unrestricted on vm-prod-bastion.', scoreDrop: 15, delay: 1200 }, + { type: 'log', val: '[CRITICAL] AZ-NET-001 detected on resource: vm-prod-bastion', delay: 100 }, + { type: 'finding', id: 'AZ-STOR-001', name: 'Public Blob Access Enabled', sev: 'CRITICAL', desc: 'Anonymous read access is allowed on storage-assets-01.', scoreDrop: 12, delay: 1500 }, + { type: 'log', val: '[CRITICAL] AZ-STOR-001 detected on resource: storage-assets-01', delay: 100 }, + { type: 'finding', id: 'AZ-KV-004', name: 'Key Vault Soft Delete Disabled', sev: 'WARNING', desc: 'kv-prod-secrets has no deletion protection.', scoreDrop: 5, delay: 1000 }, + { type: 'log', val: '[WARN] AZ-KV-004 detected on resource: kv-prod-secrets', delay: 100 }, + { type: 'log', val: '[OK] AZ-DB-001: SQL Server Transparent Data Encryption is Enabled.', delay: 400, typeUpdate: 'pass' }, + { type: 'finding', id: 'AZ-DB-002', name: 'SQL Server Auditing Disabled', sev: 'WARNING', desc: 'Audit logs are not being captured for users-db.', scoreDrop: 8, delay: 1400 }, + { type: 'log', val: '[WARN] AZ-DB-002 detected on resource: users-db', delay: 100 }, + { type: 'log', val: '[INFO] Finalizing compliance report...', delay: 800 }, + { type: 'log', val: '\n--- SCAN COMPLETE ---', delay: 100 }, + { type: 'log', val: '[SUCCESS] 2 Critical, 2 Warning findings identified.', delay: 100 }, + { type: 'log', val: '[INFO] Report generated: openshield_report_v1.pdf', delay: 100 }, + { type: 'status', val: 'Status: Completed', color: 'text-emerald-500' } + ]; + + let currentScore = 100; + let stats = { crit: 0, warn: 0, pass: 0 }; + + for (const event of events) { + if (event.delay) await new Promise(r => setTimeout(r, event.delay)); + + if (event.type === 'log') { + const div = document.createElement('div'); + div.className = event.val.includes('CRITICAL') ? 'text-red-400' : (event.val.includes('WARN') ? 'text-amber-400' : (event.val.includes('[OK]') ? 'text-emerald-400' : 'text-slate-400')); + div.textContent = event.val; + terminal.appendChild(div); + terminal.scrollTop = terminal.scrollHeight; + if (event.typeUpdate === 'pass') { + stats.pass++; + counters.pass.textContent = stats.pass; + } + } + else if (event.type === 'status') { + statusEl.textContent = event.val; + statusEl.className = 'text-[10px] font-bold uppercase tracking-tighter ' + event.color; + } + else if (event.type === 'finding') { + // Update Score + const startScore = currentScore; + currentScore -= event.scoreDrop; + animateValue(scoreEl, startScore, currentScore, 500); + + // Color logic for score + if (currentScore < 60) scoreEl.className = 'text-6xl font-black text-red-500 animate-score-pop'; + else if (currentScore < 85) scoreEl.className = 'text-6xl font-black text-amber-500 animate-score-pop'; + + // Update Counters + const key = event.sev === 'CRITICAL' ? 'crit' : 'warn'; + stats[key]++; + counters[key].textContent = stats[key]; + + // Add Card + const card = document.createElement('div'); + card.className = 'bg-white dark:bg-white/[0.03] border border-slate-200 dark:border-white/10 p-4 rounded-2xl animate-slide-in-right shadow-sm'; + const color = event.sev === 'CRITICAL' ? 'red' : 'amber'; + card.innerHTML = ` +
+ ${event.id} + ${event.sev} +
+
${event.name}
+

${event.desc}

+ `; + feed.prepend(card); + } + } + + btn.disabled = false; + btn.innerHTML = ' Re-run Scan'; + if (window.lucide) lucide.createIcons(); +} + +function animateValue(obj, start, end, duration) { + let startTimestamp = null; + const step = (timestamp) => { + if (!startTimestamp) startTimestamp = timestamp; + const progress = Math.min((timestamp - startTimestamp) / duration, 1); + obj.innerHTML = Math.floor(progress * (end - start) + start); + if (progress < 1) { + window.requestAnimationFrame(step); + } + }; + window.requestAnimationFrame(step); +} + +window.addEventListener('popstate', handleRouting); +document.getElementById('mobile-menu-btn')?.addEventListener('click', toggleMobileMenu); diff --git a/website/styles.css b/website/styles.css new file mode 100644 index 0000000..c2dc7c1 --- /dev/null +++ b/website/styles.css @@ -0,0 +1,80 @@ +/* Base resets and animations for OpenShield website */ + +body { + scroll-behavior: smooth; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Ensure images within markdown/prose don't break layout */ +.prose img { + border-radius: 0.75rem; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); +} + +/* Terminal Typing Animation Elements */ +.typing-1 { + display: inline-block; + overflow: hidden; + white-space: nowrap; + animation: typing 0.8s steps(30, end); +} +.typing-2 { + display: inline-block; + overflow: hidden; + white-space: nowrap; + animation: typing 0.6s steps(40, end); +} +.typing-3 { + display: inline-block; + overflow: hidden; + white-space: nowrap; + animation: typing 0.8s steps(20, end); + border-right: 2px solid #3b82f6; /* cursor */ + animation: typing 0.8s steps(20, end), blink-caret .75s step-end infinite; +} + +@keyframes typing { + from { width: 0 } + to { width: 100% } +} + +@keyframes blink-caret { + from, to { border-color: transparent } + 50% { border-color: #3b82f6; } +} + +/* Playground Animations */ +@keyframes slide-in-right { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.animate-slide-in-right { + animation: slide-in-right 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +@keyframes score-pop { + 0% { transform: scale(1); } + 50% { transform: scale(1.1); } + 100% { transform: scale(1); } +} + +.animate-score-pop { + animation: score-pop 0.3s ease-out; +} + +/* Hide scrollbars but allow scrolling */ +.no-scrollbar::-webkit-scrollbar { + display: none; +} +.no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} diff --git a/website/vercel.json b/website/vercel.json new file mode 100644 index 0000000..b12e58a --- /dev/null +++ b/website/vercel.json @@ -0,0 +1,24 @@ +{ + "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }], + "headers": [ + { + "source": "/assets/(.*)", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + ] + }, + { + "source": "/(.*)", + "headers": [ + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "X-Frame-Options", "value": "SAMEORIGIN" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" }, + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://unpkg.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data: https://github.com https://avatars.githubusercontent.com; frame-src https://www.youtube.com https://player.vimeo.com; connect-src 'self' https://api.github.com; object-src 'none';" + } + ] + } + ] +}