diff --git a/.dockerignore b/.dockerignore index 81599e45..60771f83 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,11 @@ .git +.gitdata .github .pytest_cache .ruff_cache .venv +.lock-tools +.test-venv venv **/.venv **/venv diff --git a/.env.example b/.env.example index e03fe417..05912d7f 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,11 @@ DATABASE_URL=postgresql://openshield:openshield@localhost:5432/openshield # Auth JWT_SECRET=change-me-in-production +# Optional - comma-separated subscription_id allowlist for POST /api/scans/trigger. +# Unset accepts any subscription_id (matches historical behavior); the API +# logs a startup warning when this is unset. See docs/api-reference.md. +OPENSHIELD_AUTHORIZED_SUBSCRIPTIONS= + # AI providers - add at least one ANTHROPIC_API_KEY= GROQ_API_KEY= @@ -12,3 +17,7 @@ GEMINI_API_KEY= # Optional NVD_API_KEY= SENTRY_DSN= + +# Optional - enables AZ-SC-007/008 (Azure DevOps pipeline scanning) +AZURE_DEVOPS_ORG_URL= +AZURE_DEVOPS_PROJECT= diff --git a/.github/ISSUE_TEMPLATE/new_rule.md b/.github/ISSUE_TEMPLATE/new_rule.md index 36065743..0ac755d8 100644 --- a/.github/ISSUE_TEMPLATE/new_rule.md +++ b/.github/ISSUE_TEMPLATE/new_rule.md @@ -8,7 +8,7 @@ assignees: '' ## Rule Details - Rule ID: AZ-XXX-000 -- Severity: HIGH / MEDIUM / LOW +- Severity: CRITICAL / HIGH / MEDIUM / LOW / INFO - Category: Storage / Network / Identity / Database / Compute / Key Vault / Kubernetes / PostQuantum - Frameworks: CIS / NIST / ISO 27001 / SOC 2 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index f5b031c0..e615bcac 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,7 +12,7 @@ ## Rule details (if applicable) - Rule ID: AZ-XXX-000 -- Severity: HIGH / MEDIUM / LOW +- Severity: CRITICAL / HIGH / MEDIUM / LOW / INFO - Category: Storage / Network / Identity / Database / Compute / Key Vault / Kubernetes - Frameworks mapped: CIS / NIST / ISO 27001 / SOC 2 @@ -26,6 +26,7 @@ Closes # ## Checklist +- [ ] Every commit includes a DCO `Signed-off-by` trailer (`git commit -s`; see `docs/dco.md`) - [ ] My code follows the rule template in CONTRIBUTING.md - [ ] I added or updated the matching CLI playbook - [ ] I added or updated all four compliance framework mappings diff --git a/.github/scripts/update_learn_page.py b/.github/scripts/update_learn_page.py new file mode 100644 index 00000000..7f46240d --- /dev/null +++ b/.github/scripts/update_learn_page.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +"""Refresh the rule/playbook/severity statistics in the Learn page and README. + +docs/learn/index.html hardcodes rule, playbook, severity and category counts +in five places: the headline metric tiles, the hero terminal line, the +pipeline step, the rules-section title/intro, the severity-box grid, and the +"coverage by category" chart. README.md hardcodes the same rule and playbook +counts in its feature table and in two nodes of its Mermaid architecture +diagram. This script recomputes every count from scanner/rules/ and +playbooks/cli/ and rewrites both files in place, so neither can drift as +rules are added. + +Every substitution is tracked. If a pattern matches zero times — because the +surrounding wording changed — the script fails loudly instead of silently +leaving the file unchanged and exiting 0. + +Running the script against already-current files produces no changes, which +lets CI commit only on a real diff. +""" + +import html +import re +import sys +from pathlib import Path +from typing import Dict, List, Tuple + +REPO_ROOT = Path(__file__).resolve().parents[2] +RULES_DIR = REPO_ROOT / "scanner" / "rules" +PLAYBOOKS_DIR = REPO_ROOT / "playbooks" / "cli" +LEARN_PAGE = REPO_ROOT / "docs" / "learn" / "index.html" +README_PATH = REPO_ROOT / "README.md" + +# Rule modules are named az__.py. Matching on that prefix is +# the same convention .github/workflows/ci.yml uses to discover rules, and it +# correctly excludes __init__.py and the shared _*_common.py helpers. +RULE_GLOB = "az_*.py" + +SEVERITY_PATTERN = re.compile(r"^SEVERITY\s*=\s*[\"']([^\"']+)[\"']", re.MULTILINE) +CATEGORY_PATTERN = re.compile(r"^CATEGORY\s*=\s*[\"']([^\"']+)[\"']", re.MULTILINE) +RULE_ID_PATTERN = re.compile(r"^RULE_ID\s*=\s*[\"']([^\"']+)[\"']", re.MULTILINE) + +# Counts that are not derived from the filesystem. +COMPLIANCE_FRAMEWORK_COUNT = 4 # CIS, NIST, ISO 27001, SOC 2 +AI_SECURITY_SKILL_COUNT = 8 + + +def count_rules() -> int: + """Return the number of scanner rule modules that declare a RULE_ID.""" + if not RULES_DIR.is_dir(): + return 0 + return sum(1 for path in RULES_DIR.glob(RULE_GLOB) if RULE_ID_PATTERN.search(path.read_text(encoding="utf-8"))) + + +def find_matching_playbooks() -> Tuple[int, List[str], List[str]]: + """Return (playbook_count, missing_for_rules, orphan_playbook_files). + + Counting every *.sh file in playbooks/cli/ (the previous approach) also + picks up shared helper scripts - e.g. review_enterprise_resilience.sh, + which several fix_*.sh wrappers `exec` into rather than a playbook any + single rule owns - silently inflating the count past rule_count and + breaking the "every rule ships with a matching playbook" claim. + + Instead this mirrors the naming convention ci.yml's playbook_check step + already enforces: playbooks/cli/fix_.sh for every + counted rule module. missing_for_rules lists rule files whose expected + playbook is absent; orphan_playbook_files lists *.sh files on disk that + no counted rule expects (informational - shared helpers are expected to + show up here and are not themselves an error). + """ + if not RULES_DIR.is_dir() or not PLAYBOOKS_DIR.is_dir(): + return 0, [], [] + + expected_names = set() + missing_for_rules: List[str] = [] + for path in sorted(RULES_DIR.glob(RULE_GLOB)): + if not RULE_ID_PATTERN.search(path.read_text(encoding="utf-8")): + continue + expected_name = f"fix_{path.stem}.sh" + expected_names.add(expected_name) + if not (PLAYBOOKS_DIR / expected_name).is_file(): + missing_for_rules.append(path.name) + + actual_names = {p.name for p in PLAYBOOKS_DIR.glob("*.sh")} + orphan_playbook_files = sorted(actual_names - expected_names) + playbook_count = len(expected_names) - len(missing_for_rules) + return playbook_count, missing_for_rules, orphan_playbook_files + + +def collect_rule_stats() -> Tuple[Dict[str, int], Dict[str, int], List[str], List[str]]: + """Parse every rule file once for its SEVERITY and CATEGORY. + + Returns (severity_counts, category_counts, files_missing_severity, + files_missing_category). Severities and categories outside the values + seen so far are still counted, keyed by whatever string the rule + declares, so a new value is never silently dropped. + + Files with no parseable RULE_ID are skipped entirely, matching + count_rules() - otherwise a non-rule file (excluded from rule_count) + could still be counted into these totals, making the severity boxes + disagree with the headline rule count. + """ + severities: Dict[str, int] = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "INFO": 0} + categories: Dict[str, int] = {} + missing_severity: List[str] = [] + missing_category: List[str] = [] + + if not RULES_DIR.is_dir(): + return severities, categories, missing_severity, missing_category + + for path in sorted(RULES_DIR.glob(RULE_GLOB)): + content = path.read_text(encoding="utf-8") + if not RULE_ID_PATTERN.search(content): + continue + + sev_match = SEVERITY_PATTERN.search(content) + if sev_match: + severity = sev_match.group(1).strip().upper() + severities[severity] = severities.get(severity, 0) + 1 + else: + missing_severity.append(path.name) + + cat_match = CATEGORY_PATTERN.search(content) + if cat_match: + category = cat_match.group(1).strip() + categories[category] = categories.get(category, 0) + 1 + else: + missing_category.append(path.name) + + return severities, categories, missing_severity, missing_category + + +def render_category_rows(categories: Dict[str, int]) -> str: + """Build the '
...' lines for the category chart. + + Rows are sorted by descending count (alphabetical tiebreak) so a newly + added category appears automatically instead of requiring a code change. + Bar width is each category's count as a percentage of the largest + category, matching the original hand-authored chart's convention. + """ + if not categories: + return "" + + max_count = max(categories.values()) + rows = [] + for name, count in sorted(categories.items(), key=lambda item: (-item[1], item[0])): + width = round(count / max_count * 100) + rows.append( + f'
{html.escape(name)}' + f'
' + f"{count}
" + ) + return "\n".join(rows) + + +def _metric(label: str) -> str: + """Build the pattern for one headline metric tile on the Learn page.""" + return rf'(
)\d+({re.escape(label)}
)' + + +def apply_replacements(content: str, replacements: Tuple[Tuple[str, str, int], ...]) -> Tuple[str, List[str]]: + """Apply each (name, pattern, value) substitution, tracking zero-match failures. + + A pattern that matches zero times means the surrounding wording no longer + matches what this script expects — that is reported as a failure rather + than silently leaving the file unchanged. + """ + failures: List[str] = [] + for name, pattern, value in replacements: + content, count = re.subn(pattern, rf"\g<1>{value}\g<2>", content) + if count == 0: + failures.append(name) + return content, failures + + +def render( + content: str, + rule_count: int, + playbook_count: int, + high_count: int, + medium_count: int, + low_count: int, + category_rows: str, +) -> Tuple[str, List[str]]: + """Return (updated_content, failed_pattern_names) for docs/learn/index.html.""" + intro = ( + r'(

\s*OpenShield currently has )\d+' + r"( dynamic rules\. The strongest contributor work improves rule " + r"accuracy, reduces false positives,)" + ) + pipeline = ( + r'(

Rule Evaluation)' + r"\d+( dynamic checks
)" + ) + section_title = r'(

)\d+( Azure security rules

)' + hero_terminal = r'(loading rules: )\d+( dynamic checks

)' + severity_high = r'(
)\d+(HIGH
)' + severity_medium = r'(
)\d+(MEDIUM
)' + severity_low = r'(
)\d+(LOW
)' + + replacements: Tuple[Tuple[str, str, int], ...] = ( + ("headline metric: Azure scan rules", _metric("Azure scan rules"), rule_count), + ("headline metric: CLI remediation playbooks", _metric("CLI remediation playbooks"), playbook_count), + ("headline metric: Compliance frameworks", _metric("Compliance frameworks"), COMPLIANCE_FRAMEWORK_COUNT), + ("headline metric: AI security skills", _metric("AI security skills"), AI_SECURITY_SKILL_COUNT), + ("headline metric: High-severity checks", _metric("High-severity checks"), high_count), + ("pipeline step: Rule Evaluation", pipeline, rule_count), + ("rules section title", section_title, rule_count), + ("rules section intro paragraph", intro, rule_count), + ("hero terminal: dynamic checks line", hero_terminal, rule_count), + ("severity box: HIGH", severity_high, high_count), + ("severity box: MEDIUM", severity_medium, medium_count), + ("severity box: LOW", severity_low, low_count), + ) + + content, failures = apply_replacements(content, replacements) + + category_block = r'(
\n)(.*?)(\n {10}
)' + content, count = re.subn( + category_block, + lambda m: m.group(1) + category_rows + m.group(3), + content, + flags=re.DOTALL, + ) + if count == 0: + failures.append("coverage-by-category chart") + + return content, failures + + +def render_readme(content: str, rule_count: int, playbook_count: int) -> Tuple[str, List[str]]: + """Return (updated_content, failed_pattern_names) for README.md.""" + feature_row = ( + r"(\| \*\*Misconfiguration Scanner\*\* \| Runs )\d+" + r"( Azure security rules across storage, network, identity, database, " + r"compute, Key Vault, AKS, post-quantum cryptography, backup, serverless, " + r"private endpoint, and supply chain posture \|)" + ) + playbook_row = ( + r"(\| \*\*Remediation Playbooks\*\* \| Every rule ships with a matching " + r"Azure CLI remediation script \()\d+( playbooks\) \|)" + ) + mermaid_scanner = r'(C\["Scanner Engine\\n)\d+( Python rules"\])' + mermaid_playbooks = r'(G\["Azure CLI Playbooks\\n)\d+( remediation scripts"\])' + + replacements: Tuple[Tuple[str, str, int], ...] = ( + ("feature table: Misconfiguration Scanner row", feature_row, rule_count), + ("feature table: Remediation Playbooks row", playbook_row, playbook_count), + ("Mermaid diagram: Scanner Engine node", mermaid_scanner, rule_count), + ("Mermaid diagram: Azure CLI Playbooks node", mermaid_playbooks, playbook_count), + ) + + return apply_replacements(content, replacements) + + +def main() -> int: + """Rewrite the Learn page and README statistics; return a process exit code.""" + for path in (LEARN_PAGE, README_PATH): + if not path.is_file(): + print(f"Error: {path} not found", file=sys.stderr) + return 1 + + rule_count = count_rules() + playbook_count, missing_playbooks, orphan_playbooks = find_matching_playbooks() + + if rule_count == 0 or playbook_count == 0: + print( + "Error: found no rules or no playbooks; refusing to write zeroes into the docs.", + file=sys.stderr, + ) + return 1 + + if missing_playbooks: + print( + f"Error: {len(missing_playbooks)} rule(s) have no matching playbook file: {', '.join(missing_playbooks)}", + file=sys.stderr, + ) + return 1 + + # Belt-and-suspenders: find_matching_playbooks() only counts playbooks + # that resolve to a counted rule, so this should be unreachable once + # missing_playbooks is empty. Failing loudly here catches a future bug + # in that derivation rather than silently writing mismatched docs - + # this is exactly the drift class (rules and playbooks disagreeing) + # this script exists to catch. + if rule_count != playbook_count: + print( + f"Error: rule/playbook count mismatch (rules: {rule_count}, playbooks: " + f"{playbook_count}); refusing to write inconsistent docs.", + file=sys.stderr, + ) + return 1 + + if orphan_playbooks: + print( + f"Warning: {len(orphan_playbooks)} playbook file(s) in playbooks/cli/ are not " + f"any rule's matching playbook and are excluded from the playbook count " + f"(expected for shared helpers other playbooks exec into): {', '.join(orphan_playbooks)}", + file=sys.stderr, + ) + + severities, categories, missing_severity, missing_category = collect_rule_stats() + + if missing_severity: + print( + f"Warning: {len(missing_severity)} rule file(s) have no parseable SEVERITY " + f"and are excluded from the severity counts: {', '.join(missing_severity)}", + file=sys.stderr, + ) + if missing_category: + print( + f"Warning: {len(missing_category)} rule file(s) have no parseable CATEGORY " + f"and are excluded from the coverage-by-category chart: {', '.join(missing_category)}", + file=sys.stderr, + ) + + chart_severities = {"HIGH", "MEDIUM", "LOW"} + excluded_severities = { + severity: count for severity, count in severities.items() if severity not in chart_severities and count + } + if excluded_severities: + excluded_detail = ", ".join(f"{severity}: {count}" for severity, count in sorted(excluded_severities.items())) + excluded_total = sum(excluded_severities.values()) + print( + f"Warning: {excluded_total} rule(s) with severities outside the " + f"HIGH/MEDIUM/LOW chart are excluded from the severity boxes: {excluded_detail}", + file=sys.stderr, + ) + + category_rows = render_category_rows(categories) + + learn_original = LEARN_PAGE.read_text(encoding="utf-8") + learn_updated, learn_failures = render( + learn_original, + rule_count, + playbook_count, + severities["HIGH"], + severities["MEDIUM"], + severities["LOW"], + category_rows, + ) + + readme_original = README_PATH.read_text(encoding="utf-8") + readme_updated, readme_failures = render_readme(readme_original, rule_count, playbook_count) + + failures = [f"docs/learn/index.html -> {name}" for name in learn_failures] + failures += [f"README.md -> {name}" for name in readme_failures] + + if failures: + print( + "Error: the following patterns matched zero times. The surrounding wording " + "has likely changed and this script needs updating to match:", + file=sys.stderr, + ) + for name in failures: + print(f" - {name}", file=sys.stderr) + return 1 + + changed: List[str] = [] + if learn_updated != learn_original: + LEARN_PAGE.write_text(learn_updated, encoding="utf-8") + changed.append(str(LEARN_PAGE.relative_to(REPO_ROOT))) + if readme_updated != readme_original: + README_PATH.write_text(readme_updated, encoding="utf-8") + changed.append(str(README_PATH.relative_to(REPO_ROOT))) + + if not changed: + print("Learn page and README statistics already current; nothing to do.") + return 0 + + print( + f"Updated {', '.join(changed)} - rules: {rule_count}, playbooks: {playbook_count}, " + f"severity HIGH: {severities['HIGH']}, MEDIUM: {severities['MEDIUM']}, LOW: {severities['LOW']}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d47b1c8..1f051a78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,8 @@ jobs: cache: pip - name: Install ruff - run: pip install ruff + run: | + python -m pip install --require-hashes --only-binary=:all: -r requirements-dev.txt - name: ruff check run: ruff check . @@ -55,8 +56,15 @@ jobs: - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install -r requirements.txt + python -m pip install --require-hashes --only-binary=:all: -r requirements.txt + python -m pip check + + - name: Verify dependency locks + run: | + LOCK_TOOL_ENV="${RUNNER_TEMP}/openshield-lock-tools" + python -m venv "$LOCK_TOOL_ENV" + "$LOCK_TOOL_ENV/bin/python" -m pip install --require-hashes --only-binary=:all: -r requirements-lock.txt + "$LOCK_TOOL_ENV/bin/python" scripts/lock_dependencies.py --check # ── CHECK 1: Python syntax on all rule files ─────────────────────── - name: Python syntax check (rule files) @@ -93,14 +101,22 @@ jobs: import importlib.util import sys from collections import defaultdict + from openshield.severity import CANONICAL_SEVERITIES rules_dir = "scanner/rules" required_fields = ["RULE_ID", "SEVERITY", "FRAMEWORKS"] - valid_severities = {"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"} failures = [] seen_ids = defaultdict(list) for filename in sorted(os.listdir(rules_dir)): + is_helper = filename == "__init__.py" or ( + filename.startswith("_") and filename.endswith("_common.py") + ) + if filename.endswith(".py") and not (filename.startswith("az_") or is_helper): + failures.append( + f"{filename}: rule-directory Python files must match az_*.py or _*_common.py" + ) + continue if not filename.startswith("az_") or not filename.endswith(".py"): continue @@ -119,9 +135,10 @@ jobs: failures.append(f"{filename}: missing field '{field}'") if hasattr(mod, "SEVERITY"): - if mod.SEVERITY not in valid_severities: + if mod.SEVERITY not in CANONICAL_SEVERITIES: failures.append( - f"{filename}: SEVERITY '{mod.SEVERITY}' not in {valid_severities}" + f"{filename}: SEVERITY '{mod.SEVERITY}' not in " + f"{sorted(CANONICAL_SEVERITIES)}" ) if hasattr(mod, "FRAMEWORKS"): @@ -500,37 +517,103 @@ jobs: path: sbom.cyclonedx.json retention-days: 90 - # ── Container image scan (Trivy) — scaffolded ahead of INFRA 1 (#154) ───── + # ── Container runtime contract + image scan (Trivy) ─────────────────────── container-scan: name: Container Scan (Trivy) runs-on: ubuntu-latest + env: + IMAGE_TAG: openshield-ci-scan:${{ github.sha }} + CONTAINER_NAME: openshield-ci-runtime + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: ci + POSTGRES_PASSWORD: ci + POSTGRES_DB: ci_db + ports: + - 5432/tcp + options: >- + --health-cmd "pg_isready -U ci -d ci_db" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - name: Check for Dockerfile - id: dockerfile_check + - name: Build image + id: build + run: docker build --tag "$IMAGE_TAG" --file Dockerfile . + + - name: Verify BM25 retrieval in fresh image run: | - if [ -f "Dockerfile" ]; then - echo "found=true" >> "$GITHUB_OUTPUT" - echo "path=Dockerfile" >> "$GITHUB_OUTPUT" - elif [ -f "api/Dockerfile" ]; then - echo "found=true" >> "$GITHUB_OUTPUT" - echo "path=api/Dockerfile" >> "$GITHUB_OUTPUT" - else - echo "found=false" >> "$GITHUB_OUTPUT" - echo "No Dockerfile yet (INFRA 1 / #154 not merged) — nothing to scan, skipping." + docker run --rm --entrypoint python "$IMAGE_TAG" -c \ + "from ai.retriever import retrieve; results = retrieve('Azure storage account security', n_results=5); assert results; print(results[0]['source'])" + + - name: Start image + env: + POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} + run: | + set -euo pipefail + JWT_SECRET="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" + docker run --detach \ + --name "$CONTAINER_NAME" \ + --network host \ + --env "DATABASE_URL=postgresql://ci:ci@127.0.0.1:${POSTGRES_PORT}/ci_db" \ + --env OPENSHIELD_ENV=production \ + --env OPENSHIELD_PUBLIC_DEMO=false \ + --env ALLOWED_ORIGINS=http://127.0.0.1:8000 \ + --env PORT=8000 \ + --env "JWT_SECRET=$JWT_SECRET" \ + "$IMAGE_TAG" + + - name: Verify readiness and non-root runtime + run: | + python3 - <<'PYEOF' + import json + import time + import urllib.error + import urllib.request + + url = "http://127.0.0.1:8000/ready" + last_error = "container did not answer" + for attempt in range(1, 31): + try: + with urllib.request.urlopen(url, timeout=3) as response: + status = response.status + payload = json.load(response) + if status == 200 and payload == {"status": "ready"}: + print(f"Runtime readiness passed on attempt {attempt}: {payload}") + break + last_error = f"HTTP {status}: {payload!r}" + except (OSError, ValueError, urllib.error.URLError) as exc: + last_error = repr(exc) + time.sleep(2) + else: + raise SystemExit(f"Runtime readiness failed after 30 attempts: {last_error}") + PYEOF + + RUNTIME_UID="$(docker exec "$CONTAINER_NAME" id -u)" + if [ "$RUNTIME_UID" = "0" ]; then + echo "ERROR: container is running as root." + exit 1 fi + echo "Container runtime UID is $RUNTIME_UID (non-root)." - - name: Build image - if: steps.dockerfile_check.outputs.found == 'true' - run: docker build -t openshield-ci-scan:${{ github.sha }} -f "${{ steps.dockerfile_check.outputs.path }}" . + - name: Show runtime logs on failure + if: failure() + run: docker logs "$CONTAINER_NAME" || true + + - name: Remove runtime smoke container + if: always() + run: docker rm --force "$CONTAINER_NAME" || true - name: Run Trivy - if: steps.dockerfile_check.outputs.found == 'true' + if: ${{ !cancelled() && steps.build.outcome == 'success' }} uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: - image-ref: openshield-ci-scan:${{ github.sha }} + image-ref: ${{ env.IMAGE_TAG }} severity: CRITICAL,HIGH ignore-unfixed: true exit-code: "1" @@ -567,8 +650,8 @@ jobs: - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install -r requirements.txt + python -m pip install --require-hashes --only-binary=:all: -r requirements-dev.txt + python -m pip check - name: Apply database migrations env: @@ -595,7 +678,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: "22" + node-version: "22.22.0" cache: npm cache-dependency-path: frontend/package-lock.json @@ -605,26 +688,48 @@ jobs: - name: Lint run: npm run lint - - name: Run aiSettings tests - run: node src/utils/aiApi.test.mjs + - name: Run API utility tests + run: node src/utils/api.test.mjs && node src/utils/scanPolling.test.mjs && node src/utils/aiApi.test.mjs + + - name: Run dashboard load-state tests + run: node src/hooks/usePageData.test.mjs + + - name: Run severity contract tests + run: npm run test:severity + + - name: Run accessibility and internationalization checks + run: npm run test:a11y && npm run test:i18n - name: Build run: npm run build + # Website validation joins CI Summary; website.yml handles Pages deployment. website: - name: Website (script tests) + name: Website (Astro build + verification) runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: website steps: - - name: Checkout repository + - name: Checkout repository with full history uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: "22" + node-version: "22.22.0" + cache: npm + cache-dependency-path: website/package-lock.json + + - name: Install dependencies + run: npm ci - - name: Run website script tests - run: node website/test_toEmbedUrl.mjs + - name: Build and verify CMS-disabled and CMS-enabled output + run: npm run check # ── Enforce branch flow: main may only receive PRs from dev ─────────────── # No-op on dev PRs (step skipped -> job succeeds). To allow hotfixes straight @@ -679,10 +784,10 @@ jobs: ("SAST (Semgrep)", os.environ["SAST_SEMGREP"]), ("SCA (pip-audit)", os.environ["SCA_PIP_AUDIT"]), ("SBOM (Syft)", os.environ["SBOM"]), - ("Container Scan (Trivy, INFRA 1 pending)", os.environ["CONTAINER_SCAN"]), + ("Container Runtime + Scan (Trivy)", os.environ["CONTAINER_SCAN"]), ("Backend Tests (pytest + coverage)", os.environ["BACKEND_TESTS"]), ("Frontend (lint + build)", os.environ["FRONTEND"]), - ("Website (script tests)", os.environ["WEBSITE"]), + ("Website (Astro build + verification)", os.environ["WEBSITE"]), ("Enforce dev to main source", os.environ["ENFORCE_SOURCE"]), ] @@ -722,7 +827,6 @@ jobs: f.write(summary + "\n") PYEOF - # container-scan excluded until INFRA 1 (#154) gives it a real image to gate on - name: Fail if any required job failed if: | needs.lint.result != 'success' || @@ -732,6 +836,7 @@ jobs: needs.sast-semgrep.result != 'success' || needs.sca-pip-audit.result != 'success' || needs.sbom.result != 'success' || + needs.container-scan.result != 'success' || needs.backend-tests.result != 'success' || needs.frontend.result != 'success' || needs.website.result != 'success' || diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml new file mode 100644 index 00000000..719600a1 --- /dev/null +++ b/.github/workflows/dco.yml @@ -0,0 +1,27 @@ +name: Developer Certificate of Origin + +on: + pull_request: + branches: [dev, main] + +permissions: + contents: read + +jobs: + signoff: + name: DCO sign-off + runs-on: ubuntu-latest + steps: + - name: Checkout pull request history + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + + - name: Verify every pull request commit + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + # DCO was introduced on dev by PR #208. Commits already reachable + # from this baseline predate enforcement and remain exempt. + DCO_BASELINE_SHA: 1d9469e162fce788bca839cfaf8a3e66ca35cf9b + run: python scripts/check_dco.py "$BASE_SHA" "$HEAD_SHA" "$DCO_BASELINE_SHA" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index b67e8603..a737a3cb 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -16,8 +16,10 @@ jobs: - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4 with: fail-on-severity: high - # The wheel omits license metadata, but Microsoft's upstream Azure - # SDK repository licenses this package under MIT: + # These wheels omit license metadata, but Microsoft's upstream Azure + # SDK repository licenses both packages under MIT: # https://github.com/Azure/azure-sdk-for-python/blob/main/LICENSE - allow-dependencies-licenses: pkg:pypi/azure-mgmt-containerservice@41.3.0 + allow-dependencies-licenses: >- + pkg:pypi/azure-mgmt-containerservice@41.3.0, + pkg:pypi/azure-mgmt-recoveryservices@4.1.0 comment-summary-in-pr: always diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 702a02a0..9f86ab20 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -49,8 +49,8 @@ jobs: - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install -r requirements.txt + python -m pip install --require-hashes --only-binary=:all: -r requirements.txt + python -m pip check # This must remain before either create step: invalid branch/environment # combinations and missing configuration must result in zero Render POSTs. @@ -74,8 +74,8 @@ jobs: AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} run: python scripts/render_deploy_preflight.py - # Creation and polling are separate so both exact deployment IDs are - # retained and independently monitored. POST creation is never retried. + # The API must become live before a new worker is created. API startup + # owns schema migration, and the worker for this SHA may require it. - name: Create API deployment id: create_api env: @@ -85,18 +85,8 @@ jobs: GITHUB_SHA: ${{ github.sha }} run: python scripts/render_deploy.py create - - name: Create worker deployment - id: create_worker - env: - RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }} - RENDER_SERVICE_ID: ${{ env.RENDER_WORKER_SERVICE_ID }} - RENDER_SERVICE_NAME: worker - GITHUB_SHA: ${{ github.sha }} - run: python scripts/render_deploy.py create - - name: Wait for API deployment id: wait_api - continue-on-error: true env: RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }} RENDER_SERVICE_ID: ${{ env.RENDER_API_SERVICE_ID }} @@ -105,6 +95,15 @@ jobs: GITHUB_SHA: ${{ github.sha }} run: python scripts/render_deploy.py wait + - name: Create worker deployment + id: create_worker + env: + RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }} + RENDER_SERVICE_ID: ${{ env.RENDER_WORKER_SERVICE_ID }} + RENDER_SERVICE_NAME: worker + GITHUB_SHA: ${{ github.sha }} + run: python scripts/render_deploy.py create + - name: Wait for worker deployment id: wait_worker continue-on-error: true diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e28dc009..6d4114ba 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,20 +1,89 @@ -name: Docker Build and Push +name: Verified Container Release +# Only the signed source-release workflow calls this publisher. on: - push: - tags: - - 'v*' - workflow_dispatch: + workflow_call: + inputs: + release_tag: + required: true + type: string + release_commit: + required: true + type: string + tag_object: + required: true + type: string permissions: contents: read packages: write + id-token: write + attestations: write jobs: docker: + # Owner opt-in after confirming OWASP package rights and the first-release plan. + if: github.repository == 'OWASP/openshield' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.CONTAINER_RELEASE_ENABLED == 'true' runs-on: ubuntu-latest + timeout-minutes: 45 + env: + RELEASE_TAG: ${{ inputs.release_tag }} + RELEASE_COMMIT: ${{ inputs.release_commit }} + EXPECTED_TAG_OBJECT: ${{ inputs.tag_object }} + IMAGE_NAME: ghcr.io/owasp/openshield + CANDIDATE: ghcr.io/owasp/openshield:candidate-${{ github.run_id }}-${{ github.run_attempt }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Checkout immutable release commit + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ inputs.release_commit }} + fetch-depth: 0 + persist-credentials: false + + - name: Reverify signed source before build + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/release_integrity.py verify > "$RUNNER_TEMP/release-source.json" + + - name: Build once and save the exact image + run: | + set -euo pipefail + docker build --tag "$CANDIDATE" \ + --label "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \ + --label "org.opencontainers.image.revision=${RELEASE_COMMIT}" \ + --label "org.opencontainers.image.version=${RELEASE_TAG}" . + docker image inspect "$CANDIDATE" --format '{{.Id}}' > "$RUNNER_TEMP/image-id" + docker save --output "$RUNNER_TEMP/image.tar" "$CANDIDATE" + + - name: Scan saved image before registry publication + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + input: ${{ runner.temp }}/image.tar + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: "1" + format: json + output: ${{ runner.temp }}/image-trivy.json + + - name: Install checksum-verified Syft + env: + SYFT_VERSION: "1.46.0" + SYFT_SHA256: d654f678b709eb53c393d38519d5ed7d2e57205529404018614cfefa0fb2b5ca + run: | + set -euo pipefail + archive="$RUNNER_TEMP/syft_${SYFT_VERSION}_linux_amd64.tar.gz" + curl --fail --silent --show-error --location --output "$archive" \ + "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz" + echo "${SYFT_SHA256} ${archive}" | sha256sum --check --strict + sudo tar --extract --gzip --file "$archive" --directory /usr/local/bin syft + + - name: Generate image SBOM from scanned archive + run: syft "docker-archive:$RUNNER_TEMP/image.tar" -o "cyclonedx-json=$RUNNER_TEMP/image.cdx.json" + + - name: Reverify tag before any registry write + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/release_integrity.py verify - name: Log in to GitHub Container Registry uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 @@ -23,20 +92,71 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata - id: meta - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 + - name: Push scanned candidate and capture registry digest + id: push + run: | + set -euo pipefail + test "$(docker image inspect "$CANDIDATE" --format '{{.Id}}')" = "$(< "$RUNNER_TEMP/image-id")" + docker push "$CANDIDATE" + docker image inspect "$CANDIDATE" --format '{{json .RepoDigests}}' | \ + python3 scripts/release_integrity.py digest --image "$IMAGE_NAME" > "$RUNNER_TEMP/image-digest.json" + + - name: Attest image provenance by digest + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + + - name: Attest image SBOM by the same digest + uses: actions/attest-sbom@4651f806c01d8637787e274ac3bdf724ef169f34 # v3 with: - images: ghcr.io/openshield-org/openshield - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push - uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.push.outputs.digest }} + sbom-path: ${{ runner.temp }}/image.cdx.json + push-to-registry: true + + - name: Verify both attestations before version promotion + env: + GH_TOKEN: ${{ github.token }} + DIGEST: ${{ steps.push.outputs.digest }} + run: | + set -euo pipefail + gh attestation verify "oci://${IMAGE_NAME}@${DIGEST}" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/docker.yml" \ + --source-digest "$RELEASE_COMMIT" --source-ref "refs/tags/$RELEASE_TAG" + gh attestation verify "oci://${IMAGE_NAME}@${DIGEST}" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/docker.yml" \ + --source-digest "$RELEASE_COMMIT" --source-ref "refs/tags/$RELEASE_TAG" \ + --predicate-type https://cyclonedx.org/bom + + - name: Promote verified image without rebuilding + env: + GH_TOKEN: ${{ github.token }} + DIGEST: ${{ steps.push.outputs.digest }} + run: | + set -euo pipefail + python3 scripts/release_integrity.py verify + test "$(docker image inspect "$CANDIDATE" --format '{{.Id}}')" = "$(< "$RUNNER_TEMP/image-id")" + VERSION_REF="${IMAGE_NAME}:${RELEASE_TAG#v}" + docker tag "$CANDIDATE" "$VERSION_REF" + docker push "$VERSION_REF" + docker image inspect "$VERSION_REF" --format '{{json .RepoDigests}}' | \ + python3 scripts/release_integrity.py digest --image "$IMAGE_NAME" --expect "$DIGEST" + printf 'Verified image: `%s@%s`\nSource: `%s`\nVersion: `%s`\n' \ + "$IMAGE_NAME" "$DIGEST" "$RELEASE_COMMIT" "$VERSION_REF" >> "$GITHUB_STEP_SUMMARY" + + - name: Retain container release evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + name: container-release-evidence-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/image-trivy.json + ${{ runner.temp }}/image.cdx.json + ${{ runner.temp }}/image-digest.json + ${{ runner.temp }}/release-source.json + if-no-files-found: warn + retention-days: 90 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6db3909e..8d92f2ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,21 +1,106 @@ -name: Release +name: Signed Release on: push: tags: - - 'v*' + - "v*" + +concurrency: + group: signed-release-${{ github.ref }} + cancel-in-progress: false permissions: contents: write + id-token: write + attestations: write jobs: release: runs-on: ubuntu-latest + outputs: + release_tag: ${{ steps.verify.outputs.release_tag }} + release_commit: ${{ steps.verify.outputs.release_commit }} + tag_object: ${{ steps.verify.outputs.tag_object }} + env: + TAG: ${{ github.ref_name }} + RELEASE_TAG: ${{ github.ref_name }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Checkout signed tag + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + persist-credentials: false + + - name: Verify signed tag and main ancestry + id: verify + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/release_integrity.py verify + + - name: Install Syft + env: + SYFT_VERSION: "1.46.0" + SYFT_SHA256: d654f678b709eb53c393d38519d5ed7d2e57205529404018614cfefa0fb2b5ca + run: | + set -euo pipefail + archive="syft_${SYFT_VERSION}_linux_amd64.tar.gz" + curl --fail --silent --show-error --location \ + --output "$archive" \ + "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${archive}" + echo "${SYFT_SHA256} ${archive}" | sha256sum --check --strict + sudo tar --extract --gzip --file "$archive" --directory /usr/local/bin syft + + - name: Build deterministic release artifacts + env: + RELEASE_COMMIT: ${{ steps.verify.outputs.release_commit }} + run: | + set -euo pipefail + mkdir -p dist + syft dir:. --source-name openshield --source-version "$TAG" \ + -o "cyclonedx-json=dist/openshield-${TAG}-sbom.cyclonedx.json" + git archive --format=tar --prefix="openshield-${TAG}/" "$RELEASE_COMMIT" | \ + gzip --no-name > "dist/openshield-${TAG}.tar.gz" + cd dist + sha256sum "openshield-${TAG}.tar.gz" "openshield-${TAG}-sbom.cyclonedx.json" > SHA256SUMS + + - name: Attest source archive + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: dist/openshield-${{ env.TAG }}.tar.gz + + - name: Attest SBOM + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: dist/openshield-${{ env.TAG }}-sbom.cyclonedx.json + + - name: Attest checksum manifest + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: dist/SHA256SUMS + + - name: Reverify source before release publication + env: + GH_TOKEN: ${{ github.token }} + RELEASE_COMMIT: ${{ steps.verify.outputs.release_commit }} + EXPECTED_TAG_OBJECT: ${{ steps.verify.outputs.tag_object }} + run: python3 scripts/release_integrity.py verify + + - name: Publish release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: generate_release_notes: true make_latest: true + files: dist/* + + container: + needs: release + permissions: + contents: read + packages: write + id-token: write + attestations: write + uses: ./.github/workflows/docker.yml + with: + release_tag: ${{ needs.release.outputs.release_tag }} + release_commit: ${{ needs.release.outputs.release_commit }} + tag_object: ${{ needs.release.outputs.tag_object }} diff --git a/.github/workflows/sbom-release.yml b/.github/workflows/sbom-release.yml deleted file mode 100644 index 3748dba0..00000000 --- a/.github/workflows/sbom-release.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: SBOM Release - -# Attach a CycloneDX SBOM to each published GitHub Release (issue #156). -on: - release: - types: [published] - -permissions: - contents: write # required to upload assets to the release - -jobs: - attach-sbom: - name: Generate and attach SBOM - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - - name: Install syft - env: - SYFT_VERSION: "1.46.0" - SYFT_SHA256: d654f678b709eb53c393d38519d5ed7d2e57205529404018614cfefa0fb2b5ca - run: | - SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz" - curl --fail --silent --show-error --location \ - --output "$SYFT_ARCHIVE" \ - "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${SYFT_ARCHIVE}" - echo "${SYFT_SHA256} ${SYFT_ARCHIVE}" | sha256sum --check --strict - sudo tar --extract --gzip --file "$SYFT_ARCHIVE" --directory /usr/local/bin syft - - - name: Generate SBOM - env: - TAG: ${{ github.event.release.tag_name }} - run: syft dir:. --source-name openshield --source-version "$TAG" -o "cyclonedx-json=openshield-${TAG}-sbom.cyclonedx.json" - - - name: Upload SBOM to release - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.event.release.tag_name }} - run: gh release upload "$TAG" "openshield-${TAG}-sbom.cyclonedx.json" --clobber diff --git a/.github/workflows/update-learn-page.yml b/.github/workflows/update-learn-page.yml new file mode 100644 index 00000000..92818c3c --- /dev/null +++ b/.github/workflows/update-learn-page.yml @@ -0,0 +1,49 @@ +name: Update Learn Page and README Stats + +on: + push: + branches: [dev] + +# Only the final commit step writes; nothing here needs any other scope. +permissions: + contents: write + +concurrency: + group: update-learn-page-${{ github.ref }} + cancel-in-progress: true + +jobs: + update-learn-page: + name: Refresh Learn page and README statistics + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Set up Python 3.11 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.11" + + - name: Refresh statistics + run: python .github/scripts/update_learn_page.py + + - name: Detect changes + id: diff + run: | + if git diff --quiet -- docs/learn/index.html README.md; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Commit and push + if: steps.diff.outputs.changed == 'true' + env: + TARGET_REF: ${{ github.ref_name }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add docs/learn/index.html README.md + git commit -s -m "docs: refresh learn page and README statistics [skip ci]" + git push origin "HEAD:$TARGET_REF" diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml new file mode 100644 index 00000000..a4c83572 --- /dev/null +++ b/.github/workflows/website.yml @@ -0,0 +1,85 @@ +# Website pipeline: build check on every PR, deploy to Pages from main. +# +# main is the release-controlled source for the official OWASP site. Changes +# can be validated and integrated through dev without becoming public before +# their promotion to main. +# +# The site derives its numbers (rules, domains, playbooks, contributors, +# releases, docs index) from the repository at build time, so a full clone +# is required for the contributor count. + +name: website + +on: + push: + branches: [main] + pull_request: + branches: [dev, main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: website-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build site + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout with full history + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: website/package-lock.json + + - name: Install dependencies + working-directory: website + run: npm ci + + - name: Build + working-directory: website + run: npm run build + + - name: Configure CMS + working-directory: website + env: + DECAP_GITHUB_APP_ID: ${{ vars.DECAP_GITHUB_APP_ID }} + run: npm run configure:cms + + - name: Verify rendered site + working-directory: website + run: npm run verify + + - name: Upload Pages artifact + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 + with: + path: website/dist + + deploy: + name: Deploy to GitHub Pages + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + permissions: + pages: write + id-token: write + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/.gitignore b/.gitignore index 87074d69..0e028775 100644 --- a/.gitignore +++ b/.gitignore @@ -151,6 +151,8 @@ activemq-data/ .env .envrc .venv +.lock-tools/ +.test-venv/ env/ venv/ ENV/ @@ -219,3 +221,6 @@ __marimo__/ ai/vectorstore/ .vercel .env* + +# Node (root package.json exists solely to track react-router for Dependabot) +node_modules/ diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..2c073331 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/CHANGELOG.md b/CHANGELOG.md index 408c9daa..142503e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Add all ten evidence-rich enterprise network and perimeter controls `AZ-NET-018` through `AZ-NET-027` for issue #253, preserving API failures and incomplete data as indeterminate. + All notable changes to OpenShield are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -9,6 +13,10 @@ OpenShield uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- Azure Network Layer Assurance API with 20-domain coverage, network-rule classification, and authoritative IP forwarding and direct Internet route checks +- Azure Resource Graph inventory snapshots as the first OpenShield Evidence Graph foundation +- Azure Data Link Layer Assurance API with LLC and MAC coverage plus ExpressRoute Direct MACsec checks +- Azure public-cloud Physical Layer Assurance API with complete OSI and IEEE PHY domain, sublayer, and provider-evidence coverage - Semgrep SAST integrated into GitHub Actions CI as an open-source, account-free complement to CodeQL - OpenSSF Best Practices Passing Badge achieved with 100% of applicable Passing-level criteria completed - Official live OpenSSF badge and verified project record added to project documentation @@ -17,18 +25,22 @@ OpenShield uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Harvest Now Decrypt Later exposure window calculation per cryptographic asset - Deterministic Render deployment workflow for separate API and worker services - Terraform configuration for Render, Vercel, and GitHub OIDC +- Database connection pool utilization and exhaustion telemetry (`openshield_db_pool_connections_*`) on `/metrics` ### Fixed - High-severity CodeQL findings in Python and JavaScript code - Security findings identified during Semgrep analysis - Sensitive identity metadata removed from scanner debug logging +- `/ready` and `/metrics` rate-limited per source IP so the unauthenticated probe/scrape surface can no longer be used to exhaust the database connection pool ### Security +- Upgraded cryptography to 50.0.0 to address CVE-2026-69247 - AI provider errors no longer expose upstream response details - Request body limits, AI rate limiting, and playbook path validation added - GitHub Actions dependencies pinned to immutable commit SHAs +- JWTs must now carry an `exp` claim and a recognized `role`; a `viewer` token can no longer perform any write operation (scan trigger, AI endpoints), and `POST /api/scans/trigger` now checks `subscription_id` against an optional `OPENSHIELD_AUTHORIZED_SUBSCRIPTIONS` allowlist ## [0.3.0] - 2026-07-08 @@ -111,9 +123,9 @@ OpenShield uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - GitHub Actions continuous integration pipeline - SBOM generation with Syft -[Unreleased]: https://github.com/openshield-org/openshield/compare/v0.3.0...HEAD -[0.3.0]: https://github.com/openshield-org/openshield/releases/tag/v0.3.0 -[0.2.3]: https://github.com/openshield-org/openshield/commit/3d6d7cc -[0.2.2]: https://github.com/openshield-org/openshield/commit/9575a33 -[0.2.0]: https://github.com/openshield-org/openshield/commit/484eb9b -[0.1.0]: https://github.com/openshield-org/openshield/releases/tag/v0.1.0 +[Unreleased]: https://github.com/OWASP/openshield/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/OWASP/openshield/releases/tag/v0.3.0 +[0.2.3]: https://github.com/OWASP/openshield/commit/3d6d7cc +[0.2.2]: https://github.com/OWASP/openshield/commit/9575a33 +[0.2.0]: https://github.com/OWASP/openshield/commit/484eb9b +[0.1.0]: https://github.com/OWASP/openshield/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c234f46..38ad19d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,6 +6,11 @@ Welcome! OpenShield is built by the community - students, developers, and securi ## What Can I Contribute? +For Python development on Linux/Python 3.11, install the runtime and test tools with +`python -m pip install --require-hashes --only-binary=:all: -r requirements-dev.txt`. +Production installs use `requirements.txt` and exclude test/lint tools. See +[dependency locking](docs/dependency-locking.md) before changing dependencies. + | Contribution Type | Difficulty | Time | |---|---|---| | New misconfiguration scan rule | Beginner | 20–30 min | @@ -26,7 +31,7 @@ Every misconfiguration rule is a self-contained Python file in `scanner/rules/`. ### Step 1 - Pick an Issue -Browse issues labelled [`good-first-issue`](https://github.com/openshield-org/openshield/issues?q=label%3Agood-first-issue) or [`help-wanted`](https://github.com/openshield-org/openshield/issues?q=label%3Ahelp-wanted). +Browse issues labelled [`good-first-issue`](https://github.com/OWASP/openshield/issues?q=label%3Agood-first-issue) or [`help-wanted`](https://github.com/OWASP/openshield/issues?q=label%3Ahelp-wanted). Comment on the issue: **"I'd like to work on this"** - we will assign it to you. @@ -50,13 +55,9 @@ from typing import Any, Dict, List RULE_ID = "AZ-STOR-001" RULE_NAME = "Public Blob Access Enabled on Storage Account" -SEVERITY = "HIGH" # HIGH / MEDIUM / LOW / INFO -CATEGORY = "Storage" # Storage / Network / Identity / Database / Compute / Key Vault / Kubernetes -FRAMEWORKS = { - "CIS": "3.5", - "NIST": "PR.AC-3", - "ISO27001": "A.9.4.1" -} +SEVERITY = "HIGH" # CRITICAL / HIGH / MEDIUM / LOW / INFO +CATEGORY = "Storage" # Storage / Network / Identity / Database / Compute / Key Vault / Kubernetes +FRAMEWORKS = {"CIS": "3.5", "NIST": "PR.AC-3", "ISO27001": "A.9.4.1"} DESCRIPTION = ( "Storage accounts with public blob access enabled allow anyone on the " "internet to read data without authentication. This can lead to data " @@ -72,26 +73,30 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: for account in azure_client.get_storage_accounts(): if getattr(account, "allow_blob_public_access", False): - findings.append({ - "rule_id": RULE_ID, - "rule_name": RULE_NAME, - "severity": SEVERITY, - "category": CATEGORY, - "resource_id": account.id, - "resource_name": account.name, - "resource_type": "Microsoft.Storage/storageAccounts", - "description": DESCRIPTION, - "remediation": REMEDIATION, - "playbook": PLAYBOOK, - "frameworks": FRAMEWORKS, - "metadata": {} - }) + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": account.id, + "resource_name": account.name, + "resource_type": "Microsoft.Storage/storageAccounts", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {}, + } + ) return findings ``` That's it. One file, one rule. +Choose severity using the versioned [finding severity contract](docs/severity-contract.md). Rule files must use a canonical contract value; aliases such as `INFORMATIONAL` are accepted at API boundaries but are not valid rule declarations. + ### Step 4 - Add a Remediation Playbook Create the matching fix in `playbooks/cli/`: @@ -208,6 +213,9 @@ Use the existing wrapper methods in `scanner/azure_client.py` rather than constr | `azure_client.get_subscription_role_assignments()` | Subscription RBAC assignments, or `None` on API failure | | `azure_client.get_service_principals()` | List of role assignments for service principals | | `azure_client.get_conditional_access_policies()` | List of Conditional Access policy dicts from Microsoft Graph | +| `azure_client.get_function_app_security_posture()` | Cached, secret-free Function App posture dicts, or `None` on API failure | +| `azure_client.get_private_endpoint_posture()` | Public-access and approved Private Link state for supported PaaS resources, or `None` on API failure | +| `azure_client.get_recovery_vault_security_posture()` | Cached Recovery Services vault security settings, or `None` on API failure | Most list methods return an empty list on failure. Methods that fetch one resource or one policy return `None` when the result cannot be determined. diff --git a/Dockerfile b/Dockerfile index 9dceebf0..7d1113cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,21 +2,30 @@ FROM python:3.11-slim-trixie WORKDIR /app -COPY requirements.txt . +RUN apt-get update \ + && apt-get dist-upgrade -y \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ RUN pip install --no-cache-dir --upgrade \ pip==26.1.2 \ setuptools==83.0.0 \ wheel==0.46.3 && \ - pip install --no-cache-dir -r requirements.txt + pip install --no-cache-dir --require-hashes --only-binary=:all: -r requirements.txt && \ + pip check COPY . . +RUN python -m ai.embed + RUN groupadd --system openshield && \ useradd --system --gid openshield --no-create-home openshield && \ chown -R openshield:openshield /app USER openshield +ENV PORT=8000 + EXPOSE 8000 -CMD ["gunicorn", "--workers", "2", "--threads", "2", "--timeout", "120", "--bind", "0.0.0.0:8000", "api.app:app"] +CMD ["./startup.sh"] diff --git a/README.md b/README.md index 0d9c900e..d9c73f6f 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,36 @@ -# OpenShield +
-[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13618/badge)](https://www.bestpractices.dev/projects/13618) -[![OpenShield CI](https://github.com/openshield-org/openshield/actions/workflows/ci.yml/badge.svg)](https://github.com/openshield-org/openshield/actions/workflows/ci.yml) -[![CodeQL](https://github.com/openshield-org/openshield/actions/workflows/codeql.yml/badge.svg)](https://github.com/openshield-org/openshield/actions/workflows/codeql.yml) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![CHANGELOG](https://img.shields.io/badge/changelog-here-blue)](CHANGELOG.md) + + + OpenShield + -> **Open source Cloud Security Posture Management (CSPM) for Azure - detect misconfigurations, map to CIS/NIST/ISO27001/SOC2, fix them with one command, and identify cryptographic assets requiring quantum-safe migration.** +
-[![GitHub Repo stars](https://img.shields.io/github/stars/openshield-org/openshield?style=flat-square)](https://github.com/openshield-org/openshield/stargazers) -[![GitHub forks](https://img.shields.io/github/forks/openshield-org/openshield?style=flat-square)](https://github.com/openshield-org/openshield/network/members) -[![GitHub contributors](https://img.shields.io/github/contributors/openshield-org/openshield?style=flat-square)](https://github.com/openshield-org/openshield/graphs/contributors) -[![GitHub last commit](https://img.shields.io/github/last-commit/openshield-org/openshield?style=flat-square)](https://github.com/openshield-org/openshield/commits/main) -[![GitHub issues](https://img.shields.io/github/issues/openshield-org/openshield?style=flat-square)](https://github.com/openshield-org/openshield/issues) -[![Python 3.11](https://img.shields.io/badge/python-3.11-blue.svg)](https://www.python.org/downloads/release/python-3110/) -[![Deploy](https://github.com/openshield-org/openshield/actions/workflows/deploy.yml/badge.svg?branch=dev)](https://github.com/openshield-org/openshield/actions/workflows/deploy.yml) -[![Security Policy](https://img.shields.io/badge/security-policy-green.svg)](.github/SECURITY.md) +**Open source Cloud Security Posture Management (CSPM) for Azure** detect misconfigurations, map them to CIS / NIST / ISO 27001 / SOC 2, remediate with one command, and identify cryptographic assets requiring quantum-safe migration. + +[**Website**](https://owasp.github.io/openshield/) · [**Documentation**](docs/) · [**Roadmap**](ROADMAP.md) · [**Changelog**](CHANGELOG.md) · [**Security Policy**](.github/SECURITY.md) · [**Discord**](https://discord.gg/openshield) + +[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13618/badge)](https://www.bestpractices.dev/projects/13618) +[![OpenShield CI](https://github.com/OWASP/openshield/actions/workflows/ci.yml/badge.svg)](https://github.com/OWASP/openshield/actions/workflows/ci.yml) +[![CodeQL](https://github.com/OWASP/openshield/actions/workflows/codeql.yml/badge.svg)](https://github.com/OWASP/openshield/actions/workflows/codeql.yml) +[![Deploy](https://github.com/OWASP/openshield/actions/workflows/deploy.yml/badge.svg?branch=dev)](https://github.com/OWASP/openshield/actions/workflows/deploy.yml) [![OWASP](https://img.shields.io/badge/OWASP-listing%20review-orange.svg)](https://owasp.org) + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Python 3.11](https://img.shields.io/badge/python-3.11-blue.svg)](https://www.python.org/downloads/release/python-3110/) +[![GitHub Repo stars](https://img.shields.io/github/stars/OWASP/openshield?style=flat-square)](https://github.com/OWASP/openshield/stargazers) +[![GitHub contributors](https://img.shields.io/github/contributors/OWASP/openshield?style=flat-square)](https://github.com/OWASP/openshield/graphs/contributors) +[![GitHub last commit](https://img.shields.io/github/last-commit/OWASP/openshield?style=flat-square)](https://github.com/OWASP/openshield/commits/main) +[![GitHub issues](https://img.shields.io/github/issues/OWASP/openshield?style=flat-square)](https://github.com/OWASP/openshield/issues) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) [![Discord](https://img.shields.io/badge/Discord-Join%20Us-7289da)](https://discord.gg/openshield) +
+ +Release artifacts include SHA-256 checksums, an SBOM, and identity-bound +provenance attestations. See [release verification](docs/release-verification.md). + --- ## The Problem @@ -48,12 +59,12 @@ Findings map to NIST FIPS 203 (ML-KEM), FIPS 204 (ML-DSA), and FIPS 205 (SLH-DSA | Feature | Description | |---|---| -| **Misconfiguration Scanner** | Runs 51 Azure security rules across storage, network, identity, database, compute, Key Vault, AKS, and post-quantum cryptography | +| **Misconfiguration Scanner** | Runs 95 Azure security rules across storage, network, identity, database, compute, Key Vault, AKS, post-quantum cryptography, backup, serverless, private endpoint, and supply chain posture | | **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, compliance posture, drift, and resource inventory over REST | -| **Remediation Playbooks** | Every rule ships with a matching Azure CLI remediation script (51 playbooks) | +| **Remediation Playbooks** | Every rule ships with a matching Azure CLI remediation script (95 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 | +| **Project Website** | Documentation and reference site at [owasp.github.io/openshield](https://owasp.github.io/openshield/) - blog, rules gallery, architecture, evidence guides, roadmap, and releases | | **Sentinel Integration** | Normalises findings and pushes them into Microsoft Sentinel via a Log Analytics custom table and KQL analytics rules | --- @@ -93,11 +104,11 @@ Project policies and assurance evidence: flowchart TD A["React Dashboard\nVercel · Live"] B["Flask REST API\nJWT · CORS · Blueprints"] - C["Scanner Engine\n51 Python rules"] + C["Scanner Engine\n95 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\n51 remediation scripts"] + G["Azure CLI Playbooks\n95 remediation scripts"] H["sentinel/ingest.py\nNormalise + HMAC upload"] I["Microsoft Sentinel\nOpenShieldFindings_CL · KQL rules"] @@ -119,7 +130,7 @@ flowchart TD |---|---| | **Security Dashboard** (Vercel) | `https://openshield-gules.vercel.app` | | **REST API** (Render) | `https://openshield-api.onrender.com` | -| **Project Website** | `https://openshield-website.vercel.app` | +| **Project Website** | `https://owasp.github.io/openshield/` | > **Note:** The API is hosted on Render. The dashboard connects automatically on load and shows live data from the PostgreSQL database. @@ -183,7 +194,7 @@ openshield/ ```bash # Clone the repo -git clone https://github.com/openshield-org/openshield.git +git clone https://github.com/OWASP/openshield.git cd openshield # Install Python dependencies @@ -215,6 +226,18 @@ FLASK_APP=api/app.py flask run See [Database Migrations](docs/database-migrations.md) for schema changes and the one-time onboarding step required for existing production databases. +**Local containers (Compose)** + +```bash +# Starts PostgreSQL 16, applies migrations, then starts the API, worker, and dashboard +docker compose --profile local up --build + +# Database-aware API readiness +curl --fail http://127.0.0.1:8000/ready +``` + +The profile is intentionally named `local`: its database credentials and JWT secret are development-only values, ports bind to loopback, and the dashboard talks to `http://localhost:8000`. Set the four `AZURE_*` variables in your shell before starting Compose if you want the worker to execute real scans. Stop the stack with `docker compose --profile local down`; add `--volumes` only when you intentionally want to remove local database and frontend dependency data. + **Frontend (React dashboard)** ```bash @@ -293,9 +316,9 @@ Learn OpenShield covers: - Documentation navigation Live Learning Portal: https://openshieldlearn.netlify.app/learn/ -Full documentation, the security rules gallery, blog, and interactive playground are available at the project website: +Full documentation, the security rules gallery, architecture guide, evidence guide, and blog are available at the project website: -**[openshield-website.vercel.app](https://openshield-website.vercel.app)** +**[owasp.github.io/openshield](https://owasp.github.io/openshield/)** ## API Reference diff --git a/ai/README.md b/ai/README.md index 9d5bd973..6f19bd45 100644 --- a/ai/README.md +++ b/ai/README.md @@ -2,18 +2,26 @@ Document loader and chunker for OpenShield rules and compliance frameworks. Loads all scanner rules and CIS, NIST, ISO 27001 and SOC2 controls -into structured documents for the RAG vector store. +into structured documents for the RAG BM25 index. + +The RAG pipeline has no optional dependencies. Everything runs on the Python +standard library (`math`, `json`, `re`). No C extensions, no chromadb, no numpy. ## Files - `ai/loader.py` — loads OpenShield rules and compliance frameworks as structured documents -- `ai/chunker.py` — splits documents into overlapping chunks for embedding -- `ai/embed.py` — builds the ChromaDB vector store (from PR 97) -- `ai/retriever.py` — queries the vector store (from PR 97) +- `ai/chunker.py` — splits documents into overlapping chunks for indexing +- `ai/embed.py` — builds the BM25 index at `ai/vectorstore/bm25_index.json` +- `ai/retriever.py` — queries the index using BM25 term scoring + +## Building the index -## Vector Store +```bash +python -m ai.embed +``` -The vector store is persisted at `ai/vectorstore/` using ChromaDB. +The index is written atomically to `ai/vectorstore/bm25_index.json` so a +partial build never leaves a corrupt file. ## How loader.py works @@ -29,10 +37,22 @@ Also reads all four compliance framework JSON files: Finally, it reads all Claude-Red AI skills from `ai/knowledge/skills/*.md`: - Extracts the full markdown content as a document for offensive methodology knowledge. -- **Dynamic Grounding:** Automatically injects relevant OpenShield scanner rules into each skill document using the mapping registry at `ai/knowledge/rule_mapping.json`. This ensures the AI provides application-specific responses rather than generic advice. +- **Dynamic Grounding:** Automatically injects relevant OpenShield scanner rules into each skill document using the mapping registry at `ai/knowledge/rule_mapping.json`. ## How chunker.py works Splits documents into 512-character overlapping chunks with 64-character overlap. Tries to split on newlines to avoid breaking mid-sentence. Each chunk inherits the metadata of its parent document. + +## How BM25 works + +At build time (`embed.py`): +1. Tokenizes each chunk (lowercase, non-alphanumeric split, stopword filter) +2. Computes per-term document frequency and IDF across the corpus +3. Stores term frequencies per chunk alongside the corpus statistics + +At query time (`retriever.py`): +1. Tokenizes the query +2. Scores each chunk with the BM25 formula (k1=1.5, b=0.75) +3. Returns the top-N chunks sorted by score diff --git a/ai/chunker.py b/ai/chunker.py index a77053b7..09627516 100644 --- a/ai/chunker.py +++ b/ai/chunker.py @@ -43,7 +43,7 @@ def _split_text(text, chunk_size, chunk_overlap): chunks.append(text[start:].strip()) break split_pos = text.rfind("\n", start, end) - if split_pos == -1 or split_pos <= start: + if split_pos == -1 or split_pos <= start + chunk_overlap: split_pos = end chunk = text[start:split_pos].strip() if chunk: diff --git a/ai/embed.py b/ai/embed.py index 67fcb406..d7b7fdf4 100644 --- a/ai/embed.py +++ b/ai/embed.py @@ -1,86 +1,115 @@ -"""Build the OpenShield knowledge base vector store for RAG AI insights""" +"""Build the OpenShield knowledge base BM25 index for RAG AI insights.""" +import json import logging -import os +import math +import re import sys from pathlib import Path -# Disable ChromaDB telemetry to prevent errors and improve performance on low-RAM machines -os.environ["ANONYMIZED_TELEMETRY"] = "False" - -try: - import chromadb -except ImportError: - chromadb = None - -# Add project root to path for imports -sys.path.append(os.getcwd()) - -from ai.loader import load_all_documents from ai.chunker import chunk_documents +from ai.loader import load_all_documents logger = logging.getLogger(__name__) REPO_ROOT = Path(__file__).resolve().parent.parent VECTORSTORE_DIR = REPO_ROOT / "ai" / "vectorstore" -COLLECTION_NAME = "openshield" - - -def build_vectorstore(): - if chromadb is None: - raise RuntimeError("chromadb is not installed. Install it with 'pip install chromadb'.") - - # 1. Load and chunk documents FIRST (if this fails, existing DB is untouched) +INDEX_PATH = VECTORSTORE_DIR / "bm25_index.json" + +_STOPWORDS = frozenset( + { + "a", + "an", + "and", + "are", + "as", + "at", + "be", + "but", + "by", + "for", + "from", + "had", + "has", + "have", + "if", + "in", + "is", + "it", + "no", + "not", + "of", + "on", + "or", + "so", + "that", + "the", + "this", + "to", + "was", + "were", + "with", + } +) + + +def _tokenize(text: str) -> list: + """Lowercase, split on non-alphanumeric, drop stopwords and short tokens.""" + return [t for t in re.split(r"[^a-z0-9]+", text.lower()) if len(t) > 2 and t not in _STOPWORDS] + + +def build_vectorstore() -> int: + """Build BM25 index from all documents and persist to JSON. + + Returns the number of chunks indexed. + """ documents = load_all_documents() if not documents: - raise RuntimeError("No documents found to embed. Check repo paths.") + raise RuntimeError("No documents found to index. Check repo paths.") chunks = chunk_documents(documents) - logger.info("Created %d chunks from %d source documents", len(chunks), len(documents)) + logger.info("Indexing %d chunks from %d source documents", len(chunks), len(documents)) + + n = len(chunks) + df: dict = {} + processed = [] + + for chunk in chunks: + terms: dict = {} + for token in _tokenize(chunk["content"]): + terms[token] = terms.get(token, 0) + 1 + for token in terms: + df[token] = df.get(token, 0) + 1 + processed.append( + { + "id": chunk["id"], + "content": chunk["content"], + "metadata": chunk["metadata"], + "terms": terms, + "dl": sum(terms.values()), + } + ) - # 2. Initialize client - VECTORSTORE_DIR.mkdir(parents=True, exist_ok=True) - from chromadb.config import Settings + avg_dl = sum(c["dl"] for c in processed) / n if n else 1.0 - client = chromadb.PersistentClient(path=str(VECTORSTORE_DIR), settings=Settings(anonymized_telemetry=False)) + idf = {term: math.log((n - freq + 0.5) / (freq + 0.5) + 1.0) for term, freq in df.items()} - # 3. Create a temporary collection for safe building - temp_name = f"{COLLECTION_NAME}_temp" - try: - client.delete_collection(temp_name) - except Exception: - pass - collection = client.create_collection(temp_name) - - # 4. Add to vector store in batches to prevent memory spikes - batch_size = 50 # Small batch size for 8GB RAM machines - for i in range(0, len(chunks), batch_size): - batch = chunks[i : i + batch_size] - - collection.add( - ids=[c["id"] for c in batch], - documents=[c["content"] for c in batch], - metadatas=[c["metadata"] for c in batch], - ) - print(f" Progress: {min(i + batch_size, len(chunks))}/{len(chunks)} chunks embedded...") + index = {"version": "1", "avg_dl": avg_dl, "idf": idf, "chunks": processed} - # 5. Atomic Swap: Delete main and rename temp to main - try: - client.delete_collection(COLLECTION_NAME) - except Exception: - pass - - collection.modify(name=COLLECTION_NAME) + VECTORSTORE_DIR.mkdir(parents=True, exist_ok=True) + tmp = INDEX_PATH.with_suffix(".tmp") + tmp.write_text(json.dumps(index, ensure_ascii=False), encoding="utf-8") + tmp.replace(INDEX_PATH) - logger.info("Successfully rebuilt vector store '%s' with %d chunks.", COLLECTION_NAME, len(chunks)) - return len(chunks) + logger.info("BM25 index written to %s (%d chunks)", INDEX_PATH, n) + return n if __name__ == "__main__": logging.basicConfig(level=logging.INFO) try: count = build_vectorstore() - print(f"Done. Vector store built with {count} chunks at {VECTORSTORE_DIR}") + print(f"Done. BM25 index built with {count} chunks at {INDEX_PATH}") except Exception as exc: - print(f"Error building vector store: {exc}") + print(f"Error building index: {exc}") sys.exit(1) diff --git a/ai/retriever.py b/ai/retriever.py index 1c705b6a..9e16af1a 100644 --- a/ai/retriever.py +++ b/ai/retriever.py @@ -1,54 +1,161 @@ -"""Retrieve relevant OpenShield knowledge from the vector store for RAG.""" +"""Retrieve relevant OpenShield knowledge using BM25 scoring.""" +import json import logging +import math +import re from pathlib import Path -try: - import chromadb -except ImportError: - chromadb = None - logger = logging.getLogger(__name__) REPO_ROOT = Path(__file__).resolve().parent.parent VECTORSTORE_DIR = REPO_ROOT / "ai" / "vectorstore" -COLLECTION_NAME = "openshield" +INDEX_PATH = VECTORSTORE_DIR / "bm25_index.json" + +_BM25_K1 = 1.5 +_BM25_B = 0.75 + +_STOPWORDS = frozenset( + { + "a", + "an", + "and", + "are", + "as", + "at", + "be", + "but", + "by", + "for", + "from", + "had", + "has", + "have", + "if", + "in", + "is", + "it", + "no", + "not", + "of", + "on", + "or", + "so", + "that", + "the", + "this", + "to", + "was", + "were", + "with", + } +) class VectorStoreNotBuilt(RuntimeError): - """Raised when the vector store is missing or chromadb is unavailable.""" + """Raised when the BM25 index is missing or unreadable.""" -def _get_collection(): - if chromadb is None: - raise VectorStoreNotBuilt("chromadb is not installed. Install it with 'pip install chromadb'.") - if not VECTORSTORE_DIR.exists(): - raise VectorStoreNotBuilt("Vector store not found. Run 'python ai/embed.py' first.") - client = chromadb.PersistentClient(path=str(VECTORSTORE_DIR)) - try: - return client.get_collection(COLLECTION_NAME) - except Exception as exc: - raise VectorStoreNotBuilt("Vector store collection missing. Run 'python ai/embed.py' first.") from exc +def _tokenize(text: str) -> list: + return [t for t in re.split(r"[^a-z0-9]+", text.lower()) if len(t) > 2 and t not in _STOPWORDS] -def retrieve(query, n_results=5): +def _load_index() -> dict: + if not INDEX_PATH.exists(): + raise VectorStoreNotBuilt("BM25 index not found. Run 'python -m ai.embed' first.") + try: + index = json.loads(INDEX_PATH.read_text(encoding="utf-8")) + except Exception as exc: + raise VectorStoreNotBuilt(f"BM25 index unreadable: {exc}") from exc + _validate_index(index) + return index + + +def _validate_index(index: object) -> None: + """Reject incompatible or malformed indexes before scoring.""" + + def invalid(reason: str) -> None: + raise VectorStoreNotBuilt(f"BM25 index invalid: {reason}") + + if not isinstance(index, dict): + invalid("root must be an object") + if index.get("version") != "1": + invalid("unsupported or missing version") + + avg_dl = index.get("avg_dl") + if isinstance(avg_dl, bool) or not isinstance(avg_dl, (int, float)) or not math.isfinite(avg_dl) or avg_dl <= 0: + invalid("avg_dl must be a positive finite number") + + idf = index.get("idf") + if not isinstance(idf, dict): + invalid("idf must be an object") + for term, value in idf.items(): + if ( + not isinstance(term, str) + or isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(value) + or value < 0 + ): + invalid("idf entries must map terms to finite non-negative numbers") + + chunks = index.get("chunks") + if not isinstance(chunks, list): + invalid("chunks must be an array") + for chunk in chunks: + if not isinstance(chunk, dict): + invalid("each chunk must be an object") + if not isinstance(chunk.get("content"), str) or not isinstance(chunk.get("metadata"), dict): + invalid("each chunk requires string content and object metadata") + terms = chunk.get("terms") + dl = chunk.get("dl") + if not isinstance(terms, dict) or isinstance(dl, bool) or not isinstance(dl, int) or dl < 0: + invalid("each chunk requires object terms and a non-negative integer dl") + for term, frequency in terms.items(): + if ( + not isinstance(term, str) + or isinstance(frequency, bool) + or not isinstance(frequency, int) + or frequency <= 0 + ): + invalid("chunk terms must map tokens to positive integer frequencies") + if sum(terms.values()) != dl: + invalid("chunk term frequencies must sum to dl") + + +def _bm25_score(query_terms: list, doc_terms: dict, dl: int, avg_dl: float, idf: dict) -> float: + score = 0.0 + for term in query_terms: + tf = doc_terms.get(term, 0) + if tf == 0: + continue + norm = tf * (_BM25_K1 + 1) / (tf + _BM25_K1 * (1 - _BM25_B + _BM25_B * dl / avg_dl)) + score += idf.get(term, 0.0) * norm + return score + + +def retrieve(query: str, n_results: int = 5) -> list: """Return the most relevant knowledge chunks for a query. - Each result is a dict with 'text' and 'source_meta'. + Each result is a dict with 'text', 'source', and 'source_meta'. """ - collection = _get_collection() - results = collection.query(query_texts=[query], n_results=n_results) - - documents = results.get("documents", [[]])[0] - metadatas = results.get("metadatas", [[]])[0] - - chunks = [] - for text, meta in zip(documents, metadatas): - meta = meta or {} - - # Build structured source for the frontend - source_id = "General" - source_resource = "" + index = _load_index() + avg_dl = index["avg_dl"] + idf = index["idf"] + query_terms = _tokenize(query) + + scored = [] + for chunk in index["chunks"]: + score = _bm25_score(query_terms, chunk["terms"], chunk["dl"], avg_dl, idf) + if score > 0: + scored.append((score, chunk)) + + scored.sort(key=lambda x: x[0], reverse=True) + top = [chunk for _, chunk in scored[:n_results]] + + results = [] + for chunk in top: + meta = chunk.get("metadata") or {} source_type = meta.get("source", "unknown") if source_type == "openShield_rule": @@ -56,14 +163,18 @@ def retrieve(query, n_results=5): source_resource = meta.get("rule_name", "") elif source_type == "claude_red_skill": source_id = meta.get("skill_name", "Skill") + source_resource = "" elif source_type == "compliance_framework": source_id = f"{meta.get('framework', 'Compliance')} {meta.get('control_id', '')}".strip() source_resource = meta.get("control_name", "") + else: + source_id = "General" + source_resource = "" - chunks.append( + results.append( { - "text": text, - "source": source_id, # for LLM prompt context + "text": chunk["content"], + "source": source_id, "source_meta": { "id": source_id, "type": source_type, @@ -72,4 +183,4 @@ def retrieve(query, n_results=5): }, } ) - return chunks + return results diff --git a/alembic/versions/3f59f83a5253_rule_evaluations.py b/alembic/versions/3f59f83a5253_rule_evaluations.py new file mode 100644 index 00000000..a68d81a2 --- /dev/null +++ b/alembic/versions/3f59f83a5253_rule_evaluations.py @@ -0,0 +1,84 @@ +"""Add rule_evaluations: per-resource coverage, not just findings (#263). + +Revision ID: 3f59f83a5253 +Revises: d8e4f6a1b2c3 +Create Date: 2026-08-29 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# Revision identifiers, used by Alembic. +revision: str = "3f59f83a5253" +down_revision: Union[str, Sequence[str], None] = "d8e4f6a1b2c3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_STATUS_CONSTRAINT = "ck_rule_evaluations_status_v1" +_SCOPE_CONSTRAINT = "ck_rule_evaluations_resource_id_not_empty" +_REASON_CONSTRAINT = "ck_rule_evaluations_reason_code_required" + + +def upgrade() -> None: + op.create_table( + "rule_evaluations", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("scan_id", postgresql.UUID(), nullable=False), + sa.Column("rule_id", sa.Text(), nullable=False), + sa.Column("resource_id", sa.Text(), nullable=False), + sa.Column("resource_type", sa.Text(), nullable=False, server_default=sa.text("''")), + sa.Column("status", sa.Text(), nullable=False), + sa.Column("reason_code", sa.Text(), nullable=True), + sa.Column("reason", sa.Text(), nullable=True), + sa.Column("evidence", postgresql.JSONB(), server_default=sa.text("'{}'::jsonb"), nullable=True), + # Nullable: only set for FAIL evaluations, and only once the finding + # row exists. Populated in the same transaction as the finding insert + # (see DatabaseManager.save_scan), never inferred after the fact. + sa.Column("finding_id", sa.Integer(), nullable=True), + sa.Column("evaluated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["scan_id"], ["scans.scan_id"], name="rule_evaluations_scan_id_fkey"), + sa.ForeignKeyConstraint( + ["finding_id"], ["findings.id"], name="rule_evaluations_finding_id_fkey", ondelete="SET NULL" + ), + sa.PrimaryKeyConstraint("id", name="rule_evaluations_pkey"), + # One coverage statement per rule per resource per scan. Also gives + # the persistence-layer FAIL -> finding_id backfill a stable join key. + sa.UniqueConstraint("scan_id", "rule_id", "resource_id", name="uq_rule_evaluations_scan_rule_resource"), + ) + + op.create_index("idx_rule_evaluations_scan_id", "rule_evaluations", ["scan_id"], unique=False) + op.create_index("idx_rule_evaluations_rule_id", "rule_evaluations", ["rule_id"], unique=False) + op.create_index("idx_rule_evaluations_status", "rule_evaluations", ["status"], unique=False) + + op.create_check_constraint( + _STATUS_CONSTRAINT, + "rule_evaluations", + "status IN ('PASS', 'FAIL', 'UNKNOWN', 'ERROR', 'NOT_APPLICABLE')", + ) + # A canonical scope identifier is required — never an empty string standing + # in for "no specific resource" (that collides across rules/subscriptions). + op.create_check_constraint( + _SCOPE_CONSTRAINT, + "rule_evaluations", + "resource_id <> ''", + ) + # UNKNOWN/ERROR/NOT_APPLICABLE must always explain themselves; only PASS + # and FAIL are self-evident from the status alone. + op.create_check_constraint( + _REASON_CONSTRAINT, + "rule_evaluations", + "status NOT IN ('UNKNOWN', 'ERROR', 'NOT_APPLICABLE') OR (reason_code IS NOT NULL AND reason_code <> '')", + ) + + +def downgrade() -> None: + op.drop_constraint(_REASON_CONSTRAINT, "rule_evaluations", type_="check") + op.drop_constraint(_SCOPE_CONSTRAINT, "rule_evaluations", type_="check") + op.drop_constraint(_STATUS_CONSTRAINT, "rule_evaluations", type_="check") + op.drop_index("idx_rule_evaluations_status", table_name="rule_evaluations") + op.drop_index("idx_rule_evaluations_rule_id", table_name="rule_evaluations") + op.drop_index("idx_rule_evaluations_scan_id", table_name="rule_evaluations") + op.drop_table("rule_evaluations") diff --git a/alembic/versions/d8e4f6a1b2c3_severity_contract_v1.py b/alembic/versions/d8e4f6a1b2c3_severity_contract_v1.py new file mode 100644 index 00000000..e36c7656 --- /dev/null +++ b/alembic/versions/d8e4f6a1b2c3_severity_contract_v1.py @@ -0,0 +1,100 @@ +"""Enforce severity contract v1 and repair historical scan scores. + +Revision ID: d8e4f6a1b2c3 +Revises: c7a2e9f1b3d4 +Create Date: 2026-08-21 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# Revision identifiers, used by Alembic. +revision: str = "d8e4f6a1b2c3" +down_revision: Union[str, Sequence[str], None] = "c7a2e9f1b3d4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_CONSTRAINT = "ck_findings_severity_v1" + + +def upgrade() -> None: + """Normalize known aliases, reject unknown data, constrain and rescore.""" + op.execute( + """ + DO $$ + DECLARE invalid_values text; + BEGIN + SELECT string_agg(value, ', ' ORDER BY value) + INTO invalid_values + FROM ( + SELECT DISTINCT UPPER(BTRIM(severity)) AS value + FROM findings + WHERE UPPER(BTRIM(severity)) NOT IN + ('CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO', 'INFORMATIONAL') + ) invalid; + + IF invalid_values IS NOT NULL THEN + RAISE EXCEPTION 'Cannot apply severity contract v1; unsupported values: %', invalid_values; + END IF; + END $$; + """ + ) + op.execute( + """ + UPDATE findings + SET severity = CASE UPPER(BTRIM(severity)) + WHEN 'INFORMATIONAL' THEN 'INFO' + ELSE UPPER(BTRIM(severity)) + END + WHERE severity <> CASE UPPER(BTRIM(severity)) + WHEN 'INFORMATIONAL' THEN 'INFO' + ELSE UPPER(BTRIM(severity)) + END + """ + ) + op.add_column( + "scans", + sa.Column( + "severity_contract_version", + sa.Text(), + nullable=True, + ), + ) + op.execute( + """ + ALTER TABLE findings + ADD CONSTRAINT ck_findings_severity_v1 + CHECK (severity IN ('CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO')) + NOT VALID + """ + ) + op.execute("ALTER TABLE findings VALIDATE CONSTRAINT ck_findings_severity_v1") + op.execute( + """ + UPDATE scans AS scan + SET score = GREATEST( + 0, + 100 - COALESCE(( + SELECT SUM(CASE finding.severity + WHEN 'CRITICAL' THEN 20 + WHEN 'HIGH' THEN 10 + WHEN 'MEDIUM' THEN 5 + WHEN 'LOW' THEN 2 + WHEN 'INFO' THEN 0 + END) + FROM findings AS finding + WHERE finding.scan_id = scan.scan_id + ), 0) + ), + severity_contract_version = '1.0.0' + WHERE scan.status = 'completed'; + """ + ) + + +def downgrade() -> None: + """Remove the v1 constraint; corrected historical scores remain corrected.""" + op.drop_constraint(_CONSTRAINT, "findings", type_="check") + op.drop_column("scans", "severity_contract_version") diff --git a/api/app.py b/api/app.py index 217742f1..0afc9033 100644 --- a/api/app.py +++ b/api/app.py @@ -10,8 +10,15 @@ from flask_cors import CORS from werkzeug.middleware.proxy_fix import ProxyFix -from api.models.finding import DatabaseManager -from api.observability import configure_logging, get_request_id, init_app, init_sentry +from api.models.finding import DatabaseManager, get_pool_stats +from api.observability import ( + configure_logging, + get_request_id, + init_app, + init_sentry, + probe_rate_limit, + set_pool_stats_provider, +) load_dotenv() @@ -29,8 +36,27 @@ _INSECURE_JWT_DEFAULT = "change-me-in-production" _MIN_JWT_SECRET_LENGTH = 32 +_MAX_AUTHORIZATION_HEADER_LENGTH = 8192 _GENERATE_CMD = 'python -c "import secrets; print(secrets.token_urlsafe(32))"' +# A token's signature proves who signed it, not what the bearer is allowed to +# do. Every accepted token must carry one of these roles (see issue #294): +# a missing/unrecognized role is treated the same as an invalid signature. +# Only operator/admin may perform a write (any non-GET/HEAD); viewer is +# read-only. This is enforced regardless of demo mode - public_demo only +# ever widens *read* access to skip the token requirement entirely, it does +# not touch write authorization. +_KNOWN_ROLES = {"viewer", "operator", "admin"} +_WRITE_ROLES = {"operator", "admin"} + +# Generous enough for legitimate manual or automated readiness checks from +# one source, but bounded well under the default pool size +# (DB_POOL_MAX_CONN=10) so a single caller can never claim more than half +# the pool's capacity by itself, even if every allowed request in the +# window lands at once. See probe_rate_limit's docstring for why this is +# in-memory rather than the shared Postgres-backed rate_limit(). +_READY_MAX_REQUESTS_PER_WINDOW = 5 + def _is_production() -> bool: return ( @@ -100,7 +126,21 @@ def create_app() -> Flask: # Trust exactly one reverse-proxy hop (Render's edge) for the client IP # and scheme, so request.remote_addr reflects the real caller instead of # collapsing every client onto Render's proxy address. Rate limiting and - # any other per-IP logic depend on this being accurate. + # any other per-IP logic (api.observability.probe_rate_limit, + # api.rate_limit.rate_limit) depend on this being accurate. + # + # This is a trust boundary, not just a convenience setting: x_for=1 makes + # Flask take the *last* entry of an inbound X-Forwarded-For header as the + # real client IP, on the assumption that Render's edge is the only thing + # capable of appending to it before the request reaches this process. If + # the origin were ever reachable directly - bypassing Render's edge, e.g. + # a misconfigured DNS record or a leaked origin IP - a direct caller's own + # X-Forwarded-For header would be trusted as-is, and they could set it to + # a fresh IP on every request. Every per-IP control in this file (the + # probe-endpoint limiter, the Postgres-backed rate limiter) would then + # bucket each request as a "new" caller, which is equivalent to no rate + # limiting for that path at all. Keeping the origin unreachable except + # through Render's edge is what this setting's correctness depends on. app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1) # ------------------------------------------------------------------ # @@ -110,6 +150,7 @@ def create_app() -> Flask: # every later before_request handler (including JWT auth) and to the # error handlers. Also mounts the public /metrics endpoint. init_app(app) + set_pool_stats_provider(get_pool_stats) # ------------------------------------------------------------------ # # Configuration & Security # @@ -140,6 +181,14 @@ def create_app() -> Flask: "Do not use this setting with real Azure scan data in production." ) + if not os.environ.get("OPENSHIELD_AUTHORIZED_SUBSCRIPTIONS"): + logger.warning( + "!!! SECURITY WARNING: OPENSHIELD_AUTHORIZED_SUBSCRIPTIONS NOT SET !!! " + "Any authenticated operator/admin token can trigger a scan against any " + "subscription_id. Set this to a comma-separated allowlist of the " + "subscription(s) this deployment is authorized to scan." + ) + @app.teardown_appcontext def close_db(error=None): """Return the request's pooled database connection after the request.""" @@ -167,7 +216,7 @@ def verify_jwt() -> None: return None auth = request.headers.get("Authorization", "") - if not auth.startswith("Bearer "): + if len(auth) > _MAX_AUTHORIZATION_HEADER_LENGTH or not auth.startswith("Bearer "): return jsonify( { "error": "Missing or malformed Authorization header", @@ -181,6 +230,12 @@ def verify_jwt() -> None: token, app.config["JWT_SECRET"], algorithms=["HS256"], + # A token with no expiry can never be invalidated short of a + # full JWT_SECRET rotation - require every accepted token to + # carry one (issue #294). MissingRequiredClaimError is a + # subclass of InvalidTokenError, so it's already handled by + # the except clause below. + options={"require": ["exp"]}, ) g.user = payload except jwt.ExpiredSignatureError: @@ -189,12 +244,25 @@ def verify_jwt() -> None: logger.warning("Invalid JWT token") return jsonify({"error": "Invalid token", "request_id": get_request_id()}), 401 + role = payload.get("role") + if role not in _KNOWN_ROLES: + logger.warning("JWT rejected: missing or unrecognized role %r", role) + return jsonify({"error": "Invalid token", "request_id": get_request_id()}), 401 + if request.method not in ("GET", "HEAD") and role not in _WRITE_ROLES: + return jsonify( + { + "error": "This token's role is not authorized for write operations", + "request_id": get_request_id(), + } + ), 403 + return None # ------------------------------------------------------------------ # # Blueprints # # ------------------------------------------------------------------ # from api.routes.ai import ai_bp + from api.routes.assurance import assurance_bp from api.routes.cbom import cbom_bp from api.routes.compliance import compliance_bp from api.routes.drift import drift_bp @@ -205,6 +273,7 @@ def verify_jwt() -> None: from api.routes.score import score_bp app.register_blueprint(ai_bp) + app.register_blueprint(assurance_bp) app.register_blueprint(cbom_bp) app.register_blueprint(compliance_bp) app.register_blueprint(drift_bp) @@ -230,11 +299,15 @@ def health(): return jsonify({"status": "ok"}) @app.get("/ready") + @probe_rate_limit(_READY_MAX_REQUESTS_PER_WINDOW) def ready(): """Readiness probe: 200 when the database is reachable, else 503.""" try: - db = DatabaseManager() - db.ping() + # Register the manager on Flask's request context before pinging. + # The existing teardown handler then returns the pooled connection + # on both the success and error paths. + g.db = DatabaseManager() + g.db.ping() return jsonify({"status": "ready"}), 200 except Exception as exc: logger.warning("Readiness check failed: %s", exc) diff --git a/api/models/finding.py b/api/models/finding.py index 2b5c1cc6..c2adaafe 100644 --- a/api/models/finding.py +++ b/api/models/finding.py @@ -12,6 +12,15 @@ import psycopg2.extras import psycopg2.pool +from openshield.severity import ( + CONTRACT_VERSION, + normalize_severity, + score_counts, + score_findings, + severity_rank, +) +from scanner.evaluation import EvaluationStatus, aggregate_status + logger = logging.getLogger(__name__) FRAMEWORKS_DIR = Path(__file__).parent.parent.parent / "compliance" / "frameworks" @@ -38,7 +47,43 @@ def _get_pool(dsn: str) -> "psycopg2.pool.ThreadedConnectionPool": return pool -SEVERITY_WEIGHTS = {"HIGH": 10, "MEDIUM": 5, "LOW": 2, "INFO": 0} +def get_pool_stats(dsn: Optional[str] = None) -> Dict[str, Any]: + """Return a point-in-time snapshot of the shared connection pool's utilization. + + Reports only counts (in-use / idle / configured maximum) - never the DSN, + host, credentials, or anything else that could describe the database + deployment. Safe to expose on a public surface (Prometheus /metrics, the + /ready probe) precisely because it carries no operational secrets, only + capacity numbers an operator needs to see the pool approaching exhaustion + before it happens. + + Returns zeroed stats with no pool created yet (no request has connected + since process start) rather than raising, so callers on the request path + (like /ready) never fail because of a stats lookup. + """ + dsn = dsn or os.environ.get("DATABASE_URL", "") + with _POOLS_LOCK: + pool = _POOLS.get(dsn) + + if pool is None: + return {"max_connections": _POOL_MAX_CONN, "in_use": 0, "idle": 0, "utilization_percent": 0.0} + + # psycopg2's pool has no public stats API; _used/_pool/maxconn are the + # same attributes getconn()/putconn() themselves mutate under this same + # lock, so a snapshot taken while holding it can't land mid-mutation. + with pool._lock: + in_use = len(pool._used) + idle = len(pool._pool) + max_connections = pool.maxconn + + utilization_percent = round((in_use / max_connections) * 100, 1) if max_connections else 0.0 + return { + "max_connections": max_connections, + "in_use": in_use, + "idle": idle, + "utilization_percent": utilization_percent, + } + FRAMEWORK_FILE_MAP = { "cis": "cis_azure_benchmark.json", @@ -159,75 +204,168 @@ def init_db(self) -> None: def save_scan(self, scan_result: Dict[str, Any]) -> None: """Persist a full scan result (scan header + all findings).""" - conn = self._get_conn() from datetime import datetime, timezone + # Validate and canonicalize the entire batch before issuing SQL. A bad + # severity must never be stored with a zero/default weight. + findings = [] + for raw_finding in scan_result.get("findings", []): + finding = dict(raw_finding) + finding["severity"] = normalize_severity(finding.get("severity")) + findings.append(finding) + + conn = self._get_conn() completed_at = scan_result.get("completed_at") or datetime.now(timezone.utc).isoformat() - with conn.cursor() as cur: - cur.execute( - """ - INSERT INTO scans ( - scan_id, subscription_id, started_at, completed_at, - total_findings, score, cve_enrichment_status, status, - attempt_count, error_message - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - ON CONFLICT (scan_id) DO UPDATE SET - completed_at = EXCLUDED.completed_at, - total_findings = EXCLUDED.total_findings, - score = EXCLUDED.score, - status = EXCLUDED.status, - error_message = EXCLUDED.error_message - """, - ( - scan_result["scan_id"], - scan_result["subscription_id"], - scan_result["started_at"], - completed_at, - scan_result.get("total_findings", 0), - scan_result.get("score"), - scan_result.get("cve_enrichment_status", "PENDING"), - scan_result.get("status", "completed"), - scan_result.get("attempt_count", 0), - scan_result.get("error_message"), - ), - ) - for f in scan_result.get("findings", []): + try: + with conn.cursor() as cur: cur.execute( """ - INSERT INTO findings - (scan_id, rule_id, rule_name, severity, category, - resource_id, resource_name, resource_type, - description, remediation, playbook, - frameworks, metadata, cve_references, - cvss_score, exploit_available, detected_at) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + INSERT INTO scans ( + scan_id, subscription_id, started_at, completed_at, + total_findings, score, cve_enrichment_status, status, + attempt_count, error_message, severity_contract_version + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (scan_id) DO UPDATE SET + completed_at = EXCLUDED.completed_at, + total_findings = EXCLUDED.total_findings, + score = EXCLUDED.score, + status = EXCLUDED.status, + error_message = EXCLUDED.error_message, + severity_contract_version = EXCLUDED.severity_contract_version """, ( - f.get("scan_id"), - f.get("rule_id"), - f.get("rule_name"), - f.get("severity"), - f.get("category"), - f.get("resource_id"), - f.get("resource_name"), - f.get("resource_type"), - f.get("description"), - f.get("remediation"), - f.get("playbook"), - json.dumps(f.get("frameworks", {})), - json.dumps(f.get("metadata", {})), - json.dumps(f.get("cve_references", [])), - f.get("cvss_score"), - f.get("exploit_available", False), - f.get("detected_at"), + scan_result["scan_id"], + scan_result["subscription_id"], + scan_result["started_at"], + completed_at, + len(findings), + score_findings(findings), + scan_result.get("cve_enrichment_status", "PENDING"), + scan_result.get("status", "completed"), + scan_result.get("attempt_count", 0), + scan_result.get("error_message"), + CONTRACT_VERSION, ), ) - conn.commit() + # A worker retry replaces the previous result atomically. This + # keeps the scan header, child rows, and recomputed score in + # agreement instead of duplicating findings on every attempt. + cur.execute("DELETE FROM findings WHERE scan_id = %s", (scan_result["scan_id"],)) + finding_id_by_key: Dict[Any, int] = {} + for f in findings: + cur.execute( + """ + INSERT INTO findings + (scan_id, rule_id, rule_name, severity, category, + resource_id, resource_name, resource_type, + description, remediation, playbook, + frameworks, metadata, cve_references, + cvss_score, exploit_available, detected_at) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + RETURNING id + """, + ( + # The parent scan owns every child in this batch. + # Never trust a caller-supplied child scan_id. + scan_result["scan_id"], + f.get("rule_id"), + f.get("rule_name"), + f.get("severity"), + f.get("category"), + f.get("resource_id"), + f.get("resource_name"), + f.get("resource_type"), + f.get("description"), + f.get("remediation"), + f.get("playbook"), + json.dumps(f.get("frameworks", {})), + json.dumps(f.get("metadata", {})), + json.dumps(f.get("cve_references", [])), + f.get("cvss_score"), + f.get("exploit_available", False), + f.get("detected_at"), + ), + ) + finding_id_by_key[(f.get("rule_id"), f.get("resource_id"))] = cur.fetchone()[0] + + # Coverage rows (#263): a status for every resource a migrated + # rule looked at, not just its violations. A FAIL evaluation + # is durably linked to the finding row it corresponds to + # right here, in the same transaction, instead of leaving + # callers to infer the relationship from rule_id/resource_id. + # + # Upserted rather than replaced wholesale: a retried/replayed + # scan result must converge on the same rows instead of a + # delete-then-reinsert racing a concurrent reader that could + # briefly see zero coverage for a scan that already has some. + evaluated_at = completed_at + evaluations = scan_result.get("evaluations", []) + for evaluation in evaluations: + status = evaluation.get("status") + finding_id = None + if status == EvaluationStatus.FAIL: + finding_id = finding_id_by_key.get((evaluation.get("rule_id"), evaluation.get("resource_id"))) + cur.execute( + """ + INSERT INTO rule_evaluations + (scan_id, rule_id, resource_id, resource_type, status, + reason_code, reason, evidence, finding_id, evaluated_at) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + ON CONFLICT (scan_id, rule_id, resource_id) DO UPDATE SET + resource_type = EXCLUDED.resource_type, + status = EXCLUDED.status, + reason_code = EXCLUDED.reason_code, + reason = EXCLUDED.reason, + evidence = EXCLUDED.evidence, + finding_id = EXCLUDED.finding_id, + evaluated_at = EXCLUDED.evaluated_at + """, + ( + scan_result["scan_id"], + evaluation.get("rule_id"), + evaluation.get("resource_id"), + evaluation.get("resource_type") or "", + status, + evaluation.get("reason_code"), + evaluation.get("reason"), + json.dumps(evaluation.get("evidence", {})), + finding_id, + evaluated_at, + ), + ) + + # A rule/resource that no longer appears (a rule removed from + # this scan's set, a resource that's gone) must not leave a + # stale coverage row behind once the current set is upserted. + if evaluations: + cur.execute( + """ + DELETE FROM rule_evaluations + WHERE scan_id = %s + AND (rule_id, resource_id) NOT IN ( + SELECT * FROM unnest(%s::text[], %s::text[]) + ) + """, + ( + scan_result["scan_id"], + [evaluation.get("rule_id") for evaluation in evaluations], + [evaluation.get("resource_id") for evaluation in evaluations], + ), + ) + else: + cur.execute("DELETE FROM rule_evaluations WHERE scan_id = %s", (scan_result["scan_id"],)) + conn.commit() + except Exception: + # psycopg2 connections remain in an aborted transaction after any + # SQL error. Roll back here so the worker can record failure and + # safely process subsequent scans on the same pooled connection. + conn.rollback() + raise logger.info( "Saved scan %s with %d findings", scan_result["scan_id"], - scan_result["total_findings"], + len(findings), ) # ------------------------------------------------------------------ # @@ -238,7 +376,7 @@ def get_findings(self, filters: Optional[Dict[str, Any]] = None) -> List[Dict[st """Return findings, optionally filtered by severity, category, or rule_id.""" filters = filters or {} severity = filters.get("severity") - severity = severity.upper() if severity is not None else None + severity = normalize_severity(severity) if severity is not None else None category = filters.get("category") rule_id = filters.get("rule_id") scan_id = filters.get("scan_id") @@ -473,7 +611,8 @@ def get_score(self) -> int: 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. + CRITICAL findings deduct 20 points each, HIGH 10, MEDIUM 5, + LOW 2, and INFO 0. Floors at 0. """ conn = self._get_conn() with conn.cursor() as cur: @@ -489,8 +628,7 @@ def get_score(self) -> int: ) rows = cur.fetchall() - deduction = sum(SEVERITY_WEIGHTS.get(sev.upper(), 0) * count for sev, count in rows) - return max(0, 100 - deduction) + return score_counts({severity: count for severity, count in rows}) def get_cve_summary(self) -> Dict[str, Any]: """Return high-level summary of CVE findings for the dashboard.""" @@ -555,42 +693,97 @@ def get_compliance_score(self, framework: str) -> Dict[str, Any]: controls = framework_data.get("controls", {}) - # Get rule IDs that fired in the latest completed scan only + # Finding detail (severity/category/resource count) still comes from + # findings — evaluations don't carry severity. Pass/fail/unknown/error + # status comes from rule_evaluations, so a rule that was never run, + # errored, or hasn't been migrated to evaluate() yet is never silently + # reported as PASS just because it produced no findings (#263). conn = self._get_conn() with conn.cursor() as cur: cur.execute( """ - SELECT DISTINCT rule_id FROM findings + SELECT rule_id, severity, category, COUNT(*) + FROM findings + WHERE scan_id = ( + SELECT scan_id FROM scans WHERE status = 'completed' ORDER BY started_at DESC LIMIT 1 + ) + GROUP BY rule_id, severity, category + """ + ) + finding_rows = cur.fetchall() + + cur.execute( + """ + SELECT rule_id, status + FROM rule_evaluations WHERE scan_id = ( SELECT scan_id FROM scans WHERE status = 'completed' ORDER BY started_at DESC LIMIT 1 ) """ ) - failed_rule_ids = {row[0] for row in cur.fetchall()} + evaluation_rows = cur.fetchall() + + failures: Dict[str, Dict[str, Any]] = {} + for rule_id, raw_severity, category, resource_count in finding_rows: + severity = normalize_severity(raw_severity) + current = failures.get(rule_id) + if current is None: + failures[rule_id] = { + "severity": severity, + "category": category, + "resources": resource_count, + } + continue + current["resources"] += resource_count + if severity_rank(severity) > severity_rank(current["severity"]): + current["severity"] = severity + current["category"] = category + + statuses_by_rule: Dict[str, List[str]] = {} + for rule_id, status in evaluation_rows: + statuses_by_rule.setdefault(rule_id, []).append(status) + aggregated_status = {rule_id: aggregate_status(statuses) for rule_id, statuses in statuses_by_rule.items()} results = [] for rule_id, control in controls.items(): - status = "FAIL" if rule_id in failed_rule_ids else "PASS" + failure = failures.get(rule_id) + # No evaluation row at all means this rule was never run against + # this scan (predates rule_evaluations, or was skipped) — report + # UNKNOWN rather than defaulting to PASS or inferring from findings. + status = aggregated_status.get(rule_id, EvaluationStatus.UNKNOWN) results.append( { "rule_id": rule_id, "control_id": control["control_id"], "control_name": control["control_name"], "status": status, + "severity": failure["severity"] if failure else None, + "category": failure["category"] if failure else None, + "resources": failure["resources"] if failure else 0, } ) total = len(results) - passed = sum(1 for r in results if r["status"] == "PASS") - failed = total - passed - score_pct = round((passed / total) * 100) if total else 0 + counts = { + "passed": sum(1 for r in results if r["status"] == EvaluationStatus.PASS), + "failed": sum(1 for r in results if r["status"] == EvaluationStatus.FAIL), + "unknown": sum(1 for r in results if r["status"] == EvaluationStatus.UNKNOWN), + "error": sum(1 for r in results if r["status"] == EvaluationStatus.ERROR), + "not_applicable": sum(1 for r in results if r["status"] == EvaluationStatus.NOT_APPLICABLE), + } + # UNKNOWN/ERROR must never improve the score: they count against the + # denominator (evaluated coverage) without counting as a pass. + # NOT_APPLICABLE controls fall outside the denominator entirely. + evaluated = total - counts["not_applicable"] + score_pct = round((counts["passed"] / evaluated) * 100) if evaluated else 0 return { "framework": framework_data.get("framework"), "version": framework_data.get("version"), + "contract_version": "2", "total_controls": total, - "passed": passed, - "failed": failed, + "evaluated": evaluated, + **counts, "score_percent": score_pct, "controls": results, } diff --git a/api/observability.py b/api/observability.py index 8f48ecd5..0d00f06c 100644 --- a/api/observability.py +++ b/api/observability.py @@ -13,10 +13,15 @@ import logging import os +import re +import threading import time import uuid +from collections import OrderedDict, deque +from functools import wraps +from typing import Callable, Deque, Dict, Optional, Tuple -from flask import Flask, Response, g, request +from flask import Flask, Response, current_app, g, jsonify, request from prometheus_client import ( CONTENT_TYPE_LATEST, Counter, @@ -35,6 +40,7 @@ logger = logging.getLogger(__name__) REQUEST_ID_HEADER = "X-Request-ID" +_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") # --------------------------------------------------------------------------- # # Prometheus metrics # @@ -80,6 +86,49 @@ "Latency of outbound LLM provider requests in seconds.", ["provider"], ) +DB_POOL_CONNECTIONS_IN_USE = Gauge( + "openshield_db_pool_connections_in_use", + "Connections currently checked out from the shared database pool.", +) +DB_POOL_CONNECTIONS_IDLE = Gauge( + "openshield_db_pool_connections_idle", + "Connections currently idle in the shared database pool.", +) +DB_POOL_CONNECTIONS_MAX = Gauge( + "openshield_db_pool_connections_max", + "Configured maximum size of the shared database pool.", +) + +# Set by api.app at startup (see set_pool_stats_provider). Kept as a plain +# module-level callable rather than importing api.models.finding directly - +# this module stays free of project imports (see module docstring) so it +# can be reused from the worker without pulling in the API's DB layer. +_pool_stats_provider: Optional[Callable[[], Dict[str, int]]] = None + + +def set_pool_stats_provider(fn: Callable[[], Dict[str, int]]) -> None: + """Register the callable /metrics uses to refresh the DB pool gauges. + + Called lazily on every scrape (see ``metrics()`` below) rather than on a + timer, so the numbers are exact at scrape time instead of aging between + scrapes. + """ + global _pool_stats_provider + _pool_stats_provider = fn + + +def _refresh_pool_metrics() -> None: + if _pool_stats_provider is None: + return + try: + stats = _pool_stats_provider() + DB_POOL_CONNECTIONS_IN_USE.set(stats["in_use"]) + DB_POOL_CONNECTIONS_IDLE.set(stats["idle"]) + DB_POOL_CONNECTIONS_MAX.set(stats["max_connections"]) + except Exception: + # A stats lookup must never take /metrics down - the rest of the + # scrape (HTTP/scan counters etc.) is still valid without it. + logger.exception("Failed to refresh database pool metrics") # --------------------------------------------------------------------------- # @@ -148,6 +197,164 @@ def get_request_id() -> str: return rid +# --------------------------------------------------------------------------- # +# Probe/scrape rate limiting # +# --------------------------------------------------------------------------- # +# /health, /ready, and /metrics are exempt from JWT auth by design (see +# api.app._ALWAYS_PUBLIC) so uptime checkers and Prometheus scrapers can +# reach them without a token. That also makes /ready and /metrics the one +# place an unauthenticated caller can trigger repeated backend work - a +# pooled database checkout for /ready - without ever presenting a token. +# +# api.rate_limit.rate_limit (used elsewhere in the API) is the wrong tool +# here: it does its own Postgres round trip per check, which would add +# database load to /ready - the exact endpoint whose job is to protect the +# database pool from overload - and risks checking out a second, separate +# pooled connection under its own `g.db` alongside the one /ready's handler +# already manages, only one of which the teardown handler would return. +# +# This limiter is pure in-memory and per-process instead. The connection +# pool it protects (api.models.finding._POOLS) is itself process-local under +# Gunicorn's multi-worker model, so a per-process budget is the matching +# granularity, not a weaker substitute for a shared one: it bounds exactly +# the pool a single worker process can exhaust. It is a defense-in-depth +# backstop, not a replacement for restricting these paths at whatever +# reverse proxy/CDN/WAF fronts the deployment - see the "Restricting +# probe/scrape endpoints at the edge" section of docs/deployment/render.md. +_PROBE_WINDOW_SECONDS = 10.0 +# Prometheus scrapes on a fixed interval (typically 15-30s) from a small, +# stable set of scraper IPs, so this stays generous; it exists to blunt a +# single caller hammering the endpoint, not to constrain normal scraping. +_METRICS_MAX_REQUESTS_PER_WINDOW = 20 +# Hard ceiling on distinct (address, path) keys tracked at once. Same-key +# cleanup alone isn't enough: a caller that continuously rotates its source +# address (or a spoofed forwarded address wherever the trusted-proxy +# boundary is misconfigured) creates a new one-shot dict entry per address, +# and a key that's never revisited is never pruned by the per-call cleanup +# below - making the tracking dict itself an unbounded memory sink. This +# caps it regardless of how many distinct addresses show up. +_PROBE_MAX_TRACKED_KEYS = 10_000 +# A full scan over every tracked key on every single request would undercut +# the point of a cheap in-memory limiter, so the sweep below runs every Nth +# call instead of every call. This bounds how long a key whose owner never +# returns can survive to at most this many requests' worth of accumulation +# - the hard cap above is what actually bounds worst-case memory regardless +# of traffic shape or how the sweep is scheduled. +_PROBE_SWEEP_INTERVAL = 200 +_probe_lock = threading.Lock() + + +class _ProbeEntry: + """One tracked (address, path) key's rate-limit state. + + window_seconds is stored per entry, not taken from whichever request + happens to trigger the periodic sweep: _probe_hits is one dict shared + across every probe_rate_limit-decorated view, so a sweep triggered by + /metrics's decorator (its own window_seconds closure) must not apply + that window to keys tracked for /ready, or vice versa. Both endpoints + currently default to the same 10s window, which is exactly why this + was dormant rather than visibly broken - the first endpoint added with + a different window would have hit premature resets or lingering stale + entries for every other endpoint's keys. + """ + + __slots__ = ("hits", "window_seconds") + + def __init__(self, window_seconds: float) -> None: + self.hits: Deque[float] = deque() + self.window_seconds = window_seconds + + +# An OrderedDict, not a plain dict: every touch (a request actually within +# budget, or a sweep pruning it survives) moves a key to the end, so the +# front is always the least recently touched key - the correct, +# deterministic thing to evict first when the hard cap above is reached. +_probe_hits: "OrderedDict[Tuple[str, str], _ProbeEntry]" = OrderedDict() +_probe_call_count = 0 + + +def _sweep_expired_probe_hits(now: float) -> None: + """Remove every tracked key whose hits have all expired. + + Must be called with _probe_lock already held. This is the global + cleanup pass: the per-call logic in probe_rate_limit only ever prunes + the one key the current request touched, which does nothing for a + key that's never hit again. Each entry's own window_seconds is used, + not the sweep caller's - see _ProbeEntry. + """ + for key in list(_probe_hits.keys()): + entry = _probe_hits[key] + cutoff = now - entry.window_seconds + while entry.hits and entry.hits[0] < cutoff: + entry.hits.popleft() + if not entry.hits: + del _probe_hits[key] + + +def probe_rate_limit(max_requests: int, window_seconds: float = _PROBE_WINDOW_SECONDS): + """Limit a probe/scrape view to ``max_requests`` per ``window_seconds`` per client IP. + + The check runs before the wrapped view body, so a request rejected here + never reaches the database work it would otherwise trigger. Disabled in + testing mode, matching the convention in api.rate_limit.rate_limit. + """ + + def decorator(fn): + @wraps(fn) + def wrapped(*args, **kwargs): + if current_app.testing: + return fn(*args, **kwargs) + + global _probe_call_count + key = (request.remote_addr or "unknown", request.path) + now = time.monotonic() + with _probe_lock: + _probe_call_count += 1 + if _probe_call_count % _PROBE_SWEEP_INTERVAL == 0: + _sweep_expired_probe_hits(now) + + entry = _probe_hits.get(key) + if entry is not None: + cutoff = now - entry.window_seconds + while entry.hits and entry.hits[0] < cutoff: + entry.hits.popleft() + if not entry.hits: + del _probe_hits[key] + entry = None + + if entry is None: + if len(_probe_hits) >= _PROBE_MAX_TRACKED_KEYS: + # Deterministic eviction: drop the least recently + # touched key, not an arbitrary/insertion-order one. + _probe_hits.popitem(last=False) + entry = _ProbeEntry(window_seconds) + # OrderedDict places a newly-inserted key at the end, + # so a brand-new entry doesn't need an explicit + # move_to_end - it's already the most recent. + _probe_hits[key] = entry + + allowed = len(entry.hits) < max_requests + if allowed: + entry.hits.append(now) + # Only a request that actually counted against the + # budget refreshes this key's position. A rejected + # request must not keep an over-budget key artificially + # warm - otherwise a caller that never lets its own + # budget recover (an attacker) stays permanently + # protected from eviction while quiet, legitimate + # keys drift toward the front and get evicted instead. + _probe_hits.move_to_end(key) + + if not allowed: + return jsonify({"status": "rate_limited"}), 429, {"Retry-After": str(int(window_seconds))} + + return fn(*args, **kwargs) + + return wrapped + + return decorator + + # --------------------------------------------------------------------------- # # Flask wiring # # --------------------------------------------------------------------------- # @@ -160,7 +367,8 @@ def init_app(app: Flask) -> None: @app.before_request def _start_observability() -> None: - g.request_id = request.headers.get(REQUEST_ID_HEADER) or str(uuid.uuid4()) + supplied_request_id = request.headers.get(REQUEST_ID_HEADER, "") + g.request_id = supplied_request_id if _REQUEST_ID_RE.fullmatch(supplied_request_id) else str(uuid.uuid4()) g.request_start_time = time.perf_counter() @app.after_request @@ -176,5 +384,7 @@ def _record_observability(response: Response) -> Response: return response @app.get("/metrics") + @probe_rate_limit(_METRICS_MAX_REQUESTS_PER_WINDOW) def metrics() -> Response: + _refresh_pool_metrics() return Response(generate_latest(), content_type=CONTENT_TYPE_LATEST) diff --git a/api/routes/ai.py b/api/routes/ai.py index 776796a7..3907957f 100644 --- a/api/routes/ai.py +++ b/api/routes/ai.py @@ -8,27 +8,31 @@ from api.rate_limit import rate_limit from api.services.ai_provider import PROVIDERS as SUPPORTED_PROVIDERS from api.services.ai_provider import get_completion +from api.validation import ( + MAX_API_KEY_LENGTH, + MAX_MODEL_LENGTH, + MAX_QUESTION_LENGTH, + MODEL_RE, + VALIDATION_ERROR_MESSAGE, + ValidationError, + bounded_string, + choice, + findings_list, + reject_unknown_fields, + require_json_object, +) from ai.retriever import retrieve, VectorStoreNotBuilt +from openshield.severity import severity_rank as contract_severity_rank ai_bp = Blueprint("ai", __name__) logger = logging.getLogger(__name__) _AI_RATE_LIMIT = 20 # requests per minute per client IP, per endpoint -_SEVERITY_RANK = { - "CRITICAL": 5, - "HIGH": 4, - "MEDIUM": 3, - "LOW": 2, - "INFORMATIONAL": 1, - "INFO": 1, -} - -SEVERITY_ORDER = {"CRITICAL": -1, "HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3, "INFORMATIONAL": 3} - def severity_rank(finding: dict) -> int: - return _SEVERITY_RANK.get(str(finding.get("severity", "")).upper(), 0) + value = finding.get("severity") + return contract_severity_rank(value) if value not in (None, "") else -1 def _build_summary_prompt(findings: list) -> str: @@ -126,7 +130,8 @@ def _build_threat_simulation_prompt(findings_text: str, context: str) -> str: def _findings_to_text(findings): ordered = sorted( findings, - key=lambda f: SEVERITY_ORDER.get(str(f.get("severity", "")).upper(), 4), + key=severity_rank, + reverse=True, ) lines = [] for i, f in enumerate(ordered, 1): @@ -148,14 +153,18 @@ def _context_for(query): def _read_request(): - body = request.get_json(silent=True) - if not body: - return None, (jsonify({"error": "Request body must be JSON"}), 400) - if not body.get("provider"): - return None, (jsonify({"error": "provider is required"}), 400) - if not body.get("api_key"): - return None, (jsonify({"error": "api_key is required"}), 400) - return body, None + try: + body = require_json_object(request.get_json(silent=True)) + reject_unknown_fields(body, {"provider", "api_key", "model", "findings", "question"}) + body["provider"] = choice(body.get("provider"), "provider", SUPPORTED_PROVIDERS, case="lower") + body["api_key"] = bounded_string(body.get("api_key"), "api_key", maximum=MAX_API_KEY_LENGTH) + if body.get("model") is not None: + body["model"] = bounded_string(body["model"], "model", maximum=MAX_MODEL_LENGTH, pattern=MODEL_RE) + if ".." in body["model"]: + raise ValidationError("model has an invalid format") + return body, None + except ValidationError: + return None, (jsonify({"error": VALIDATION_ERROR_MESSAGE}), 400) _AI_ERROR_MESSAGES = { @@ -180,27 +189,21 @@ def _ai_error_response(exc: Exception, status: int, log_context: str): @ai_bp.post("/api/ai/insights") @rate_limit(_AI_RATE_LIMIT) def insights(): - data = request.get_json(silent=True) - if data is None: - return jsonify({"error": "Request body must be valid JSON"}), 400 - - provider = str(data.get("provider") or "").strip().lower() - api_key = str(data.get("api_key") or "").strip() - findings = data.get("findings") - question = str(data.get("question") or "").strip() - - if not provider: - return jsonify({"error": "Missing required field: provider"}), 400 - if provider not in SUPPORTED_PROVIDERS: - return jsonify({"error": f"Unsupported provider: {provider}"}), 400 - if not api_key: - return jsonify({"error": "Missing required field: api_key"}), 400 - if findings is None: - return jsonify({"error": "Missing required field: findings"}), 400 - if not isinstance(findings, list): - return jsonify({"error": "findings must be a list"}), 400 - if len(findings) == 0: - return jsonify({"error": "findings must not be empty"}), 400 + data, error = _read_request() + if error: + return error + try: + provider = data["provider"] + api_key = data["api_key"] + findings = findings_list(data.get("findings"), required=True) + question = "" + if data.get("question") is not None: + if not isinstance(data["question"], str): + raise ValidationError("question must be a string") + if data["question"].strip(): + question = bounded_string(data["question"], "question", maximum=MAX_QUESTION_LENGTH) + except ValidationError: + return jsonify({"error": VALIDATION_ERROR_MESSAGE}), 400 sorted_findings = sorted(findings, key=severity_rank, reverse=True) @@ -234,9 +237,10 @@ def ai_summary(): body, error = _read_request() if error: return error - findings = body.get("findings", []) - if not isinstance(findings, list): - return jsonify({"error": "findings must be a list"}), 400 + try: + findings = findings_list(body.get("findings")) + except ValidationError: + return jsonify({"error": VALIDATION_ERROR_MESSAGE}), 400 findings_text = _findings_to_text(findings) try: @@ -273,9 +277,10 @@ def ai_prioritise(): body, error = _read_request() if error: return error - findings = body.get("findings", []) - if not isinstance(findings, list): - return jsonify({"error": "findings must be a list"}), 400 + try: + findings = findings_list(body.get("findings")) + except ValidationError: + return jsonify({"error": VALIDATION_ERROR_MESSAGE}), 400 findings_text = _findings_to_text(findings) try: @@ -319,16 +324,17 @@ def ai_ask(): body, error = _read_request() if error: return error - question = body.get("question", "") - if not question or not question.strip(): - return jsonify({"error": "question is required"}), 400 + try: + question = bounded_string(body.get("question"), "question", maximum=MAX_QUESTION_LENGTH) + findings = findings_list(body.get("findings")) + except ValidationError: + return jsonify({"error": VALIDATION_ERROR_MESSAGE}), 400 try: context, sources = _context_for(question) except VectorStoreNotBuilt as exc: return _ai_error_response(exc, 503, "Vector store unavailable in ai_ask") - findings = body.get("findings", []) findings_text = _findings_to_text(findings) if findings else "Not provided." prompt = ( @@ -361,11 +367,10 @@ def ai_threat_simulation(): body, error = _read_request() if error: return error - findings = body.get("findings", []) - if not isinstance(findings, list): - return jsonify({"error": "findings must be a list"}), 400 - if not findings: - return jsonify({"error": "findings must not be empty"}), 400 + try: + findings = findings_list(body.get("findings"), required=True) + except ValidationError: + return jsonify({"error": VALIDATION_ERROR_MESSAGE}), 400 findings_text = _findings_to_text(findings) try: diff --git a/api/routes/assurance.py b/api/routes/assurance.py new file mode 100644 index 00000000..eafe10a8 --- /dev/null +++ b/api/routes/assurance.py @@ -0,0 +1,52 @@ +"""Provider-assurance routes for infrastructure that tenants cannot scan.""" + +import logging + +from flask import Blueprint, jsonify + +from api.services.physical_assurance import CatalogValidationError, get_physical_assurance_report +from api.services.data_link_assurance import get_data_link_assurance_report +from api.services.network_layer_assurance import get_network_layer_assurance_report + + +assurance_bp = Blueprint("assurance", __name__) +logger = logging.getLogger(__name__) + + +@assurance_bp.get("/api/assurance/physical-layer") +def get_physical_layer_assurance(): + """Return Azure public-cloud OSI Layer 1 responsibility and evidence coverage.""" + try: + return jsonify(get_physical_assurance_report()) + except CatalogValidationError as exc: + logger.error("Physical assurance catalog validation failed: %s", exc) + return jsonify({"error": "Physical assurance catalog is unavailable"}), 500 + except Exception as exc: + logger.error("Failed to build physical assurance report: %s", exc) + return jsonify({"error": "Physical assurance report generation failed"}), 500 + + +@assurance_bp.get("/api/assurance/data-link-layer") +def get_data_link_layer_assurance(): + """Return Azure public-cloud OSI Layer 2 responsibility and evidence coverage.""" + try: + return jsonify(get_data_link_assurance_report()) + except CatalogValidationError as exc: + logger.error("Data Link assurance catalog validation failed: %s", exc) + return jsonify({"error": "Data Link assurance catalog is unavailable"}), 500 + except Exception as exc: + logger.error("Failed to build Data Link assurance report: %s", exc) + return jsonify({"error": "Data Link assurance report generation failed"}), 500 + + +@assurance_bp.get("/api/assurance/network-layer") +def get_network_layer_assurance(): + """Return Azure public-cloud OSI Layer 3 responsibility and evidence coverage.""" + try: + return jsonify(get_network_layer_assurance_report()) + except CatalogValidationError as exc: + logger.error("Network Layer assurance catalog validation failed: %s", exc) + return jsonify({"error": "Network Layer assurance catalog is unavailable"}), 500 + except Exception as exc: + logger.error("Failed to build Network Layer assurance report: %s", exc) + return jsonify({"error": "Network Layer assurance report generation failed"}), 500 diff --git a/api/routes/compliance.py b/api/routes/compliance.py index e6024e90..1fade57c 100644 --- a/api/routes/compliance.py +++ b/api/routes/compliance.py @@ -5,6 +5,7 @@ from flask import Blueprint, g, jsonify from api.models.finding import DatabaseManager +from api.validation import VALIDATION_ERROR_MESSAGE, ValidationError, choice compliance_bp = Blueprint("compliance", __name__) logger = logging.getLogger(__name__) @@ -31,21 +32,17 @@ def get_compliance(framework: str): Returns control-level pass/fail status mapped to current open findings. """ try: - if framework.lower() not in SUPPORTED_FRAMEWORKS: - return jsonify( - { - "error": f"Unknown framework '{framework}'", - "supported": list(SUPPORTED_FRAMEWORKS), - } - ), 400 + framework = choice(framework, "framework", SUPPORTED_FRAMEWORKS, case="lower") db = _get_db() - result = db.get_compliance_score(framework.lower()) + result = db.get_compliance_score(framework) if "error" in result: return jsonify(result), 500 return jsonify(result) + except ValidationError: + return jsonify({"error": VALIDATION_ERROR_MESSAGE, "supported": list(SUPPORTED_FRAMEWORKS)}), 400 except FileNotFoundError as exc: logger.error("Frameworks directory not found: %s", exc) return jsonify({"error": "Compliance frameworks are not available"}), 500 diff --git a/api/routes/findings.py b/api/routes/findings.py index 91f6afbd..4325ba1e 100644 --- a/api/routes/findings.py +++ b/api/routes/findings.py @@ -2,19 +2,27 @@ import logging import os -import re from pathlib import Path from flask import Blueprint, g, jsonify, request from api.models.finding import DatabaseManager +from api.validation import ( + CATEGORIES, + RULE_ID_RE, + VALIDATION_ERROR_MESSAGE, + ValidationError, + bounded_string, + canonical_choice, + positive_integer, + severity_value, + uuid_string, +) _PLAYBOOKS_DIR = (Path(__file__).parent.parent.parent / "playbooks" / "cli").resolve() # Known rule_id shape, e.g. AZ-STOR-001. Anything else is rejected before it # ever reaches the filesystem, closing off path traversal via a crafted or # corrupted rule_id. -_RULE_ID_RE = re.compile(r"^[A-Z0-9]+(?:-[A-Z0-9]+)*$") - findings_bp = Blueprint("findings", __name__) logger = logging.getLogger(__name__) @@ -31,16 +39,36 @@ def list_findings(): """Return findings, optionally filtered by severity, category, or rule_id. Query parameters: - severity - HIGH | MEDIUM | LOW | INFO + severity - CRITICAL | HIGH | MEDIUM | LOW | INFO category - Storage | Network | Identity | Database | Compute | KeyVault rule_id - e.g. AZ-STOR-001 scan_id - UUID of a specific scan """ try: - filters = {k: v for k, v in request.args.items() if k in ("severity", "category", "rule_id", "scan_id")} + allowed = {"severity", "category", "rule_id", "scan_id"} + unknown = set(request.args) - allowed + if unknown: + raise ValidationError(f"Unsupported query parameter: {sorted(unknown)[0]}") + for key in request.args: + if len(request.args.getlist(key)) != 1: + raise ValidationError(f"Query parameter {key} must be provided once") + + filters = {} + if "severity" in request.args: + filters["severity"] = severity_value(request.args["severity"]) + if "category" in request.args: + filters["category"] = canonical_choice(request.args["category"], "category", CATEGORIES) + if "rule_id" in request.args: + filters["rule_id"] = bounded_string( + request.args["rule_id"].upper(), "rule_id", maximum=64, pattern=RULE_ID_RE + ) + if "scan_id" in request.args: + filters["scan_id"] = uuid_string(request.args["scan_id"], "scan_id") db = _get_db() findings = db.get_findings(filters) return jsonify({"count": len(findings), "findings": findings}) + except ValidationError: + return jsonify({"error": VALIDATION_ERROR_MESSAGE}), 400 except Exception as exc: logger.error("Failed to list findings: %s", exc) return jsonify({"error": "Failed to retrieve findings"}), 500 @@ -50,11 +78,14 @@ def list_findings(): def get_finding(finding_id: int): """Return a single finding by its integer ID.""" try: + finding_id = positive_integer(finding_id, "finding_id") db = _get_db() finding = db.get_finding_by_id(finding_id) if not finding: return jsonify({"error": "Finding not found"}), 404 return jsonify(finding) + except ValidationError: + return jsonify({"error": VALIDATION_ERROR_MESSAGE}), 400 except Exception as exc: logger.error("Failed to get finding %d: %s", finding_id, exc) return jsonify({"error": "Database error"}), 500 @@ -68,6 +99,7 @@ def get_playbook(finding_id: int): and combines it with the finding's remediation guidance and any CVE references. """ try: + finding_id = positive_integer(finding_id, "finding_id") db = _get_db() finding = db.get_finding_by_id(finding_id) if not finding: @@ -79,7 +111,7 @@ def get_playbook(finding_id: int): cli_commands = [] script_path = None - if _RULE_ID_RE.match(rule_id or ""): + if RULE_ID_RE.match(rule_id 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" candidate = (_PLAYBOOKS_DIR / script_name).resolve() @@ -124,6 +156,8 @@ def get_playbook(finding_id: int): } ) + except ValidationError: + return jsonify({"error": VALIDATION_ERROR_MESSAGE}), 400 except Exception as exc: logger.error("Failed to get playbook for finding %d: %s", finding_id, exc) return jsonify({"error": "Failed to retrieve playbook"}), 500 diff --git a/api/routes/prioritization.py b/api/routes/prioritization.py index d19fe3d0..e138d9c0 100644 --- a/api/routes/prioritization.py +++ b/api/routes/prioritization.py @@ -4,7 +4,13 @@ import os from flask import Blueprint, g, jsonify -from api.models.finding import DatabaseManager, SEVERITY_WEIGHTS +from api.models.finding import DatabaseManager +from openshield.severity import ( + normalize_severity, + severity_rank, + severity_risk_score, + severity_weight, +) prioritization_bp = Blueprint("prioritization", __name__) logger = logging.getLogger(__name__) @@ -24,19 +30,19 @@ _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: +def _impact(score: int, finding_severity: str) -> str: if score >= 40: - return "CRITICAL" - if score >= 20: - return "HIGH" - if score >= 10: - return "MEDIUM" - return "LOW" + aggregate_impact = "CRITICAL" + elif score >= 20: + aggregate_impact = "HIGH" + elif score >= 10: + aggregate_impact = "MEDIUM" + else: + aggregate_impact = "LOW" + severity = normalize_severity(finding_severity) + return max((aggregate_impact, severity), key=severity_rank) def _get_db() -> DatabaseManager: @@ -86,24 +92,31 @@ def get_prioritization(): ) rules = cur.fetchall() - cur.execute("SELECT COUNT(*) AS total FROM findings WHERE scan_id = %s", (latest_scan_id,)) - total_findings = cur.fetchone()["total"] + cur.execute( + """ + SELECT UPPER(severity) AS severity, COUNT(*) AS count + FROM findings + WHERE scan_id = %s + GROUP BY UPPER(severity) + """, + (latest_scan_id,), + ) + severity_rows = cur.fetchall() matrix = [] rankings = [] - action_items = [] - severity_counts: dict = {} + remediation_by_rule = {} + severity_counts = {normalize_severity(row["severity"]): row["count"] for row in severity_rows} + total_findings = sum(severity_counts.values()) for idx, rule in enumerate(rules): - sev = (rule["severity"] or "LOW").upper() + sev = normalize_severity(rule["severity"]) cat = rule["category"] or "Other" effort = _EFFORT.get(cat, _DEFAULT_EFFORT) - weight = SEVERITY_WEIGHTS.get(sev, 2) + weight = severity_weight(sev) affected = rule["affected_count"] score = weight * affected - risk = _RISK_SCORE.get(sev, 2) - - severity_counts[sev] = severity_counts.get(sev, 0) + affected + risk = severity_risk_score(sev) matrix.append( { @@ -128,33 +141,35 @@ def get_prioritization(): "severity": sev, "category": cat, "effort": effort, - "impact": _impact(score), + "impact": _impact(score, sev), "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"], - } - ) + remediation_by_rule[rule["rule_id"]] = rule["remediation"] # 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) + action_items = [ + { + "id": ranking["rank"], + "action": remediation_by_rule[ranking["rule_id"]] or f"Remediate {ranking['name']}", + "impact": ranking["impact"], + "effort": _EFFORT_LABEL.get(ranking["effort"], "MEDIUM"), + "eta": _EFFORT_ETA.get(ranking["effort"], "1 hour"), + "rule_id": ranking["rule_id"], + "resource": ranking["resource"], + } + for ranking in rankings[:10] + ] + + critical = severity_counts.get("CRITICAL", 0) total_hours = sum( - _EFFORT.get(r["category"], _DEFAULT_EFFORT) for r in matrix if r["severity"] in ("HIGH", "MEDIUM") + _EFFORT.get(r["category"], _DEFAULT_EFFORT) + for r in matrix + if r["severity"] in ("CRITICAL", "HIGH", "MEDIUM") ) estimated_time = f"{total_hours} hours" if total_hours < 24 else f"{total_hours // 8} days" diff --git a/api/routes/resources.py b/api/routes/resources.py index 56a0309e..26583b16 100644 --- a/api/routes/resources.py +++ b/api/routes/resources.py @@ -5,6 +5,7 @@ from flask import Blueprint, g, jsonify from api.models.finding import DatabaseManager +from openshield.severity import LEVELS, severity_from_rank, severity_rank_sql resources_bp = Blueprint("resources", __name__) logger = logging.getLogger(__name__) @@ -52,36 +53,33 @@ def get_resources(): } ) + rank_expression = severity_rank_sql("severity") cur.execute( - """ + f""" 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 + MAX({rank_expression}) 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 - """, + """, # nosec B608 - expression is generated from the repository-owned contract (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} + by_risk_level: dict = {level.id: 0 for level in LEVELS} + by_risk_level["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") + risk = severity_from_rank(row["risk_rank"]) detected = row["discovered_at"] discovered_at = detected.isoformat() if hasattr(detected, "isoformat") else str(detected) diff --git a/api/routes/scans.py b/api/routes/scans.py index 17c2c591..9ec2a289 100644 --- a/api/routes/scans.py +++ b/api/routes/scans.py @@ -7,11 +7,41 @@ from flask import Blueprint, g, jsonify, request from api.models.finding import DatabaseManager +from api.validation import ( + VALIDATION_ERROR_MESSAGE, + ValidationError, + reject_unknown_fields, + require_json_object, + uuid_string, +) from scanner.cve_correlator import enrich_findings scans_bp = Blueprint("scans", __name__) logger = logging.getLogger(__name__) +_AUTHORIZED_SUBSCRIPTIONS_ENV = "OPENSHIELD_AUTHORIZED_SUBSCRIPTIONS" + + +def _subscription_is_authorized(subscription_id: str) -> bool: + """Return True unless an allowlist is configured and this ID isn't on it. + + OPENSHIELD_AUTHORIZED_SUBSCRIPTIONS is a comma-separated allowlist an + operator can set to bound which subscription_id values this deployment + will accept for scanning - the single-tenant containment boundary issue + #294 asks for until a real multi-tenant/OIDC boundary exists. Any + authenticated operator/admin token can otherwise trigger a scan against + an arbitrary subscription_id, since role alone doesn't say which + subscription a caller is entitled to. + + Left unset (the default), every subscription_id is accepted - identical + to today's behavior, so existing single-operator deployments aren't + broken by this change. api.app's startup check warns loudly when it's + left unset. + """ + raw = os.environ.get(_AUTHORIZED_SUBSCRIPTIONS_ENV, "") + allowlist = {value.strip().lower() for value in raw.split(",") if value.strip()} + return not allowlist or subscription_id.lower() in allowlist + def _get_db() -> DatabaseManager: if "db" not in g: @@ -39,11 +69,14 @@ def list_scans(): def get_scan_status(scan_id): """Return the details and status of a specific scan.""" try: + scan_id = uuid_string(scan_id, "scan_id") db = _get_db() scan = db.get_scan(scan_id) if not scan: return jsonify({"error": "Scan not found"}), 404 return jsonify(scan) + except ValidationError: + return jsonify({"error": VALIDATION_ERROR_MESSAGE}), 400 except Exception as exc: logger.error("Failed to get scan status: %s", exc) return jsonify({"error": "Database error"}), 500 @@ -59,11 +92,18 @@ def trigger_scan(): Returns 202 Accepted with the scan_id immediately. """ try: - body = request.get_json(silent=True) or {} + raw_body = request.get_json(silent=True) + body = {} if raw_body is None and not request.data else require_json_object(raw_body) + reject_unknown_fields(body, {"subscription_id"}) subscription_id = body.get("subscription_id") or os.environ.get("AZURE_SUBSCRIPTION_ID") if not subscription_id: return jsonify({"error": "subscription_id is required"}), 400 + subscription_id = uuid_string(subscription_id, "subscription_id") + + if not _subscription_is_authorized(subscription_id): + logger.warning("Scan trigger rejected: subscription %s is not on the authorized allowlist", subscription_id) + return jsonify({"error": "Subscription is not authorized for this deployment"}), 403 scan_id = str(uuid.uuid4()) logger.info("Async scan triggered for subscription %s (id: %s)", subscription_id, scan_id) @@ -79,6 +119,8 @@ def trigger_scan(): {"scan_id": scan_id, "status": "pending", "message": "Scan has been queued and will start shortly."} ), 202 + except ValidationError: + return jsonify({"error": VALIDATION_ERROR_MESSAGE}), 400 except Exception as exc: logger.error("Critical error in trigger_scan route: %s", exc, exc_info=True) return jsonify({"error": "Critical route failure"}), 500 @@ -153,6 +195,7 @@ def enrich_scan(scan_id): rate-limited to one every ~7 seconds. """ try: + scan_id = uuid_string(scan_id, "scan_id") db = _get_db() # Check current status to avoid redundant NVD calls @@ -189,6 +232,8 @@ def enrich_scan(scan_id): } ), 202 + except ValidationError: + return jsonify({"error": VALIDATION_ERROR_MESSAGE}), 400 except Exception as exc: logger.error("Failed to start enrichment for scan %s: %s", scan_id, exc) return jsonify({"error": "Internal server error"}), 500 diff --git a/api/routes/score.py b/api/routes/score.py index 22d157da..77481267 100644 --- a/api/routes/score.py +++ b/api/routes/score.py @@ -25,8 +25,8 @@ def get_score(): """Return the overall security posture score (0-100). Score calculation: - Starts at 100. Deducts 10 per HIGH finding, 5 per MEDIUM, 2 per LOW. - Floors at 0. + Starts at 100. Deducts 20 per CRITICAL finding, 10 per HIGH, + 5 per MEDIUM, 2 per LOW, and 0 per INFO. Floors at 0. """ try: db = _get_db() diff --git a/api/services/assurance_catalog.py b/api/services/assurance_catalog.py new file mode 100644 index 00000000..723a42de --- /dev/null +++ b/api/services/assurance_catalog.py @@ -0,0 +1,73 @@ +"""Shared validation primitives for closed assurance catalogs.""" + +from __future__ import annotations + +from datetime import date +from typing import Any +from urllib.parse import urlparse + + +class CatalogValidationError(ValueError): + """Raised when a bundled assurance catalog is incomplete or unsafe.""" + + +def require_string(item: dict[str, Any], field: str, context: str) -> str: + """Return a required non-empty string field.""" + value = item.get(field) + if not isinstance(value, str) or not value.strip(): + raise CatalogValidationError(f"{context}: {field} must be a non-empty string") + return value + + +def require_unique(items: Any, name: str) -> dict[str, dict[str, Any]]: + """Index a required object list by unique string ID.""" + if not isinstance(items, list): + raise CatalogValidationError(f"{name} must be a list") + indexed: dict[str, dict[str, Any]] = {} + for item in items: + if not isinstance(item, dict): + raise CatalogValidationError(f"{name}: every entry must be an object") + item_id = require_string(item, "id", name) + if item_id in indexed: + raise CatalogValidationError(f"{name}: duplicate id {item_id}") + indexed[item_id] = item + return indexed + + +def require_reference_list(item: dict[str, Any], field: str, allowed_ids: set[str], context: str) -> list[str]: + """Validate a non-empty list of unique cross-references.""" + values = item.get(field) + if not isinstance(values, list) or not values: + raise CatalogValidationError(f"{context}: {field} must be a non-empty list") + if any(not isinstance(value, str) for value in values): + raise CatalogValidationError(f"{context}: {field} must contain only strings") + if len(values) != len(set(values)): + raise CatalogValidationError(f"{context}: {field} contains duplicate references") + unknown = set(values) - allowed_ids + if unknown: + raise CatalogValidationError(f"{context}: {field} contains unknown ids {sorted(unknown)}") + return values + + +def parse_iso_date(value: Any, context: str) -> date: + """Parse a required ISO-8601 calendar date.""" + if not isinstance(value, str): + raise CatalogValidationError(f"{context}: date must be an ISO-8601 string") + try: + return date.fromisoformat(value) + except ValueError as exc: + raise CatalogValidationError(f"{context}: invalid ISO-8601 date {value!r}") from exc + + +def validate_evidence_source(source: dict[str, Any], source_id: str, allowed_hosts: set[str]) -> tuple[date, date]: + """Validate common evidence metadata and return its review dates.""" + require_string(source, "title", source_id) + url = require_string(source, "url", source_id) + parsed_url = urlparse(url) + if parsed_url.scheme != "https" or parsed_url.hostname not in allowed_hosts: + raise CatalogValidationError(f"{source_id}: evidence URL must use HTTPS on an allowed host") + reviewed_at = parse_iso_date(source.get("reviewed_at"), f"{source_id}.reviewed_at") + review_due_at = parse_iso_date(source.get("review_due_at"), f"{source_id}.review_due_at") + if review_due_at <= reviewed_at: + raise CatalogValidationError(f"{source_id}: review_due_at must be after reviewed_at") + return reviewed_at, review_due_at diff --git a/api/services/data_link_assurance.py b/api/services/data_link_assurance.py new file mode 100644 index 00000000..e7072e5a --- /dev/null +++ b/api/services/data_link_assurance.py @@ -0,0 +1,153 @@ +"""Load, validate, and report Azure Data Link assurance coverage.""" + +from __future__ import annotations + +import copy +import json +from datetime import date +from pathlib import Path +from typing import Any + +from api.services.assurance_catalog import ( + CatalogValidationError, + require_reference_list, + require_string, + require_unique, + validate_evidence_source, +) + +CATALOG_PATH = Path(__file__).resolve().parents[2] / "compliance" / "assurance" / "data_link_layer.json" +EXPECTED_DOMAIN_IDS = {f"DL-{number:02d}" for number in range(1, 20)} +EXPECTED_SUBLAYER_IDS = {"LLC", "MAC"} +ALLOWED_RESPONSIBILITIES = {"Microsoft", "Customer", "Shared"} +ALLOWED_APPLICABILITY = {"APPLICABLE", "NOT_APPLICABLE", "UNSUPPORTED"} +ALLOWED_VERIFICATION = { + "PROVIDER_ATTESTED", + "PLATFORM_ENFORCED", + "AUTOMATICALLY_CHECKED", + "MANUALLY_VERIFIABLE", + "UNSUPPORTED", + "NOT_APPLICABLE", +} + + +def validate_catalog(catalog: dict[str, Any]) -> None: + """Fail closed when any Layer 2 domain, sublayer, decision, or cross-reference is missing.""" + if not isinstance(catalog, dict): + raise CatalogValidationError("Catalog root must be an object") + layer = catalog.get("layer") + if not isinstance(layer, dict) or layer.get("number") != 2 or layer.get("name") != "Data Link": + raise CatalogValidationError("Catalog must describe OSI Layer 2 Data Link") + require_string(catalog, "catalog_version", "catalog") + scope = catalog.get("scope") + if not isinstance(scope, dict): + raise CatalogValidationError("scope must be an object") + require_string(scope, "statement", "scope") + require_string(scope, "responsibility_boundary", "scope") + + domains = require_unique(catalog.get("domains"), "domains") + sublayers = require_unique(catalog.get("sublayers"), "sublayers") + evidence = require_unique(catalog.get("evidence_sources"), "evidence_sources") + automated = require_unique(catalog.get("automated_controls"), "automated_controls") + if set(domains) != EXPECTED_DOMAIN_IDS: + raise CatalogValidationError("domains must contain the complete DL-01 through DL-19 set") + if set(sublayers) != EXPECTED_SUBLAYER_IDS: + raise CatalogValidationError("sublayers must contain LLC and MAC") + + for sublayer_id, sublayer in sublayers.items(): + require_string(sublayer, "name", sublayer_id) + require_string(sublayer, "description", sublayer_id) + for evidence_id, source in evidence.items(): + validate_evidence_source(source, evidence_id, {"learn.microsoft.com"}) + + referenced_sublayers: set[str] = set() + referenced_evidence: set[str] = set() + referenced_automated: set[str] = set() + for domain_id, domain in domains.items(): + require_string(domain, "name", domain_id) + require_string(domain, "observability_method", domain_id) + responsibility = require_string(domain, "responsibility_owner", domain_id) + applicability = require_string(domain, "azure_applicability", domain_id) + verification = require_string(domain, "verification", domain_id) + if responsibility not in ALLOWED_RESPONSIBILITIES: + raise CatalogValidationError(f"{domain_id}: invalid responsibility_owner") + if applicability not in ALLOWED_APPLICABILITY: + raise CatalogValidationError(f"{domain_id}: invalid azure_applicability") + if verification not in ALLOWED_VERIFICATION: + raise CatalogValidationError(f"{domain_id}: invalid verification") + referenced_sublayers.update(require_reference_list(domain, "sublayer_ids", set(sublayers), domain_id)) + referenced_evidence.update(require_reference_list(domain, "evidence_source_ids", set(evidence), domain_id)) + control_ids = domain.get("automated_control_ids", []) + if verification == "AUTOMATICALLY_CHECKED": + referenced_automated.update( + require_reference_list(domain, "automated_control_ids", set(automated), domain_id) + ) + elif control_ids: + raise CatalogValidationError(f"{domain_id}: only automatically checked domains may reference rules") + if referenced_sublayers != set(sublayers): + raise CatalogValidationError("LLC and MAC must both be covered") + if referenced_evidence != set(evidence): + raise CatalogValidationError("every evidence source must be referenced") + if referenced_automated != set(automated): + raise CatalogValidationError("every automated control must be cross-referenced") + + for control_id, control in automated.items(): + require_string(control, "name", control_id) + require_string(control, "playbook", control_id) + require_reference_list(control, "domain_ids", set(domains), control_id) + frameworks = control.get("frameworks") + if not isinstance(frameworks, dict) or set(frameworks) != {"CIS", "NIST", "ISO27001", "SOC2"}: + raise CatalogValidationError(f"{control_id}: all required framework mappings must be present") + + +def load_catalog(path: Path = CATALOG_PATH) -> dict[str, Any]: + """Load and validate the bundled catalog.""" + try: + with path.open(encoding="utf-8") as catalog_file: + catalog = json.load(catalog_file) + except (OSError, json.JSONDecodeError) as exc: + raise CatalogValidationError(f"Unable to load Data Link assurance catalog: {exc}") from exc + validate_catalog(catalog) + return catalog + + +def build_report(catalog: dict[str, Any], as_of: date | None = None) -> dict[str, Any]: + """Build static responsibility coverage with independent evidence freshness.""" + validate_catalog(catalog) + report = copy.deepcopy(catalog) + as_of = as_of or date.today() + evidence = {item["id"]: item for item in report["evidence_sources"]} + current = sum(date.fromisoformat(item["review_due_at"]) >= as_of for item in evidence.values()) + for domain in report["domains"]: + domain["evidence"] = [evidence[source_id] for source_id in domain["evidence_source_ids"]] + report["catalog_coverage"] = { + "domains_covered": len(report["domains"]), + "domains_total": 19, + "sublayers_covered": 2, + "sublayers_total": 2, + "percent": 100, + } + report["evidence_freshness"] = { + "current_sources": current, + "total_sources": len(evidence), + "percent": round(current / len(evidence) * 100) if evidence else 0, + "assessed_as_of": as_of.isoformat(), + } + report["provider_assurance_state"] = "DOCUMENTED" + report["platform_enforcement_state"] = "DOCUMENTED_NOT_LIVE_INSPECTED" + report["automated_control_applicability"] = { + "state": "REQUIRES_SUBSCRIPTION_INVENTORY", + "without_expressroute_direct": "NOT_APPLICABLE", + "api_or_permission_failure": "INDETERMINATE", + } + report["limitations"] = [ + "This report is not a live inspection of Microsoft switches, forwarding tables, VLANs, or fabric internals.", + "Provider-owned assurance domains do not create findings or change the tenant security score.", + "Only ExpressRoute Direct management-plane configuration is automatically checked.", + ] + return report + + +def get_data_link_assurance_report(as_of: date | None = None) -> dict[str, Any]: + """Return the bundled Data Link assurance report.""" + return build_report(load_catalog(), as_of=as_of) diff --git a/api/services/network_layer_assurance.py b/api/services/network_layer_assurance.py new file mode 100644 index 00000000..b73c9a98 --- /dev/null +++ b/api/services/network_layer_assurance.py @@ -0,0 +1,233 @@ +"""Load, validate, and report Azure Network Layer assurance coverage.""" + +from __future__ import annotations + +import copy +import json +from datetime import date +from pathlib import Path +from typing import Any + +from api.services.assurance_catalog import ( + CatalogValidationError, + require_reference_list, + require_string, + require_unique, + validate_evidence_source, +) + +CATALOG_PATH = Path(__file__).resolve().parents[2] / "compliance" / "assurance" / "network_layer.json" +EXPECTED_DOMAIN_IDS = {f"NL-{number:02d}" for number in range(1, 21)} +EXPECTED_SUBDOMAIN_IDS = {"ADDRESSING", "ROUTING", "TRANSIT", "PROTECTION", "OBSERVABILITY"} +EXPECTED_CONTROL_IDS = {f"NL-C{number:02d}" for number in range(1, 21)} +EXPECTED_RULE_IDS = {f"AZ-NET-{number:03d}" for number in range(1, 28)} | {"AZ-DL-001", "AZ-DL-002"} +ALLOWED_RESPONSIBILITIES = {"Microsoft", "Customer", "Shared"} +ALLOWED_APPLICABILITY = {"APPLICABLE", "NOT_APPLICABLE", "UNSUPPORTED"} +ALLOWED_VERIFICATION = { + "PROVIDER_ATTESTED", + "PLATFORM_ENFORCED", + "AUTOMATICALLY_CHECKED", + "MANUALLY_VERIFIABLE", + "UNSUPPORTED", + "NOT_APPLICABLE", +} +ALLOWED_CLASSIFICATIONS = {"Layer 2", "Layer 3", "Layer 4", "Layer 7", "Cross-layer"} +ALLOWED_CONTROL_STATUSES = {"DOCUMENTED", "REVIEW_DUE"} + + +def validate_catalog(catalog: dict[str, Any]) -> None: + """Fail closed when any Layer 3 domain, rule audit, decision, or reference is missing.""" + if not isinstance(catalog, dict): + raise CatalogValidationError("Catalog root must be an object") + layer = catalog.get("layer") + if not isinstance(layer, dict) or layer.get("number") != 3 or layer.get("name") != "Network": + raise CatalogValidationError("Catalog must describe OSI Layer 3 Network") + require_string(catalog, "catalog_version", "catalog") + if catalog.get("schema_version") != 1: + raise CatalogValidationError("schema_version must be 1") + scope = catalog.get("scope") + if not isinstance(scope, dict): + raise CatalogValidationError("scope must be an object") + require_string(scope, "statement", "scope") + require_string(scope, "responsibility_boundary", "scope") + if scope.get("environment") != "azure_public_cloud": + raise CatalogValidationError("scope.environment must be azure_public_cloud") + + domains = require_unique(catalog.get("domains"), "domains") + subdomains = require_unique(catalog.get("subdomains"), "subdomains") + controls = require_unique(catalog.get("controls"), "controls") + evidence = require_unique(catalog.get("evidence_sources"), "evidence_sources") + rules = require_unique(catalog.get("rule_classifications"), "rule_classifications") + if set(domains) != EXPECTED_DOMAIN_IDS: + raise CatalogValidationError("domains must contain the complete NL-01 through NL-20 set") + if set(subdomains) != EXPECTED_SUBDOMAIN_IDS: + raise CatalogValidationError("subdomains must contain the complete Layer 3 functional set") + if set(controls) != EXPECTED_CONTROL_IDS: + raise CatalogValidationError("controls must contain the complete NL-C01 through NL-C20 set") + if set(rules) != EXPECTED_RULE_IDS: + raise CatalogValidationError( + "rule_classifications must contain AZ-NET-001 through AZ-NET-027 and both MACsec rules" + ) + + for evidence_id, source in evidence.items(): + validate_evidence_source(source, evidence_id, {"learn.microsoft.com"}) + for subdomain_id, subdomain in subdomains.items(): + require_string(subdomain, "name", subdomain_id) + require_string(subdomain, "description", subdomain_id) + + referenced_evidence: set[str] = set() + referenced_rules: set[str] = set() + covered_domains: set[str] = set() + covered_subdomains: set[str] = set() + for control_id, control in controls.items(): + require_string(control, "title", control_id) + responsibility = require_string(control, "responsibility", control_id) + applicability = require_string(control, "applicability", control_id) + verification = require_string(control, "verification", control_id) + status = require_string(control, "status", control_id) + if responsibility not in ALLOWED_RESPONSIBILITIES: + raise CatalogValidationError(f"{control_id}: invalid responsibility") + if applicability not in ALLOWED_APPLICABILITY: + raise CatalogValidationError(f"{control_id}: invalid applicability") + if verification not in ALLOWED_VERIFICATION: + raise CatalogValidationError(f"{control_id}: invalid verification") + if status not in ALLOWED_CONTROL_STATUSES: + raise CatalogValidationError(f"{control_id}: invalid status") + covered_domains.update(require_reference_list(control, "domain_ids", set(domains), control_id)) + covered_subdomains.update(require_reference_list(control, "subdomain_ids", set(subdomains), control_id)) + referenced_evidence.update(require_reference_list(control, "evidence_source_ids", set(evidence), control_id)) + scanner_rule_ids = control.get("scanner_rule_ids") + if not isinstance(scanner_rule_ids, list) or any(not isinstance(rule_id, str) for rule_id in scanner_rule_ids): + raise CatalogValidationError(f"{control_id}: scanner_rule_ids must be a list of strings") + if len(scanner_rule_ids) != len(set(scanner_rule_ids)) or set(scanner_rule_ids) - set(rules): + raise CatalogValidationError(f"{control_id}: scanner_rule_ids contains invalid references") + referenced_rules.update(scanner_rule_ids) + + if covered_domains != set(domains): + raise CatalogValidationError(f"Uncovered Network Layer domains: {sorted(set(domains) - covered_domains)}") + if covered_subdomains != set(subdomains): + raise CatalogValidationError( + f"Uncovered Network Layer subdomains: {sorted(set(subdomains) - covered_subdomains)}" + ) + + for domain_id, domain in domains.items(): + require_string(domain, "name", domain_id) + require_string(domain, "observability_method", domain_id) + require_string(domain, "automation_decision", domain_id) + responsibility = require_string(domain, "responsibility_owner", domain_id) + applicability = require_string(domain, "azure_applicability", domain_id) + verification = require_string(domain, "verification", domain_id) + if responsibility not in ALLOWED_RESPONSIBILITIES: + raise CatalogValidationError(f"{domain_id}: invalid responsibility_owner") + if applicability not in ALLOWED_APPLICABILITY: + raise CatalogValidationError(f"{domain_id}: invalid azure_applicability") + if verification not in ALLOWED_VERIFICATION: + raise CatalogValidationError(f"{domain_id}: invalid verification") + domain_evidence = require_reference_list(domain, "evidence_source_ids", set(evidence), domain_id) + referenced_evidence.update(domain_evidence) + rule_ids = domain.get("rule_ids") + if not isinstance(rule_ids, list) or any(not isinstance(rule_id, str) for rule_id in rule_ids): + raise CatalogValidationError(f"{domain_id}: rule_ids must be a list of strings") + if len(rule_ids) != len(set(rule_ids)) or set(rule_ids) - set(rules): + raise CatalogValidationError(f"{domain_id}: rule_ids contains invalid references") + + if referenced_evidence != set(evidence): + raise CatalogValidationError("every evidence source must be referenced") + + for rule_id, rule in rules.items(): + require_string(rule, "name", rule_id) + classification = require_string(rule, "osi_classification", rule_id) + require_string(rule, "classification_basis", rule_id) + if classification not in ALLOWED_CLASSIFICATIONS: + raise CatalogValidationError(f"{rule_id}: invalid osi_classification") + domain_ids = rule.get("layer_3_domain_ids") + if not isinstance(domain_ids, list) or any(not isinstance(item, str) for item in domain_ids): + raise CatalogValidationError(f"{rule_id}: layer_3_domain_ids must be a list of strings") + if len(domain_ids) != len(set(domain_ids)) or set(domain_ids) - set(domains): + raise CatalogValidationError(f"{rule_id}: invalid Layer 3 domain reference") + if classification == "Layer 3" and not domain_ids: + raise CatalogValidationError(f"{rule_id}: Layer 3 rules must cross-reference a domain") + if classification not in {"Layer 3", "Cross-layer"} and domain_ids: + raise CatalogValidationError(f"{rule_id}: non-Layer 3 rules cannot claim Layer 3 coverage") + if domain_ids and rule_id not in referenced_rules: + raise CatalogValidationError(f"{rule_id}: domain cross-reference is not reciprocal") + + +def load_catalog(path: Path = CATALOG_PATH) -> dict[str, Any]: + """Load and validate the bundled catalog.""" + try: + with path.open(encoding="utf-8") as catalog_file: + catalog = json.load(catalog_file) + except (OSError, json.JSONDecodeError) as exc: + raise CatalogValidationError(f"Unable to load Network Layer assurance catalog: {exc}") from exc + validate_catalog(catalog) + return catalog + + +def build_report(catalog: dict[str, Any], as_of: date | None = None) -> dict[str, Any]: + """Build static responsibility coverage with independent evidence freshness.""" + validate_catalog(catalog) + report = copy.deepcopy(catalog) + as_of = as_of or date.today() + evidence = {item["id"]: item for item in report["evidence_sources"]} + current = sum(date.fromisoformat(item["review_due_at"]) >= as_of for item in evidence.values()) + for domain in report["domains"]: + domain["evidence"] = [evidence[source_id] for source_id in domain["evidence_source_ids"]] + domain["control_ids"] = [ + control["id"] for control in report["controls"] if domain["id"] in control["domain_ids"] + ] + domain["subdomain_ids"] = sorted( + { + subdomain_id + for control in report["controls"] + if domain["id"] in control["domain_ids"] + for subdomain_id in control["subdomain_ids"] + } + ) + for subdomain in report["subdomains"]: + subdomain["control_ids"] = [ + control["id"] for control in report["controls"] if subdomain["id"] in control["subdomain_ids"] + ] + subdomain["domain_ids"] = sorted( + { + domain_id + for control in report["controls"] + if subdomain["id"] in control["subdomain_ids"] + for domain_id in control["domain_ids"] + } + ) + report["catalog_coverage"] = { + "controls_covered": 20, + "controls_total": 20, + "domains_covered": 20, + "domains_total": 20, + "subdomains_covered": 5, + "subdomains_total": 5, + "percent": 100, + } + report["evidence_freshness"] = { + "current_sources": current, + "total_sources": len(evidence), + "percent": round(current / len(evidence) * 100) if evidence else 0, + "assessed_as_of": as_of.isoformat(), + } + report["provider_assurance_state"] = "DOCUMENTED" + report["platform_enforcement_state"] = "DOCUMENTED_NOT_LIVE_INSPECTED" + report["automated_control_applicability"] = { + "state": "REQUIRES_RELEVANT_SUBSCRIPTION_INVENTORY", + "empty_inventory": "NOT_APPLICABLE", + "api_or_permission_failure": "INDETERMINATE", + } + report["limitations"] = [ + "This report does not inspect Microsoft forwarding tables, tenant-isolated fabric internals, " + "packets, MTU paths, or anti-spoofing implementation.", + "Provider-owned and platform-enforced domains do not create findings or alter the tenant security score.", + "Rule cross-references describe actual OSI behavior; Network-category rules at Layers 2, 4, " + "and 7 are not counted as Layer 3 controls.", + ] + return report + + +def get_network_layer_assurance_report(as_of: date | None = None) -> dict[str, Any]: + """Return the bundled Network Layer assurance report.""" + return build_report(load_catalog(), as_of=as_of) diff --git a/api/services/physical_assurance.py b/api/services/physical_assurance.py new file mode 100644 index 00000000..b86d662f --- /dev/null +++ b/api/services/physical_assurance.py @@ -0,0 +1,225 @@ +"""Load and validate Azure Physical-layer provider assurance evidence.""" + +from __future__ import annotations + +import copy +import json +from datetime import date +from pathlib import Path +from typing import Any + +from api.services.assurance_catalog import ( + CatalogValidationError, + require_reference_list as _require_reference_list, + require_string as _require_string, + require_unique as _require_unique, + validate_evidence_source, +) + + +CATALOG_PATH = Path(__file__).resolve().parents[2] / "compliance" / "assurance" / "physical_layer.json" + +EXPECTED_MICROSOFT_CONTROLS = {f"PE-{number}" for number in range(1, 9)} +EXPECTED_ISO_CONTROLS = { + *(f"A.11.1.{number}" for number in range(1, 7)), + *(f"A.11.2.{number}" for number in range(1, 10)), +} +EXPECTED_DOMAIN_IDS = {f"PHY-{number:02d}" for number in range(1, 22)} +EXPECTED_SUBLAYER_IDS = { + "GENERIC-L1", + "IEEE-PLCP", + "IEEE-PCS", + "IEEE-FEC", + "IEEE-PMA", + "IEEE-PMD", + "IEEE-AN", + "IEEE-MDI", +} +ALLOWED_STATUSES = {"PROVIDER_ATTESTED", "REVIEW_DUE", "NOT_APPLICABLE", "UNKNOWN"} +ALLOWED_EVIDENCE_HOSTS = {"learn.microsoft.com"} + + +def load_catalog(path: Path = CATALOG_PATH) -> dict[str, Any]: + """Load and validate a physical assurance catalog from disk.""" + try: + with path.open(encoding="utf-8") as catalog_file: + catalog = json.load(catalog_file) + except (OSError, json.JSONDecodeError) as exc: + raise CatalogValidationError(f"Unable to load physical assurance catalog: {exc}") from exc + + validate_catalog(catalog) + return catalog + + +def validate_catalog(catalog: dict[str, Any]) -> None: + """Enforce the closed Layer 1 catalog and all cross-reference invariants.""" + if not isinstance(catalog, dict): + raise CatalogValidationError("Catalog root must be an object") + + layer = catalog.get("layer") + if not isinstance(layer, dict) or layer.get("number") != 1 or layer.get("name") != "Physical": + raise CatalogValidationError("Catalog must describe OSI Layer 1 Physical") + + scope = catalog.get("scope") + if not isinstance(scope, dict): + raise CatalogValidationError("scope must be an object") + if scope.get("environment") != "azure_public_cloud": + raise CatalogValidationError("scope.environment must be azure_public_cloud") + if scope.get("owner") != "Microsoft" or scope.get("runtime_hardware_observable") is not False: + raise CatalogValidationError("Azure physical infrastructure must be Microsoft-owned and unobservable") + + domains = catalog.get("domains") + sublayers = catalog.get("sublayers") + evidence_sources = catalog.get("evidence_sources") + controls = catalog.get("controls") + if not all(isinstance(items, list) for items in (domains, sublayers, evidence_sources, controls)): + raise CatalogValidationError("domains, sublayers, evidence_sources, and controls must be lists") + + domain_index = _require_unique(domains, "domains") + sublayer_index = _require_unique(sublayers, "sublayers") + evidence_index = _require_unique(evidence_sources, "evidence_sources") + control_index = _require_unique(controls, "controls") + + if set(domain_index) != EXPECTED_DOMAIN_IDS: + raise CatalogValidationError("domains must contain the complete PHY-01 through PHY-21 set") + if set(sublayer_index) != EXPECTED_SUBLAYER_IDS: + raise CatalogValidationError("sublayers must contain the complete generic and IEEE PHY set") + if len(control_index) != 23: + raise CatalogValidationError("controls must contain exactly 23 baseline controls") + + methodology = catalog.get("methodology") + if not isinstance(methodology, dict): + raise CatalogValidationError("methodology must be an object") + expected_counts = { + "baseline_control_count": len(control_index), + "domain_count": len(domain_index), + "sublayer_count": len(sublayer_index), + } + for field, expected in expected_counts.items(): + if methodology.get(field) != expected: + raise CatalogValidationError(f"methodology.{field} must equal {expected}") + + for domain_id, domain in domain_index.items(): + _require_string(domain, "name", domain_id) + _require_string(domain, "description", domain_id) + + domain_ids = set(domain_index) + sublayer_ids = set(sublayer_index) + evidence_ids = set(evidence_index) + + for sublayer_id, sublayer in sublayer_index.items(): + _require_string(sublayer, "name", sublayer_id) + _require_string(sublayer, "profile", sublayer_id) + _require_string(sublayer, "description", sublayer_id) + _require_reference_list(sublayer, "domain_ids", domain_ids, sublayer_id) + + for evidence_id, evidence in evidence_index.items(): + _require_string(evidence, "evidence_type", evidence_id) + validate_evidence_source(evidence, evidence_id, ALLOWED_EVIDENCE_HOSTS) + + microsoft_controls: set[str] = set() + iso_controls: set[str] = set() + referenced_domains: set[str] = set() + referenced_sublayers: set[str] = set() + referenced_evidence: set[str] = set() + + for control_id, control in control_index.items(): + framework = _require_string(control, "framework", control_id) + baseline_id = _require_string(control, "control_id", control_id) + _require_string(control, "title", control_id) + if control.get("responsibility") != "Microsoft": + raise CatalogValidationError(f"{control_id}: responsibility must be Microsoft") + if control.get("applicability") != "APPLICABLE": + raise CatalogValidationError(f"{control_id}: Azure baseline controls must be APPLICABLE") + if control.get("verification") != "PROVIDER_ASSURANCE": + raise CatalogValidationError(f"{control_id}: verification must be PROVIDER_ASSURANCE") + if control.get("status") not in ALLOWED_STATUSES: + raise CatalogValidationError(f"{control_id}: unsupported status {control.get('status')!r}") + + control_sublayers = _require_reference_list(control, "sublayer_ids", sublayer_ids, control_id) + control_domains = _require_reference_list(control, "domain_ids", domain_ids, control_id) + control_evidence = _require_reference_list(control, "evidence_source_ids", evidence_ids, control_id) + referenced_sublayers.update(control_sublayers) + referenced_domains.update(control_domains) + referenced_evidence.update(control_evidence) + + if framework == "Microsoft SOC": + microsoft_controls.add(baseline_id) + elif framework == "ISO/IEC 27001:2013": + iso_controls.add(baseline_id) + else: + raise CatalogValidationError(f"{control_id}: unsupported baseline framework {framework!r}") + + if microsoft_controls != EXPECTED_MICROSOFT_CONTROLS: + raise CatalogValidationError("Microsoft baseline must contain PE-1 through PE-8 exactly once") + if iso_controls != EXPECTED_ISO_CONTROLS: + raise CatalogValidationError("ISO baseline must contain all fifteen A.11 controls exactly once") + if referenced_domains != domain_ids: + raise CatalogValidationError(f"Uncovered physical domains: {sorted(domain_ids - referenced_domains)}") + if referenced_sublayers != sublayer_ids: + raise CatalogValidationError(f"Uncovered physical sublayers: {sorted(sublayer_ids - referenced_sublayers)}") + if referenced_evidence != evidence_ids: + raise CatalogValidationError(f"Unreferenced evidence sources: {sorted(evidence_ids - referenced_evidence)}") + + +def build_report(catalog: dict[str, Any], as_of: date | None = None) -> dict[str, Any]: + """Build the API report while keeping coverage and freshness independent.""" + validate_catalog(catalog) + report = copy.deepcopy(catalog) + as_of = as_of or date.today() + + evidence_by_id = {item["id"]: item for item in report["evidence_sources"]} + current_controls = 0 + status_counts = {status: 0 for status in ALLOWED_STATUSES} + + for control in report["controls"]: + expanded_evidence = [evidence_by_id[source_id] for source_id in control["evidence_source_ids"]] + evidence_current = all(date.fromisoformat(item["review_due_at"]) >= as_of for item in expanded_evidence) + if not evidence_current and control["status"] == "PROVIDER_ATTESTED": + control["status"] = "REVIEW_DUE" + control["evidence_current"] = evidence_current + control["evidence"] = expanded_evidence + current_controls += int(evidence_current) + status_counts[control["status"]] += 1 + + control_ids_by_domain = { + domain["id"]: [control["id"] for control in report["controls"] if domain["id"] in control["domain_ids"]] + for domain in report["domains"] + } + sublayer_ids_by_domain = { + domain["id"]: [sublayer["id"] for sublayer in report["sublayers"] if domain["id"] in sublayer["domain_ids"]] + for domain in report["domains"] + } + for domain in report["domains"]: + domain["control_ids"] = control_ids_by_domain[domain["id"]] + domain["sublayer_ids"] = sublayer_ids_by_domain[domain["id"]] + for sublayer in report["sublayers"]: + sublayer["control_ids"] = [ + control["id"] for control in report["controls"] if sublayer["id"] in control["sublayer_ids"] + ] + + total_controls = len(report["controls"]) + report["summary"] = { + "baseline_controls": total_controls, + "covered_controls": total_controls, + "coverage_percent": 100, + "domains": len(report["domains"]), + "covered_domains": len(report["domains"]), + "sublayers": len(report["sublayers"]), + "covered_sublayers": len(report["sublayers"]), + "evidence_current_controls": current_controls, + "evidence_current_percent": round((current_controls / total_controls) * 100) if total_controls else 0, + "status_counts": status_counts, + "assessed_as_of": as_of.isoformat(), + } + report["limitations"] = [ + "This is provider-assurance coverage, not a live scan of Microsoft datacenter hardware.", + "A 100 percent catalog score does not assert tenant compliance or certify physical infrastructure.", + "Expired evidence is reported as REVIEW_DUE and never converted into a technical security finding.", + ] + return report + + +def get_physical_assurance_report(as_of: date | None = None) -> dict[str, Any]: + """Load the bundled catalog and return its derived assurance report.""" + return build_report(load_catalog(), as_of=as_of) diff --git a/api/validation.py b/api/validation.py new file mode 100644 index 00000000..cb578b9e --- /dev/null +++ b/api/validation.py @@ -0,0 +1,164 @@ +"""Reusable allowlist and shape validation for untrusted API input.""" + +from __future__ import annotations + +import re +import uuid +from typing import Any, Iterable + +from openshield.severity import ACCEPTED_SEVERITIES, SeverityContractError, normalize_severity + + +class ValidationError(ValueError): + """Raised when a client-controlled value violates the public API contract.""" + + +VALIDATION_ERROR_MESSAGE = "Invalid request parameters" + + +RULE_ID_RE = re.compile(r"^[A-Z0-9]+(?:-[A-Z0-9]+)*$") +MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]*$") + +SEVERITIES = ACCEPTED_SEVERITIES +CATEGORIES = frozenset( + { + "Backup", + "Compute", + "Database", + "Identity", + "Key Vault", + "KeyVault", + "Kubernetes", + "Network", + "PostQuantum", + "Serverless", + "Storage", + "Supply Chain", + } +) + +MAX_API_KEY_LENGTH = 4096 +MAX_MODEL_LENGTH = 128 +MAX_QUESTION_LENGTH = 4000 +MAX_FINDINGS = 1000 +MAX_FINDING_TEXT_LENGTH = 8192 + + +def require_json_object(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + raise ValidationError("Request body must be a JSON object") + return value + + +def reject_unknown_fields(value: dict[str, Any], allowed: Iterable[str]) -> None: + unknown = set(value) - set(allowed) + if unknown: + raise ValidationError(f"Unsupported field: {sorted(unknown)[0]}") + + +def bounded_string( + value: Any, + field: str, + *, + minimum: int = 1, + maximum: int, + pattern: re.Pattern[str] | None = None, +) -> str: + if not isinstance(value, str): + raise ValidationError(f"{field} must be a string") + result = value.strip() + if len(result) < minimum: + raise ValidationError(f"{field} is required") + if len(result) > maximum: + raise ValidationError(f"{field} must be at most {maximum} characters") + if pattern is not None and pattern.fullmatch(result) is None: + raise ValidationError(f"{field} has an invalid format") + return result + + +def choice(value: Any, field: str, allowed: Iterable[str], *, case: str = "preserve") -> str: + result = bounded_string(value, field, maximum=128) + if case == "upper": + result = result.upper() + elif case == "lower": + result = result.lower() + allowed_set = set(allowed) + if result not in allowed_set: + raise ValidationError(f"Unsupported {field}") + return result + + +def canonical_choice(value: Any, field: str, allowed: Iterable[str]) -> str: + """Return the allowlisted spelling while accepting case-insensitive input.""" + result = bounded_string(value, field, maximum=128) + canonical = {item.casefold(): item for item in allowed} + try: + return canonical[result.casefold()] + except KeyError as exc: + raise ValidationError(f"Unsupported {field}") from exc + + +def severity_value(value: Any, field: str = "severity") -> str: + """Validate a public severity value and return its canonical ID.""" + bounded_string(value, field, maximum=128) + try: + return normalize_severity(value) + except SeverityContractError as exc: + raise ValidationError(f"Unsupported {field}") from exc + + +def uuid_string(value: Any, field: str) -> str: + result = bounded_string(value, field, maximum=36) + try: + parsed = uuid.UUID(result) + except (ValueError, AttributeError) as exc: + raise ValidationError(f"{field} must be a valid UUID") from exc + if str(parsed) != result.lower(): + raise ValidationError(f"{field} must use canonical UUID format") + return str(parsed) + + +def positive_integer(value: int, field: str) -> int: + if value <= 0: + raise ValidationError(f"{field} must be a positive integer") + return value + + +def findings_list(value: Any, *, required: bool = False) -> list[dict[str, Any]]: + if value is None: + if required: + raise ValidationError("findings is required") + return [] + if not isinstance(value, list): + raise ValidationError("findings must be a list") + if required and not value: + raise ValidationError("findings must not be empty") + if len(value) > MAX_FINDINGS: + raise ValidationError(f"findings must contain at most {MAX_FINDINGS} items") + + text_fields = ( + "rule_id", + "rule_name", + "title", + "severity", + "resource_name", + "description", + "remediation", + ) + validated: list[dict[str, Any]] = [] + for index, finding in enumerate(value): + if not isinstance(finding, dict): + raise ValidationError(f"findings[{index}] must be an object") + for key in text_fields: + field_value = finding.get(key) + if field_value is not None and ( + not isinstance(field_value, str) or len(field_value) > MAX_FINDING_TEXT_LENGTH + ): + raise ValidationError( + f"findings[{index}].{key} must be a string of at most {MAX_FINDING_TEXT_LENGTH} characters" + ) + normalized = dict(finding) + if normalized.get("severity") is not None: + normalized["severity"] = severity_value(normalized["severity"], f"findings[{index}].severity") + validated.append(normalized) + return validated diff --git a/compliance/assurance/data_link_layer.json b/compliance/assurance/data_link_layer.json new file mode 100644 index 00000000..148d21ca --- /dev/null +++ b/compliance/assurance/data_link_layer.json @@ -0,0 +1,45 @@ +{ + "schema_version": 1, + "catalog_version": "2026.08", + "layer": {"number": 2, "name": "Data Link", "model": "OSI", "assessment_type": "mixed_assurance"}, + "scope": { + "environment": "azure_public_cloud", + "statement": "Azure tenants cannot inspect Microsoft fabric switching or forwarding internals. ExpressRoute Direct is the customer-visible Ethernet boundary with authoritative management-plane configuration.", + "responsibility_boundary": "Microsoft owns the Azure fabric. Customers own their ExpressRoute Direct port configuration and connected devices." + }, + "sublayers": [ + {"id": "LLC", "name": "Logical Link Control", "description": "Link service adaptation, flow and error semantics, and protocol multiplexing."}, + {"id": "MAC", "name": "Media Access Control", "description": "Framing, addressing, medium access, switching, VLAN, QoS, and link security."} + ], + "evidence_sources": [ + {"id": "AZ-L2-BOUNDARY", "title": "Azure virtual network overview", "url": "https://learn.microsoft.com/en-us/azure/virtual-network/virtual-networks-overview", "reviewed_at": "2026-08-10", "review_due_at": "2027-08-10"}, + {"id": "AZ-ERD-OVERVIEW", "title": "About ExpressRoute Direct", "url": "https://learn.microsoft.com/en-us/azure/expressroute/expressroute-erdirect-about", "reviewed_at": "2026-08-10", "review_due_at": "2027-08-10"}, + {"id": "AZ-ERD-MACSEC", "title": "Configure MACsec for ExpressRoute Direct ports", "url": "https://learn.microsoft.com/en-us/azure/expressroute/expressroute-howto-macsec", "reviewed_at": "2026-08-10", "review_due_at": "2027-08-10"}, + {"id": "AZ-ERD-API", "title": "Express Route Ports REST API", "url": "https://learn.microsoft.com/en-us/rest/api/expressroute/express-route-ports", "reviewed_at": "2026-08-10", "review_due_at": "2027-08-10"} + ], + "automated_controls": [ + {"id": "AZ-DL-001", "name": "ExpressRoute Direct link uses MACsec", "domain_ids": ["DL-15"], "frameworks": {"CIS": "N/A-DL-001", "NIST": "PR.DS-2", "ISO27001": "A.13.1.1", "SOC2": "CC6.7"}, "playbook": "playbooks/cli/fix_az_dl_001.sh"}, + {"id": "AZ-DL-002", "name": "High-speed ExpressRoute Direct link uses XPN MACsec", "domain_ids": ["DL-07", "DL-15"], "frameworks": {"CIS": "N/A-DL-002", "NIST": "PR.DS-2", "ISO27001": "A.13.1.1", "SOC2": "CC6.7"}, "playbook": "playbooks/cli/fix_az_dl_002.sh"} + ], + "domains": [ + {"id":"DL-01","name":"Frame construction and delimiting","sublayer_ids":["MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Microsoft","observability_method":"provider documentation","evidence_source_ids":["AZ-L2-BOUNDARY"],"verification":"PROVIDER_ATTESTED"}, + {"id":"DL-02","name":"Source and destination MAC addressing","sublayer_ids":["MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Shared","observability_method":"provider documentation and ExpressRoute configuration","evidence_source_ids":["AZ-L2-BOUNDARY","AZ-ERD-OVERVIEW"],"verification":"MANUALLY_VERIFIABLE"}, + {"id":"DL-03","name":"Media access control","sublayer_ids":["MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Microsoft","observability_method":"provider documentation","evidence_source_ids":["AZ-L2-BOUNDARY"],"verification":"PROVIDER_ATTESTED"}, + {"id":"DL-04","name":"Frame check sequence and error detection","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Microsoft","observability_method":"platform behavior and provider documentation","evidence_source_ids":["AZ-ERD-OVERVIEW"],"verification":"PLATFORM_ENFORCED"}, + {"id":"DL-05","name":"Link-level flow control","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Microsoft","observability_method":"provider documentation","evidence_source_ids":["AZ-ERD-OVERVIEW"],"verification":"PROVIDER_ATTESTED"}, + {"id":"DL-06","name":"Link establishment and teardown","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Shared","observability_method":"ExpressRoute administrative link state","evidence_source_ids":["AZ-ERD-API"],"verification":"MANUALLY_VERIFIABLE"}, + {"id":"DL-07","name":"MTU, frame size, and jumbo-frame behavior","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Shared","observability_method":"ExpressRoute configuration and documentation","evidence_source_ids":["AZ-ERD-OVERVIEW","AZ-ERD-API"],"verification":"MANUALLY_VERIFIABLE"}, + {"id":"DL-08","name":"VLAN tagging, 802.1Q, and QinQ encapsulation","sublayer_ids":["MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Shared","observability_method":"ExpressRoute encapsulation configuration","evidence_source_ids":["AZ-ERD-OVERVIEW","AZ-ERD-API"],"verification":"MANUALLY_VERIFIABLE"}, + {"id":"DL-09","name":"Switching, bridging, filtering, and forwarding tables","sublayer_ids":["MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Microsoft","observability_method":"provider assurance only","evidence_source_ids":["AZ-L2-BOUNDARY"],"verification":"PROVIDER_ATTESTED"}, + {"id":"DL-10","name":"Loop prevention and spanning-tree behavior","sublayer_ids":["MAC"],"azure_applicability":"NOT_APPLICABLE","responsibility_owner":"Microsoft","observability_method":"not exposed to Azure tenants","evidence_source_ids":["AZ-L2-BOUNDARY"],"verification":"NOT_APPLICABLE"}, + {"id":"DL-11","name":"Link aggregation and LACP","sublayer_ids":["MAC"],"azure_applicability":"UNSUPPORTED","responsibility_owner":"Shared","observability_method":"connected-device review","evidence_source_ids":["AZ-ERD-OVERVIEW"],"verification":"UNSUPPORTED"}, + {"id":"DL-12","name":"Neighbor and link discovery such as LLDP","sublayer_ids":["LLC","MAC"],"azure_applicability":"UNSUPPORTED","responsibility_owner":"Shared","observability_method":"connected-device review","evidence_source_ids":["AZ-ERD-OVERVIEW"],"verification":"UNSUPPORTED"}, + {"id":"DL-13","name":"ARP and neighbor-discovery boundary protection","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Shared","observability_method":"provider assurance and connected-device review","evidence_source_ids":["AZ-L2-BOUNDARY","AZ-ERD-OVERVIEW"],"verification":"MANUALLY_VERIFIABLE"}, + {"id":"DL-14","name":"Broadcast and multicast handling","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Microsoft","observability_method":"provider documentation","evidence_source_ids":["AZ-L2-BOUNDARY"],"verification":"PLATFORM_ENFORCED"}, + {"id":"DL-15","name":"MACsec and port-access security","sublayer_ids":["MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Customer","observability_method":"ExpressRoute Direct management API","evidence_source_ids":["AZ-ERD-MACSEC","AZ-ERD-API"],"verification":"AUTOMATICALLY_CHECKED","automated_control_ids":["AZ-DL-001","AZ-DL-002"]}, + {"id":"DL-16","name":"Layer 2 quality of service and priority handling","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Microsoft","observability_method":"provider documentation","evidence_source_ids":["AZ-ERD-OVERVIEW"],"verification":"PROVIDER_ATTESTED"}, + {"id":"DL-17","name":"Operations, administration, monitoring, and packet visibility","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Shared","observability_method":"Azure metrics and connected-device telemetry","evidence_source_ids":["AZ-ERD-OVERVIEW","AZ-ERD-API"],"verification":"MANUALLY_VERIFIABLE"}, + {"id":"DL-18","name":"Virtual switching, SR-IOV, and overlay adaptation","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Microsoft","observability_method":"platform documentation","evidence_source_ids":["AZ-L2-BOUNDARY"],"verification":"PLATFORM_ENFORCED"}, + {"id":"DL-19","name":"Link redundancy and failover","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Shared","observability_method":"ExpressRoute Direct link inventory and connected-device review","evidence_source_ids":["AZ-ERD-OVERVIEW","AZ-ERD-API"],"verification":"MANUALLY_VERIFIABLE"} + ] +} diff --git a/compliance/assurance/network_layer.json b/compliance/assurance/network_layer.json new file mode 100644 index 00000000..4cafa3f7 --- /dev/null +++ b/compliance/assurance/network_layer.json @@ -0,0 +1,1159 @@ +{ + "schema_version": 1, + "catalog_version": "2026.08", + "layer": { + "number": 3, + "name": "Network", + "model": "OSI", + "assessment_type": "mixed_assurance" + }, + "scope": { + "environment": "azure_public_cloud", + "statement": "Azure public-cloud IPv4 and IPv6 addressing, routing, transit, isolation, IP-boundary protection, and Layer 3 diagnostics exposed through documented management-plane state.", + "responsibility_boundary": "Customers configure address spaces, routes, peerings, gateways, public IP boundaries, and supported diagnostics. Microsoft owns and operates the physical fabric, tenant isolation, system-route implementation, packet forwarding, and other internals not exposed as tenant-verifiable state." + }, + "subdomains": [ + { + "id": "ADDRESSING", + "name": "Addressing and packet behavior", + "description": "IP addressing, prefix allocation, subnetting, NAT, public/private boundaries, fragmentation, MTU, ICMP, and source validation." + }, + { + "id": "ROUTING", + "name": "Routing and forwarding", + "description": "Packet forwarding, next-hop selection, system and user-defined routes, propagation, effective routes, BGP, redundancy, and failover." + }, + { + "id": "TRANSIT", + "name": "Network transit", + "description": "Virtual network peering, hub-and-spoke topology, VPN Gateway, and ExpressRoute routing and advertisement." + }, + { + "id": "PROTECTION", + "name": "Protection and isolation", + "description": "DDoS protection, tenant boundaries, segmentation, and network isolation." + }, + { + "id": "OBSERVABILITY", + "name": "Layer 3 observability", + "description": "Route diagnostics, flow visibility, reachability diagnostics, and monitoring coverage." + } + ], + "controls": [ + { + "id": "NL-C01", + "title": "IPv4 addressing and prefix management assurance", + "responsibility": "Customer", + "applicability": "APPLICABLE", + "verification": "MANUALLY_VERIFIABLE", + "status": "DOCUMENTED", + "subdomain_ids": [ + "ADDRESSING" + ], + "domain_ids": [ + "NL-01" + ], + "evidence_source_ids": [ + "E-NETWORKING" + ], + "scanner_rule_ids": [ + "AZ-NET-006" + ] + }, + { + "id": "NL-C02", + "title": "IPv6 addressing and dual-stack assurance", + "responsibility": "Shared", + "applicability": "APPLICABLE", + "verification": "MANUALLY_VERIFIABLE", + "status": "DOCUMENTED", + "subdomain_ids": [ + "ADDRESSING" + ], + "domain_ids": [ + "NL-02" + ], + "evidence_source_ids": [ + "E-IPV6" + ], + "scanner_rule_ids": [] + }, + { + "id": "NL-C03", + "title": "Subnet and overlap assurance", + "responsibility": "Customer", + "applicability": "APPLICABLE", + "verification": "MANUALLY_VERIFIABLE", + "status": "DOCUMENTED", + "subdomain_ids": [ + "ADDRESSING" + ], + "domain_ids": [ + "NL-03" + ], + "evidence_source_ids": [ + "E-NETWORKING", + "E-PEERING" + ], + "scanner_rule_ids": [] + }, + { + "id": "NL-C04", + "title": "Packet forwarding and next-hop assurance", + "responsibility": "Shared", + "applicability": "APPLICABLE", + "verification": "MANUALLY_VERIFIABLE", + "status": "DOCUMENTED", + "subdomain_ids": [ + "ROUTING" + ], + "domain_ids": [ + "NL-04" + ], + "evidence_source_ids": [ + "E-NEXT-HOP", + "E-NETWORK-POLICY" + ], + "scanner_rule_ids": [ + "AZ-NET-011", + "AZ-NET-016" + ] + }, + { + "id": "NL-C05", + "title": "System and user-defined route assurance", + "responsibility": "Shared", + "applicability": "APPLICABLE", + "verification": "MANUALLY_VERIFIABLE", + "status": "DOCUMENTED", + "subdomain_ids": [ + "ROUTING" + ], + "domain_ids": [ + "NL-05" + ], + "evidence_source_ids": [ + "E-ROUTING", + "E-ROUTE-API" + ], + "scanner_rule_ids": [ + "AZ-NET-017" + ] + }, + { + "id": "NL-C06", + "title": "Route propagation and effective-route assurance", + "responsibility": "Shared", + "applicability": "APPLICABLE", + "verification": "MANUALLY_VERIFIABLE", + "status": "DOCUMENTED", + "subdomain_ids": [ + "ROUTING", + "OBSERVABILITY" + ], + "domain_ids": [ + "NL-06" + ], + "evidence_source_ids": [ + "E-ROUTING", + "E-EFFECTIVE" + ], + "scanner_rule_ids": [ + "AZ-NET-011" + ] + }, + { + "id": "NL-C07", + "title": "Virtual network peering and transit assurance", + "responsibility": "Customer", + "applicability": "APPLICABLE", + "verification": "AUTOMATICALLY_CHECKED", + "status": "DOCUMENTED", + "subdomain_ids": [ + "TRANSIT" + ], + "domain_ids": [ + "NL-07" + ], + "evidence_source_ids": [ + "E-PEERING" + ], + "scanner_rule_ids": [ + "AZ-NET-014" + ] + }, + { + "id": "NL-C08", + "title": "Hub-and-spoke routing assurance", + "responsibility": "Customer", + "applicability": "APPLICABLE", + "verification": "MANUALLY_VERIFIABLE", + "status": "DOCUMENTED", + "subdomain_ids": [ + "ROUTING", + "TRANSIT" + ], + "domain_ids": [ + "NL-08" + ], + "evidence_source_ids": [ + "E-HUB-SPOKE" + ], + "scanner_rule_ids": [ + "AZ-NET-013", + "AZ-NET-014" + ] + }, + { + "id": "NL-C09", + "title": "Network address translation assurance", + "responsibility": "Shared", + "applicability": "APPLICABLE", + "verification": "MANUALLY_VERIFIABLE", + "status": "DOCUMENTED", + "subdomain_ids": [ + "ADDRESSING" + ], + "domain_ids": [ + "NL-09" + ], + "evidence_source_ids": [ + "E-NAT" + ], + "scanner_rule_ids": [ + "AZ-NET-006" + ] + }, + { + "id": "NL-C10", + "title": "Public and private IP boundary assurance", + "responsibility": "Customer", + "applicability": "APPLICABLE", + "verification": "AUTOMATICALLY_CHECKED", + "status": "DOCUMENTED", + "subdomain_ids": [ + "ADDRESSING", + "PROTECTION" + ], + "domain_ids": [ + "NL-10" + ], + "evidence_source_ids": [ + "E-NETWORKING" + ], + "scanner_rule_ids": [ + "AZ-NET-006", + "AZ-NET-010", + "AZ-NET-013", + "AZ-NET-017", + "AZ-NET-018", + "AZ-NET-019", + "AZ-NET-022" + ] + }, + { + "id": "NL-C11", + "title": "IP fragmentation, MTU, and path MTU assurance", + "responsibility": "Shared", + "applicability": "APPLICABLE", + "verification": "MANUALLY_VERIFIABLE", + "status": "DOCUMENTED", + "subdomain_ids": [ + "ADDRESSING" + ], + "domain_ids": [ + "NL-11" + ], + "evidence_source_ids": [ + "E-MTU" + ], + "scanner_rule_ids": [] + }, + { + "id": "NL-C12", + "title": "ICMP and diagnostic reachability assurance", + "responsibility": "Shared", + "applicability": "APPLICABLE", + "verification": "MANUALLY_VERIFIABLE", + "status": "DOCUMENTED", + "subdomain_ids": [ + "ADDRESSING", + "OBSERVABILITY" + ], + "domain_ids": [ + "NL-12" + ], + "evidence_source_ids": [ + "E-DIAGNOSTICS" + ], + "scanner_rule_ids": [ + "AZ-NET-011" + ] + }, + { + "id": "NL-C13", + "title": "Source-address validation and anti-spoofing assurance", + "responsibility": "Microsoft", + "applicability": "APPLICABLE", + "verification": "PROVIDER_ATTESTED", + "status": "DOCUMENTED", + "subdomain_ids": [ + "ADDRESSING", + "PROTECTION" + ], + "domain_ids": [ + "NL-13" + ], + "evidence_source_ids": [ + "E-SECURITY" + ], + "scanner_rule_ids": [] + }, + { + "id": "NL-C14", + "title": "BGP routing assurance", + "responsibility": "Shared", + "applicability": "APPLICABLE", + "verification": "MANUALLY_VERIFIABLE", + "status": "DOCUMENTED", + "subdomain_ids": [ + "ROUTING" + ], + "domain_ids": [ + "NL-14" + ], + "evidence_source_ids": [ + "E-BGP" + ], + "scanner_rule_ids": [ + "AZ-NET-009" + ] + }, + { + "id": "NL-C15", + "title": "VPN Gateway routing assurance", + "responsibility": "Shared", + "applicability": "APPLICABLE", + "verification": "AUTOMATICALLY_CHECKED", + "status": "DOCUMENTED", + "subdomain_ids": [ + "ROUTING", + "TRANSIT" + ], + "domain_ids": [ + "NL-15" + ], + "evidence_source_ids": [ + "E-VPN" + ], + "scanner_rule_ids": [ + "AZ-NET-009" + ] + }, + { + "id": "NL-C16", + "title": "ExpressRoute routing and advertisement assurance", + "responsibility": "Shared", + "applicability": "APPLICABLE", + "verification": "MANUALLY_VERIFIABLE", + "status": "DOCUMENTED", + "subdomain_ids": [ + "ROUTING", + "TRANSIT" + ], + "domain_ids": [ + "NL-16" + ], + "evidence_source_ids": [ + "E-EXPRESSROUTE" + ], + "scanner_rule_ids": [] + }, + { + "id": "NL-C17", + "title": "Route redundancy and failover assurance", + "responsibility": "Shared", + "applicability": "APPLICABLE", + "verification": "MANUALLY_VERIFIABLE", + "status": "DOCUMENTED", + "subdomain_ids": [ + "ROUTING" + ], + "domain_ids": [ + "NL-17" + ], + "evidence_source_ids": [ + "E-RELIABILITY" + ], + "scanner_rule_ids": [ + "AZ-NET-008", + "AZ-NET-009" + ] + }, + { + "id": "NL-C18", + "title": "DDoS protection at the IP boundary assurance", + "responsibility": "Shared", + "applicability": "APPLICABLE", + "verification": "AUTOMATICALLY_CHECKED", + "status": "DOCUMENTED", + "subdomain_ids": [ + "PROTECTION" + ], + "domain_ids": [ + "NL-18" + ], + "evidence_source_ids": [ + "E-DDOS" + ], + "scanner_rule_ids": [ + "AZ-NET-005" + ] + }, + { + "id": "NL-C19", + "title": "Network isolation and segmentation assurance", + "responsibility": "Shared", + "applicability": "APPLICABLE", + "verification": "AUTOMATICALLY_CHECKED", + "status": "DOCUMENTED", + "subdomain_ids": [ + "PROTECTION" + ], + "domain_ids": [ + "NL-19" + ], + "evidence_source_ids": [ + "E-SECURITY" + ], + "scanner_rule_ids": [ + "AZ-NET-004", + "AZ-NET-010", + "AZ-NET-013", + "AZ-NET-014", + "AZ-NET-016", + "AZ-NET-017", + "AZ-NET-018", + "AZ-NET-019", + "AZ-NET-022", + "AZ-NET-023" + ] + }, + { + "id": "NL-C20", + "title": "Layer 3 monitoring and route diagnostics assurance", + "responsibility": "Shared", + "applicability": "APPLICABLE", + "verification": "AUTOMATICALLY_CHECKED", + "status": "DOCUMENTED", + "subdomain_ids": [ + "OBSERVABILITY" + ], + "domain_ids": [ + "NL-20" + ], + "evidence_source_ids": [ + "E-DIAGNOSTICS", + "E-EFFECTIVE" + ], + "scanner_rule_ids": [ + "AZ-NET-011", + "AZ-NET-012" + ] + } + ], + "domains": [ + { + "id": "NL-01", + "name": "IPv4 addressing and prefix management", + "azure_applicability": "APPLICABLE", + "responsibility_owner": "Customer", + "observability_method": "Inspect VNet, subnet, NIC, and public IP management-plane configuration.", + "evidence_source_ids": [ + "E-NETWORKING" + ], + "verification": "MANUALLY_VERIFIABLE", + "rule_ids": [ + "AZ-NET-006" + ], + "automation_decision": "Existing public-IP association check covers one boundary condition; broad prefix policy is deployment-specific." + }, + { + "id": "NL-02", + "name": "IPv6 addressing and dual-stack behavior", + "azure_applicability": "APPLICABLE", + "responsibility_owner": "Shared", + "observability_method": "Inspect IPv6 VNet prefixes, subnet prefixes, NIC configurations, and public IP SKUs.", + "evidence_source_ids": [ + "E-IPV6" + ], + "verification": "MANUALLY_VERIFIABLE", + "rule_ids": [], + "automation_decision": "No universal requirement to enable dual stack; absence is not a safe finding." + }, + { + "id": "NL-03", + "name": "Subnetting and address-space overlap", + "azure_applicability": "APPLICABLE", + "responsibility_owner": "Customer", + "observability_method": "Compare authoritative VNet, subnet, peering, and connected-network prefixes.", + "evidence_source_ids": [ + "E-NETWORKING", + "E-PEERING" + ], + "verification": "MANUALLY_VERIFIABLE", + "rule_ids": [], + "automation_decision": "Azure prevents invalid local subnet overlap, while intended cross-network overlap requires topology context not currently inventoried." + }, + { + "id": "NL-04", + "name": "Packet forwarding and next-hop selection", + "azure_applicability": "APPLICABLE", + "responsibility_owner": "Shared", + "observability_method": "Use Network Watcher next-hop diagnostics for a selected VM NIC and destination.", + "evidence_source_ids": [ + "E-NEXT-HOP", + "E-NETWORK-POLICY" + ], + "verification": "AUTOMATICALLY_CHECKED", + "rule_ids": [ + "AZ-NET-011", + "AZ-NET-016" + ], + "automation_decision": "NIC IP forwarding is checked from authoritative configuration; path-specific next-hop intent remains manual." + }, + { + "id": "NL-05", + "name": "System routes and user-defined routes", + "azure_applicability": "APPLICABLE", + "responsibility_owner": "Shared", + "observability_method": "Inspect route tables and documented Azure system routes.", + "evidence_source_ids": [ + "E-ROUTING", + "E-ROUTE-API" + ], + "verification": "AUTOMATICALLY_CHECKED", + "rule_ids": [ + "AZ-NET-017" + ], + "automation_decision": "Explicit user-defined default Internet routes are checked; system routes and intended private routing remain contextual." + }, + { + "id": "NL-06", + "name": "Route propagation and effective routes", + "azure_applicability": "APPLICABLE", + "responsibility_owner": "Shared", + "observability_method": "Inspect route-table propagation settings and Network Watcher effective routes for selected NICs.", + "evidence_source_ids": [ + "E-ROUTING", + "E-EFFECTIVE" + ], + "verification": "MANUALLY_VERIFIABLE", + "rule_ids": [ + "AZ-NET-011" + ], + "automation_decision": "Effective-route evaluation requires workload intent and NIC scope." + }, + { + "id": "NL-07", + "name": "Virtual network peering and transit", + "azure_applicability": "APPLICABLE", + "responsibility_owner": "Customer", + "observability_method": "Inspect both peering directions, forwarded-traffic, remote-gateway, and gateway-transit flags.", + "evidence_source_ids": [ + "E-PEERING" + ], + "verification": "AUTOMATICALLY_CHECKED", + "rule_ids": [ + "AZ-NET-014" + ], + "automation_decision": "Existing peering transit rule supplies a limited management-plane check; full topology validity remains manual." + }, + { + "id": "NL-08", + "name": "Hub-and-spoke routing", + "azure_applicability": "APPLICABLE", + "responsibility_owner": "Customer", + "observability_method": "Correlate peerings, route tables, gateways, and network virtual appliances against the intended topology.", + "evidence_source_ids": [ + "E-HUB-SPOKE" + ], + "verification": "MANUALLY_VERIFIABLE", + "rule_ids": [ + "AZ-NET-013", + "AZ-NET-014" + ], + "automation_decision": "Inventory does not identify intended hubs, spokes, or routing functions, so topology assumptions cannot create findings." + }, + { + "id": "NL-09", + "name": "Network address translation", + "azure_applicability": "APPLICABLE", + "responsibility_owner": "Shared", + "observability_method": "Inspect NAT Gateway attachment, outbound rules, and Azure-managed translation behavior.", + "evidence_source_ids": [ + "E-NAT" + ], + "verification": "MANUALLY_VERIFIABLE", + "rule_ids": [ + "AZ-NET-006" + ], + "automation_decision": "NAT design is workload-specific; public IP association is the only existing related check." + }, + { + "id": "NL-10", + "name": "Public and private IP boundaries", + "azure_applicability": "APPLICABLE", + "responsibility_owner": "Customer", + "observability_method": "Inspect public IP resources and associations plus private subnet and NIC addressing.", + "evidence_source_ids": [ + "E-NETWORKING" + ], + "verification": "AUTOMATICALLY_CHECKED", + "rule_ids": [ + "AZ-NET-006", + "AZ-NET-010", + "AZ-NET-013", + "AZ-NET-017" + ], + "automation_decision": "Existing rules check unused public IPs and selected segmentation boundaries; they do not prove end-to-end exposure." + }, + { + "id": "NL-11", + "name": "IP fragmentation, MTU, and path MTU behavior", + "azure_applicability": "APPLICABLE", + "responsibility_owner": "Shared", + "observability_method": "Validate workload paths with documented Azure MTU guidance and controlled diagnostics.", + "evidence_source_ids": [ + "E-MTU" + ], + "verification": "MANUALLY_VERIFIABLE", + "rule_ids": [], + "automation_decision": "Management-plane inventory cannot prove packet fragmentation or path MTU behavior." + }, + { + "id": "NL-12", + "name": "ICMP and diagnostic reachability", + "azure_applicability": "APPLICABLE", + "responsibility_owner": "Shared", + "observability_method": "Use Network Watcher connection troubleshoot and IP flow verification for an explicitly selected path.", + "evidence_source_ids": [ + "E-DIAGNOSTICS" + ], + "verification": "MANUALLY_VERIFIABLE", + "rule_ids": [ + "AZ-NET-011" + ], + "automation_decision": "Reachability is path- and policy-specific; scanner inventory alone is insufficient." + }, + { + "id": "NL-13", + "name": "Source-address validation and anti-spoofing", + "azure_applicability": "APPLICABLE", + "responsibility_owner": "Microsoft", + "observability_method": "Rely on Microsoft platform documentation; tenant inventory cannot inspect fabric enforcement.", + "evidence_source_ids": [ + "E-SECURITY" + ], + "verification": "PROVIDER_ATTESTED", + "rule_ids": [], + "automation_decision": "No customer-actionable authoritative state exists; no finding is safe." + }, + { + "id": "NL-14", + "name": "BGP routing", + "azure_applicability": "APPLICABLE", + "responsibility_owner": "Shared", + "observability_method": "Inspect gateway BGP configuration and learned or advertised routes for an applicable gateway.", + "evidence_source_ids": [ + "E-BGP" + ], + "verification": "MANUALLY_VERIFIABLE", + "rule_ids": [ + "AZ-NET-009" + ], + "automation_decision": "BGP correctness depends on intended on-premises prefixes and policy." + }, + { + "id": "NL-15", + "name": "VPN gateway routing", + "azure_applicability": "APPLICABLE", + "responsibility_owner": "Shared", + "observability_method": "Inspect VPN gateway, connection, local network gateway, routing, and effective route state.", + "evidence_source_ids": [ + "E-VPN" + ], + "verification": "AUTOMATICALLY_CHECKED", + "rule_ids": [ + "AZ-NET-009" + ], + "automation_decision": "Existing VPN rule checks IKE configuration; route correctness remains contextual." + }, + { + "id": "NL-16", + "name": "ExpressRoute routing and route advertisement", + "azure_applicability": "APPLICABLE", + "responsibility_owner": "Shared", + "observability_method": "Inspect circuit, gateway, peering, BGP, and advertised-route management-plane state when ExpressRoute is deployed.", + "evidence_source_ids": [ + "E-EXPRESSROUTE" + ], + "verification": "MANUALLY_VERIFIABLE", + "rule_ids": [], + "automation_decision": "The MACsec rules are Layer 2 and deliberately excluded; Layer 3 advertisement correctness requires customer route intent." + }, + { + "id": "NL-17", + "name": "Route redundancy and failover", + "azure_applicability": "APPLICABLE", + "responsibility_owner": "Shared", + "observability_method": "Inspect gateway redundancy mode and validate planned failover using service-specific diagnostics.", + "evidence_source_ids": [ + "E-RELIABILITY" + ], + "verification": "MANUALLY_VERIFIABLE", + "rule_ids": [ + "AZ-NET-008", + "AZ-NET-009" + ], + "automation_decision": "Backend presence and VPN configuration are related cross-layer signals, not proof of route failover." + }, + { + "id": "NL-18", + "name": "DDoS protection at the IP boundary", + "azure_applicability": "APPLICABLE", + "responsibility_owner": "Shared", + "observability_method": "Inspect VNet DDoS plan association and Microsoft platform protection documentation.", + "evidence_source_ids": [ + "E-DDOS" + ], + "verification": "AUTOMATICALLY_CHECKED", + "rule_ids": [ + "AZ-NET-005" + ], + "automation_decision": "Existing VNet DDoS rule checks authoritative plan association." + }, + { + "id": "NL-19", + "name": "Network isolation and segmentation", + "azure_applicability": "APPLICABLE", + "responsibility_owner": "Shared", + "observability_method": "Inspect prefixes, subnets, peerings, route boundaries, NSGs, and firewall placement against architecture intent.", + "evidence_source_ids": [ + "E-SECURITY" + ], + "verification": "AUTOMATICALLY_CHECKED", + "rule_ids": [ + "AZ-NET-004", + "AZ-NET-010", + "AZ-NET-013", + "AZ-NET-014", + "AZ-NET-016", + "AZ-NET-017" + ], + "automation_decision": "Existing cross-layer rules provide limited configuration checks; platform tenant isolation is provider-owned." + }, + { + "id": "NL-20", + "name": "Layer 3 monitoring, flow visibility, and route diagnostics", + "azure_applicability": "APPLICABLE", + "responsibility_owner": "Shared", + "observability_method": "Inspect Network Watcher availability and supported flow and route diagnostic configuration.", + "evidence_source_ids": [ + "E-DIAGNOSTICS", + "E-EFFECTIVE" + ], + "verification": "AUTOMATICALLY_CHECKED", + "rule_ids": [ + "AZ-NET-011", + "AZ-NET-012" + ], + "automation_decision": "Existing rules check selected diagnostic services; they do not constitute live traffic or fabric inspection." + } + ], + "rule_classifications": [ + { + "id": "AZ-DL-001", + "name": "ExpressRoute Direct link without MACsec", + "osi_classification": "Layer 2", + "classification_basis": "MACsec protects Ethernet frames on the direct link and remains a Data Link control.", + "layer_3_domain_ids": [] + }, + { + "id": "AZ-DL-002", + "name": "ExpressRoute Direct non-XPN MACsec cipher", + "osi_classification": "Layer 2", + "classification_basis": "The MACsec cipher suite protects Ethernet frames and remains a Data Link control.", + "layer_3_domain_ids": [] + }, + { + "id": "AZ-NET-001", + "name": "Unrestricted inbound SSH", + "osi_classification": "Layer 4", + "classification_basis": "The finding is selected by TCP destination port 22.", + "layer_3_domain_ids": [] + }, + { + "id": "AZ-NET-002", + "name": "Unrestricted inbound RDP", + "osi_classification": "Layer 4", + "classification_basis": "The finding is selected by TCP destination port 3389.", + "layer_3_domain_ids": [] + }, + { + "id": "AZ-NET-003", + "name": "Unrestricted inbound HTTPS", + "osi_classification": "Layer 4", + "classification_basis": "The finding is selected by TCP destination port 443.", + "layer_3_domain_ids": [] + }, + { + "id": "AZ-NET-004", + "name": "NSG with no rules", + "osi_classification": "Cross-layer", + "classification_basis": "NSGs evaluate Layer 3 prefixes and Layer 4 protocol and port tuples.", + "layer_3_domain_ids": [ + "NL-19" + ] + }, + { + "id": "AZ-NET-005", + "name": "VNet without DDoS plan", + "osi_classification": "Layer 3", + "classification_basis": "The control protects the VNet IP boundary against network-layer denial of service.", + "layer_3_domain_ids": [ + "NL-18" + ] + }, + { + "id": "AZ-NET-006", + "name": "Unassociated public IP", + "osi_classification": "Layer 3", + "classification_basis": "The resource represents an IP address and public/private boundary.", + "layer_3_domain_ids": [ + "NL-01", + "NL-09", + "NL-10" + ] + }, + { + "id": "AZ-NET-007", + "name": "Application Gateway without WAF", + "osi_classification": "Layer 7", + "classification_basis": "Application Gateway WAF inspects HTTP application traffic.", + "layer_3_domain_ids": [] + }, + { + "id": "AZ-NET-008", + "name": "Load balancer without backend pool", + "osi_classification": "Cross-layer", + "classification_basis": "Azure Load Balancer spans IP frontend selection and transport-layer load balancing.", + "layer_3_domain_ids": [ + "NL-17" + ] + }, + { + "id": "AZ-NET-009", + "name": "VPN gateway using IKEv1", + "osi_classification": "Cross-layer", + "classification_basis": "IKE establishes the VPN security association while the gateway carries routed IP traffic.", + "layer_3_domain_ids": [ + "NL-14", + "NL-15", + "NL-17" + ] + }, + { + "id": "AZ-NET-010", + "name": "Subnet without NSG", + "osi_classification": "Cross-layer", + "classification_basis": "The subnet is Layer 3 while NSG policy can match Layer 3 and Layer 4 fields.", + "layer_3_domain_ids": [ + "NL-10", + "NL-19" + ] + }, + { + "id": "AZ-NET-011", + "name": "Network Watcher regional coverage", + "osi_classification": "Cross-layer", + "classification_basis": "Network Watcher exposes Layer 3 route and reachability diagnostics plus broader network tooling.", + "layer_3_domain_ids": [ + "NL-04", + "NL-06", + "NL-12", + "NL-20" + ] + }, + { + "id": "AZ-NET-012", + "name": "NSG flow logs disabled", + "osi_classification": "Cross-layer", + "classification_basis": "Flow records contain Layer 3 addresses and Layer 4 protocol and port information.", + "layer_3_domain_ids": [ + "NL-20" + ] + }, + { + "id": "AZ-NET-013", + "name": "VNet without Azure Firewall", + "osi_classification": "Cross-layer", + "classification_basis": "Firewall placement creates a Layer 3 boundary while policy can enforce through Layer 7.", + "layer_3_domain_ids": [ + "NL-08", + "NL-10", + "NL-19" + ] + }, + { + "id": "AZ-NET-014", + "name": "Peering gateway transit restrictions", + "osi_classification": "Layer 3", + "classification_basis": "VNet peering and gateway transit determine routed IP reachability.", + "layer_3_domain_ids": [ + "NL-07", + "NL-08", + "NL-19" + ] + }, + { + "id": "AZ-NET-015", + "name": "Public DNS exposing private infrastructure", + "osi_classification": "Layer 7", + "classification_basis": "DNS is an application-layer naming protocol even when records contain IP addresses.", + "layer_3_domain_ids": [] + }, + { + "id": "AZ-NET-016", + "name": "Network interface with IP forwarding enabled", + "osi_classification": "Layer 3", + "classification_basis": "IP forwarding changes Layer 3 source, destination, and transit behavior on the network interface.", + "layer_3_domain_ids": [ + "NL-04", + "NL-19" + ] + }, + { + "id": "AZ-NET-017", + "name": "User-defined default route using direct Internet next hop", + "osi_classification": "Layer 3", + "classification_basis": "The route explicitly selects the Layer 3 next hop for all IPv4 or IPv6 destinations.", + "layer_3_domain_ids": [ + "NL-05", + "NL-10", + "NL-19" + ] + }, + { + "id": "AZ-NET-018", + "name": "Private Endpoint target retaining public access", + "osi_classification": "Layer 3", + "classification_basis": "The control evaluates whether a PaaS resource retains a public IP network path alongside its private endpoint path.", + "layer_3_domain_ids": [ + "NL-09", + "NL-10", + "NL-19" + ] + }, + { + "id": "AZ-NET-019", + "name": "Private Endpoint connection not approved", + "osi_classification": "Layer 3", + "classification_basis": "Private Endpoint connection state determines whether the target has an operational private IP network path.", + "layer_3_domain_ids": [ + "NL-01", + "NL-09", + "NL-19" + ] + }, + { + "id": "AZ-NET-020", + "name": "Private Endpoint missing Private DNS zone association", + "osi_classification": "Layer 7", + "classification_basis": "The finding evaluates DNS naming configuration even though the resulting record identifies a private IP address.", + "layer_3_domain_ids": [] + }, + { + "id": "AZ-NET-021", + "name": "Private Endpoint DNS configuration reports only public addresses", + "osi_classification": "Layer 7", + "classification_basis": "DNS is an application-layer naming protocol; this rule evaluates ARM custom DNS configuration and does not claim effective resolver-path evidence.", + "layer_3_domain_ids": [] + }, + { + "id": "AZ-NET-022", + "name": "Critical PaaS resource publicly accessible without exception", + "osi_classification": "Layer 3", + "classification_basis": "The control evaluates whether a critical service retains an unapproved public IP network path.", + "layer_3_domain_ids": [ + "NL-10", + "NL-19" + ] + }, + { + "id": "AZ-NET-023", + "name": "Azure Firewall threat intelligence not enforcing deny", + "osi_classification": "Cross-layer", + "classification_basis": "Azure Firewall threat intelligence enforces the perimeter across IP addresses, domains, and URLs.", + "layer_3_domain_ids": [ + "NL-19" + ] + }, + { + "id": "AZ-NET-024", + "name": "Application Gateway WAF not in Prevention mode", + "osi_classification": "Layer 7", + "classification_basis": "Application Gateway WAF Prevention mode evaluates and blocks HTTP application requests.", + "layer_3_domain_ids": [] + }, + { + "id": "AZ-NET-025", + "name": "Application Gateway WAF diagnostic logging incomplete", + "osi_classification": "Layer 7", + "classification_basis": "The control evaluates the diagnostic log categories supported by the Application Gateway SKU; v2 performance telemetry is metric-based.", + "layer_3_domain_ids": [] + }, + { + "id": "AZ-NET-026", + "name": "WAF missing current managed rules or bot protection", + "osi_classification": "Layer 7", + "classification_basis": "Managed WAF rules inspect HTTP application requests and bot behavior.", + "layer_3_domain_ids": [] + }, + { + "id": "AZ-NET-027", + "name": "Public Application Gateway missing rate limiting", + "osi_classification": "Layer 7", + "classification_basis": "WAF RateLimitRule evaluates HTTP request volume and application-layer match conditions.", + "layer_3_domain_ids": [] + } + ], + "evidence_sources": [ + { + "id": "E-NETWORKING", + "title": "Azure Virtual Network overview", + "url": "https://learn.microsoft.com/azure/virtual-network/virtual-networks-overview", + "reviewed_at": "2026-08-12", + "review_due_at": "2027-02-12" + }, + { + "id": "E-IPV6", + "title": "IPv6 for Azure Virtual Network", + "url": "https://learn.microsoft.com/azure/virtual-network/ip-services/ipv6-overview", + "reviewed_at": "2026-08-12", + "review_due_at": "2027-02-12" + }, + { + "id": "E-ROUTING", + "title": "Azure virtual network traffic routing", + "url": "https://learn.microsoft.com/azure/virtual-network/virtual-networks-udr-overview", + "reviewed_at": "2026-08-12", + "review_due_at": "2027-02-12" + }, + { + "id": "E-NETWORK-POLICY", + "title": "Built-in policy definitions for Azure networking services", + "url": "https://learn.microsoft.com/azure/networking/policy-reference", + "reviewed_at": "2026-08-12", + "review_due_at": "2027-02-12" + }, + { + "id": "E-ROUTE-API", + "title": "Microsoft.Network route tables resource reference", + "url": "https://learn.microsoft.com/azure/templates/microsoft.network/routetables", + "reviewed_at": "2026-08-12", + "review_due_at": "2027-02-12" + }, + { + "id": "E-EFFECTIVE", + "title": "Diagnose a virtual machine routing problem", + "url": "https://learn.microsoft.com/azure/virtual-network/diagnose-network-routing-problem", + "reviewed_at": "2026-08-12", + "review_due_at": "2027-02-12" + }, + { + "id": "E-NEXT-HOP", + "title": "Network Watcher next hop overview", + "url": "https://learn.microsoft.com/azure/network-watcher/next-hop-overview", + "reviewed_at": "2026-08-12", + "review_due_at": "2027-02-12" + }, + { + "id": "E-PEERING", + "title": "Azure virtual network peering", + "url": "https://learn.microsoft.com/azure/virtual-network/virtual-network-peering-overview", + "reviewed_at": "2026-08-12", + "review_due_at": "2027-02-12" + }, + { + "id": "E-HUB-SPOKE", + "title": "Hub-spoke network topology in Azure", + "url": "https://learn.microsoft.com/azure/architecture/networking/architecture/hub-spoke", + "reviewed_at": "2026-08-12", + "review_due_at": "2027-02-12" + }, + { + "id": "E-NAT", + "title": "Azure NAT Gateway overview", + "url": "https://learn.microsoft.com/azure/nat-gateway/nat-overview", + "reviewed_at": "2026-08-12", + "review_due_at": "2027-02-12" + }, + { + "id": "E-MTU", + "title": "Azure VPN Gateway packet capture and MTU guidance", + "url": "https://learn.microsoft.com/azure/vpn-gateway/packet-capture", + "reviewed_at": "2026-08-12", + "review_due_at": "2027-02-12" + }, + { + "id": "E-DIAGNOSTICS", + "title": "Azure Network Watcher overview", + "url": "https://learn.microsoft.com/azure/network-watcher/network-watcher-monitoring-overview", + "reviewed_at": "2026-08-12", + "review_due_at": "2027-02-12" + }, + { + "id": "E-SECURITY", + "title": "Azure network security overview", + "url": "https://learn.microsoft.com/azure/security/fundamentals/network-overview", + "reviewed_at": "2026-08-12", + "review_due_at": "2027-02-12" + }, + { + "id": "E-BGP", + "title": "BGP with Azure VPN Gateway", + "url": "https://learn.microsoft.com/azure/vpn-gateway/vpn-gateway-bgp-overview", + "reviewed_at": "2026-08-12", + "review_due_at": "2027-02-12" + }, + { + "id": "E-VPN", + "title": "About Azure VPN Gateway", + "url": "https://learn.microsoft.com/azure/vpn-gateway/vpn-gateway-about-vpngateways", + "reviewed_at": "2026-08-12", + "review_due_at": "2027-02-12" + }, + { + "id": "E-EXPRESSROUTE", + "title": "ExpressRoute routing requirements", + "url": "https://learn.microsoft.com/azure/expressroute/expressroute-routing", + "reviewed_at": "2026-08-12", + "review_due_at": "2027-02-12" + }, + { + "id": "E-RELIABILITY", + "title": "Reliability in Azure Virtual Network Gateways", + "url": "https://learn.microsoft.com/azure/reliability/reliability-virtual-network-gateway", + "reviewed_at": "2026-08-12", + "review_due_at": "2027-02-12" + }, + { + "id": "E-DDOS", + "title": "Azure DDoS Protection overview", + "url": "https://learn.microsoft.com/azure/ddos-protection/ddos-protection-overview", + "reviewed_at": "2026-08-12", + "review_due_at": "2027-02-12" + } + ] +} diff --git a/compliance/assurance/physical_layer.json b/compliance/assurance/physical_layer.json new file mode 100644 index 00000000..b28c42bc --- /dev/null +++ b/compliance/assurance/physical_layer.json @@ -0,0 +1,269 @@ +{ + "schema_version": 1, + "catalog_version": "2026.08", + "layer": { + "number": 1, + "name": "Physical", + "model": "OSI", + "assessment_type": "provider_assurance" + }, + "scope": { + "environment": "azure_public_cloud", + "cloud_models": ["IaaS", "PaaS", "SaaS"], + "owner": "Microsoft", + "runtime_hardware_observable": false, + "statement": "Azure tenants consume software-defined networking and cannot inspect Microsoft datacenter cabling, signaling hardware, environmental systems, or physical access records. Coverage therefore measures responsibility and evidence completeness, not a tenant-side hardware scan." + }, + "methodology": { + "coverage_denominator": "baseline_controls", + "coverage_rule": "A baseline control is covered only when it has an owner, applicability decision, verification method, at least one physical domain, at least one relevant sublayer, and at least one evidence source.", + "evidence_freshness_rule": "Evidence is current through review_due_at. Expiry changes evidence status but does not erase catalog mapping coverage.", + "baseline_control_count": 23, + "domain_count": 21, + "sublayer_count": 8 + }, + "sublayers": [ + { + "id": "GENERIC-L1", + "name": "Generic OSI Layer 1 functions", + "profile": "osi", + "description": "Technology-neutral transmission of raw bits, physical interfaces, timing, rates, modes, topology, and physical link lifecycle.", + "domain_ids": ["PHY-01", "PHY-02", "PHY-03", "PHY-04", "PHY-10", "PHY-11", "PHY-12", "PHY-13", "PHY-14", "PHY-15", "PHY-17"] + }, + { + "id": "IEEE-PLCP", + "name": "Physical Layer Convergence Procedure", + "profile": "ieee_802_wireless", + "description": "Adapts MAC protocol data units to the physical medium, including physical framing, synchronization, rate signaling, and channel assessment functions.", + "domain_ids": ["PHY-08", "PHY-10", "PHY-11", "PHY-14", "PHY-16"] + }, + { + "id": "IEEE-PCS", + "name": "Physical Coding Sublayer", + "profile": "ieee_802_ethernet", + "description": "Encodes and decodes data blocks, aligns lanes, and provides physical coding and synchronization functions above the attachment layer.", + "domain_ids": ["PHY-04", "PHY-05", "PHY-10", "PHY-11", "PHY-12"] + }, + { + "id": "IEEE-FEC", + "name": "Forward Error Correction", + "profile": "ieee_802_ethernet", + "description": "Adds and checks redundant coding used to correct physical transmission errors on supported links.", + "domain_ids": ["PHY-06", "PHY-11", "PHY-15"] + }, + { + "id": "IEEE-PMA", + "name": "Physical Medium Attachment", + "profile": "ieee_802_ethernet", + "description": "Provides serialization, deserialization, clock recovery, lane distribution, and attachment between coding and medium-dependent functions.", + "domain_ids": ["PHY-03", "PHY-07", "PHY-10", "PHY-11", "PHY-12", "PHY-15"] + }, + { + "id": "IEEE-PMD", + "name": "Physical Medium Dependent", + "profile": "ieee_802", + "description": "Defines medium-specific transmission, reception, modulation, optical, electrical, or radio characteristics and signal measurements.", + "domain_ids": ["PHY-01", "PHY-02", "PHY-03", "PHY-04", "PHY-11", "PHY-15", "PHY-16"] + }, + { + "id": "IEEE-AN", + "name": "Auto-Negotiation and Link Training", + "profile": "ieee_802_ethernet", + "description": "Establishes compatible link capabilities, operating rates, duplex modes, lanes, and trained signal parameters where supported.", + "domain_ids": ["PHY-09", "PHY-11", "PHY-14", "PHY-15"] + }, + { + "id": "IEEE-MDI", + "name": "Medium Dependent Interface", + "profile": "ieee_802_ethernet", + "description": "Covers the physical connector, port, pin, fiber, or other attachment boundary between equipment and the transmission medium.", + "domain_ids": ["PHY-01", "PHY-02", "PHY-03", "PHY-17"] + } + ], + "domains": [ + {"id": "PHY-01", "name": "Transmission media and cabling", "description": "Copper, fiber, radio, and other media used to carry physical signals."}, + {"id": "PHY-02", "name": "Connectors and medium interfaces", "description": "Ports, connectors, pinouts, patching, and medium-dependent attachment boundaries."}, + {"id": "PHY-03", "name": "Transceivers and physical attachment", "description": "Optical, electrical, and radio transmit and receive hardware and its attachment functions."}, + {"id": "PHY-04", "name": "Signal representation and line coding", "description": "Conversion of bits into physical symbols, line codes, or modulated signals."}, + {"id": "PHY-05", "name": "Physical coding and lane alignment", "description": "Block coding, scrambling, alignment, and lane distribution functions."}, + {"id": "PHY-06", "name": "Forward error correction", "description": "Physical-layer redundancy used to detect or correct transmission errors."}, + {"id": "PHY-07", "name": "Serialization and clock recovery", "description": "Serialization, deserialization, clock generation, and recovered timing functions."}, + {"id": "PHY-08", "name": "Physical convergence and framing", "description": "Technology-specific convergence, physical preambles, headers, framing, and channel assessment."}, + {"id": "PHY-09", "name": "Auto-negotiation and link training", "description": "Capability exchange and adaptation used to establish a compatible physical link."}, + {"id": "PHY-10", "name": "Bit timing and synchronization", "description": "Symbol timing, bit synchronization, clocking, and alignment required for reliable transmission."}, + {"id": "PHY-11", "name": "Data rate, bandwidth, and transmission mode", "description": "Supported rates, bandwidth, simplex, half-duplex, full-duplex, and parallel or serial operation."}, + {"id": "PHY-12", "name": "Multiplexing and channelization", "description": "Physical aggregation, lanes, wavelengths, frequencies, and channel allocation."}, + {"id": "PHY-13", "name": "Physical topology", "description": "Physical arrangement of endpoints, devices, paths, tiers, and interconnects."}, + {"id": "PHY-14", "name": "Physical link lifecycle", "description": "Activation, deactivation, initialization, training, and loss-of-signal handling."}, + {"id": "PHY-15", "name": "Signal integrity and interference", "description": "Attenuation, noise, crosstalk, electromagnetic interference, optical budget, and physical error symptoms."}, + {"id": "PHY-16", "name": "Radio spectrum and antennas", "description": "Radio channels, frequencies, antennas, propagation, interference, and wireless physical boundaries."}, + {"id": "PHY-17", "name": "Physical devices, ports, and cross-connects", "description": "Switching and routing hardware, racks, ports, patching, and cross-connect protection."}, + {"id": "PHY-18", "name": "Path and failure-domain resilience", "description": "Redundant devices, diverse paths, power and cooling domains, and physical fault isolation."}, + {"id": "PHY-19", "name": "Facility access and surveillance", "description": "Perimeters, authorization, entry mechanisms, access reviews, guards, alarms, and monitoring."}, + {"id": "PHY-20", "name": "Power, cooling, fire, water, and environment", "description": "Supporting utilities and environmental controls protecting physical availability."}, + {"id": "PHY-21", "name": "Equipment lifecycle and incident handling", "description": "Siting, maintenance, movement, removal, reuse, disposal, and response to physical incidents."} + ], + "evidence_sources": [ + { + "id": "MS-SHARED-RESPONSIBILITY", + "title": "Shared responsibility in the cloud", + "url": "https://learn.microsoft.com/en-us/azure/security/fundamentals/shared-responsibility", + "evidence_type": "provider_documentation", + "reviewed_at": "2026-08-08", + "review_due_at": "2027-08-08" + }, + { + "id": "MS-DATACENTER-SECURITY", + "title": "Datacenter security overview", + "url": "https://learn.microsoft.com/en-us/compliance/assurance/assurance-datacenter-security", + "evidence_type": "provider_assurance", + "reviewed_at": "2026-08-08", + "review_due_at": "2027-08-08" + }, + { + "id": "MS-PHYSICAL-ACCESS", + "title": "Datacenter physical access security", + "url": "https://learn.microsoft.com/en-us/compliance/assurance/assurance-datacenter-physical-access-security", + "evidence_type": "provider_assurance", + "reviewed_at": "2026-08-08", + "review_due_at": "2027-08-08" + }, + { + "id": "MS-AZURE-NETWORK-ARCHITECTURE", + "title": "Azure network architecture", + "url": "https://learn.microsoft.com/en-us/azure/security/fundamentals/infrastructure-network", + "evidence_type": "provider_documentation", + "reviewed_at": "2026-08-08", + "review_due_at": "2027-08-08" + }, + { + "id": "MS-ISO-27001-POLICY", + "title": "Regulatory Compliance details for ISO 27001:2013", + "url": "https://learn.microsoft.com/en-us/azure/governance/policy/samples/iso-27001", + "evidence_type": "regulatory_mapping", + "reviewed_at": "2026-08-08", + "review_due_at": "2027-08-08" + } + ], + "controls": [ + { + "id": "MS-PE-1", "framework": "Microsoft SOC", "control_id": "PE-1", "title": "Datacenter physical access provisioning", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1"], "domain_ids": ["PHY-19"], "evidence_source_ids": ["MS-SHARED-RESPONSIBILITY", "MS-DATACENTER-SECURITY", "MS-PHYSICAL-ACCESS"] + }, + { + "id": "MS-PE-2", "framework": "Microsoft SOC", "control_id": "PE-2", "title": "Datacenter security verification", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1"], "domain_ids": ["PHY-19"], "evidence_source_ids": ["MS-DATACENTER-SECURITY", "MS-PHYSICAL-ACCESS"] + }, + { + "id": "MS-PE-3", "framework": "Microsoft SOC", "control_id": "PE-3", "title": "Datacenter user access review", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1"], "domain_ids": ["PHY-19"], "evidence_source_ids": ["MS-DATACENTER-SECURITY", "MS-PHYSICAL-ACCESS"] + }, + { + "id": "MS-PE-4", "framework": "Microsoft SOC", "control_id": "PE-4", "title": "Datacenter physical access mechanisms", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1", "IEEE-MDI"], "domain_ids": ["PHY-02", "PHY-17", "PHY-19"], "evidence_source_ids": ["MS-DATACENTER-SECURITY", "MS-PHYSICAL-ACCESS"] + }, + { + "id": "MS-PE-5", "framework": "Microsoft SOC", "control_id": "PE-5", "title": "Datacenter physical surveillance monitoring", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1"], "domain_ids": ["PHY-19"], "evidence_source_ids": ["MS-DATACENTER-SECURITY", "MS-PHYSICAL-ACCESS"] + }, + { + "id": "MS-PE-6", "framework": "Microsoft SOC", "control_id": "PE-6", "title": "Datacenter critical environment maintenance", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1", "IEEE-PMD"], "domain_ids": ["PHY-15", "PHY-20", "PHY-21"], "evidence_source_ids": ["MS-DATACENTER-SECURITY", "MS-AZURE-NETWORK-ARCHITECTURE"] + }, + { + "id": "MS-PE-7", "framework": "Microsoft SOC", "control_id": "PE-7", "title": "Datacenter environmental controls", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1", "IEEE-PMD"], "domain_ids": ["PHY-15", "PHY-18", "PHY-20"], "evidence_source_ids": ["MS-DATACENTER-SECURITY", "MS-AZURE-NETWORK-ARCHITECTURE"] + }, + { + "id": "MS-PE-8", "framework": "Microsoft SOC", "control_id": "PE-8", "title": "Datacenter incident response", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1"], "domain_ids": ["PHY-18", "PHY-19", "PHY-20", "PHY-21"], "evidence_source_ids": ["MS-DATACENTER-SECURITY", "MS-PHYSICAL-ACCESS"] + }, + { + "id": "ISO-A.11.1.1", "framework": "ISO/IEC 27001:2013", "control_id": "A.11.1.1", "title": "Physical security perimeter", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1"], "domain_ids": ["PHY-19"], "evidence_source_ids": ["MS-SHARED-RESPONSIBILITY", "MS-DATACENTER-SECURITY", "MS-PHYSICAL-ACCESS", "MS-ISO-27001-POLICY"] + }, + { + "id": "ISO-A.11.1.2", "framework": "ISO/IEC 27001:2013", "control_id": "A.11.1.2", "title": "Physical entry controls", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1"], "domain_ids": ["PHY-19"], "evidence_source_ids": ["MS-DATACENTER-SECURITY", "MS-PHYSICAL-ACCESS", "MS-ISO-27001-POLICY"] + }, + { + "id": "ISO-A.11.1.3", "framework": "ISO/IEC 27001:2013", "control_id": "A.11.1.3", "title": "Securing offices, rooms, and facilities", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1"], "domain_ids": ["PHY-17", "PHY-19"], "evidence_source_ids": ["MS-DATACENTER-SECURITY", "MS-PHYSICAL-ACCESS", "MS-ISO-27001-POLICY"] + }, + { + "id": "ISO-A.11.1.4", "framework": "ISO/IEC 27001:2013", "control_id": "A.11.1.4", "title": "Protecting against external and environmental threats", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1", "IEEE-PMD"], "domain_ids": ["PHY-15", "PHY-18", "PHY-20"], "evidence_source_ids": ["MS-DATACENTER-SECURITY", "MS-AZURE-NETWORK-ARCHITECTURE", "MS-ISO-27001-POLICY"] + }, + { + "id": "ISO-A.11.1.5", "framework": "ISO/IEC 27001:2013", "control_id": "A.11.1.5", "title": "Working in secure areas", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1"], "domain_ids": ["PHY-19", "PHY-21"], "evidence_source_ids": ["MS-DATACENTER-SECURITY", "MS-PHYSICAL-ACCESS", "MS-ISO-27001-POLICY"] + }, + { + "id": "ISO-A.11.1.6", "framework": "ISO/IEC 27001:2013", "control_id": "A.11.1.6", "title": "Delivery and loading areas", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1"], "domain_ids": ["PHY-17", "PHY-19", "PHY-21"], "evidence_source_ids": ["MS-DATACENTER-SECURITY", "MS-PHYSICAL-ACCESS", "MS-ISO-27001-POLICY"] + }, + { + "id": "ISO-A.11.2.1", "framework": "ISO/IEC 27001:2013", "control_id": "A.11.2.1", "title": "Equipment siting and protection", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1", "IEEE-MDI", "IEEE-PMD"], "domain_ids": ["PHY-02", "PHY-03", "PHY-15", "PHY-17", "PHY-18", "PHY-20"], "evidence_source_ids": ["MS-DATACENTER-SECURITY", "MS-AZURE-NETWORK-ARCHITECTURE", "MS-ISO-27001-POLICY"] + }, + { + "id": "ISO-A.11.2.2", "framework": "ISO/IEC 27001:2013", "control_id": "A.11.2.2", "title": "Supporting utilities", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1", "IEEE-PMD"], "domain_ids": ["PHY-15", "PHY-18", "PHY-20"], "evidence_source_ids": ["MS-DATACENTER-SECURITY", "MS-AZURE-NETWORK-ARCHITECTURE", "MS-ISO-27001-POLICY"] + }, + { + "id": "ISO-A.11.2.3", "framework": "ISO/IEC 27001:2013", "control_id": "A.11.2.3", "title": "Cabling security", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1", "IEEE-PLCP", "IEEE-PCS", "IEEE-FEC", "IEEE-PMA", "IEEE-PMD", "IEEE-AN", "IEEE-MDI"], + "domain_ids": ["PHY-01", "PHY-02", "PHY-03", "PHY-04", "PHY-05", "PHY-06", "PHY-07", "PHY-08", "PHY-09", "PHY-10", "PHY-11", "PHY-12", "PHY-13", "PHY-14", "PHY-15", "PHY-16", "PHY-17", "PHY-18"], + "evidence_source_ids": ["MS-SHARED-RESPONSIBILITY", "MS-DATACENTER-SECURITY", "MS-AZURE-NETWORK-ARCHITECTURE", "MS-ISO-27001-POLICY"] + }, + { + "id": "ISO-A.11.2.4", "framework": "ISO/IEC 27001:2013", "control_id": "A.11.2.4", "title": "Equipment maintenance", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1", "IEEE-PLCP", "IEEE-PCS", "IEEE-FEC", "IEEE-PMA", "IEEE-PMD", "IEEE-AN", "IEEE-MDI"], + "domain_ids": ["PHY-01", "PHY-02", "PHY-03", "PHY-05", "PHY-06", "PHY-07", "PHY-08", "PHY-09", "PHY-10", "PHY-11", "PHY-12", "PHY-14", "PHY-15", "PHY-16", "PHY-17", "PHY-18", "PHY-20", "PHY-21"], + "evidence_source_ids": ["MS-DATACENTER-SECURITY", "MS-AZURE-NETWORK-ARCHITECTURE", "MS-ISO-27001-POLICY"] + }, + { + "id": "ISO-A.11.2.5", "framework": "ISO/IEC 27001:2013", "control_id": "A.11.2.5", "title": "Removal of assets", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1", "IEEE-MDI", "IEEE-PMD"], "domain_ids": ["PHY-01", "PHY-02", "PHY-03", "PHY-17", "PHY-21"], "evidence_source_ids": ["MS-DATACENTER-SECURITY", "MS-PHYSICAL-ACCESS", "MS-ISO-27001-POLICY"] + }, + { + "id": "ISO-A.11.2.6", "framework": "ISO/IEC 27001:2013", "control_id": "A.11.2.6", "title": "Security of equipment and assets off-premises", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1", "IEEE-PMD", "IEEE-MDI"], "domain_ids": ["PHY-01", "PHY-02", "PHY-03", "PHY-15", "PHY-16", "PHY-17", "PHY-21"], "evidence_source_ids": ["MS-SHARED-RESPONSIBILITY", "MS-DATACENTER-SECURITY", "MS-ISO-27001-POLICY"] + }, + { + "id": "ISO-A.11.2.7", "framework": "ISO/IEC 27001:2013", "control_id": "A.11.2.7", "title": "Secure disposal or reuse of equipment", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1", "IEEE-PMD", "IEEE-MDI"], "domain_ids": ["PHY-01", "PHY-02", "PHY-03", "PHY-17", "PHY-21"], "evidence_source_ids": ["MS-DATACENTER-SECURITY", "MS-ISO-27001-POLICY"] + }, + { + "id": "ISO-A.11.2.8", "framework": "ISO/IEC 27001:2013", "control_id": "A.11.2.8", "title": "Unattended user equipment", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1"], "domain_ids": ["PHY-17", "PHY-19", "PHY-21"], "evidence_source_ids": ["MS-DATACENTER-SECURITY", "MS-PHYSICAL-ACCESS", "MS-ISO-27001-POLICY"] + }, + { + "id": "ISO-A.11.2.9", "framework": "ISO/IEC 27001:2013", "control_id": "A.11.2.9", "title": "Clear desk and clear screen policy", + "responsibility": "Microsoft", "applicability": "APPLICABLE", "verification": "PROVIDER_ASSURANCE", "status": "PROVIDER_ATTESTED", + "sublayer_ids": ["GENERIC-L1"], "domain_ids": ["PHY-19", "PHY-21"], "evidence_source_ids": ["MS-DATACENTER-SECURITY", "MS-PHYSICAL-ACCESS", "MS-ISO-27001-POLICY"] + } + ] +} diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 78112c3b..9adc0520 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -13,6 +13,32 @@ "control_name": "Ensure that 'Secure transfer required' is set to 'Enabled'", "description": "Enabling 'Secure transfer required' on a storage account ensures that all requests made to the storage account use HTTPS. Any requests using HTTP are rejected, protecting data in transit from eavesdropping and man-in-the-middle attacks." }, + "AZ-STOR-006": { + "control_id": "N/A-STOR-006", + "control_name": "Storage Account Shared-Key Authorization Enabled", + "description": "OpenShield checks this service-specific control without claiming an unrelated CIS recommendation." + }, + "AZ-STOR-007": { + "control_id": "N/A-STOR-007", + "control_name": "Storage Account Allows TLS Below 1.2", + "description": "OpenShield checks this service-specific control without claiming an unrelated CIS recommendation." + }, + "AZ-STOR-008": { + "control_id": "N/A-STOR-008", + "control_name": "Required Storage Customer-Managed Key Protection Missing", + "description": "OpenShield checks this service-specific control without claiming an unrelated CIS recommendation." + }, + "AZ-STOR-009": { + "control_id": "N/A-STOR-009", + "control_name": "Required Blob Container Immutability Missing", + "description": "OpenShield checks this service-specific control without claiming an unrelated CIS recommendation." + }, + "AZ-DB-005": {"control_id": "N/A-DB-005", "control_name": "SQL Server Microsoft Entra-Only Authentication Not Enforced", "description": "Service-specific OpenShield control."}, + "AZ-DB-006": {"control_id": "N/A-DB-006", "control_name": "SQL Vulnerability Assessment Not Configured", "description": "Service-specific OpenShield control."}, + "AZ-DB-007": {"control_id": "N/A-DB-007", "control_name": "SQL Auditing Retention Below Minimum", "description": "Service-specific OpenShield control."}, + "AZ-COSMOS-001": {"control_id": "N/A-COSMOS-001", "control_name": "Cosmos DB Local Authentication Enabled", "description": "Service-specific OpenShield control."}, + "AZ-COSMOS-002": {"control_id": "N/A-COSMOS-002", "control_name": "Cosmos DB Public Network Access Enabled", "description": "Service-specific OpenShield control."}, + "AZ-CACHE-001": {"control_id": "N/A-CACHE-001", "control_name": "Managed Cache Public or Non-TLS Access", "description": "Service-specific OpenShield control."}, "AZ-NET-001": { "control_id": "6.2", "control_name": "Ensure that SSH access from the Internet is evaluated and restricted", @@ -133,10 +159,15 @@ "control_name": "Ensure that 'OS patching' is enabled for virtual machines", "description": "The virtual machine does not have automatic OS patching enabled. CIS 8.3 requires that OS patches are applied in a timely manner. Unpatched VMs are vulnerable to known exploits targeting unpatched OS vulnerabilities." }, + "AZ-CMP-007": { + "control_id": "N/A-CMP-007", + "control_name": "Just-In-Time (JIT) VM access - Defender for Cloud recommendation, no numbered CIS Azure Foundations 2.0.0 control", + "description": "CIS Microsoft Azure Foundations Benchmark 2.0.0 has no numbered recommendation for Just-In-Time VM access (it is a Microsoft Defender for Cloud recommendation), so under the repository's one-CIS-ID-per-rule convention this rule is not assigned a fabricated control id. It is mapped under NIST CSF PR.AC-3, ISO 27001 A.13.1.1, and SOC 2 CC6.6 instead." + }, "AZ-KV-001": { - "control_id": "8.8", - "control_name": "Ensure the Key Vault is Recoverable", - "description": "Azure Key Vault soft delete should be enabled on all Key Vaults. The soft delete feature allows recovery of deleted vaults and vault objects (keys, secrets, certificates) for a configurable retention period (7–90 days), protecting against accidental or malicious deletion." + "control_id": "N/A-KV-001", + "control_name": "Key Vault soft-delete baseline (covered by the repository's CIS 8.5 purge-protection rule)", + "description": "Soft delete is part of CIS Azure Foundations 2.0.0 recommendation 8.5, which is assigned to AZ-KV-004 under the repository's one-CIS-ID-per-rule convention. This overlapping prerequisite check is explicitly not assigned a second numbered mapping." }, "AZ-STOR-003": { "control_id": "3.7", @@ -171,7 +202,7 @@ "AZ-NET-012": { "control_id": "6.7", "control_name": "Ensure that Network Watcher flow logs are enabled for Network Security Groups", - "description": "Network Security Group flow logs should be enabled through Network Watcher so network traffic can be audited and investigated. Without flow logs, lateral movement and suspicious network activity cannot be reconstructed." + "description": "A VNet flow log (or an existing legacy NSG flow log) should cover this virtual network's traffic so it can be audited and investigated. Microsoft blocks new NSG flow log creation as of 2025-06-30 and retires the feature on 2027-09-30, so VNet flow logs are the current, supported mechanism. Without either, lateral movement and suspicious network activity cannot be reconstructed." }, "AZ-DB-003": { "control_id": "4.3.6", @@ -179,8 +210,8 @@ "description": "SSL enforcement should be enabled on PostgreSQL Flexible Server to ensure data in transit is encrypted. Without SSL, database connections transmit data in plaintext, exposing it to interception." }, "AZ-KV-004": { - "control_id": "8.6", - "control_name": "Ensure that Azure Key Vault Purge Protection is Enabled", + "control_id": "8.5", + "control_name": "Ensure the Key Vault is Recoverable", "description": "Azure Key Vaults without purge protection enabled allow permanent deletion of vaults and their secrets, keys, and certificates during the soft-delete retention period. Even with soft delete enabled, a malicious insider or privileged account can purge vault objects before the retention period expires. Enabling purge protection prevents this by blocking purge operations for the full retention period." }, "AZ-DB-004": { @@ -194,9 +225,14 @@ "description": "Privileged Identity Management provides time-based and approval-based role activation to mitigate the risk of excessive, unnecessary, or misused access permissions on resources. Without PIM, admin roles are permanently assigned with no just-in-time controls or approval workflows." }, "AZ-KV-005": { - "control_id": "8.5", - "control_name": "Ensure that the expiration date is set on all certificates", - "description": "A certificate stored in Azure Key Vault is expiring within 30 days and does not have auto-renewal configured. CIS 8.5 requires that expiration dates are monitored and certificates are renewed before expiry to prevent service outages and broken authentication flows." + "control_id": "N/A-KV-005", + "control_name": "Key Vault certificate renewal baseline (not directly mapped in CIS Azure Foundations 2.0.0)", + "description": "This rule detects certificates expiring within 30 days without automatic renewal. CIS Azure Foundations 2.0.0 contains certificate expiration-date recommendations, but does not directly prescribe this proactive 30-day renewal check, so no numbered mapping is claimed." + }, + "AZ-KV-006": { + "control_id": "8.6", + "control_name": "Ensure that Azure Key Vault Uses Azure RBAC for Data Plane Authorization", + "description": "CIS Azure Foundations Benchmark 2.0.0 recommendation 8.6 requires Azure Key Vault to use the Azure RBAC permission model. Key Vaults using legacy access policies lack centrally auditable, scoped role assignments and increase the risk of over-privileged access to secrets, keys, and certificates." }, "AZ-NET-013": { "control_id": "6.4", @@ -259,34 +295,270 @@ "description": "Microsoft recommends a managed node OS upgrade channel for timely security patches. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." }, "AZ-IDN-010": { - "control_id": "TBD-IDN-010", + "control_id": "N/A-IDN-010", "control_name": "App Registration ownership (not mapped in CIS Azure Foundations 2.0.0)", "description": "Microsoft recommends accountable App Registration ownership. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." }, "AZ-IDN-011": { - "control_id": "TBD-IDN-011", + "control_id": "N/A-IDN-011", "control_name": "App Registration redirect URI security (not mapped in CIS Azure Foundations 2.0.0)", "description": "Microsoft requires secure redirect URI handling. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." }, "AZ-IDN-012": { - "control_id": "TBD-IDN-012", + "control_id": "N/A-IDN-012", "control_name": "OAuth implicit grant security (not mapped in CIS Azure Foundations 2.0.0)", "description": "Microsoft recommends authorization code flow instead of implicit grant. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." }, "AZ-IDN-013": { - "control_id": "TBD-IDN-013", + "control_id": "N/A-IDN-013", "control_name": "App Registration password credentials (not mapped in CIS Azure Foundations 2.0.0)", "description": "Microsoft recommends managed identity, federation, or certificates instead of client secrets. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." }, "AZ-IDN-014": { - "control_id": "TBD-IDN-014", + "control_id": "N/A-IDN-014", "control_name": "Application-instance property lock (not mapped in CIS Azure Foundations 2.0.0)", "description": "Microsoft recommends locking sensitive service-principal instance properties. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." }, "AZ-IDN-015": { - "control_id": "TBD-IDN-015", + "control_id": "N/A-IDN-015", "control_name": "Managed Identity least privilege (not mapped in CIS Azure Foundations 2.0.0)", "description": "Microsoft recommends least-privilege roles and scopes for managed identities. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-FUNC-001": {"control_id":"N/A-FUNC-001","control_name":"Function App HTTPS enforcement","description":"No direct CIS Azure recommendation is assigned; OpenShield evaluates the service-specific HTTPS control."}, + "AZ-FUNC-002": {"control_id":"N/A-FUNC-002","control_name":"Function App minimum TLS version","description":"No direct CIS Azure recommendation is assigned; OpenShield evaluates the service-specific TLS control."}, + "AZ-FUNC-003": {"control_id":"N/A-FUNC-003","control_name":"Function App FTP publishing","description":"No direct CIS Azure recommendation is assigned; OpenShield evaluates the service-specific publishing control."}, + "AZ-FUNC-004": {"control_id":"N/A-FUNC-004","control_name":"Function App remote debugging","description":"No direct CIS Azure recommendation is assigned; OpenShield evaluates the service-specific debugging control."}, + "AZ-FUNC-005": {"control_id":"N/A-FUNC-005","control_name":"Function App managed identity","description":"No direct CIS Azure recommendation is assigned; OpenShield evaluates the service-specific identity control."}, + "AZ-PE-001": {"control_id":"N/A-PE-001","control_name":"Storage public network access","description":"No direct CIS Azure recommendation is assigned; OpenShield evaluates the service-specific Private Link control."}, + "AZ-PE-002": {"control_id":"N/A-PE-002","control_name":"SQL public network access","description":"No direct CIS Azure recommendation is assigned; OpenShield evaluates the service-specific Private Link control."}, + "AZ-PE-003": {"control_id":"N/A-PE-003","control_name":"PostgreSQL public network access","description":"No direct CIS Azure recommendation is assigned; OpenShield evaluates the service-specific private-networking control."}, + "AZ-PE-004": {"control_id":"N/A-PE-004","control_name":"App Service public network access","description":"No direct CIS Azure recommendation is assigned; OpenShield evaluates the service-specific Private Link control."}, + "AZ-PE-005": {"control_id":"N/A-PE-005","control_name":"Recovery Services public network access","description":"No direct CIS Azure recommendation is assigned; OpenShield evaluates the service-specific Private Link control."}, + "AZ-PE-006": {"control_id":"N/A-PE-006","control_name":"Private endpoint connection approval","description":"No direct CIS Azure recommendation is assigned; OpenShield evaluates the private-endpoint connection state."}, + "AZ-BAK-001": {"control_id":"N/A-BAK-001","control_name":"Backup soft-delete protection","description":"No direct CIS Azure recommendation is assigned; OpenShield evaluates the Azure Backup recovery control."}, + "AZ-BAK-002": {"control_id":"N/A-BAK-002","control_name":"Backup vault immutability","description":"No direct CIS Azure recommendation is assigned; OpenShield evaluates the Azure Backup immutability control."}, + "AZ-BAK-004": {"control_id":"N/A-BAK-004","control_name":"Backup multi-user authorization","description":"No direct CIS Azure recommendation is assigned; OpenShield evaluates the Azure Backup authorization control."}, + "AZ-BAK-006": {"control_id":"N/A-BAK-006","control_name":"Backup security monitoring","description":"No direct CIS Azure recommendation is assigned; OpenShield evaluates the Azure Backup monitoring control."}, + "AZ-IDN-016": {"control_id": "N/A-IDN-016", "control_name": "Privileged User Missing Phishing-Resistant MFA", "description": "Service-specific OpenShield control for phishing-resistant MFA enforcement on privileged users."}, + "AZ-IDN-017": {"control_id": "N/A-IDN-017", "control_name": "Global Administrator Permanently Assigned Outside PIM", "description": "Service-specific OpenShield control for PIM-governed Global Administrator assignments."}, + "AZ-IDN-018": {"control_id": "N/A-IDN-018", "control_name": "Privileged Role Assigned Outside PIM", "description": "Service-specific OpenShield control requiring all privileged roles to be managed via PIM."}, + "AZ-IDN-019": {"control_id": "N/A-IDN-019", "control_name": "Stale Privileged Account Retains Active Access", "description": "Service-specific OpenShield control for dormant privileged accounts."}, + "AZ-IDN-020": {"control_id": "N/A-IDN-020", "control_name": "Emergency Access Accounts Missing or Misconfigured", "description": "Service-specific OpenShield control for break-glass account configuration."}, + "AZ-IDN-021": {"control_id": "N/A-IDN-021", "control_name": "Legacy Authentication Not Blocked by Conditional Access", "description": "Service-specific OpenShield control for blocking legacy authentication protocols."}, + "AZ-IDN-022": {"control_id": "N/A-IDN-022", "control_name": "Azure Management Not Protected by Conditional Access", "description": "Service-specific OpenShield control requiring CA protection for Azure management."}, + "AZ-IDN-023": {"control_id": "N/A-IDN-023", "control_name": "Identity Protection Risk Policies Not Enabled", "description": "Service-specific OpenShield control for Identity Protection risk policy enablement."}, + "AZ-IDN-024": {"control_id": "N/A-IDN-024", "control_name": "Workload Identities Excluded From Conditional Access", "description": "Service-specific OpenShield control for workload identity CA coverage."}, + "AZ-IDN-025": {"control_id": "N/A-IDN-025", "control_name": "Privileged Role-Assignable Group Has No Owner", "description": "Service-specific OpenShield control for privileged group ownership governance."}, + + "AZ-SC-001": { + "control_id": "N/A-SC-001", + "control_name": "Container Registry admin user baseline (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends disabling the Azure Container Registry admin account in favor of individual Microsoft Entra identities. This check has no direct recommendation in CIS Azure Foundations 2.0.0." + }, + "AZ-SC-002": { + "control_id": "N/A-SC-002", + "control_name": "Container Registry public network baseline (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends restricting Azure Container Registry network access with private endpoints or selected networks. This check has no direct recommendation in CIS Azure Foundations 2.0.0." + }, + "AZ-SC-003": { + "control_id": "N/A-SC-003", + "control_name": "Container Registry anonymous pull baseline (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends disabling anonymous pull unless a registry intentionally distributes public images. This check has no direct recommendation in CIS Azure Foundations 2.0.0." + }, + "AZ-SC-004": { + "control_id": "N/A-SC-004", + "control_name": "Container Registry retention and quarantine baseline (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft documents retention and quarantine policies for managing untagged and potentially unsafe artifacts. This combined check has no direct recommendation in CIS Azure Foundations 2.0.0." + }, + "AZ-SC-005": { + "control_id": "N/A-SC-005", + "control_name": "Terraform state container access baseline (not directly mapped in CIS Azure Foundations 2.0.0)", + "description": "Terraform state can contain sensitive infrastructure data and must not be anonymously readable. The repository does not claim a direct CIS recommendation because this rule specifically identifies Terraform state rather than evaluating every blob container." + }, + "AZ-SC-006": { + "control_id": "N/A-SC-006", + "control_name": "Terraform state recovery baseline (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends blob versioning and soft delete to recover Terraform state from accidental or malicious changes. This combined Terraform-specific check has no direct recommendation in CIS Azure Foundations 2.0.0." + }, + "AZ-SC-007": { + "control_id": "N/A-SC-007", + "control_name": "Pipeline service connection scope baseline (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends least-privilege scopes for Azure DevOps service connections. Azure DevOps pipeline connection scope is outside the direct recommendations in CIS Azure Foundations 2.0.0." + }, + "AZ-SC-008": { + "control_id": "N/A-SC-008", + "control_name": "Pipeline workload identity federation baseline (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends workload identity federation instead of stored service-principal secrets for Azure DevOps service connections. This check has no direct recommendation in CIS Azure Foundations 2.0.0." + }, + "AZ-DL-001": { + "control_id": "N/A-DL-001", + "control_name": "ExpressRoute Direct MACsec baseline (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft supports MACsec for encrypting ExpressRoute Direct physical links. CIS Azure Foundations 2.0.0 has no direct recommendation for ExpressRoute Direct MACsec." + }, + "AZ-DL-002": { + "control_id": "N/A-DL-002", + "control_name": "ExpressRoute Direct XPN MACsec baseline (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft requires the XPN cipher for MACsec on 100-Gbps ExpressRoute Direct ports. CIS Azure Foundations 2.0.0 has no direct recommendation for this link-layer setting." + }, + "AZ-NET-016": { + "control_id": "N/A-NET-016", + "control_name": "Network interface IP forwarding review (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Azure recommends disabling NIC IP forwarding unless the interface belongs to a reviewed routing function, but CIS Azure Foundations 2.0.0 does not assign this check a direct recommendation number." + }, + "AZ-NET-017": { + "control_id": "N/A-NET-017", + "control_name": "Direct Internet default route review (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Azure exposes and documents user-defined Internet next hops, but CIS Azure Foundations 2.0.0 does not assign this route check a direct recommendation number." + }, + "AZ-NET-018": { + "control_id": "N/A-NET-018", + "control_name": "Private Endpoint public access baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Private connectivity should replace unnecessary public PaaS exposure; CIS Azure Foundations 2.0.0 has no universal control covering every supported Private Link target." + }, + "AZ-NET-019": { + "control_id": "N/A-NET-019", + "control_name": "Private Endpoint connection approval baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Private Endpoint connections must be approved to provide the intended private path; no universal CIS Azure Foundations 2.0.0 recommendation covers this state." + }, + "AZ-NET-020": { + "control_id": "N/A-NET-020", + "control_name": "Private Endpoint DNS association baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Private Endpoints require service-appropriate private DNS integration; CIS Azure Foundations 2.0.0 has no universal recommendation for this association." + }, + "AZ-NET-021": { + "control_id": "N/A-NET-021", + "control_name": "Private Endpoint custom DNS configuration baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Private Endpoint custom DNS configuration should associate service names with private addresses. This is ARM configuration evidence, not an effective-resolution probe; CIS Azure Foundations 2.0.0 has no universal recommendation for it." + }, + "AZ-NET-022": { + "control_id": "N/A-NET-022", + "control_name": "Critical PaaS public exposure baseline (no universal CIS Azure Foundations 2.0.0 control)", + "description": "Critical PaaS resources should use private access or an approved exception; CIS Azure Foundations 2.0.0 provides service-specific rather than universal coverage." + }, + "AZ-NET-023": { + "control_id": "N/A-NET-023", + "control_name": "Azure Firewall threat intelligence enforcement baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Deny mode blocks traffic involving known malicious addresses and domains; CIS Azure Foundations 2.0.0 has no direct recommendation for this mode." + }, + "AZ-NET-024": { + "control_id": "N/A-NET-024", + "control_name": "Application Gateway WAF Prevention mode baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Prevention mode blocks matching application attacks; CIS Azure Foundations 2.0.0 has no direct recommendation for the gateway mode." + }, + "AZ-NET-025": { + "control_id": "N/A-NET-025", + "control_name": "Application Gateway WAF diagnostic logging baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "SKU-supported Application Gateway diagnostic logs support perimeter monitoring. Performance logging is required on v1; v2 exposes performance telemetry through metrics. CIS Azure Foundations 2.0.0 has no direct universal recommendation for these categories." + }, + "AZ-NET-026": { + "control_id": "N/A-NET-026", + "control_name": "Current WAF managed rules and bot protection baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Current base and bot managed rule sets protect the application perimeter; CIS Azure Foundations 2.0.0 has no direct rule-set-version recommendation." + }, + "AZ-NET-027": { + "control_id": "N/A-NET-027", + "control_name": "Internet-facing application rate limiting baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Rate limiting protects public applications from abusive request volume; CIS Azure Foundations 2.0.0 has no direct Application Gateway rate-rule recommendation." + }, + "AZ-SECOPS-001": { + "control_id": "5.1.1", + "control_name": "Ensure that a 'Diagnostic Setting' exists", + "description": "The subscription's Activity Log has no diagnostic setting exporting it to an organisation-approved central destination. CIS 5.1.1 requires a diagnostic setting exporting the Activity Log so administrative, security, and policy events are retained beyond the platform default and available for centralized analysis." + }, + "AZ-SECOPS-002": { + "control_id": "5.1.2", + "control_name": "Ensure Diagnostic Setting captures appropriate categories", + "description": "An Activity Log diagnostic setting exists but does not enable every required category (Administrative, Security, Policy, ServiceHealth). CIS 5.1.2 requires the diagnostic setting to capture all appropriate categories, not just an arbitrary subset." + }, + "AZ-SECOPS-003": { + "control_id": "5.4", + "control_name": "Ensure that Azure Monitor Resource Logging is Enabled for All Services that Support it", + "description": "A critical resource (as defined by the organisation's security-operations policy) has no diagnostic setting exporting to an approved destination. CIS 5.4 requires resource-level logging to be enabled for all services that support it so activity on individual resources is captured, not just subscription-level events." + }, + "AZ-SECOPS-004": { + "control_id": "N/A-SECOPS-004", + "control_name": "Security log retention below organisation minimum", + "description": "A Storage Account log export's retention_policy is disabled or set below the organisation's minimum retention requirement. The CIS Azure Foundations Benchmark addresses diagnostic-setting existence (5.1.1) and category coverage (5.1.2) as distinct numbered controls but does not assign its own control ID to the specific retention-duration value, so no single CIS control maps 1:1 to this rule." + }, + "AZ-SECOPS-005": { + "control_id": "N/A-SECOPS-005", + "control_name": "Security logs stored only in a workload-administrator-modifiable destination", + "description": "A critical resource's only log export destination sits in the same resource group as the workload, so an administrator of that workload can alter or delete the exported logs. The CIS Azure Foundations Benchmark does not have a numbered control for log-destination ownership or tamper-protection separation of duties." + }, + "AZ-SECOPS-006": { + "control_id": "N/A-SECOPS-006", + "control_name": "Required Microsoft Defender for Cloud plan not enabled", + "description": "An organisation-required Microsoft Defender for Cloud plan is not set to the 'Standard' pricing tier. CIS assigns each Defender plan its own leaf control (e.g. 2.1.1 Servers, 2.1.7 Storage, 2.1.4 Azure SQL Databases); because this rule evaluates whichever plans the organisation configures as required, no single fixed CIS control ID applies at the rule level (the matching per-plan CIS control is recorded on each finding's metadata instead)." + }, + "AZ-SECOPS-007": { + "control_id": "2.1.13", + "control_name": "Ensure that Microsoft Defender Recommendation for 'Apply system updates' status is 'Completed'", + "description": "A High-severity Microsoft Defender for Cloud recommendation remains Unhealthy beyond the organisation's remediation SLA. CIS 2.1.13 requires Defender recommendations to reach a 'Completed'/remediated status rather than being left open indefinitely; this rule generalizes that expectation to all High-severity recommendations against an organisation-defined SLA rather than only the update-management recommendation." + }, + "AZ-SECOPS-008": { + "control_id": "N/A-SECOPS-008", + "control_name": "Required Microsoft Sentinel data connector disconnected or unhealthy", + "description": "A required Microsoft Sentinel data connector is missing or has no enabled data type on a Sentinel-onboarded workspace. The CIS Azure Foundations Benchmark is an infrastructure-configuration benchmark and does not include SIEM/XDR operational controls such as Sentinel connector health." + }, + "AZ-SECOPS-009": { + "control_id": "N/A-SECOPS-009", + "control_name": "Sentinel missing required high-severity analytics coverage", + "description": "A Sentinel-onboarded workspace has no enabled High-severity analytics rule covering an organisation-required detection use case. The CIS Azure Foundations Benchmark does not evaluate SIEM detection content or analytics coverage." + }, + "AZ-SECOPS-010": { + "control_id": "2.1.20", + "control_name": "Ensure That 'Notify about alerts with the following severity' is Set to 'High'", + "description": "No enabled Azure Monitor action group with a notification receiver exists, and no Sentinel automation rule routes incidents onward. CIS 2.1.20 requires Defender security alerts to notify a monitored destination; this rule generalizes that requirement to the concrete Azure notification primitive (action groups) and the Sentinel-native incident routing mechanism (automation rules)." + }, + "AZ-NET-018": { + "control_id": "N/A-NET-018", + "control_name": "Private Endpoint public access baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Private connectivity should replace unnecessary public PaaS exposure; CIS Azure Foundations 2.0.0 has no universal control covering every supported Private Link target." + }, + "AZ-NET-019": { + "control_id": "N/A-NET-019", + "control_name": "Private Endpoint connection approval baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Private Endpoint connections must be approved to provide the intended private path; no universal CIS Azure Foundations 2.0.0 recommendation covers this state." + }, + "AZ-NET-020": { + "control_id": "N/A-NET-020", + "control_name": "Private Endpoint DNS association baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Private Endpoints require service-appropriate private DNS integration; CIS Azure Foundations 2.0.0 has no universal recommendation for this association." + }, + "AZ-NET-021": { + "control_id": "N/A-NET-021", + "control_name": "Private Endpoint FQDN resolution baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Private Endpoint names should resolve to private addresses; CIS Azure Foundations 2.0.0 has no universal recommendation for this resolution evidence." + }, + "AZ-NET-022": { + "control_id": "N/A-NET-022", + "control_name": "Critical PaaS public exposure baseline (no universal CIS Azure Foundations 2.0.0 control)", + "description": "Critical PaaS resources should use private access or an approved exception; CIS Azure Foundations 2.0.0 provides service-specific rather than universal coverage." + }, + "AZ-NET-023": { + "control_id": "N/A-NET-023", + "control_name": "Azure Firewall threat intelligence enforcement baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "AlertAndDeny blocks traffic involving known malicious addresses and domains; CIS Azure Foundations 2.0.0 has no direct recommendation for this mode." + }, + "AZ-NET-024": { + "control_id": "N/A-NET-024", + "control_name": "Application Gateway WAF Prevention mode baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Prevention mode blocks matching application attacks; CIS Azure Foundations 2.0.0 has no direct recommendation for the gateway mode." + }, + "AZ-NET-025": { + "control_id": "N/A-NET-025", + "control_name": "Application Gateway WAF diagnostic logging baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Access, performance, and firewall logs support perimeter monitoring; CIS Azure Foundations 2.0.0 has no direct universal recommendation for all categories." + }, + "AZ-NET-026": { + "control_id": "N/A-NET-026", + "control_name": "Current WAF managed rules and bot protection baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Current base and bot managed rule sets protect the application perimeter; CIS Azure Foundations 2.0.0 has no direct rule-set-version recommendation." + }, + "AZ-NET-027": { + "control_id": "N/A-NET-027", + "control_name": "Internet-facing application rate limiting baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Rate limiting protects public applications from abusive request volume; CIS Azure Foundations 2.0.0 has no direct Application Gateway rate-rule recommendation." } } } diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index c4b88d8e..e328198b 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -13,6 +13,32 @@ "control_name": "Policy on the use of cryptographic controls", "description": "Requiring secure transfer ensures cryptographic controls are applied to data in transit. A policy on the use of cryptographic controls for protection of information should be developed and implemented." }, + "AZ-STOR-006": { + "control_id": "N/A-STOR-006", + "control_name": "Storage Account Shared-Key Authorization Enabled", + "description": "OpenShield checks this service-specific control without claiming an unrelated ISO 27001 recommendation." + }, + "AZ-STOR-007": { + "control_id": "N/A-STOR-007", + "control_name": "Storage Account Allows TLS Below 1.2", + "description": "OpenShield checks this service-specific control without claiming an unrelated ISO 27001 recommendation." + }, + "AZ-STOR-008": { + "control_id": "N/A-STOR-008", + "control_name": "Required Storage Customer-Managed Key Protection Missing", + "description": "OpenShield checks this service-specific control without claiming an unrelated ISO 27001 recommendation." + }, + "AZ-STOR-009": { + "control_id": "N/A-STOR-009", + "control_name": "Required Blob Container Immutability Missing", + "description": "OpenShield checks this service-specific control without claiming an unrelated ISO 27001 recommendation." + }, + "AZ-DB-005": {"control_id": "A.9.4.2", "control_name": "Secure log-on procedures", "description": "SQL authentication is restricted to approved Entra identities."}, + "AZ-DB-006": {"control_id": "A.12.6.1", "control_name": "Management of technical vulnerabilities", "description": "Required SQL vulnerability assessment is configured."}, + "AZ-DB-007": {"control_id": "A.12.4.1", "control_name": "Event logging", "description": "SQL audit logs are retained according to policy."}, + "AZ-COSMOS-001": {"control_id": "A.9.4.2", "control_name": "Secure log-on procedures", "description": "Cosmos authentication is restricted to approved Entra identities."}, + "AZ-COSMOS-002": {"control_id": "A.13.1.1", "control_name": "Network controls", "description": "Cosmos public network access is restricted according to policy."}, + "AZ-CACHE-001": {"control_id": "A.13.1.1", "control_name": "Network controls", "description": "Managed cache access is private and uses approved TLS."}, "AZ-NET-001": { "control_id": "A.13.1.1", "control_name": "Network controls", @@ -128,6 +154,11 @@ "control_name": "Management of technical vulnerabilities", "description": "The virtual machine does not have automatic OS patching enabled. A.12.6.1 requires that information about technical vulnerabilities is obtained and the organisation's exposure evaluated. Without automatic patching, known OS vulnerabilities remain unmitigated." }, + "AZ-CMP-007": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "A VM has management ports (SSH/RDP) open to the internet with no Just-In-Time VM access policy covering them. A.13.1.1 requires network controls that manage and protect access to systems. JIT limits management-port exposure to approved, time-boxed windows." + }, "AZ-CMP-003": { "control_id": "A.12.2.1", "control_name": "Controls against malware", @@ -171,7 +202,7 @@ "AZ-NET-012": { "control_id": "A.12.4.1", "control_name": "Event logging", - "description": "Network Security Group flow logs record network traffic activity for investigation and monitoring. Without flow logs, event records needed to reconstruct suspicious network activity are not produced." + "description": "A VNet flow log (or an existing legacy NSG flow log) records network traffic activity for investigation and monitoring. New NSG flow log creation is blocked as of 2025-06-30, so VNet flow logs are the current mechanism. Without either, event records needed to reconstruct suspicious network activity are not produced." }, "AZ-DB-003": { "control_id": "A.10.1.1", @@ -188,6 +219,11 @@ "control_name": "Key management", "description": "A certificate stored in Azure Key Vault is expiring within 30 days with no auto-renewal configured. A.10.1.2 requires that a policy on the use, protection, and lifetime of cryptographic keys is developed and implemented. Certificates approaching expiry without renewal represent a failure in cryptographic key lifecycle management." }, + "AZ-KV-006": { + "control_id": "A.9.2.3", + "control_name": "Management of privileged access rights", + "description": "Key Vaults authorizing access through legacy vault access policies instead of Azure RBAC lack scoped, reviewable privileged-access management. A.9.2.3 requires that the allocation of privileged access rights is restricted and controlled. Access policies do not provide the granular, role-based control needed to enforce least privilege on secrets, keys, and certificates." + }, "AZ-DB-004": { "control_id": "A.13.1.1", "control_name": "Network controls", @@ -287,6 +323,302 @@ "control_id": "A.9.2.3", "control_name": "Management of privileged access rights", "description": "Subscription Owner and Contributor assignments to managed identities require least-privilege reduction." + }, + "AZ-BAK-001": { + "control_id": "A.12.3.1", + "control_name": "Information backup", + "description": "The Recovery Services vault lacks the approved soft-delete recovery window, risking permanent loss of backup data before it can be restored." + }, + "AZ-BAK-002": { + "control_id": "A.12.3.1", + "control_name": "Information backup", + "description": "Vault immutability is disabled, allowing destructive changes to protected recovery points and undermining the integrity of backup copies." + }, + "AZ-BAK-004": { + "control_id": "A.9.2.3", + "control_name": "Management of privileged access rights", + "description": "The vault does not enable Resource Guard multiuser authorization, allowing a single compromised or malicious identity to disable backup protections unilaterally." + }, + "AZ-BAK-006": { + "control_id": "A.12.4.1", + "control_name": "Event logging", + "description": "The Recovery Services vault does not enable built-in monitoring for backup job failures, so a failed or tampered backup could go undetected." + }, + "AZ-FUNC-001": { + "control_id": "A.13.2.1", + "control_name": "Information transfer policies and procedures", + "description": "The Function App accepts unencrypted HTTP traffic, so requests and responses can cross the network without encryption in transit." + }, + "AZ-FUNC-002": { + "control_id": "A.13.2.1", + "control_name": "Information transfer policies and procedures", + "description": "The Function App permits TLS older than 1.2, weakening the encryption protecting traffic in transit." + }, + "AZ-FUNC-003": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "The Function App exposes an FTP or FTPS deployment channel, widening the network attack surface beyond the primary HTTPS endpoint." + }, + "AZ-FUNC-004": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Remote debugging expands the Function App management attack surface by opening an additional network-reachable control channel." + }, + "AZ-FUNC-005": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "The Function App has no Azure managed identity for secretless resource access, pushing workloads toward long-lived credentials that cross network and service boundaries." + }, + "AZ-PE-001": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "A Storage Account remains publicly reachable; an approved private endpoint alone does not disable its public endpoint, leaving the network boundary uncontrolled." + }, + "AZ-PE-002": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "An Azure SQL logical server remains publicly reachable, regardless of whether a private endpoint also exists, leaving the network boundary uncontrolled." + }, + "AZ-PE-003": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "A PostgreSQL Flexible Server remains publicly reachable instead of using private networking only, leaving the network boundary uncontrolled." + }, + "AZ-PE-004": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "An App Service workload remains publicly reachable without a default-deny access policy, leaving the network boundary uncontrolled." + }, + "AZ-PE-005": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "A Recovery Services vault permits public access, even if a private endpoint also exists, leaving the network boundary uncontrolled." + }, + "AZ-PE-006": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "A private endpoint connection is pending, rejected, or disconnected and does not provide an active private path, leaving traffic to traverse the public network boundary instead." + }, + "AZ-IDN-016": {"control_id": "A.9.4.2", "control_name": "Secure log-on procedures", "description": "Privileged users must use phishing-resistant multi-factor authentication."}, + "AZ-IDN-017": {"control_id": "A.9.2.3", "control_name": "Management of privileged access rights", "description": "Global Administrator access must be time-bound through PIM rather than permanently assigned."}, + "AZ-IDN-018": {"control_id": "A.9.2.3", "control_name": "Management of privileged access rights", "description": "Privileged role assignments must be managed and audited through PIM."}, + "AZ-IDN-019": {"control_id": "A.9.2.5", "control_name": "Review of user access rights", "description": "Dormant privileged accounts must be periodically reviewed and revoked."}, + "AZ-IDN-020": {"control_id": "A.9.1.2", "control_name": "Access to networks and network services", "description": "Emergency access accounts ensure administrative access during lockout scenarios."}, + "AZ-IDN-021": {"control_id": "A.9.4.2", "control_name": "Secure log-on procedures", "description": "Legacy authentication protocols that bypass MFA controls must be blocked."}, + "AZ-IDN-022": {"control_id": "A.9.4.1", "control_name": "Information access restriction", "description": "Azure management interfaces must require MFA via Conditional Access."}, + "AZ-IDN-023": {"control_id": "A.12.4.1", "control_name": "Event logging", "description": "Identity Protection risk policies detect and respond to anomalous sign-in activity."}, + "AZ-IDN-024": {"control_id": "A.9.2.3", "control_name": "Management of privileged access rights", "description": "Workload identities must not be broadly excluded from Conditional Access enforcement."}, + "AZ-IDN-025": {"control_id": "A.9.2.5", "control_name": "Review of user access rights", "description": "Role-assignable groups must have owners to govern privileged membership changes."}, + + "AZ-SC-001": { + "control_id": "A.9.2.1", + "control_name": "User registration and de-registration", + "description": "The Container Registry admin user is enabled, providing a shared credential that bypasses individual identity management and cannot be attributed to a single user." + }, + "AZ-SC-002": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "The Container Registry is reachable from the public internet, leaving the network boundary that protects the organization's built container images uncontrolled." + }, + "AZ-SC-003": { + "control_id": "A.9.2.1", + "control_name": "User registration and de-registration", + "description": "The Container Registry allows anonymous pull, letting any client access every image without an authenticated, individually attributable identity." + }, + "AZ-SC-004": { + "control_id": "A.12.1.2", + "control_name": "Change management", + "description": "The Container Registry has no retention or quarantine policy, so stale images accumulate and newly pushed images are deployable before any vulnerability scan evaluates them." + }, + "AZ-SC-005": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "A Terraform remote state container is publicly readable, leaving the network boundary around infrastructure layout and captured secrets uncontrolled." + }, + "AZ-SC-006": { + "control_id": "A.12.3.1", + "control_name": "Information backup", + "description": "A storage account holding Terraform remote state has neither versioning nor soft delete enabled, so an overwritten or deleted state file cannot be recovered." + }, + "AZ-SC-007": { + "control_id": "A.9.2.3", + "control_name": "Management of privileged access rights", + "description": "A pipeline service connection is scoped to the entire subscription rather than a single resource group, so every pipeline that uses it inherits subscription-wide access beyond what it needs." + }, + "AZ-SC-008": { + "control_id": "A.9.4.3", + "control_name": "Password management system", + "description": "A pipeline service connection authenticates with a stored service principal secret instead of a federated credential, leaving a static credential to rotate and potentially leak." + }, + "AZ-DL-001": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "MACsec protects traffic on the customer-visible ExpressRoute Direct Ethernet boundary." + }, + "AZ-DL-002": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "XPN MACsec provides appropriate packet-number capacity for high-speed ExpressRoute Direct links." + }, + "AZ-NET-016": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "NIC IP forwarding must be restricted to approved routing functions." + }, + "AZ-NET-017": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Default routes must preserve the approved controlled egress boundary." + }, + "AZ-NET-018": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "PaaS resources using Private Link should not retain unnecessary public network exposure." + }, + "AZ-NET-019": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Private Endpoint connections must be approved and operational." + }, + "AZ-NET-020": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Private Endpoints require an associated service-appropriate Private DNS zone." + }, + "AZ-NET-021": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Private Endpoint ARM DNS configuration must associate service names with private addresses; effective resolver-path validation remains separate evidence." + }, + "AZ-NET-022": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Critical PaaS resources restrict public exposure unless an approved exception exists." + }, + "AZ-NET-023": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Azure Firewall denies traffic identified by Microsoft threat intelligence." + }, + "AZ-NET-024": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Application Gateway WAF operates in Prevention mode at the application boundary." + }, + "AZ-NET-025": { + "control_id": "A.12.4.1", + "control_name": "Event logging", + "description": "Application Gateway WAF diagnostic categories supported by its SKU are exported to an approved monitoring destination." + }, + "AZ-NET-026": { + "control_id": "A.14.2.5", + "control_name": "Secure system engineering principles", + "description": "Current managed application and bot rules are maintained at the web perimeter." + }, + "AZ-NET-027": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Rate limiting protects internet-facing application entry points." + }, + "AZ-SECOPS-001": { + "control_id": "A.12.4.1", + "control_name": "Event logging", + "description": "The subscription's Activity Log is not exported to an approved central destination. A.12.4.1 requires event logs recording user activities, exceptions, faults, and information security events to be produced, kept, and regularly reviewed; a log that is never centrally exported cannot be reviewed or retained beyond the platform default." + }, + "AZ-SECOPS-002": { + "control_id": "A.12.4.1", + "control_name": "Event logging", + "description": "The Activity Log export omits organisation-required categories (Administrative, Security, Policy, ServiceHealth). A.12.4.1 requires event logging to capture the events relevant to information security; a partial category export leaves gaps in the recorded evidence base." + }, + "AZ-SECOPS-003": { + "control_id": "A.12.4.1", + "control_name": "Event logging", + "description": "A critical resource has no diagnostic setting exporting to an approved destination. A.12.4.1 requires event logs to be produced for systems, and resource-level activity that is never logged cannot support later review, investigation, or evidence of misuse." + }, + "AZ-SECOPS-004": { + "control_id": "A.12.4.1", + "control_name": "Event logging", + "description": "A security-relevant log export's retention is below the organisation's minimum. A.12.4.1 requires logs to be kept for an agreed period; retention that expires before an incident is discovered defeats the control's purpose of supporting later investigation." + }, + "AZ-SECOPS-005": { + "control_id": "A.12.4.2", + "control_name": "Protection of log information", + "description": "A critical resource's only log export sits in a destination the workload's own administrators can modify. A.12.4.2 requires logging facilities and log information to be protected against tampering and unauthorised access, including by administrators of the systems being logged, which this single-destination, same-resource-group export does not achieve." + }, + "AZ-SECOPS-006": { + "control_id": "A.12.6.1", + "control_name": "Management of technical vulnerabilities", + "description": "A required Microsoft Defender for Cloud plan is not enabled for a critical workload type. A.12.6.1 requires timely information about technical vulnerabilities to be obtained and the organisation's exposure evaluated; Defender for Cloud is the Azure-native control providing that vulnerability and threat information, and an unlicensed workload type receives none of it." + }, + "AZ-SECOPS-007": { + "control_id": "A.12.6.1", + "control_name": "Management of technical vulnerabilities", + "description": "A High-severity Defender recommendation remains unresolved beyond the organisation's SLA. A.12.6.1 requires timely action to address identified technical vulnerabilities according to the organisation's associated risk; an SLA breach on a High-severity item is direct evidence that this control's timeliness requirement was not met." + }, + "AZ-SECOPS-008": { + "control_id": "A.12.4.1", + "control_name": "Event logging", + "description": "A required Sentinel data connector is missing or unhealthy on an onboarded workspace. A.12.4.1 requires the systems that generate security-relevant events to have their logs actually collected; a disconnected connector means the corresponding event source produces no logs for the SIEM to review." + }, + "AZ-SECOPS-009": { + "control_id": "A.12.4.1", + "control_name": "Event logging", + "description": "Sentinel lacks enabled High-severity analytics coverage for a required detection use case. A.12.4.1's objective of recording and reviewing security-relevant events is not met when the events are ingested but no analytics rule evaluates them for the specific threat pattern the organisation has identified as high-risk." + }, + "AZ-SECOPS-010": { + "control_id": "A.16.1.2", + "control_name": "Reporting information security events", + "description": "No monitored destination exists for security alerts or Sentinel incidents. A.16.1.2 requires information security events to be reported through appropriate management channels as quickly as possible; an alert with no notified recipient cannot be reported or acted on." + }, + "AZ-NET-018": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "PaaS resources using Private Link should not retain unnecessary public network exposure." + }, + "AZ-NET-019": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Private Endpoint connections must be approved and operational." + }, + "AZ-NET-020": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Private Endpoints require an associated service-appropriate Private DNS zone." + }, + "AZ-NET-021": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Private Endpoint FQDNs must resolve to private addresses through the controlled network path." + }, + "AZ-NET-022": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Critical PaaS resources restrict public exposure unless an approved exception exists." + }, + "AZ-NET-023": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Azure Firewall denies traffic identified by Microsoft threat intelligence." + }, + "AZ-NET-024": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Application Gateway WAF operates in Prevention mode at the application boundary." + }, + "AZ-NET-025": { + "control_id": "A.12.4.1", + "control_name": "Event logging", + "description": "Application Gateway WAF diagnostic categories are exported to an approved monitoring destination." + }, + "AZ-NET-026": { + "control_id": "A.14.2.5", + "control_name": "Secure system engineering principles", + "description": "Current managed application and bot rules are maintained at the web perimeter." + }, + "AZ-NET-027": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Rate limiting protects internet-facing application entry points." } } } diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 563c614f..ae145cf9 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -13,6 +13,32 @@ "control_name": "Data-in-transit is protected", "description": "Requiring secure transfer ensures data in transit between clients and Azure Storage is encrypted using HTTPS, protecting against interception and tampering." }, + "AZ-STOR-006": { + "control_id": "N/A-STOR-006", + "control_name": "Storage Account Shared-Key Authorization Enabled", + "description": "OpenShield checks this service-specific control without claiming an unrelated NIST recommendation." + }, + "AZ-STOR-007": { + "control_id": "N/A-STOR-007", + "control_name": "Storage Account Allows TLS Below 1.2", + "description": "OpenShield checks this service-specific control without claiming an unrelated NIST recommendation." + }, + "AZ-STOR-008": { + "control_id": "N/A-STOR-008", + "control_name": "Required Storage Customer-Managed Key Protection Missing", + "description": "OpenShield checks this service-specific control without claiming an unrelated NIST recommendation." + }, + "AZ-STOR-009": { + "control_id": "N/A-STOR-009", + "control_name": "Required Blob Container Immutability Missing", + "description": "OpenShield checks this service-specific control without claiming an unrelated NIST recommendation." + }, + "AZ-DB-005": {"control_id": "PR.AC-6", "control_name": "Identity proofing and authentication", "description": "SQL authentication is restricted to approved Entra identities."}, + "AZ-DB-006": {"control_id": "DE.CM-8", "control_name": "Vulnerability scans are performed", "description": "Required SQL vulnerability assessment is configured."}, + "AZ-DB-007": {"control_id": "PR.PT-1", "control_name": "Audit/log records are determined, documented, implemented, and reviewed", "description": "SQL audit logs are retained for at least 90 days in accordance with the defined audit policy."}, + "AZ-COSMOS-001": {"control_id": "PR.AC-6", "control_name": "Identity proofing and authentication", "description": "Cosmos authentication is restricted to approved Entra identities."}, + "AZ-COSMOS-002": {"control_id": "PR.AC-5", "control_name": "Network integrity is protected", "description": "Cosmos public network access is restricted according to policy."}, + "AZ-CACHE-001": {"control_id": "PR.AC-5", "control_name": "Network integrity is protected", "description": "Managed cache access is private and uses approved TLS."}, "AZ-NET-001": { "control_id": "PR.AC-3", "control_name": "Remote access is managed", @@ -133,6 +159,11 @@ "control_name": "A vulnerability management plan is developed and implemented", "description": "The virtual machine does not have automatic OS patching enabled. PR.IP-12 requires that a vulnerability management plan is developed and implemented. Without automatic patching, known OS vulnerabilities remain unmitigated and exploitable." }, + "AZ-CMP-007": { + "control_id": "PR.AC-3", + "control_name": "Remote access is managed", + "description": "A VM has management ports (SSH/RDP) open to the internet with no Just-In-Time VM access policy covering them. PR.AC-3 requires that remote access is managed. JIT restricts management-port access to approved, time-boxed requests instead of leaving the ports standing open." + }, "AZ-KV-001": { "control_id": "PR.IP-4", "control_name": "Backups of information are conducted, maintained, and tested", @@ -171,7 +202,7 @@ "AZ-NET-012": { "control_id": "DE.CM-1", "control_name": "The network is monitored to detect potential cybersecurity events", - "description": "Network Security Group flow logs provide visibility into network traffic patterns and blocked or allowed flows. Without flow logs, potential cybersecurity events in network traffic cannot be detected or reconstructed." + "description": "A VNet flow log (or an existing legacy NSG flow log) provides visibility into network traffic patterns and blocked or allowed flows. New NSG flow log creation is blocked as of 2025-06-30, so VNet flow logs are the current mechanism. Without either, potential cybersecurity events in network traffic cannot be detected or reconstructed." }, "AZ-DB-003": { "control_id": "PR.DS-2", @@ -188,6 +219,11 @@ "control_name": "Maintenance and repair of organisational assets is performed", "description": "A certificate stored in Azure Key Vault is expiring within 30 days with no auto-renewal configured. PR.MA-1 requires that maintenance of organisational assets is performed and logged. Certificate renewal is a critical maintenance task and failure to renew before expiry causes immediate service disruption." }, + "AZ-KV-006": { + "control_id": "PR.AC-4", + "control_name": "Access permissions and authorizations are managed", + "description": "Key Vaults authorizing access through legacy vault access policies instead of Azure RBAC lack centrally managed, auditable access permissions. PR.AC-4 requires that access permissions are managed incorporating the principles of least privilege and separation of duties. Access policies cannot express fine-grained, role-scoped permissions the way Azure RBAC role assignments can." + }, "AZ-DB-004": { "control_id": "PR.AC-3", "control_name": "Remote access is managed", @@ -287,6 +323,302 @@ "control_id": "PR.AC-4", "control_name": "Access permissions and authorizations are managed", "description": "Managed identities should receive only the minimum role and scope required by their workloads." + }, + "AZ-BAK-001": { + "control_id": "PR.IP-4", + "control_name": "Backups of information are conducted, maintained, and tested", + "description": "The Recovery Services vault lacks the approved soft-delete recovery window, risking permanent loss of backup data before it can be restored." + }, + "AZ-BAK-002": { + "control_id": "PR.IP-4", + "control_name": "Backups of information are conducted, maintained, and tested", + "description": "Vault immutability is disabled, allowing destructive changes to protected recovery points and undermining the integrity of backup copies." + }, + "AZ-BAK-004": { + "control_id": "PR.AC-4", + "control_name": "Access permissions and authorizations are managed", + "description": "The vault does not enable Resource Guard multiuser authorization, allowing a single compromised or malicious identity to disable backup protections unilaterally." + }, + "AZ-BAK-006": { + "control_id": "DE.CM-1", + "control_name": "The network is monitored to detect potential cybersecurity events", + "description": "The Recovery Services vault does not enable built-in monitoring for backup job failures, so a failed or tampered backup could go undetected." + }, + "AZ-FUNC-001": { + "control_id": "PR.DS-2", + "control_name": "Data-in-transit is protected", + "description": "The Function App accepts unencrypted HTTP traffic, so requests and responses can cross the network without encryption in transit." + }, + "AZ-FUNC-002": { + "control_id": "PR.DS-2", + "control_name": "Data-in-transit is protected", + "description": "The Function App permits TLS older than 1.2, weakening the encryption protecting traffic in transit." + }, + "AZ-FUNC-003": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "The Function App exposes an FTP or FTPS deployment channel, widening the network attack surface beyond the primary HTTPS endpoint." + }, + "AZ-FUNC-004": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "Remote debugging expands the Function App management attack surface by opening an additional network-reachable control channel." + }, + "AZ-FUNC-005": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "The Function App has no Azure managed identity for secretless resource access, pushing workloads toward long-lived credentials that cross network and service boundaries." + }, + "AZ-PE-001": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "A Storage Account remains publicly reachable; an approved private endpoint alone does not disable its public endpoint, leaving the network boundary uncontrolled." + }, + "AZ-PE-002": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "An Azure SQL logical server remains publicly reachable, regardless of whether a private endpoint also exists, leaving the network boundary uncontrolled." + }, + "AZ-PE-003": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "A PostgreSQL Flexible Server remains publicly reachable instead of using private networking only, leaving the network boundary uncontrolled." + }, + "AZ-PE-004": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "An App Service workload remains publicly reachable without a default-deny access policy, leaving the network boundary uncontrolled." + }, + "AZ-PE-005": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "A Recovery Services vault permits public access, even if a private endpoint also exists, leaving the network boundary uncontrolled." + }, + "AZ-PE-006": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "A private endpoint connection is pending, rejected, or disconnected and does not provide an active private path, leaving traffic to traverse the public network boundary instead." + }, + "AZ-IDN-016": {"control_id": "PR.AC-7", "control_name": "Users, devices, and other assets are authenticated", "description": "Privileged users must register phishing-resistant authentication methods."}, + "AZ-IDN-017": {"control_id": "PR.AC-4", "control_name": "Access permissions and authorizations are managed", "description": "Global Administrator assignments must be time-bound and managed through PIM."}, + "AZ-IDN-018": {"control_id": "PR.AC-4", "control_name": "Access permissions and authorizations are managed", "description": "All privileged role assignments must be governed through Privileged Identity Management."}, + "AZ-IDN-019": {"control_id": "PR.AC-1", "control_name": "Identities and credentials are managed", "description": "Dormant privileged accounts must be reviewed and disabled."}, + "AZ-IDN-020": {"control_id": "PR.AC-4", "control_name": "Access permissions and authorizations are managed", "description": "At least two correctly configured emergency access accounts must exist."}, + "AZ-IDN-021": {"control_id": "PR.AC-7", "control_name": "Users, devices, and other assets are authenticated", "description": "Legacy authentication protocols that bypass MFA must be blocked."}, + "AZ-IDN-022": {"control_id": "PR.AC-4", "control_name": "Access permissions and authorizations are managed", "description": "Azure management interfaces require MFA enforcement via Conditional Access."}, + "AZ-IDN-023": {"control_id": "DE.CM-3", "control_name": "Personnel activity is monitored to detect potential cybersecurity events", "description": "Identity Protection risk policies must be enabled to detect and respond to compromised accounts."}, + "AZ-IDN-024": {"control_id": "PR.AC-4", "control_name": "Access permissions and authorizations are managed", "description": "Workload identities with privileged roles must not be broadly excluded from CA policies."}, + "AZ-IDN-025": {"control_id": "PR.AC-4", "control_name": "Access permissions and authorizations are managed", "description": "Role-assignable groups must have designated owners to control privileged group membership."}, + + "AZ-SC-001": { + "control_id": "PR.AC-1", + "control_name": "Identities and credentials are issued, managed, verified, revoked, and audited", + "description": "The Container Registry admin user is enabled, providing a shared credential that bypasses individual identity management and cannot be attributed to a single user." + }, + "AZ-SC-002": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "The Container Registry is reachable from the public internet, leaving the network boundary that protects the organization's built container images uncontrolled." + }, + "AZ-SC-003": { + "control_id": "PR.AC-1", + "control_name": "Identities and credentials are issued, managed, verified, revoked, and audited", + "description": "The Container Registry allows anonymous pull, letting any client access every image without an authenticated, individually attributable identity." + }, + "AZ-SC-004": { + "control_id": "PR.IP-1", + "control_name": "A baseline configuration is created and maintained", + "description": "The Container Registry has no retention or quarantine policy, so stale images accumulate and newly pushed images are deployable before any vulnerability scan evaluates them." + }, + "AZ-SC-005": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "A Terraform remote state container is publicly readable, leaving the network boundary around infrastructure layout and captured secrets uncontrolled." + }, + "AZ-SC-006": { + "control_id": "PR.IP-4", + "control_name": "Backups of information are conducted, maintained, and tested", + "description": "A storage account holding Terraform remote state has neither versioning nor soft delete enabled, so an overwritten or deleted state file cannot be recovered." + }, + "AZ-SC-007": { + "control_id": "PR.AC-4", + "control_name": "Access permissions and authorizations are managed", + "description": "A pipeline service connection is scoped to the entire subscription rather than a single resource group, so every pipeline that uses it inherits subscription-wide access beyond what it needs." + }, + "AZ-SC-008": { + "control_id": "PR.AC-1", + "control_name": "Identities and credentials are issued, managed, verified, revoked, and audited", + "description": "A pipeline service connection authenticates with a stored service principal secret instead of a federated credential, leaving a static credential to rotate and potentially leak." + }, + "AZ-DL-001": { + "control_id": "PR.DS-2", + "control_name": "Data in transit is protected", + "description": "MACsec protects traffic on the customer-visible ExpressRoute Direct Ethernet boundary." + }, + "AZ-DL-002": { + "control_id": "PR.DS-2", + "control_name": "Data in transit is protected", + "description": "XPN MACsec avoids packet-number exhaustion risk on high-speed ExpressRoute Direct links." + }, + "AZ-NET-016": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "Unnecessary NIC IP forwarding weakens Azure source and destination validation and can create an unintended transit path." + }, + "AZ-NET-017": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "An explicit default Internet UDR can bypass the approved inspected egress path." + }, + "AZ-NET-018": { + "control_id": "PR.AC-3", + "control_name": "Remote access is managed", + "description": "Disabling unnecessary public access ensures the Private Endpoint is the managed remote access path." + }, + "AZ-NET-019": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "Approved Private Endpoint connections preserve the intended private network boundary." + }, + "AZ-NET-020": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "Private DNS zone association directs service names through the intended private endpoint path." + }, + "AZ-NET-021": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "Private Endpoint ARM DNS configuration associates service names with private addresses; effective resolver-path validation remains separate evidence." + }, + "AZ-NET-022": { + "control_id": "PR.AC-3", + "control_name": "Remote access is managed", + "description": "Critical PaaS public access is disabled unless an explicit approved exception exists." + }, + "AZ-NET-023": { + "control_id": "DE.CM-1", + "control_name": "The network is monitored", + "description": "Azure Firewall threat intelligence alerts on and denies traffic involving known malicious infrastructure." + }, + "AZ-NET-024": { + "control_id": "PR.PT-4", + "control_name": "Communications and control networks are protected", + "description": "Application Gateway WAF Prevention mode actively blocks matching application attacks." + }, + "AZ-NET-025": { + "control_id": "DE.CM-1", + "control_name": "The network is monitored", + "description": "Application Gateway SKU-supported diagnostic logs provide perimeter monitoring evidence; v2 performance telemetry is supplied through metrics." + }, + "AZ-NET-026": { + "control_id": "PR.PT-4", + "control_name": "Communications and control networks are protected", + "description": "Current base and bot managed rules protect the web application perimeter." + }, + "AZ-NET-027": { + "control_id": "PR.PT-4", + "control_name": "Communications and control networks are protected", + "description": "Rate-limit rules protect internet-facing applications from abusive request volume." + }, + "AZ-SECOPS-001": { + "control_id": "PR.PT-1", + "control_name": "Audit/log records are determined, documented, implemented, and reviewed in accordance with policy", + "description": "The subscription's Activity Log is not exported to an approved central destination. PR.PT-1 requires audit/log records to be implemented in accordance with policy; an unexported Activity Log is not available to the review process the policy requires." + }, + "AZ-SECOPS-002": { + "control_id": "PR.PT-1", + "control_name": "Audit/log records are determined, documented, implemented, and reviewed in accordance with policy", + "description": "Required Activity Log categories are missing from the central export. PR.PT-1 requires audit/log records to be determined and implemented per policy; an export missing organisation-required categories does not implement the full policy-defined log scope." + }, + "AZ-SECOPS-003": { + "control_id": "DE.AE-3", + "control_name": "Event data are collected and correlated from multiple sources and sensors", + "description": "A critical resource lacks diagnostic settings exporting to an approved destination. DE.AE-3 requires event data to be collected and correlated from multiple sources; a critical resource with no export is a source contributing no data to that correlation." + }, + "AZ-SECOPS-004": { + "control_id": "PR.PT-1", + "control_name": "Audit/log records are determined, documented, implemented, and reviewed in accordance with policy", + "description": "A security-relevant log export's retention is below the organisation's minimum. PR.PT-1 requires audit/log records to be reviewed in accordance with policy, which presumes the records still exist at review time; retention shorter than the policy's minimum breaks that assumption." + }, + "AZ-SECOPS-005": { + "control_id": "PR.DS-6", + "control_name": "Integrity checking mechanisms are used to verify software, firmware, and information integrity", + "description": "A critical resource's only log export sits in a destination its own workload administrators can modify. PR.DS-6 requires integrity-checking mechanisms to verify information integrity; a single, workload-owned destination provides no independent verification that the exported logs have not been altered or deleted by the resource's own administrators." + }, + "AZ-SECOPS-006": { + "control_id": "DE.CM-8", + "control_name": "Vulnerability scans are performed", + "description": "A required Microsoft Defender for Cloud plan is not enabled for a critical workload type. DE.CM-8 requires vulnerability scans to be performed; Defender for Cloud performs this scanning for the workload types it protects, and an unlicensed type receives no such scanning." + }, + "AZ-SECOPS-007": { + "control_id": "RS.MI-3", + "control_name": "Newly identified vulnerabilities are mitigated or documented as accepted risks", + "description": "A High-severity Defender recommendation remains unresolved beyond the organisation's SLA. RS.MI-3 requires newly identified vulnerabilities to be mitigated or documented as accepted risk; an SLA breach with no recorded exception means neither has happened." + }, + "AZ-SECOPS-008": { + "control_id": "DE.AE-3", + "control_name": "Event data are collected and correlated from multiple sources and sensors", + "description": "A required Sentinel data connector is missing or unhealthy. DE.AE-3 requires event data to be collected and correlated from multiple sources and sensors; a disconnected connector is a required sensor that is not contributing data." + }, + "AZ-SECOPS-009": { + "control_id": "DE.CM-1", + "control_name": "The network is monitored to detect potential cybersecurity events", + "description": "Sentinel lacks enabled High-severity analytics coverage for a required detection use case. DE.CM-1 requires the network to be monitored to detect potential cybersecurity events; ingested logs with no analytics rule evaluating them for a known high-risk pattern do not achieve that monitoring outcome for that use case." + }, + "AZ-SECOPS-010": { + "control_id": "RS.CO-2", + "control_name": "Incidents are reported consistent with established criteria", + "description": "No monitored destination exists for security alerts or Sentinel incidents. RS.CO-2 requires incidents to be reported consistent with established criteria; an alert with no notified recipient is never reported to anyone who can act on it." + }, + "AZ-NET-018": { + "control_id": "PR.AC-3", + "control_name": "Remote access is managed", + "description": "Disabling unnecessary public access ensures the Private Endpoint is the managed remote access path." + }, + "AZ-NET-019": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "Approved Private Endpoint connections preserve the intended private network boundary." + }, + "AZ-NET-020": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "Private DNS zone association directs service names through the intended private endpoint path." + }, + "AZ-NET-021": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "Private address resolution provides evidence that service traffic follows the private network boundary." + }, + "AZ-NET-022": { + "control_id": "PR.AC-3", + "control_name": "Remote access is managed", + "description": "Critical PaaS public access is disabled unless an explicit approved exception exists." + }, + "AZ-NET-023": { + "control_id": "DE.CM-1", + "control_name": "The network is monitored", + "description": "Azure Firewall threat intelligence alerts on and denies traffic involving known malicious infrastructure." + }, + "AZ-NET-024": { + "control_id": "PR.PT-4", + "control_name": "Communications and control networks are protected", + "description": "Application Gateway WAF Prevention mode actively blocks matching application attacks." + }, + "AZ-NET-025": { + "control_id": "DE.CM-1", + "control_name": "The network is monitored", + "description": "Application Gateway access, performance, and firewall logs provide perimeter monitoring evidence." + }, + "AZ-NET-026": { + "control_id": "PR.PT-4", + "control_name": "Communications and control networks are protected", + "description": "Current base and bot managed rules protect the web application perimeter." + }, + "AZ-NET-027": { + "control_id": "PR.PT-4", + "control_name": "Communications and control networks are protected", + "description": "Rate-limit rules protect internet-facing applications from abusive request volume." } } } diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index 56457932..22ae99ac 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -13,6 +13,32 @@ "control_name": "Protects Data in Transit", "description": "Allowing unencrypted HTTP traffic to a storage account exposes data in transit to interception and tampering. CC6.7 requires that data transmitted over networks is protected using encryption. Enforcing HTTPS-only ensures all storage traffic is encrypted in transit." }, + "AZ-STOR-006": { + "control_id": "N/A-STOR-006", + "control_name": "Storage Account Shared-Key Authorization Enabled", + "description": "OpenShield checks this service-specific control without claiming an unrelated SOC 2 recommendation." + }, + "AZ-STOR-007": { + "control_id": "N/A-STOR-007", + "control_name": "Storage Account Allows TLS Below 1.2", + "description": "OpenShield checks this service-specific control without claiming an unrelated SOC 2 recommendation." + }, + "AZ-STOR-008": { + "control_id": "N/A-STOR-008", + "control_name": "Required Storage Customer-Managed Key Protection Missing", + "description": "OpenShield checks this service-specific control without claiming an unrelated SOC 2 recommendation." + }, + "AZ-STOR-009": { + "control_id": "N/A-STOR-009", + "control_name": "Required Blob Container Immutability Missing", + "description": "OpenShield checks this service-specific control without claiming an unrelated SOC 2 recommendation." + }, + "AZ-DB-005": {"control_id": "CC6.3", "control_name": "Logical access security", "description": "SQL authentication is restricted to approved Entra identities."}, + "AZ-DB-006": {"control_id": "CC7.1", "control_name": "Detection of security events", "description": "Required SQL vulnerability assessment is configured."}, + "AZ-DB-007": {"control_id": "CC7.2", "control_name": "System monitoring", "description": "SQL audit logs are retained according to policy."}, + "AZ-COSMOS-001": {"control_id": "CC6.3", "control_name": "Logical access security", "description": "Cosmos authentication is restricted to approved Entra identities."}, + "AZ-COSMOS-002": {"control_id": "CC6.6", "control_name": "Logical access security measures", "description": "Cosmos public network access is restricted according to policy."}, + "AZ-CACHE-001": {"control_id": "CC6.6", "control_name": "Logical access security measures", "description": "Managed cache access is private and uses approved TLS."}, "AZ-STOR-003": { "control_id": "CC8.1", "control_name": "Change Management", @@ -148,6 +174,11 @@ "control_name": "System Vulnerabilities are Identified and Managed", "description": "The virtual machine does not have automatic OS patching enabled. CC7.1 requires that vulnerabilities in system components are identified and managed through a defined process. Without automatic patching, known OS vulnerabilities are left unmitigated and exploitable." }, + "AZ-CMP-007": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "A VM has management ports (SSH/RDP) open to the internet with no Just-In-Time VM access policy covering them. CC6.6 requires that access from outside the network boundary is restricted. JIT opens management ports only for approved, time-boxed requests instead of continuously." + }, "AZ-KV-001": { "control_id": "A1.2", "control_name": "Environmental Threats and Recovery", @@ -171,7 +202,7 @@ "AZ-NET-012": { "control_id": "CC7.2", "control_name": "System monitoring", - "description": "Network Security Group flow logs support continuous monitoring of network traffic and investigation of anomalous connections. Without flow logs, network-level security events may not be detected or reconstructed." + "description": "A VNet flow log (or an existing legacy NSG flow log) supports continuous monitoring of network traffic and investigation of anomalous connections. New NSG flow log creation is blocked as of 2025-06-30, so VNet flow logs are the current mechanism. Without either, network-level security events may not be detected or reconstructed." }, "AZ-DB-003": { "control_id": "CC6.1", @@ -188,6 +219,11 @@ "control_name": "Risk Mitigation", "description": "A certificate stored in Azure Key Vault is expiring within 30 days with no auto-renewal configured. CC9.1 requires that identified risks are mitigated through controls that reduce the likelihood or impact of risk events. An expiring certificate without auto-renewal represents an unmitigated operational risk that will cause service outages if not addressed." }, + "AZ-KV-006": { + "control_id": "CC6.1", + "control_name": "Logical Access Security", + "description": "Key Vaults authorizing access through legacy vault access policies instead of Azure RBAC lack the scoped, role-based logical access controls CC6.1 requires. Access policies grant broad, per-permission-type access rather than least-privilege role assignments, increasing the risk of unauthorized access to secrets, keys, and certificates." + }, "AZ-DB-004": { "control_id": "CC6.6", "control_name": "Restricts Access from Outside the Network Boundary", @@ -287,6 +323,302 @@ "control_id": "CC6.3", "control_name": "Role-Based Access", "description": "Managed identities should not receive broad subscription roles beyond workload requirements." + }, + "AZ-BAK-001": { + "control_id": "A1.2", + "control_name": "Environmental Threats and Recovery", + "description": "The Recovery Services vault lacks the approved soft-delete recovery window, risking permanent loss of backup data before it can be restored." + }, + "AZ-BAK-002": { + "control_id": "A1.2", + "control_name": "Environmental Threats and Recovery", + "description": "Vault immutability is disabled, allowing destructive changes to protected recovery points and undermining the integrity of backup copies." + }, + "AZ-BAK-004": { + "control_id": "CC6.1", + "control_name": "Logical Access Security Measures", + "description": "The vault does not enable Resource Guard multiuser authorization, allowing a single compromised or malicious identity to disable backup protections unilaterally." + }, + "AZ-BAK-006": { + "control_id": "CC7.2", + "control_name": "System monitoring", + "description": "The Recovery Services vault does not enable built-in monitoring for backup job failures, so a failed or tampered backup could go undetected." + }, + "AZ-FUNC-001": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "The Function App accepts unencrypted HTTP traffic, allowing requests and responses to cross the network boundary without encryption in transit." + }, + "AZ-FUNC-002": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "The Function App permits TLS older than 1.2, weakening the network controls that protect traffic crossing the network boundary." + }, + "AZ-FUNC-003": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "The Function App exposes an FTP or FTPS deployment channel, widening the network attack surface beyond the primary HTTPS endpoint." + }, + "AZ-FUNC-004": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "Remote debugging expands the Function App management attack surface by opening an additional network-reachable control channel." + }, + "AZ-FUNC-005": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "The Function App has no Azure managed identity for secretless resource access, pushing workloads toward long-lived credentials that cross network and service boundaries." + }, + "AZ-PE-001": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "A Storage Account remains publicly reachable; an approved private endpoint alone does not disable its public endpoint, leaving the network boundary uncontrolled." + }, + "AZ-PE-002": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "An Azure SQL logical server remains publicly reachable, regardless of whether a private endpoint also exists, leaving the network boundary uncontrolled." + }, + "AZ-PE-003": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "A PostgreSQL Flexible Server remains publicly reachable instead of using private networking only, leaving the network boundary uncontrolled." + }, + "AZ-PE-004": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "An App Service workload remains publicly reachable without a default-deny access policy, leaving the network boundary uncontrolled." + }, + "AZ-PE-005": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "A Recovery Services vault permits public access, even if a private endpoint also exists, leaving the network boundary uncontrolled." + }, + "AZ-PE-006": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "A private endpoint connection is pending, rejected, or disconnected and does not provide an active private path, leaving traffic to traverse the public network boundary instead." + }, + "AZ-IDN-016": {"control_id": "CC6.1", "control_name": "Logical and Physical Access Controls", "description": "Privileged users must register phishing-resistant authentication methods."}, + "AZ-IDN-017": {"control_id": "CC6.3", "control_name": "Role-Based Access", "description": "Global Administrator access must be time-bound and governed through PIM."}, + "AZ-IDN-018": {"control_id": "CC6.3", "control_name": "Role-Based Access", "description": "All privileged role assignments must be managed through PIM with time and approval controls."}, + "AZ-IDN-019": {"control_id": "CC6.2", "control_name": "User Registration and Authorization", "description": "Dormant privileged accounts must be reviewed and access revoked."}, + "AZ-IDN-020": {"control_id": "CC6.3", "control_name": "Role-Based Access", "description": "Emergency access accounts ensure continuity of administrative access."}, + "AZ-IDN-021": {"control_id": "CC6.1", "control_name": "Logical and Physical Access Controls", "description": "Legacy authentication protocols that bypass MFA must be blocked."}, + "AZ-IDN-022": {"control_id": "CC6.6", "control_name": "Logical Access Security Measures", "description": "Azure management portal and API access must be protected by MFA."}, + "AZ-IDN-023": {"control_id": "CC7.2", "control_name": "System Monitoring", "description": "Identity Protection risk policies must detect and respond to anomalous authentication events."}, + "AZ-IDN-024": {"control_id": "CC6.3", "control_name": "Role-Based Access", "description": "Workload identities must not be broadly excluded from Conditional Access MFA requirements."}, + "AZ-IDN-025": {"control_id": "CC6.3", "control_name": "Role-Based Access", "description": "Role-assignable groups must have owners to control privileged access grants."}, + + "AZ-SC-001": { + "control_id": "CC6.1", + "control_name": "Logical Access Security Measures", + "description": "The Container Registry admin user is enabled, providing a shared credential that bypasses individual identity management and cannot be attributed to a single user." + }, + "AZ-SC-002": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "The Container Registry is reachable from the public internet, leaving the network boundary that protects the organization's built container images uncontrolled." + }, + "AZ-SC-003": { + "control_id": "CC6.1", + "control_name": "Logical Access Security Measures", + "description": "The Container Registry allows anonymous pull, letting any client access every image without an authenticated, individually attributable identity." + }, + "AZ-SC-004": { + "control_id": "CC7.1", + "control_name": "System Vulnerabilities are Identified and Managed", + "description": "The Container Registry has no retention or quarantine policy, so stale images accumulate and newly pushed images are deployable before any vulnerability scan evaluates them." + }, + "AZ-SC-005": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "A Terraform remote state container is publicly readable, leaving the network boundary around infrastructure layout and captured secrets uncontrolled." + }, + "AZ-SC-006": { + "control_id": "A1.2", + "control_name": "Environmental Threats and Recovery", + "description": "A storage account holding Terraform remote state has neither versioning nor soft delete enabled, so an overwritten or deleted state file cannot be recovered." + }, + "AZ-SC-007": { + "control_id": "CC6.1", + "control_name": "Logical Access Security Measures", + "description": "A pipeline service connection is scoped to the entire subscription rather than a single resource group, so every pipeline that uses it inherits subscription-wide access beyond what it needs." + }, + "AZ-SC-008": { + "control_id": "CC6.1", + "control_name": "Logical Access Security Measures", + "description": "A pipeline service connection authenticates with a stored service principal secret instead of a federated credential, leaving a static credential to rotate and potentially leak." + }, + "AZ-DL-001": { + "control_id": "CC6.7", + "control_name": "Restricts Transmission and Movement of Information", + "description": "MACsec protects traffic crossing the customer-visible ExpressRoute Direct Ethernet boundary." + }, + "AZ-DL-002": { + "control_id": "CC6.7", + "control_name": "Restricts Transmission and Movement of Information", + "description": "XPN MACsec provides suitable packet-number capacity for high-speed protected links." + }, + "AZ-NET-016": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "NIC IP forwarding is restricted to reviewed network virtual appliances and routing functions." + }, + "AZ-NET-017": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "User-defined default routes preserve approved inspected egress paths." + }, + "AZ-NET-018": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Private Link targets restrict unnecessary public network access." + }, + "AZ-NET-019": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Private Endpoint connections are approved and operational before they are relied upon as an access boundary." + }, + "AZ-NET-020": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Private DNS association preserves the approved private access path." + }, + "AZ-NET-021": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Private Endpoint ARM DNS configuration associates service names with private addresses; effective resolver-path validation remains separate evidence." + }, + "AZ-NET-022": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Critical PaaS resources restrict public access unless an approved exception exists." + }, + "AZ-NET-023": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Azure Firewall denies traffic involving infrastructure identified by threat intelligence." + }, + "AZ-NET-024": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Application Gateway WAF Prevention mode blocks matching malicious requests." + }, + "AZ-NET-025": { + "control_id": "CC7.2", + "control_name": "System monitoring", + "description": "Application Gateway SKU-supported diagnostic logs support anomaly monitoring; v2 performance telemetry is supplied through metrics." + }, + "AZ-NET-026": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Current application and bot managed rules protect the logical access boundary." + }, + "AZ-NET-027": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Rate-limit rules protect public application access from abusive request volume." + }, + "AZ-SECOPS-001": { + "control_id": "CC7.2", + "control_name": "System Monitoring", + "description": "The subscription's Activity Log is not exported to an approved central destination. CC7.2 requires the entity to monitor system components for anomalies; an unexported Activity Log removes the raw evidence that monitoring depends on." + }, + "AZ-SECOPS-002": { + "control_id": "CC7.2", + "control_name": "System Monitoring", + "description": "Required Activity Log categories are missing from the central export. CC7.2 requires monitoring to cover the events relevant to detecting security anomalies; a partial category export leaves gaps in what can be monitored." + }, + "AZ-SECOPS-003": { + "control_id": "CC7.2", + "control_name": "System Monitoring", + "description": "A critical resource lacks diagnostic settings exporting to an approved destination. CC7.2 requires monitoring of infrastructure and software for anomalies; a critical resource with no export is not being monitored at all." + }, + "AZ-SECOPS-004": { + "control_id": "CC7.2", + "control_name": "System Monitoring", + "description": "A security-relevant log export's retention is below the organisation's minimum. CC7.2's monitoring objective depends on evidence remaining available long enough to detect and investigate anomalies; retention below the organisation's minimum shortens that window." + }, + "AZ-SECOPS-005": { + "control_id": "CC7.2", + "control_name": "System Monitoring", + "description": "A critical resource's only log export sits in a destination its own workload administrators can modify. CC7.2 requires monitoring information to be reliable; a destination the monitored workload's own administrators can alter undermines that reliability." + }, + "AZ-SECOPS-006": { + "control_id": "CC7.1", + "control_name": "Detection and Monitoring of New Vulnerabilities", + "description": "A required Microsoft Defender for Cloud plan is not enabled for a critical workload type. CC7.1 requires the entity to use detection and monitoring procedures to identify changes and vulnerabilities; Defender for Cloud is the mechanism providing that detection for the affected workload type." + }, + "AZ-SECOPS-007": { + "control_id": "CC7.1", + "control_name": "Detection and Monitoring of New Vulnerabilities", + "description": "A High-severity Defender recommendation remains unresolved beyond the organisation's SLA. CC7.1 requires identified vulnerabilities to be evaluated and addressed; an SLA breach indicates the vulnerability-management process required by CC7.1 is not operating effectively." + }, + "AZ-SECOPS-008": { + "control_id": "CC7.2", + "control_name": "System Monitoring", + "description": "A required Sentinel data connector is missing or unhealthy. CC7.2 requires monitoring of system components for anomalies; a disconnected connector is a monitored source that has stopped contributing data without detection." + }, + "AZ-SECOPS-009": { + "control_id": "CC7.2", + "control_name": "System Monitoring", + "description": "Sentinel lacks enabled High-severity analytics coverage for a required detection use case. CC7.2 requires monitoring to actually evaluate collected data for anomalies; ingested logs with no analytics rule evaluating a known high-risk pattern do not fulfil that requirement for that use case." + }, + "AZ-SECOPS-010": { + "control_id": "CC7.4", + "control_name": "Incident Response", + "description": "No monitored destination exists for security alerts or Sentinel incidents. CC7.4 requires the entity to respond to identified security incidents; an alert nobody is notified of cannot trigger the incident-response process CC7.4 requires." + }, + "AZ-NET-018": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Private Link targets restrict unnecessary public network access." + }, + "AZ-NET-019": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Private Endpoint connections are approved and operational before they are relied upon as an access boundary." + }, + "AZ-NET-020": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Private DNS association preserves the approved private access path." + }, + "AZ-NET-021": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Private Endpoint service names resolve to private addresses within the approved access boundary." + }, + "AZ-NET-022": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Critical PaaS resources restrict public access unless an approved exception exists." + }, + "AZ-NET-023": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Azure Firewall denies traffic involving infrastructure identified by threat intelligence." + }, + "AZ-NET-024": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Application Gateway WAF Prevention mode blocks matching malicious requests." + }, + "AZ-NET-025": { + "control_id": "CC7.2", + "control_name": "System monitoring", + "description": "Application Gateway access, performance, and firewall logs support anomaly monitoring." + }, + "AZ-NET-026": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Current application and bot managed rules protect the logical access boundary." + }, + "AZ-NET-027": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Rate-limit rules protect public application access from abusive request volume." } } } diff --git a/config/security-operations-policy.example.json b/config/security-operations-policy.example.json new file mode 100644 index 00000000..4d6da436 --- /dev/null +++ b/config/security-operations-policy.example.json @@ -0,0 +1,32 @@ +{ + "critical_resource_types": [ + "Microsoft.KeyVault/vaults", + "Microsoft.Sql/servers", + "Microsoft.Storage/storageAccounts" + ], + "approved_destination_ids": [ + "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/security/providers/Microsoft.OperationalInsights/workspaces/central-security" + ], + "required_activity_categories": [ + "Administrative", + "Security", + "Policy", + "ServiceHealth" + ], + "minimum_retention_days": 90, + "required_defender_plans": [ + "VirtualMachines", + "StorageAccounts", + "SqlServers" + ], + "defender_recommendation_sla_days": 30, + "required_sentinel_connectors": [ + "AzureActiveDirectory", + "AzureSecurityCenter" + ], + "required_high_severity_analytics": [ + "privileged-role-change", + "credential-abuse" + ], + "approved_exclusions": [] +} diff --git a/contracts/severity.v1.json b/contracts/severity.v1.json new file mode 100644 index 00000000..344a14a9 --- /dev/null +++ b/contracts/severity.v1.json @@ -0,0 +1,54 @@ +{ + "contract": "openshield.finding-severity", + "version": "1.0.0", + "aliases": { + "INFORMATIONAL": "INFO" + }, + "levels": [ + { + "id": "CRITICAL", + "rank": 4, + "score_weight": 20, + "risk_score": 10, + "label": "Critical", + "color": "#b91c1c", + "tone": "critical" + }, + { + "id": "HIGH", + "rank": 3, + "score_weight": 10, + "risk_score": 8, + "label": "High", + "color": "#ef4444", + "tone": "danger" + }, + { + "id": "MEDIUM", + "rank": 2, + "score_weight": 5, + "risk_score": 5, + "label": "Medium", + "color": "#f97316", + "tone": "warning" + }, + { + "id": "LOW", + "rank": 1, + "score_weight": 2, + "risk_score": 2, + "label": "Low", + "color": "#10b981", + "tone": "success" + }, + { + "id": "INFO", + "rank": 0, + "score_weight": 0, + "risk_score": 1, + "label": "Info", + "color": "#6b7280", + "tone": "neutral" + } + ] +} diff --git a/docker-compose.yml b/docker-compose.yml index aca8bc89..70c73834 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,7 @@ -version: '3.8' - services: db: - image: postgres:15-alpine + profiles: ["local"] + image: postgres:16-alpine read_only: true security_opt: - no-new-privileges:true @@ -16,28 +15,65 @@ services: volumes: - postgres_data:/var/lib/postgresql/data ports: - - "5432:5432" + - "127.0.0.1:5432:5432" healthcheck: - test: ["CMD-SHELL", "pg_isready -U openshield"] + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] interval: 10s timeout: 5s retries: 5 api: + profiles: ["local"] build: . ports: - - "8000:8000" + - "127.0.0.1:8000:8000" environment: + PORT: "8000" + OPENSHIELD_ENV: development DATABASE_URL: postgresql://openshield:openshield@db:5432/openshield - JWT_SECRET: change-me-in-production + JWT_SECRET: local-development-only-secret-change-me + ALLOWED_ORIGINS: http://localhost:5173 ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} NVD_API_KEY: ${NVD_API_KEY:-} depends_on: db: condition: service_healthy + healthcheck: + test: + - CMD + - python + - -c + - >- + import json, urllib.request; + payload = json.load(urllib.request.urlopen('http://127.0.0.1:8000/ready', timeout=5)); + assert payload.get('status') == 'ready' + interval: 10s + timeout: 6s + retries: 6 + start_period: 30s + + worker: + profiles: ["local"] + build: . + command: ["python", "-m", "scanner.worker"] + environment: + OPENSHIELD_ENV: development + DATABASE_URL: postgresql://openshield:openshield@db:5432/openshield + AZURE_SUBSCRIPTION_ID: ${AZURE_SUBSCRIPTION_ID:-} + AZURE_CLIENT_ID: ${AZURE_CLIENT_ID:-} + AZURE_CLIENT_SECRET: ${AZURE_CLIENT_SECRET:-} + AZURE_TENANT_ID: ${AZURE_TENANT_ID:-} + AZURE_DEVOPS_ORG_URL: ${AZURE_DEVOPS_ORG_URL:-} + AZURE_DEVOPS_PROJECT: ${AZURE_DEVOPS_PROJECT:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + NVD_API_KEY: ${NVD_API_KEY:-} + depends_on: + api: + condition: service_healthy frontend: - image: node:20-alpine + profiles: ["local"] + image: node:22.22.0-alpine read_only: true security_opt: - no-new-privileges:true @@ -46,11 +82,17 @@ services: working_dir: /app volumes: - ./frontend:/app - command: sh -c "npm install && npm run dev -- --host" + - frontend_node_modules:/app/node_modules + command: ["sh", "-c", "npm ci && npm run dev -- --host 0.0.0.0"] ports: - - "5173:5173" + - "127.0.0.1:5173:5173" environment: - VITE_API_BASE_URL: http://localhost:8000 + VITE_API_URL: http://localhost:8000 + NPM_CONFIG_CACHE: /tmp/.npm + depends_on: + api: + condition: service_healthy volumes: postgres_data: + frontend_node_modules: diff --git a/docs/access-continuity.md b/docs/access-continuity.md new file mode 100644 index 00000000..f366598c --- /dev/null +++ b/docs/access-continuity.md @@ -0,0 +1,37 @@ +# Project Access Continuity + +OpenShield's public component ownership is recorded in `.github/CODEOWNERS`. +OpenSSF continuity requires more than public names: organization owners must +verify that the project can continue if any one person becomes unavailable. + +## Required capability matrix + +At least two currently available people must independently be able to perform +each critical capability, or use a tested organization-controlled recovery +process: + +| Capability | Primary confirmed | Backup confirmed | Last tested | +|---|---|---|---| +| Triage and close issues | Owner record | Owner record | Date required | +| Review and merge approved changes | Owner record | Owner record | Date required | +| Publish and verify a release | Owner record | Owner record | Date required | +| Recover GitHub organization access | Owner record | Owner record | Date required | +| Manage production deployment access | Owner record | Owner record | Date required | +| Manage domain/DNS access, if applicable | Owner record | Owner record | Date required | +| Rotate security-reporting access | Owner record | Owner record | Date required | + +Names and recovery details may remain in a private owner-controlled record when +publishing them would increase risk. The public OpenSSF justification should +state the date of verification and that two independent holders were confirmed, +without exposing secrets. + +## Review process + +- Review the matrix at least every six months and before each major release. +- Remove access promptly when a role ends and confirm the backup remains valid. +- Test recovery without sharing credentials between individuals. +- Store recovery material outside any single maintainer's personal account. +- Record the review in issue #205 or another auditable owner-approved record. + +The continuity and bus-factor criteria must remain pending until an organization +owner completes and records this verification. Documentation alone is not proof. diff --git a/docs/accessibility-audit.md b/docs/accessibility-audit.md new file mode 100644 index 00000000..026cfaab --- /dev/null +++ b/docs/accessibility-audit.md @@ -0,0 +1,38 @@ +# Accessibility Audit + +Assessment date: 16 July 2026. Scope: React dashboard and static project +website. Target: practical alignment with WCAG 2.2 AA; this is not a formal +conformance certification. + +## Controls added + +- A keyboard-visible skip link targets the dashboard's main content. +- Primary navigation and mobile navigation have accessible names. +- Decorative navigation icons are hidden from assistive technology. +- Icon-only close and status actions have accessible labels. +- Popovers and connection errors expose dialog semantics and names. +- Scan results and backend connectivity expose polite live status updates. +- The off-screen mobile navigation is inert while closed. +- A source-level CI check rejects positive tab order, non-semantic clickable + `div`/`span` elements, missing image alternative text, and missing document + language. + +## Keyboard review + +The expected keyboard path is: skip link, mobile menu when present, language +selector, scan control, primary navigation, then page content. Native buttons, +links, inputs and selects retain browser focus behavior. Escape handling remains +available in the scan input. A full screen-reader/browser matrix remains a +release-quality follow-up rather than a claim made by this audit. + +## Known limitations + +- Some data visualizations need separate screen-reader summaries as their + components evolve. +- Focus trapping and restoration for every future modal must be checked during + component review. +- Colour contrast should be rechecked whenever theme tokens change. +- The static website has its own editing surface and needs repeat manual review + when that interface changes. + +Run `npm run test:a11y` from `frontend/` for the automated source checks. diff --git a/docs/adding-a-rule.md b/docs/adding-a-rule.md index e62f43b0..5da5d29b 100644 --- a/docs/adding-a-rule.md +++ b/docs/adding-a-rule.md @@ -24,27 +24,26 @@ logger = logging.getLogger(__name__) # ── Required module-level constants ───────────────────────────────────────── -RULE_ID = "AZ-XXXX-000" # Unique ID. Check existing rules to avoid clashes. -RULE_NAME = "Human-readable name" # Shown in the dashboard and reports. -SEVERITY = "HIGH" # HIGH | MEDIUM | LOW | INFO -CATEGORY = "Storage" # Storage | Network | Identity | Database | Compute | Key Vault | Kubernetes +RULE_ID = "AZ-XXXX-000" # Unique ID. Check existing rules to avoid clashes. +RULE_NAME = "Human-readable name" # Shown in the dashboard and reports. +SEVERITY = "HIGH" # CRITICAL | HIGH | MEDIUM | LOW | INFO +CATEGORY = "Storage" # Storage | Network | Identity | Database | Compute | Key Vault | Kubernetes FRAMEWORKS = { - "CIS": "3.5", # CIS Azure Benchmark control ID - "NIST": "PR.AC-3", # NIST CSF subcategory - "ISO27001": "A.9.4.1", # ISO 27001 Annex A control + "CIS": "3.5", # CIS Azure Benchmark control ID + "NIST": "PR.AC-3", # NIST CSF subcategory + "ISO27001": "A.9.4.1", # ISO 27001 Annex A control } DESCRIPTION = ( "Explain WHY this is a security risk. One or two sentences. " "What can an attacker do if this misconfiguration exists?" ) -REMEDIATION = ( - "Explain HOW to fix it. What setting to change, or what command to run." -) +REMEDIATION = "Explain HOW to fix it. What setting to change, or what command to run." PLAYBOOK = "playbooks/cli/fix_az_xxxx_000.sh" # path to the matching fix script # ── Required scan function ─────────────────────────────────────────────────── + def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: """Return a list of findings. Return [] if no issues are found. @@ -74,20 +73,22 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: continue if status is False: - findings.append({ - "rule_id": RULE_ID, - "rule_name": RULE_NAME, - "severity": SEVERITY, - "category": CATEGORY, - "resource_id": resource_id, - "resource_name": resource_name, - "resource_type": "Microsoft.Storage/storageAccounts", # ← update - "description": DESCRIPTION, - "remediation": REMEDIATION, - "playbook": PLAYBOOK, - "frameworks": FRAMEWORKS, - "metadata": {}, - }) + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": resource_id, + "resource_name": resource_name, + "resource_type": "Microsoft.Storage/storageAccounts", # ← update + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {}, + } + ) return findings ``` @@ -99,7 +100,7 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: | Field | What to write | |---|---| | `RULE_ID` | `AZ-[CATEGORY]-[NUMBER]`. Prefix map: STOR, NET, IDN, DB, CMP, KV. Look at existing rules for the next number. | -| `SEVERITY` | `HIGH` = direct exploitation risk, `MEDIUM` = indirect or partial risk, `LOW` = best practice, `INFO` = informational only | +| `SEVERITY` | Use the canonical [finding severity contract](severity-contract.md): `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, or `INFO` | | `CATEGORY` | Matches the resource type being scanned | | `FRAMEWORKS` | Use real CIS, NIST, and ISO 27001 control IDs. SOC 2 is mapped in `compliance/frameworks/soc2.json`. | | `DESCRIPTION` | Focus on WHY it matters — what is the real-world attack scenario? | @@ -130,12 +131,47 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: | `azure_client.get_subscription_role_assignments()` | Subscription RBAC assignments, or `None` on API failure | | `azure_client.get_service_principals()` | List of RoleAssignment objects for service principals | | `azure_client.get_conditional_access_policies()` | List of CA policy dicts from MS Graph | +| `azure_client.get_function_app_security_posture()` | Cached, secret-free Function App posture dicts, or `None` on API failure | +| `azure_client.get_private_endpoint_posture()` | Public-access and approved Private Link state for supported PaaS resources, or `None` on API failure | +| `azure_client.get_recovery_vault_security_posture()` | Cached Recovery Services vault security settings, or `None` on API failure | +| `azure_client.get_container_registries()` | List of ACR Registry objects, or `None` on API failure | +| `azure_client.get_blob_containers(rg, account)` | List of blob container items (with `public_access`), or `None` on API failure | +| `azure_client.get_blob_service_properties(rg, account)` | BlobServiceProperties (versioning, soft delete), or `None` on API failure | +| `azure_client.devops_client` | `DevOpsClient` instance, or `None` if `AZURE_DEVOPS_ORG_URL`/`AZURE_DEVOPS_PROJECT` are not configured | +| `azure_client.devops_client.get_service_endpoints()` | List of Azure DevOps service connections, or `None` on API failure | | `azure_client.parse_resource_id(id)` | Dict with `resource_group` and `name` | List methods return an empty list on failure. Single-resource methods return `None` when the resource cannot be fetched. Three-state checks, such as `get_storage_lifecycle_policy()`, return `True` for compliant, `False` for non-compliant, and `None` when the scanner cannot determine the state. When a helper returns `None`, skip the resource and log a warning. Never create a finding from an unknown state. +`azure_client.devops_client` is `None` whenever Azure DevOps is not configured for the scanned subscription — treat that the same as "not applicable" and return no findings, not as an indeterminate failure. + +--- + +## Optional: Reporting Evaluation Coverage (`evaluate()`) + +`scan()` only ever reports violations, so a scan with no findings for your rule is indistinguishable from "everything is compliant," "nothing of this resource type exists," and "the rule errored before it could check anything." A rule can additionally expose: + +```python +from scanner.evaluation import EvaluationStatus, RuleEvaluation, subscription_scope_id + + +def evaluate(azure_client: Any, subscription_id: str) -> List[RuleEvaluation]: + """Report a status for every resource this rule looked at, PASS included.""" +``` + +to state a `PASS`/`FAIL`/`UNKNOWN`/`ERROR`/`NOT_APPLICABLE` result per resource instead of only per violation. This is additive: `scan()` keeps working unchanged, and a rule without `evaluate()` still runs, its coverage is just recorded as `UNKNOWN`/`LEGACY_RULE_NOT_MIGRATED` rather than assumed to be a pass. + +Rules of the contract (see `scanner/evaluation.py` and `scanner/rules/az_kv_006.py` for the reference implementation): + +- `resource_id` must be a real, non-empty identifier. For a subscription-level result with no single resource to blame, use `subscription_scope_id(subscription_id)`, never `""`. +- `UNKNOWN`, `ERROR`, and `NOT_APPLICABLE` require a `reason_code` explaining why — never leave one unexplained. +- A `FAIL` result may attach `finding=` with the same dict shape `scan()` returns; the engine deduplicates it against anything `scan()` already reported for the same `(rule_id, resource_id)`, so implementing both never double-counts. +- If you can't tell "no resources of this type exist" apart from "the list call failed" (a real gap in some `AzureClient` methods today), report `NOT_APPLICABLE` rather than guessing `PASS`. + +You don't need to migrate an existing rule's `scan()` to add `evaluate()` — most rules can leave `scan()` exactly as-is. + --- ## Write the Remediation Playbook diff --git a/docs/api-reference.md b/docs/api-reference.md index 82a2084d..0ddbf8fd 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1,12 +1,38 @@ # API Reference -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`. - -The OpenShield API is a Flask app registered in `api/app.py`. +The OpenShield API is a Flask app registered in `api/app.py`. By default, every +`/api/*` route requires an `Authorization: Bearer ` header signed with +`JWT_SECRET`; only the explicitly listed health and observability endpoints are +public. Read-only API routes become public only when the deliberate demo-mode +setting is enabled. ## Authentication -`/health` and `/` are always public. All other routes — including all `/api/*` GET endpoints — require an `Authorization: Bearer ` header signed with `JWT_SECRET`. +`/`, `/health`, `/ready`, and `/metrics` are always public. All other routes — including all `/api/*` GET endpoints — require an `Authorization: Bearer ` header signed with `JWT_SECRET`. + +Every accepted token must carry: + +- `exp` — a token with no expiry is rejected outright. There is no way to mint a permanently-valid token; regenerate before it expires. +- `role` — one of `viewer`, `operator`, or `admin`. A missing or unrecognized role is treated the same as an invalid signature (`401`). + +`viewer` is read-only: any non-`GET`/`HEAD` request (scan trigger, AI endpoints) from a `viewer` token is rejected with `403`, regardless of demo mode. Only `operator` and `admin` may perform a write. This is enforced in `api/app.py`'s JWT middleware, not per-route, so it applies uniformly to every current and future write endpoint. + +`scripts/generate_demo_jwt.py` mints a `viewer` token with a bounded expiry (`DEMO_JWT_TTL_HOURS`, default 24h) — see the script's own docstring before embedding one as `VITE_JWT_TOKEN`. + +### Subscription authorization + +`POST /api/scans/trigger` also checks `subscription_id` against `OPENSHIELD_AUTHORIZED_SUBSCRIPTIONS`, a comma-separated allowlist. A valid `operator`/`admin` token can otherwise trigger a scan against *any* subscription_id — role alone doesn't say which subscription a caller is entitled to. Left unset, every subscription_id is accepted (matches historical behavior); the API logs a loud startup warning when it's unset. This is a single-tenant containment boundary, not a substitute for real per-tenant authorization — see issue #294 for the full multi-tenant/OIDC scope this is a stopgap for. + +## Input limits + +- Request bodies are limited to 2 MiB. +- Scan and subscription identifiers use canonical UUID format. +- Finding filters accept only `severity`, `category`, `rule_id`, and `scan_id`; + unknown or repeated parameters return `400`. +- AI routes accept a supported provider, an API key of at most 4,096 characters, + an optional model identifier of at most 128 characters, questions of at most + 4,000 characters, and at most 1,000 finding objects. +- Full boundary details are maintained in `docs/input-validation-audit.md`. ### Public demo mode @@ -44,7 +70,7 @@ Query parameters: | Name | Description | |---|---| -| `severity` | `HIGH`, `MEDIUM`, `LOW`, or `INFO` | +| `severity` | `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, or `INFO` (`INFORMATIONAL` is normalized to `INFO`) | | `category` | Rule category, such as `Storage`, `Network`, `Identity`, `Database`, `Compute`, or `Key Vault` | | `rule_id` | Rule ID, such as `AZ-STOR-001` | | `scan_id` | UUID of a specific scan | @@ -206,7 +232,7 @@ Missing subscription response: ## GET /api/score -Returns the overall security posture score from 0 to 100. The score starts at 100 and deducts 10 per HIGH finding, 5 per MEDIUM finding, and 2 per LOW finding. +Returns the overall security posture score from 0 to 100. Under [severity contract v1](severity-contract.md), the score starts at 100 and deducts 20 per CRITICAL finding, 10 per HIGH finding, 5 per MEDIUM finding, and 2 per LOW finding. INFO findings deduct zero. Query parameters: none @@ -281,7 +307,7 @@ Example response: "summary": { "total": 12, "by_category": { "Storage": 3, "Network": 4, "Identity": 3, "Database": 2 }, - "by_risk_level": { "HIGH": 4, "MEDIUM": 6, "LOW": 2 }, + "by_risk_level": { "CRITICAL": 1, "HIGH": 3, "MEDIUM": 6, "LOW": 2, "INFO": 0, "NONE": 0 }, "last_scan_at": "2026-06-03T15:12:51Z" }, "resources": [ diff --git a/docs/architecture.md b/docs/architecture.md index 2249851f..e03539e1 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 51 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. +OpenShield is a modular, open source Cloud Security Posture Management (CSPM) platform for Azure. It scans your Azure subscription against 80 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. --- @@ -43,8 +43,9 @@ OpenShield is a modular, open source Cloud Security Posture Management (CSPM) pl ┌───────────▼──────────────────────────────────────────────────────┐ │ Rule Modules (scanner/rules/) │ │ │ -│ 51 rule files across Storage, Network, Identity, Database, │ -│ Compute, Key Vault, AKS, and post-quantum cryptography │ +│ 80 rule files across Storage, Network, Identity, Database, │ +│ Compute, Key Vault, AKS, post-quantum cryptography, Backup, │ +│ Serverless, Private Endpoint posture, and Supply Chain │ └───────────┬───────────────────────────────────────────────────────┘ │ calls ┌───────────▼──────────────────────────────────────────────────────┐ @@ -110,18 +111,24 @@ result = engine.run_scan() ### 4. Current Rule Modules -There are 51 rule files in `scanner/rules/`. See `docs/rules-reference.md` for the full table. +There are 80 rule files in `scanner/rules/`. See `docs/rules-reference.md` for the full table. | Category | Count | Rules | |---|---|---| -| Storage | 5 | AZ-STOR-001 to 005 | +| Storage | 9 | AZ-STOR-001 to 009 | | Network | 15 | AZ-NET-001 to 015 | -| Identity | 4 | AZ-IDN-001 to 004 | -| Database | 4 | AZ-DB-001 to 004 | +| Identity | 25 | AZ-IDN-001 to 025 | +| Database | 7 | AZ-DB-001 to 007 | +| Cosmos DB | 2 | AZ-COSMOS-001 to 002 | +| Managed Cache | 1 | AZ-CACHE-001 | | Compute | 4 | AZ-CMP-001 to 004 | | Key Vault | 5 | AZ-KV-001 to 005 | | Kubernetes | 6 | AZ-AKS-001 to 006 | | Post-quantum | 3 | AZ-PQC-001 to 003 | +| Backup | 4 | AZ-BAK-001, 002, 004, 006 | +| Serverless | 5 | AZ-FUNC-001 to 005 | +| Private Endpoint | 6 | AZ-PE-001 to 006 | +| Supply Chain | 8 | AZ-SC-001 to 008 | Every rule has a matching Azure CLI playbook in `playbooks/cli/`. @@ -131,20 +138,20 @@ Every finding returned by a rule must conform to this schema: ```python { - "rule_id": str, # e.g. "AZ-STOR-001" - "rule_name": str, - "severity": str, # HIGH | MEDIUM | LOW | INFO - "category": str, # Storage | Network | Identity | Database | Compute | Key Vault - "resource_id": str, # full Azure resource ID + "rule_id": str, # e.g. "AZ-STOR-001" + "rule_name": str, + "severity": str, # CRITICAL | HIGH | MEDIUM | LOW | INFO + "category": str, # Storage | Network | Identity | Database | Compute | Key Vault + "resource_id": str, # full Azure resource ID "resource_name": str, - "resource_type": str, # e.g. "Microsoft.Storage/storageAccounts" - "description": str, - "remediation": str, - "playbook": str, # path to the CLI remediation script - "frameworks": dict, # {"CIS": "3.5", "NIST": "PR.AC-3", "ISO27001": "A.9.4.1"} - "metadata": dict, # optional rule-specific context - "detected_at": str, # ISO 8601, added by engine - "scan_id": str, # UUID, added by engine + "resource_type": str, # e.g. "Microsoft.Storage/storageAccounts" + "description": str, + "remediation": str, + "playbook": str, # path to the CLI remediation script + "frameworks": dict, # {"CIS": "3.5", "NIST": "PR.AC-3", "ISO27001": "A.9.4.1"} + "metadata": dict, # optional rule-specific context + "detected_at": str, # ISO 8601, added by engine + "scan_id": str, # UUID, added by engine } ``` @@ -179,7 +186,8 @@ run_scan() → 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) + total_findings, score (severity-weighted 0-100), + severity_contract_version → findings rows: one per finding with full metadata + CVE fields → return scan result JSON @@ -192,7 +200,7 @@ GET /api/findings → returns { count, findings[] } GET /api/score - → db.get_score() # severity-weighted: HIGH -10, MEDIUM -5, LOW -2 + → db.get_score() # contract v1: CRITICAL -20, HIGH -10, MEDIUM -5, LOW -2 → returns plain integer (e.g. 18) GET /api/resources @@ -247,9 +255,10 @@ The flow: 2. Use the second CLI argument as `scan_id`, or generate one from the current UTC timestamp. 3. Accept either a raw findings list or an object with a `findings` array. 4. Normalise each finding into Sentinel-friendly fields such as `RuleId`, `RuleName`, `Severity`, `SeverityScore`, `ResourceId`, and `TimeGenerated`. -5. HMAC-sign the payload with `SENTINEL_SHARED_KEY`. -6. POST the records to the Log Analytics Data Collector API. -7. Query and analytics rules in `sentinel/rules/` operate on `OpenShieldFindings_CL`. +5. Validate the complete batch before sending it. If configuration or any record is invalid, reject the entire batch with a concise error and send no records. +6. HMAC-sign the payload with `SENTINEL_SHARED_KEY`. +7. POST the records to the Log Analytics Data Collector API. +8. Query and analytics rules in `sentinel/rules/` operate on `OpenShieldFindings_CL`. Required environment variables: diff --git a/docs/assets/openshield-logo-dark.png b/docs/assets/openshield-logo-dark.png new file mode 100644 index 00000000..2d4a1538 Binary files /dev/null and b/docs/assets/openshield-logo-dark.png differ diff --git a/docs/assets/openshield-logo-light.png b/docs/assets/openshield-logo-light.png new file mode 100644 index 00000000..5d679adc Binary files /dev/null and b/docs/assets/openshield-logo-light.png differ diff --git a/docs/azure-setup.md b/docs/azure-setup.md index 63e345ea..36d21f93 100644 --- a/docs/azure-setup.md +++ b/docs/azure-setup.md @@ -282,6 +282,27 @@ The AZ-DB-002 remediation playbook writes SQL audit logs to a storage account. T --- +## Step 10 — Configure Azure DevOps Pipeline Scanning (Optional) + +AZ-SC-007 and AZ-SC-008 check Azure DevOps pipeline service connections for +subscription-wide sharing and password-based authentication. Azure DevOps is +a separate system from Azure Resource Manager, so it needs two additional +environment variables. Both must be set or neither rule will produce +findings — this is treated as "not applicable," not an error. + +```bash +AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-org +AZURE_DEVOPS_PROJECT=your-project-name +``` + +The scanner reuses the same service principal configured in Step 3, requesting +a token scoped to Azure DevOps' well-known resource ID +(`499b84ac-1321-427f-aa17-267ca6975798`). Grant that service principal at +least **Reader** access to the target Azure DevOps project's service +connections (Project Settings > Service connections > Security). + +--- + ## Troubleshooting | Problem | Fix | @@ -291,3 +312,4 @@ The AZ-DB-002 remediation playbook writes SQL audit logs to a storage account. T | `psycopg2.OperationalError` | Check your PostgreSQL container is running and `DATABASE_URL` is correct | | Empty findings | Verify the service principal has `Reader` role on the subscription | | AZ-IDN-002 always fires | The service principal needs `Policy.Read.All` Graph permission — see Step 4 | +| AZ-SC-007/008 never fire | Confirm `AZURE_DEVOPS_ORG_URL` and `AZURE_DEVOPS_PROJECT` are both set — see Step 10 | diff --git a/docs/ci-pipeline.md b/docs/ci-pipeline.md index 0b5c015b..efa2de4f 100644 --- a/docs/ci-pipeline.md +++ b/docs/ci-pipeline.md @@ -19,7 +19,7 @@ This document explains each job, how to reproduce every check locally before ope | **SAST (Semgrep)** | `semgrep scan --config p/security-audit --config p/owasp-top-ten --config p/python --config p/javascript --error` | A finding from the security-audit / OWASP Top 10 / Python / JS rulesets | | **SCA (pip-audit)** | `pip-audit -r requirements.txt` | A dependency with a known CVE (minus documented ignores) | | **SBOM (Syft)** | CycloneDX SBOM generated + uploaded as an artifact | SBOM generation error | -| **Container Scan (Trivy)** | Dormant scaffold — skips until a `Dockerfile` exists (INFRA 1 / #154) | (nothing today) | +| **Container Runtime + Scan (Trivy)** | Builds one image, starts that tag against PostgreSQL, requires `/ready`, verifies its runtime UID is non-root, then scans the same tag | Boot failure, database-readiness failure, root runtime, or an unfixed policy-blocking vulnerability | | **Backend Tests (pytest + coverage)** | Full `tests/` suite against an ephemeral Postgres, `--cov-fail-under=80` | A failing test or coverage below the Silver-level floor | | **Frontend (lint + build)** | `npm ci` → `eslint` → `vite build` | An eslint error or a broken dashboard build | | **Enforce dev to main source** | `main` PRs must come from `dev` | A non-`dev` branch opening a PR into `main` | @@ -27,9 +27,9 @@ This document explains each job, how to reproduce every check locally before ope `.github/workflows/codeql.yml` (separate workflow, PRs to `dev`/`main` + weekly cron): **Analyze (python)** and **Analyze (javascript)** — CodeQL semantic/taint analysis. -`.github/workflows/sbom-release.yml` (triggered `on: release: published`): generates a CycloneDX SBOM from the tagged code and uploads it to the GitHub Release assets. +`.github/workflows/release.yml` (triggered by `v*` tags): requires a verified signed annotated tag, builds a deterministic source archive and CycloneDX SBOM, publishes SHA-256 checksums and identity-bound provenance attestations, then creates the GitHub Release. -The **Container Scan** job is intentionally **not** a required check yet: no `Dockerfile` exists, so it has nothing to scan. It activates automatically once INFRA 1 (#154) adds one. +The **Container Runtime + Scan** job is a required gate. Deleting the Dockerfile, breaking the WSGI target, losing database connectivity, running as root, or failing the Trivy policy makes the final CI summary fail. --- @@ -136,6 +136,20 @@ Runs the **entire** `tests/` suite once (not just rule tests). Tests requiring a cd frontend && npm ci && npm run lint && npm run build ``` +### Container runtime + vulnerability scan + +The CI job builds a single commit-tagged image, starts that exact tag as a non-root user against PostgreSQL 16, and polls the database-aware `/ready` endpoint before passing the same tag to Trivy. It always removes the smoke container, prints its logs when runtime verification fails, and still runs Trivy when a successfully built image fails to boot. + +For a local end-to-end runtime check: + +```bash +docker compose --profile local up --build +curl --fail http://127.0.0.1:8000/ready +docker compose --profile local down +``` + +Compose's `local` profile is not production configuration. Its credentials are development-only, all exposed ports bind to loopback, API startup applies Alembic migrations, and the worker waits for API readiness before polling the shared database. + --- ## Branch protection and the promotion flow @@ -188,7 +202,7 @@ It catches the deletion case: a rule file removed but its compliance-JSON entry ## The CI summary -The `ci-summary` job uses `needs: [...]` + `if: always()` so it runs after every other job regardless of outcome, reads each job's `result`, and writes a markdown pass/fail table to `$GITHUB_STEP_SUMMARY` (rendered on the Actions run page). A final step fails the job if any **required** job failed — Container Scan is excluded from that gate until it has an image to scan. +The `ci-summary` job uses `needs: [...]` + `if: always()` so it runs after every other job regardless of outcome, reads each job's `result`, and writes a markdown pass/fail table to `$GITHUB_STEP_SUMMARY` (rendered on the Actions run page). A final step fails the job if any required job failed, including the container runtime and Trivy result. --- @@ -202,6 +216,8 @@ The `ci-summary` job uses `needs: [...]` + `if: always()` so it runs after every | `pip-audit` reports a CVE | Bump the pin to a fixed version; only add `--ignore-vuln` with a documented reason | | `pytest` coverage below 80% | Add tests, or investigate the regression that removed coverage | | Frontend eslint error | Fix the reported rule (e.g. remove an unused import); warnings do not fail CI | +| Container runtime or `/ready` fails | Check the Docker entrypoint, PostgreSQL connection, migration/startup logs, and non-root user; CI prints the container logs on failure | +| Trivy reports a blocking image CVE | Update the base image or affected package and rebuild; do not bypass the required container job | | `Enforce dev to main source` fails | Open the PR from `dev`; merge feature work into `dev` first | | `missing field 'RULE_ID'` | Add `RULE_ID = "AZ-XXX-000"` at module level in the rule file | | `DUPLICATE RULE_ID '...'` | Assign a unique ID to the newer rule file | diff --git a/docs/container-release-integrity.md b/docs/container-release-integrity.md new file mode 100644 index 00000000..f7f18379 --- /dev/null +++ b/docs/container-release-integrity.md @@ -0,0 +1,109 @@ +# Container release integrity + +This is the container-publication slice of #304. It does not claim that the +deployment topology, restore drills or complete enterprise release criteria are +finished. #336 separately addresses Python dependency locking. + +## Release path + +1. A stable `vMAJOR.MINOR.PATCH` tag push starts `release.yml`. Branch pushes, + manual dispatch, lightweight tags, unverified signatures and nested tags do + not qualify. The tag must directly identify the event/checkout commit, and + that commit must be on the fetched `main` history. +2. Source artifacts are built from that immutable commit, attested and published + through the signed-release job. The tag object is checked again before + publishing source assets. +3. Only after the source job succeeds can its dependent reusable `docker.yml` + job run. It requires owner opt-in via the repository variable + `CONTAINER_RELEASE_ENABLED=true`. +4. The container gate rechecks repository, event, annotated-tag identity, + signature, commit and `main` ancestry. It builds one Linux runner-native image + and exports a Docker archive. Trivy scans that archive; Syft generates the + image CycloneDX SBOM from the same archive, not from the source directory. +5. A passing scan and another tag check permit pushing a unique + `candidate-RUN_ID-RUN_ATTEMPT` tag. The image ID must still match the built + image. Both provenance and SBOM attestations bind to the exact registry + manifest digest, using GitHub's keyless Sigstore-backed attestation actions. +6. Both attestations must verify against the repository, reusable signer + workflow, source commit and tag ref. Only then is the existing local image + tagged as `MAJOR.MINOR.PATCH` and pushed, without rebuilding. The promoted + digest is checked against the attested digest. No moving `latest` or + `MAJOR.MINOR` aliases are updated by this workflow. + +All publishing steps use normal success dependencies. Scan errors, unavailable +verification services, ambiguous digests and failed attestations stop version +promotion. The last evidence-upload step may run on failure but cannot publish +an image. Trivy retains the current CI policy: fail on HIGH/CRITICAL findings +with available fixes (`ignore-unfixed: true`). This is not a claim that the +image has no vulnerabilities; no new ignore list is introduced. + +## OWASP transfer and first-release approval + +GitHub now identifies this repository as `OWASP/openshield`. New image releases +target **`ghcr.io/owasp/openshield`**, not the historical organization namespace. +Nothing in this change migrates, deletes or overwrites historical packages. + +Before enabling the container job, an OWASP repository/package administrator must: + +- Confirm that the repository's `GITHUB_TOKEN` may create/write this package + and that its intended visibility and repository association are correct. +- Confirm the tag-creation/signing authority, `main` protections and review + process. A GitHub-verified signature plus ancestry is not an independent + authorization check on the signer; trusted tag writers and protected workflow + files remain essential. Effective protection enforcement is tracked in #298. +- Review the workflow and approve a first-release verification plan, then set + `CONTAINER_RELEASE_ENABLED=true`. Leaving it unset disables container + publication; it does not disable source releases. +- Use a new signed stable release tag only after this workflow is promoted to + `main` through the normal process. Old tags retain their old workflow code; + this change is not a retroactive gate for historical workflows. +- Verify the published image and evidence below before announcing availability. + +No administrator settings, tags, registry writes, release dispatches or deployments +are required to review this PR. Local unit tests use fake GitHub responses and +temporary local Git histories; they do not prove live OIDC/registry integration. +The first owner-authorized release must supply that operating evidence. + +## Verify an image + +Use the digest recorded in the successful workflow summary and +`container-release-evidence-RUN_ID-RUN_ATTEMPT` artifact. That artifact retains +the Trivy report, image SBOM, source identity and registry digest for 90 days. +Image attestations are also pushed to the registry and recorded by GitHub. + +```bash +# Substitute the actual digest and source commit recorded by the release. +IMAGE=ghcr.io/owasp/openshield@sha256:ACTUAL_DIGEST +COMMIT=ACTUAL_SOURCE_COMMIT +TAG=vX.Y.Z + +gh attestation verify "oci://$IMAGE" --repo OWASP/openshield \ + --signer-workflow OWASP/openshield/.github/workflows/docker.yml \ + --source-digest "$COMMIT" --source-ref "refs/tags/$TAG" + +gh attestation verify "oci://$IMAGE" --repo OWASP/openshield \ + --signer-workflow OWASP/openshield/.github/workflows/docker.yml \ + --source-digest "$COMMIT" --source-ref "refs/tags/$TAG" \ + --predicate-type https://cyclonedx.org/bom +``` + +Authenticate to GHCR if required for package access. Use a current GitHub CLI +supporting these attestation flags. For reusable workflows, the reusable workflow +is the signer identity, not the caller. See the +[GitHub CLI verification reference](https://cli.github.com/manual/gh_attestation_verify). + +## Failures and reruns + +A failed run can leave an unpromoted candidate in GHCR: registry publication and +signing are not one atomic transaction. Never deploy a candidate or infer trust +from its tag. Verify the image by digest with both predicates. A source release +may already exist when the dependent container job fails; do not announce the +container until its job and verification finish. + +Tag moves are rechecked before writes but are not locked atomically across GitHub +and GHCR. An administrator must restrict moving/deleting release tags. Reruns +can rebuild different bytes from mutable base/OS dependencies and can replace +the version tag; use a new release version for changed images and deploy pinned +digests. Digest pinning, not tag spelling, gives immutable consumption. Candidate +cleanup, immutable package-tag enforcement, multi-architecture publishing and +base-image reproducibility remain follow-up work. diff --git a/docs/data-link-layer-assurance.md b/docs/data-link-layer-assurance.md new file mode 100644 index 00000000..562c7f2e --- /dev/null +++ b/docs/data-link-layer-assurance.md @@ -0,0 +1,19 @@ +# Azure Data Link Layer Assurance + +OpenShield treats Azure OSI Layer 2 as a shared responsibility boundary. Microsoft owns the virtual switching fabric, forwarding tables, broadcast behavior, and tenant-isolation internals. Azure customers cannot inspect those systems, so OpenShield records provider assurance and platform enforcement without creating findings or changing the tenant security score. + +ExpressRoute Direct is different because Azure exposes customer-controlled Ethernet link configuration through the management API. OpenShield checks enabled links for MACsec and checks ports of 40 Gbps or greater for an XPN MACsec cipher. A subscription with no ExpressRoute Direct ports is not applicable. An Azure API or permission failure is indeterminate and never creates a finding. + +The customer-actionable checks use the dedicated Data Link identifiers `AZ-DL-001` and `AZ-DL-002`. The `AZ-DL` namespace distinguishes these Layer 2 controls from the mixed-layer rules historically stored under `AZ-NET`. + +## Coverage + +The closed catalog covers both IEEE 802 Data Link sublayers, LLC and MAC, and all 19 functional domains required by issue #241. Each domain records Azure applicability, responsibility, observability, evidence, and one of the supported verification states. + +`GET /api/assurance/data-link-layer` requires JWT authentication. It returns the layer and scope, responsibility boundary, domain and sublayer coverage, provider and platform states, automated-control applicability, evidence review dates, source links, and explicit limitations. Catalog coverage and evidence freshness are separate measurements. + +The endpoint does not claim live access to Microsoft switches, VLANs, forwarding tables, or fabric internals. It requires no paid OpenShield service or external runtime API. + +## Secret handling + +The checks test only whether a MACsec configuration exists and which cipher is selected. They never read, store, log, or return CAK or CKN secret values. Findings contain only the port identity, link name, bandwidth, and non-secret cipher name. diff --git a/docs/dco.md b/docs/dco.md new file mode 100644 index 00000000..e2a80686 --- /dev/null +++ b/docs/dco.md @@ -0,0 +1,40 @@ +# Developer Certificate of Origin + +OpenShield uses the [Developer Certificate of Origin 1.1](https://developercertificate.org/) +as its contribution authorization mechanism. A `Signed-off-by` trailer states +that the contributor is legally entitled to submit the work under the project's +license and agrees to the DCO certification. + +Add the trailer automatically when committing: + +```bash +git commit -s -m "feat: describe the change" +``` + +The name and email in the trailer should identify the contributor and should +match the commit author unless a documented contribution workflow requires a +different authorized signer. Every non-merge commit introduced by a pull +request is checked; a sign-off only in the pull request description is +insufficient. + +DCO enforcement began with commit +`1d9469e162fce788bca839cfaf8a3e66ca35cf9b` (PR #208). Commits already +reachable from that baseline—or recorded before it—are exempt, so release pull +requests and still-open legacy branches do not retroactively reject work created +before the policy. New commits remain subject to DCO even when they are added to +a branch created before the baseline. + +Merge commits (e.g. from running `git merge origin/dev` to bring your branch +up to date) are exempt — they carry Git's own default message, not your +authorship, so there is nothing for you to sign off on. Only commits you +authored yourself need the trailer. + +To repair the latest local commit before review: + +```bash +git commit --amend --signoff --no-edit +git push --force-with-lease +``` + +For multiple commits, use an interactive rebase and add a sign-off to each +commit. Do not add another person's sign-off without their authorization. diff --git a/docs/dependency-locking.md b/docs/dependency-locking.md new file mode 100644 index 00000000..5750761f --- /dev/null +++ b/docs/dependency-locking.md @@ -0,0 +1,72 @@ +# Python dependency locking + +This implements the Python dependency slice of #304, not its deployment or +disaster-recovery acceptance criteria. The supported lock target is CPython 3.11 +on Linux, matching CI and the container. Other Python versions/platforms are not +validated by these locks; use that environment to regenerate them. + +`.python-version` declares the same minor version for native Render builds. +Before deployment, verify that no dashboard `PYTHON_VERSION` overrides it; +Render gives that environment variable precedence. No live settings are changed +by this PR. Python patch versions and base images remain separate update controls. + +| Input maintained by contributors | Generated install file | Purpose | +| --- | --- | --- | +| `requirements.in` | `requirements.txt` | Runtime, including transitive dependencies | +| `requirements-dev.in` | `requirements-dev.txt` | Runtime plus pytest, coverage and ruff | +| `requirements-lock.in` | `requirements-lock.txt` | Isolated lock-generation tooling | + +The development resolution is constrained by the runtime lock so tests use the +same runtime package versions. All three outputs pin versions and SHA-256 hashes. +Do not hand-edit generated files. Direct runtime pins were retained from the +existing requirements; previously open ranges and transitive packages are now +resolved explicitly. Hashes establish artifact integrity, not absence of vulnerabilities. + +## Installation + +Use a clean virtual environment. For production: + +```bash +python3.11 -m venv .venv +.venv/bin/python -m pip install --require-hashes --only-binary=:all: -r requirements.txt +.venv/bin/python -m pip check +``` + +For development, substitute `requirements-dev.txt`. CI and the application +container use hash checking explicitly. Wheel-only installation avoids running +unlocked source-build dependencies; an unavailable wheel must fail rather than +silently fall back to building from source. Plain `pip install -r requirements.txt` +also enables hash checking because hashes are present, but use the explicit flags +above to enforce the full policy. + +## Updating and checking + +```bash +python3.11 -m venv .lock-tools +.lock-tools/bin/python -m pip install --require-hashes --only-binary=:all: -r requirements-lock.txt +# Edit the relevant .in file, then regenerate all locks: +.lock-tools/bin/python scripts/lock_dependencies.py +# Verify without changing the checkout: +.lock-tools/bin/python scripts/lock_dependencies.py --check +``` + +Generation preserves existing pins where they satisfy the inputs. `--upgrade` +explicitly refreshes allowed versions; review that diff and run the full tests +and dependency audit before merging. Checking seeds a temporary directory with +committed locks, so new upstream releases alone do not cause drift failures. It +requires access to the package index and fails closed on resolution errors. +When changing the lock tool's own version, update the version guard in the script +and regenerate with that reviewed toolchain. + +CI checks input/lock consistency within the existing rule-validation job, already +required by CI Summary. Backend tests install the development lock; production +does not install pytest, coverage, ruff or pip-tools. Security scanners retain +their independent tool environments; runtime SCA reads the resolved runtime lock. + +## Remaining work under #304 + +These locks do not freeze the base image, OS packages, package-index availability, +or the container's existing pip/setuptools/wheel bootstrap. They also do not set +cloud topology, authorize deployment, create backups, define SLOs, or establish +release attestations. Those remain separate work. No existing vulnerability +exceptions are added or relaxed here. Lock generation alone is not security approval. diff --git a/docs/deployment/render.md b/docs/deployment/render.md index 6a53c2f7..9e21e100 100644 --- a/docs/deployment/render.md +++ b/docs/deployment/render.md @@ -69,6 +69,33 @@ the Flask application. Leaving this value unset enables wildcard CORS and is not acceptable for a real staging or production environment. Do not add this setting to worker services. +## Restricting probe/scrape endpoints at the edge + +`/health`, `/ready`, and `/metrics` are intentionally exempt from JWT auth (see +`api.app._ALWAYS_PUBLIC`) so uptime checkers and Prometheus scrapers can reach them +without a token. `/health` is a pure liveness check with no backend dependency and is +meant to stay reachable from anywhere - it is what `render.yaml`'s +`healthCheckPath` uses. `/ready` and `/metrics` are different: `/ready` checks out a +pooled database connection on every call, and `/metrics` returns operational counters +(including database pool utilization from `openshield_db_pool_connections_*` - +counts only, never the DSN, host, or credentials - see `get_pool_stats()` in +`api/models/finding.py`). Neither should be reachable by arbitrary internet clients. + +Render's Blueprint format has no path-based access control, so this repository +cannot restrict `/ready`/`/metrics` from `render.yaml` itself. The application +carries its own in-process, in-memory rate limit on both paths +(`api.observability.probe_rate_limit`, wired in `api/app.py`) as a defense-in-depth +backstop - deliberately not the shared Postgres-backed `api.rate_limit.rate_limit` +used elsewhere, since that would add database load to the exact endpoint meant to +protect the database from overload. That backstop bounds a single source hammering +one process; it is not a substitute for restricting network reachability. + +Whoever operates the reverse proxy, CDN, or WAF in front of a Render deployment +(Cloudflare or similar, if one is configured) should restrict `/ready` and `/metrics` +to known monitoring/scraping source IPs or an internal network path, the same way +they would for any other backend-only operational endpoint. This is an operational +configuration step outside this repository, not something `render.yaml` can express. + ## Coordinated deterministic deployment For the selected environment, the workflow: diff --git a/docs/enterprise-resilience-rules.md b/docs/enterprise-resilience-rules.md new file mode 100644 index 00000000..9142c550 --- /dev/null +++ b/docs/enterprise-resilience-rules.md @@ -0,0 +1,69 @@ +# Azure Enterprise Resilience Rules + +OpenShield evaluates Azure Functions, Private Link, and Recovery Services vault +configuration through Azure Resource Manager. It does not read application +settings, connection strings, deployment credentials, private IP addresses, +backup items, recovery points, encryption keys, or customer data. + +## Coverage + +| Pack | Rules | Controls | +|---|---|---| +| Azure Functions | `AZ-FUNC-001`–`005` | HTTPS, TLS 1.2, FTP publishing, remote debugging, managed identity | +| Private Endpoint | `AZ-PE-001`–`006` | Storage, SQL, PostgreSQL, App Service, Recovery Services, connection approval | +| Azure Backup | `AZ-BAK-001`, `002`, `004`, `006` | Soft delete, immutability, MUA, Azure Monitor alerts | + +Private Link checks evaluate public-network state and approved target groups +together. A private endpoint does not itself disable a service's public +endpoint. Storage currently requires the `blob` target group as the universal +minimum; additional service groups such as `file`, `queue`, `table`, `dfs`, and +`web` depend on which data services the account uses and are not inferred. + +The Backup baseline requires soft delete to be `Enabled` or `AlwaysON` with at +least 35 days of retention. Immutability locking is deliberately not automated: +the locked state is irreversible. Storage redundancy and production-only lock +policies remain outside this first phase because they require organization +specific exceptions and production classification. + +## Required permissions + +Azure's built-in Reader role normally includes the required management-plane +read actions: + +```text +Microsoft.Web/sites/read +Microsoft.Web/sites/config/read +Microsoft.Network/privateEndpoints/read +Microsoft.Storage/storageAccounts/read +Microsoft.Sql/servers/read +Microsoft.DBforPostgreSQL/flexibleServers/read +Microsoft.RecoveryServices/vaults/read +``` + +Each inventory has an explicit indeterminate state. If a required API fails or +a security property is absent, affected rules log and skip the resource instead +of creating a finding or claiming compliance. + +## Remediation safety + +Network isolation can interrupt applications, deployment systems, backup +agents, and administrators when private DNS or routing is incomplete. Validate +the resource, hosting tier, private endpoint approval, DNS, and client path +before disabling public access. Backup immutability must be reviewed separately +and is never locked by an OpenShield playbook. + +## Compliance mappings + +The repository's CIS Azure Foundations version has no direct controls for all +of these settings. Each rule therefore uses a unique `N/A-FUNC-*`, `N/A-PE-*`, +or `N/A-BAK-*` identifier to state explicitly that no direct CIS recommendation +is assigned. NIST, ISO 27001, and SOC 2 mappings use the framework versions +already represented by OpenShield. + +## References + +- [Azure Functions security](https://learn.microsoft.com/azure/azure-functions/security-concepts) +- [Azure Private Endpoint overview](https://learn.microsoft.com/azure/private-link/private-endpoint-overview) +- [Azure Storage private endpoints](https://learn.microsoft.com/azure/storage/common/storage-private-endpoints) +- [Azure Backup security best practices](https://learn.microsoft.com/azure/backup/azure-backup-data-protection-best-practices) +- [Azure Backup multiuser authorization](https://learn.microsoft.com/azure/backup/multi-user-authorization-concept) diff --git a/docs/enterprise-rule-pack-v1.md b/docs/enterprise-rule-pack-v1.md new file mode 100644 index 00000000..ccc8c9c8 --- /dev/null +++ b/docs/enterprise-rule-pack-v1.md @@ -0,0 +1,226 @@ +# OpenShield Enterprise Rule Pack v1 + +## Status + +This document is a researched backlog of 100 proposed enterprise security rules. It is not evidence that these rules are implemented. A rule becomes production-ready only after its collector, evaluation logic, tests, permission handling, evidence output, documentation, and live validation are complete. + +The candidates are aligned with the Microsoft Cloud Security Benchmark, Microsoft Defender for Cloud recommendations, Azure service security baselines, Zero Trust principles, and control areas commonly covered by commercial CSPM and CNAPP platforms. They are not copied from a proprietary paid-product rule library. + +PQC and CBOM are intentionally excluded from this pack. + +## Required rule contract + +Every rule must return one of the following states: + +- `PASS`: sufficient evidence proves that the control is satisfied. +- `FAIL`: sufficient evidence proves that the control is not satisfied. +- `UNKNOWN`: the scanner could not establish the result, including permission or API failures. +- `NOT_APPLICABLE`: the control does not apply to the evaluated resource. + +Every finding should include: + +- Rule and resource identifiers +- Tenant, subscription, resource group, and region +- Observed and expected configuration +- Evidence source and collection timestamp +- Exposure, privilege, and data-sensitivity context +- Attack-path relevance +- Remediation guidance and required permissions +- Verified compliance references +- Confidence and reason for `UNKNOWN` + +An API or authorization error must never produce a false `PASS`. + +## Proposed rules + +### 1. Identity and privileged access + +1. Privileged users do not use phishing-resistant MFA. +2. Global Administrator roles are permanently assigned. +3. Privileged roles are assigned outside Privileged Identity Management. +4. Stale privileged accounts retain active access. +5. Emergency access accounts are missing or incorrectly protected. +6. Conditional Access does not block legacy authentication. +7. Conditional Access does not protect Azure management operations. +8. Risky-user or risky-sign-in protection is missing. +9. Workload identities are excluded from applicable access controls. +10. Privileged group membership is not appropriately governed. + +### 2. Application identities and OAuth + +11. An application has high-risk Microsoft Graph permissions. +12. An application has tenant-wide admin consent without an approved justification. +13. An unverified publisher application has privileged permissions. +14. Application credentials have an excessive validity period. +15. An application contains multiple active secrets without a documented need. +16. A stale application retains credentials or privileged permissions. +17. A service principal allows access without required user assignment. +18. A federated identity credential uses an overly broad subject or trust condition. +19. An application permits public-client authentication unnecessarily. +20. Application ownership contains inactive, guest, or otherwise unsuitable accounts. + +### 3. Network, Private Link, and perimeter security + +21. A private endpoint exists while public network access remains enabled. +22. A private endpoint connection is pending, rejected, or disconnected. +23. A private endpoint lacks the required private DNS zone association. +24. A private endpoint FQDN does not resolve through the expected private path. +25. A critical PaaS resource is internet-accessible without an approved exception. +26. Azure Firewall threat intelligence is not configured for alert-and-deny enforcement. +27. Application Gateway WAF is not operating in Prevention mode. +28. WAF diagnostic logging is not enabled. +29. WAF bot protection or an approved current managed rule set is missing. +30. An internet-facing application lacks an approved rate-limiting control. + +### 4. Azure Functions, App Service, and API Management + +31. A Function App does not enforce HTTPS-only access. +32. A Function App permits obsolete TLS versions. +33. A Function App permits public access without an approved requirement. +34. A Function App lacks a managed identity where supported. +35. Function App platform authentication is disabled where authentication is required. +36. Function App or App Service CORS configuration contains a wildcard origin. +37. App Service FTP or basic publishing authentication is enabled. +38. The SCM deployment endpoint is unnecessarily publicly accessible. +39. API Management developer portal authentication is insufficiently protected. +40. API Management lacks approved JWT validation, throttling, or equivalent gateway controls. + +### 5. Data protection and databases + +41. Storage account shared-key authorization remains enabled without an exception. +42. A storage account permits TLS below the approved minimum version. +43. Sensitive storage data is not protected with a required customer-managed key. +44. A critical blob container lacks an immutability policy. +45. Azure SQL does not enforce Microsoft Entra-only authentication where required. +46. SQL vulnerability assessment is not configured. +47. SQL auditing has insufficient coverage or retention. +48. Cosmos DB local authentication remains enabled without an approved requirement. +49. Cosmos DB public network access is enabled without an exception. +50. A managed cache permits public or non-TLS access. + +### 6. AKS and container workload security + +51. The AKS API server lacks approved IP restrictions. +52. An AKS cluster has no Kubernetes network policy. +53. Defender for Containers protection is disabled for an in-scope AKS cluster. +54. AKS secrets lack required Key Vault or KMS-backed protection. +55. Secrets Store CSI secret rotation is disabled. +56. Kubernetes workloads permit privileged containers. +57. Workloads permit unrestricted host network, host PID, or host IPC access. +58. Workloads permit unrestricted `hostPath` volumes. +59. Kubernetes `cluster-admin` access is assigned too broadly. +60. Workloads use untrusted registries, mutable tags, or the `latest` image tag. + +### 7. Backup, ransomware resilience, and recovery + +61. A critical resource is not protected by an approved backup policy. +62. Backup vault soft delete is disabled. +63. Enhanced soft delete is not enabled where required. +64. Backup vault immutability is not enabled. +65. Required backup immutability has not been locked. +66. Multi-user authorization through Resource Guard is missing. +67. Backup administration and Resource Guard permissions are not separated. +68. A backup vault permits unnecessary public network access. +69. Backup security alerts lack a monitored notification destination. +70. Recovery capability lacks evidence of a successful restore test within the required period. + +### 8. Logging, detection, and security operations + +71. Subscription activity logs are not exported to an approved central destination. +72. Required administrative, security, policy, or service-health log categories are missing. +73. A critical resource lacks required diagnostic settings. +74. Security logs have insufficient retention. +75. Security logs are stored only in a destination that can be modified by workload administrators. +76. Required Defender for Cloud protection is missing for a critical workload. +77. A high-risk Defender recommendation remains unresolved beyond its SLA. +78. A required Microsoft Sentinel data connector is disconnected or unhealthy. +79. Sentinel lacks required high-severity analytics coverage. +80. Security alerts have no monitored incident-response destination. + +### 9. Governance, policy, and tenant control + +81. A subscription is outside the approved management-group hierarchy. +82. A required security policy initiative is not assigned at the correct scope. +83. A mandatory preventive policy uses Audit instead of an approved enforcement effect. +84. A policy exemption lacks an owner, justification, or expiration date. +85. A critical production resource lacks an approved deletion lock. +86. A subscription has excessive Owner assignments. +87. Privileged access is assigned at an unnecessarily broad scope. +88. A resource provider is registered without a documented operational requirement. +89. A production resource lacks accountable ownership metadata. +90. Security configuration drift remains unresolved beyond the approved SLA. + +### 10. DevSecOps, supply chain, and AI services + +91. A CI/CD workflow uses long-lived Azure credentials instead of workload identity federation. +92. A CI workflow has unnecessarily broad token permissions. +93. A third-party workflow action is not pinned to an immutable commit. +94. Untrusted pull-request input can reach a privileged workflow context. +95. A protected branch permits unreviewed production changes. +96. A release artifact lacks an approved signature or provenance attestation. +97. Infrastructure deployment can bypass required security scanning. +98. An Azure OpenAI or Foundry resource permits unnecessary public access. +99. An AI service uses static keys where managed identity is available and required. +100. An AI resource lacks required diagnostic logging, encryption, or content-safety controls. + +## Collection architecture + +The 100 rules cannot be implemented correctly through a single Azure API. + +| Evidence area | Preferred collector | +| --- | --- | +| Azure resource inventory and configuration | Azure Resource Graph | +| Configuration unavailable through ARG | Targeted Azure management SDK calls | +| Entra users, roles, applications, consent, and Conditional Access | Microsoft Graph | +| Kubernetes RBAC, pod specifications, and workload policies | Kubernetes API | +| GitHub and Azure DevOps security controls | Provider APIs and repository analysis | +| Runtime reachability and private DNS validation | Explicit, opt-in validation probes | + +The intended flow is: + +```text +ARG inventory + -> rule applicability filtering + -> Microsoft Graph, SDK, Kubernetes, or DevOps enrichment + -> deterministic rule evaluation + -> evidence-backed findings + -> contextual risk and attack-path correlation +``` + +## Recommended delivery order + +Implement the pack as five reviewable releases rather than one 100-rule pull request: + +1. Private Link, perimeter, Functions, App Service, and API Management +2. Backup, recovery, logging, and detection +3. Identity, privileged access, application identities, and OAuth +4. Data protection, AKS, and container workload security +5. Governance, DevSecOps, supply chain, and AI services + +Each release should include approximately 20 rules, rule documentation, unit and failure-path tests, permission tests, regression tests, and representative live Azure evidence. + +## Production readiness gate + +A proposed rule is ready only when all of the following are complete: + +- The rule does not duplicate existing OpenShield behavior. +- Applicability and required permissions are documented. +- API errors and missing permissions return `UNKNOWN`. +- Positive, negative, malformed-data, and permission-failure tests pass. +- Evidence is stable, minimal, and useful to an operator. +- Severity is based on impact and context, not only the failed setting. +- Remediation is least-privilege and does not claim to be automatically safe. +- Compliance mappings use verified control identifiers rather than placeholders. +- A representative live validation has been recorded where practical. +- Documentation clearly distinguishes implemented behavior from future correlation features. + +## Primary research sources + +- [Microsoft Cloud Security Benchmark](https://learn.microsoft.com/en-us/security/benchmark/azure/) +- [Microsoft Defender for Cloud security recommendations](https://learn.microsoft.com/en-us/azure/defender-for-cloud/security-recommendations) +- [Microsoft Entra identity security guidance](https://learn.microsoft.com/en-us/azure/security/fundamentals/steps-secure-identity) +- [Azure Private Endpoint DNS guidance](https://learn.microsoft.com/en-us/azure/private-link/private-endpoint-dns) +- [Application Gateway WAF overview](https://learn.microsoft.com/en-us/azure/web-application-firewall/ag/ag-overview) +- [Azure Backup security overview](https://learn.microsoft.com/en-us/azure/backup/security-overview) +- [Azure Backup multi-user authorization](https://learn.microsoft.com/en-us/azure/backup/multi-user-authorization-tutorial) +- [Azure Policy regulatory compliance](https://learn.microsoft.com/en-us/azure/governance/policy/concepts/regulatory-compliance) diff --git a/docs/input-validation-audit.md b/docs/input-validation-audit.md new file mode 100644 index 00000000..bec41a81 --- /dev/null +++ b/docs/input-validation-audit.md @@ -0,0 +1,53 @@ +# Input Validation Audit + +This audit records every untrusted input boundary reviewed for OpenSSF Silver +issue #201. Validation occurs before values reach PostgreSQL, filesystem paths, +Azure/Sentinel integrations, subprocesses, or external AI providers. + +## API-wide controls + +- JSON request bodies are limited to 2 MiB by Flask. +- Protected routes require a bounded Bearer token and reject malformed tokens. +- Client request IDs accept only 1-128 letters, digits, `.`, `_`, `:` or `-`; + invalid values are replaced with a server-generated UUID before logging. +- Validation failures return a fixed `400` response without reflecting + exception details and without database or provider access. Authentication + failures remain `401`. + +## Boundary inventory + +| Boundary | Accepted input | Enforcement | +|---|---|---| +| `POST /api/scans/trigger` | Optional JSON object; Azure subscription UUID | Object/field allowlist and canonical UUID validation before queue insertion | +| `GET /api/scans/` | Scan UUID | Canonical UUID validation before database access | +| `POST /api/scans//enrich` | Scan UUID | Canonical UUID validation before lookup or background work | +| `GET /api/findings` | `severity`, `category`, `rule_id`, `scan_id` | Unknown/duplicate parameters rejected; severity/category allowlists; bounded rule pattern; UUID scan ID | +| Finding and playbook paths | Positive integer finding ID; stored rule ID | Positive ID check, strict rule pattern, resolved-path containment check | +| `GET /api/compliance/` | Named compliance framework | Existing framework allowlist; unknown frameworks rejected | +| AI POST routes | Provider, API key, optional model/question and findings | Field/provider allowlists; bounded strings; model pattern; maximum 1,000 object findings; bounded prompt fields | +| JWT header | HS256 Bearer token | 8 KiB header ceiling, exact prefix and PyJWT signature/expiry validation | +| Sentinel ingestion CLI | JSON file, scan ID, finding records and environment configuration | Existing regular `.json` file under 10 MiB; at most 1,000 object findings; bounded fields; severity/config format checks | +| Azure resource data | Management-plane SDK objects | Typed SDK accessors; failures preserved as unknown; no subprocess interpolation | +| Playbook selection | Rule ID derived from stored finding | Allowlisted identifier converted to a filename and constrained beneath `playbooks/cli` | +| Website media URLs | User-entered video URL | HTTPS host allowlist and embed conversion tests in `website/test_toEmbedUrl.mjs` | +| Website editor text | Titles, excerpts, names and Markdown content | Intentionally free-form client-side content; repository write still requires the operator's GitHub token and GitHub authorization | + +## Intentionally unrestricted text + +AI questions, finding descriptions, remediation text and website article content +cannot use semantic allowlists without breaking legitimate use. They are instead +type checked, length bounded, kept out of SQL/file paths, and passed only through +parameterized or fixed-destination interfaces. AI prompts explicitly treat +findings as evidence and instruct providers not to invent unsupported facts. + +## Regression evidence + +`tests/test_input_validation.py` covers malformed JSON shapes, invalid and +oversized AI fields, injection-style identifiers, unknown and duplicated query +parameters, UUID enforcement, request-ID sanitization and authorization-header +limits. `tests/test_sentinel_input_validation.py` covers file, record, severity +and field-shape rejection. Existing authentication, error-exposure, playbook and +route tests protect prior behavior. + +Re-run the audit when a route, query parameter, upload, CLI input, filesystem +selection, subprocess call, or external-provider integration is added. diff --git a/docs/internationalization.md b/docs/internationalization.md new file mode 100644 index 00000000..f8480831 --- /dev/null +++ b/docs/internationalization.md @@ -0,0 +1,22 @@ +# Internationalization + +The dashboard uses message catalogs through `I18nContext`. English is the +fallback language and Spanish demonstrates a second complete catalog for core +navigation, page titles, scan controls, status messages and theme controls. + +The selected locale is stored locally, applied to the document `lang` +attribute, and used with `Intl.DateTimeFormat` and `Intl.NumberFormat`. An +unsupported locale falls back to English. Security identifiers, Azure resource +names, findings and compliance control IDs are data and are never translated. + +## Adding a locale + +1. Add a catalog to `frontend/src/i18n/messages.js` using exactly the English + keys. +2. Translate meaning rather than word order; retain `{name}` placeholders. +3. Add the language's self-name to each catalog. +4. Run `npm run test:i18n`; missing keys fail the catalog test. +5. Review navigation at narrow and wide widths and verify date/number output. + +The website remains English-first. Additional website locales should reuse the +same terminology but must not duplicate security data or rule definitions. diff --git a/docs/learn/index.html b/docs/learn/index.html index a9aaa4f1..891f19ef 100644 --- a/docs/learn/index.html +++ b/docs/learn/index.html @@ -752,7 +752,7 @@

Learn Azure security posture with OpenShield.

openshield scan --subscription Azure

-

loading rules: 39 dynamic checks

+

loading rules: 95 dynamic checks

enrichment: NVD / CVE intelligence

storage: PostgreSQL scan history

api: Flask + JWT + CORS

@@ -764,11 +764,11 @@

Learn Azure security posture with OpenShield.
-
39Azure scan rules
-
39CLI remediation playbooks
+
95Azure scan rules
+
95CLI remediation playbooks
4Compliance frameworks
8AI security skills
-
22High-severity checks
+
58High-severity checks
@@ -821,7 +821,7 @@

Production-shaped, MVP-friendly architecture

Azure SubscriptionResources and configuration
Scanner EnginePython rule execution
-
Rule Evaluation39 dynamic checks
+
Rule Evaluation95 dynamic checks
CVE EnrichmentNVD risk context
PostgreSQLFindings and scan history
Flask APIJWT-protected REST routes
@@ -841,9 +841,9 @@

Production-shaped, MVP-friendly architecture

Rule coverage

-

51 Azure security rules

+

95 Azure security rules

- OpenShield currently has 39 dynamic rules. The strongest contributor work improves rule accuracy, reduces false positives, + OpenShield currently has 95 dynamic rules. The strongest contributor work improves rule accuracy, reduces false positives, strengthens validation, or improves remediation quality.

@@ -851,13 +851,19 @@

51 Azure security rules

Coverage by category

-
Network
14
-
Storage
5
-
Key Vault
5
-
Compute
4
-
Database
4
-
Identity
4
-
PostQuantum
3
+
Network
23
+
Identity
15
+
Security Operations
10
+
Supply Chain
8
+
KeyVault
6
+
Kubernetes
6
+
Serverless
5
+
Storage
5
+
Backup
4
+
Compute
4
+
Database
4
+
PostQuantum
3
+
Data Link
2
@@ -865,11 +871,10 @@

Coverage by category

Severity distribution

Most checks are high severity. That makes validation important: high-severity false positives damage trust quickly.

-
22HIGH
-
13MEDIUM
+
58HIGH
+
31MEDIUM
4LOW
-

Known cleanup item: keep category names consistent, especially KeyVault vs Key Vault.

@@ -931,7 +936,7 @@

Current cleanup items

Documentation drift

    -
  • Rule coverage and documentation counts are checked and updated with each release.
  • +
  • Rule, playbook, and severity statistics on this page and in README.md are generated from scanner/rules/ and playbooks/cli/ by .github/scripts/update_learn_page.py on every push to dev.
  • Some startup commands assume python, but local environments may only expose python3.
  • API docs and implementation should stay aligned, especially score response shape.
diff --git a/docs/network-layer-assurance.md b/docs/network-layer-assurance.md new file mode 100644 index 00000000..5ef1ba5a --- /dev/null +++ b/docs/network-layer-assurance.md @@ -0,0 +1,15 @@ +# Azure Network Layer assurance + +OpenShield's authenticated `GET /api/assurance/network-layer` endpoint returns a closed OSI Layer 3 catalog for Azure public-cloud networking. It covers all 20 required addressing, routing, transit, isolation, IP-boundary protection, and diagnostic domains and reports catalog completeness separately from evidence freshness. + +## Responsibility boundary + +Customers control exposed address spaces, subnets, routes, peerings, gateways, public IP associations, and supported diagnostics. Microsoft owns the Azure fabric, underlying forwarding implementation, tenant isolation, physical packet handling, and system internals that subscriptions cannot inspect. Provider-owned domains are documented but never create findings or change the tenant score. + +The catalog classifies every `AZ-NET-001` through `AZ-NET-027` rule by actual behavior. Port-specific NSG rules remain Layer 4, DNS and WAF rules remain Layer 7, and ExpressRoute Direct MACsec rules remain Layer 2. Cross-layer rules only reference Layer 3 domains when part of their behavior genuinely covers IP addressing, routing, or segmentation. + +## Automation limits + +The report cross-references authoritative management-plane checks. `AZ-NET-016` reports NICs with IP forwarding enabled for network-security review, and `AZ-NET-017` reports explicit IPv4 or IPv6 default user-defined routes that select the direct Internet next hop. It does not add speculative findings for address overlap, path-specific next-hop correctness, MTU, ICMP reachability, BGP advertisement, or Microsoft anti-spoofing internals. Those conditions require architecture intent, selected endpoints, packet tests, or provider evidence that the scanner does not possess. + +Empty relevant inventory is `NOT_APPLICABLE`; Azure API or permission failure is `INDETERMINATE`. Neither state creates a finding. The endpoint is documentation-backed assurance, not packet capture, live traffic inspection, or access to Microsoft forwarding tables. diff --git a/docs/openshield-evidence-graph.md b/docs/openshield-evidence-graph.md new file mode 100644 index 00000000..e683bde0 --- /dev/null +++ b/docs/openshield-evidence-graph.md @@ -0,0 +1,226 @@ +# OpenShield Evidence Graph + +## Status + +This document is an architecture proposal tracked by issue #249. Azure Resource Graph inventory is Phase 1. Attack-path correlation, +Code-to-Cloud tracing, and controlled remediation are future phases and are not presented as implemented features. + +## Purpose + +OpenShield currently evaluates many security controls independently. The proposed Evidence Graph will connect Azure +resources, identities, permissions, exposures, findings, infrastructure code, and remediation evidence into one +traceable security model. + +## Target architecture + +The target is a hybrid scanner. ARG performs broad resource discovery, Python remains the orchestration and rule +engine, and Azure SDK calls are retained only where ARG cannot supply the required evidence. + +```mermaid +flowchart TB + subgraph Scope[Customer Azure boundary] + A[Azure tenant] + B[Authorised subscriptions] + C[Least-privilege read credential] + A --> B --> C + end + + subgraph Collection[Phase 1 - inventory foundation] + D[ARG KQL batch query] + E[Pagination and bounded retry] + F[Truncation and repeated-token guards] + G[Normalised inventory snapshot] + H{Snapshot status} + I[COMPLETE] + J[PARTIAL] + K[FAILED] + C --> D --> E --> F --> G --> H + H --> I + H --> J + H --> K + end + + subgraph Evaluation[Hybrid security evaluation] + L[ARG-compatible resource evidence] + M[Missing security properties] + N[Targeted Azure SDK enrichment] + O[Python rule engine] + P[Findings with snapshot and resource provenance] + Q[Score, API, database and dashboard] + I --> L --> O + I --> M --> N --> O + O --> P --> Q + R[Current SDK rule path retained during migration] + C --> R --> O + end + + subgraph Graph[Future evidence reasoning] + S[Evidence graph nodes and validated edges] + T[Toxic-combination and attack-path detection] + U[Prioritised path with evidence and blast radius] + P --> S --> T --> U + end + + subgraph Code[Future Code-to-Cloud-to-Code] + V[Terraform and Bicep source mapping] + W[Repository, commit, file and line evidence] + X[Reviewable remediation pull request] + S --> V --> W --> X + end + + subgraph Remediation[Future dual-gated remediation] + Y[Security and operational impact preview] + Z[Human approval] + AA[Short-lived scoped write credential] + AB[Execute approved playbook] + AC[Rescan and verify] + U --> Y + X --> Y --> Z --> AA --> AB --> AC + AC --> D + end + + subgraph Trust[Cross-cutting customer trust controls] + T1[Tenant and subscription isolation] + T2[Read-only collection by default] + T3[Complete, partial and failed states remain explicit] + T4[No Phase 1 persistence or external LLM transfer] + T5[Future tamper-evident approval and action audit] + end + + T1 -. protects .-> G + T2 -. constrains .-> D + T2 -. constrains .-> N + T3 -. controls .-> H + T4 -. limits .-> G + T5 -. records .-> Y + T5 -. records .-> AC + + classDef implemented fill:#dbeafe,stroke:#2563eb,color:#111827 + classDef current fill:#fef3c7,stroke:#d97706,color:#111827 + classDef next fill:#dcfce7,stroke:#16a34a,color:#111827 + classDef future fill:#f3e8ff,stroke:#9333ea,color:#111827 + classDef trust fill:#f1f5f9,stroke:#475569,color:#111827 + + class D,E,F,G,H,I,J,K implemented + class C,O,P,Q,R current + class L,M,N next + class S,T,U,V,W,X,Y,Z,AA,AB,AC future + class T1,T2,T3,T4,T5 trust +``` + +Diagram status: + +- **Blue:** implemented by PR #250. +- **Amber:** existing OpenShield components retained during migration. +- **Green:** the next integration step required for a fair end-to-end speed benchmark. +- **Purple:** later Evidence Graph, Code-to-Cloud, and controlled-remediation phases. +- **Grey:** customer-trust controls that apply across the architecture. + +The live Azure for Students benchmark returned five resources with a 0.438-second median ARG inventory time. The +existing Python scanner ran 68 rules and returned 643 findings in 113.167 seconds. These results demonstrate the +inventory opportunity but are not presented as an end-to-end speedup until migrated rules produce equivalent +findings from the shared snapshot. + +## Customer Trust Layer + +The Evidence Graph must be built on controls that protect customer environments and keep evidence attributable. + +- **Tenant isolation:** every record carries a tenant, subscription, and snapshot boundary. Cross-boundary records are + rejected rather than silently combined. +- **Read-only collection:** ARG inventory and normal scanning use read permissions and do not change Azure resources. +- **Approved temporary write access:** a future remediation path must request narrowly scoped, short-lived access only + after human approval. +- **Tamper-evident audit:** future execution must record the proposal, approval, identity, action, and verified outcome. + +Phase 1 keeps collected data in the scanner process. It does not send the snapshot to an LLM or another external +service, and it does not persist the snapshot until a tenant-scoped storage design is approved. + +Open source code provides inspectability, but these runtime boundaries are still required before an enterprise can +trust the platform with cloud metadata or remediation access. + +## Phase 1: High-speed Azure resource scanning + +Phase 1 uses Azure Resource Graph for broad, batched discovery across authorised subscriptions. ARG results are +normalised into a bounded snapshot containing resource identity, type, location, tenant, subscription, resource +group, tags, properties, collection time, pagination count, and collection errors. + +ARG does not expose every security-relevant property. OpenShield will therefore use a hybrid model: + +```text +ARG batch discovery -> normalised snapshot -> targeted Azure SDK enrichment +``` + +The current SDK scanner remains available while ARG completeness is measured. No existing rule is migrated until its +required evidence is confirmed to be present or safely enriched. + +### Failure semantics + +- `COMPLETE`: every requested ARG page was collected, including a valid empty result. +- `PARTIAL`: some resources were collected, but a later page, scope, or record could not be trusted. +- `FAILED`: collection failed before any ARG page was accepted. + +A partial or failed collection must never be represented as a clean security result. + +### Benchmark protocol + +Performance claims must be measured against a defined environment. Record: + +- number of authorised subscriptions; +- total resources returned; +- ARG page count and collection duration; +- resource types and required fields missing from ARG; +- targeted SDK calls required after discovery; +- throttling, partial-scope, and permission errors; +- equivalent current-scanner collection time. + +The proposed sub-30-second scan time is a benchmark target, not a guarantee. + +## Phase 2: Evidence-based attack paths + +Resources become graph nodes and validated relationships become edges. Every edge must retain its source, collection +time, and confidence. The first proof should connect one realistic combination, such as public exposure, +overprivileged identity, and access to sensitive data, into one prioritised path. + +Deterministic security logic should construct and score the path. An LLM may explain verified evidence but must not +invent relationships. + +## Phase 3: Code-to-Cloud-to-Code + +Runtime resources will be traced to Terraform or Bicep using deployment metadata, resource addresses, source ranges, +repository identity, and commit identity. Resource-name similarity alone is insufficient evidence. A proposed fix +must be delivered as a reviewable pull request rather than an unreviewed code change. + +## Phase 4: Dual-gated remediation + +A future remediation workflow must: + +1. Preview the expected security and operational effect. +2. Require explicit human approval. +3. Obtain temporary, narrowly scoped write access. +4. Execute only the approved operation. +5. Rescan and verify whether the attack path was broken. +6. Preserve before-and-after evidence in the audit record. + +Simulation and deployment previews reduce risk but cannot prove zero production disruption. + +## Initial success criteria + +- A tenant-isolated ARG snapshot can cover one or more authorised subscriptions. +- Pagination, throttling, empty results, partial results, and failures remain distinguishable. +- Resource completeness and scan duration are measured against the existing scanner. +- One later attack path can cite evidence from the snapshot for every relationship. +- No production resource is modified during Phase 1. + +## Non-goals for Phase 1 + +- Replacing every Azure SDK call. +- Building the complete attack graph. +- Generating infrastructure remediation pull requests. +- Executing production changes. +- Claiming a performance target before benchmark evidence exists. + +## References + +- [Azure Resource Graph overview](https://learn.microsoft.com/azure/governance/resource-graph/overview) +- [ARG pagination API](https://learn.microsoft.com/rest/api/azureresourcegraph/resourcegraph/resources/resources) +- [ARG throttling guidance](https://learn.microsoft.com/azure/governance/resource-graph/concepts/guidance-for-throttled-requests) diff --git a/docs/openssf-silver-evidence.md b/docs/openssf-silver-evidence.md index 921d1c5c..bc3cb239 100644 --- a/docs/openssf-silver-evidence.md +++ b/docs/openssf-silver-evidence.md @@ -43,6 +43,7 @@ submit the public URL or justification. | `crypto_certificate_verification` | Standard verification defaults; no disabled verification in source | | `crypto_verification_private` | Verification occurs in the TLS client before HTTP data is sent | | `hardening` | Website/frontend CSP and security headers, production fail-closed configuration | +| `input_validation` | `docs/input-validation-audit.md`, centralized validators and security regression tests | | `assurance_case` | `docs/security-assurance-case.md` | | `static_analysis_common_vulnerabilities` | CodeQL, Bandit and Semgrep | | `dynamic_analysis_unsafe` | N/A: project code is Python/JavaScript, not C/C++ | @@ -66,7 +67,6 @@ submit the public URL or justification. | `internationalization` | English-only UI; implement localization or mark Unmet with justification | | `regression_tests_added50` | Preliminary audit shows 9 of 12 fixes with test changes; verify behavioral assertions before marking Met | | `interfaces_current` | Review deprecated API warnings and document the periodic check | -| `input_validation` | Complete route-by-route allowlist audit and close discovered gaps | | `crypto_algorithm_agility` | Review JWT and signing algorithm agility; document supported migration path | | `build_repeatable` | Demonstrate repeatable frontend/release output or provide an accurate scripting-language N/A rationale | diff --git a/docs/physical-layer-assurance.md b/docs/physical-layer-assurance.md new file mode 100644 index 00000000..48ffecf8 --- /dev/null +++ b/docs/physical-layer-assurance.md @@ -0,0 +1,31 @@ +# Physical Layer Assurance + +OpenShield reports Azure public-cloud OSI Layer 1 coverage through provider assurance rather than tenant-side hardware scanning. Microsoft owns and operates the physical datacenter network for Azure IaaS, PaaS, and SaaS. Azure tenants cannot inspect its cables, optics, radios, racks, physical access records, power, or cooling systems. + +## Coverage definition + +The bundled catalog is closed and validated at runtime and in tests. It contains: + +- 21 physical domains covering generic OSI Layer 1 functions, Ethernet and wireless PHY functions, network resilience, facility protection, environmental systems, and equipment lifecycle controls. +- Eight generic and IEEE PHY profiles: generic Layer 1, PLCP, PCS, FEC, PMA, PMD, auto-negotiation and link training, and MDI. +- Microsoft SOC controls PE-1 through PE-8. +- All ISO/IEC 27001:2013 A.11 controls, A.11.1.1 through A.11.1.6 and A.11.2.1 through A.11.2.9. + +Catalog coverage and evidence freshness are separate measurements. A control remains mapped when evidence reaches its review date, but its status changes from `PROVIDER_ATTESTED` to `REVIEW_DUE`. OpenShield never converts provider evidence into a technical scan pass or failure and does not include it in the tenant security score. + +## API + +`GET /api/assurance/physical-layer` requires the same JWT authentication as other API routes. The response includes: + +- scope and shared-responsibility boundaries; +- catalog and evidence coverage summaries; +- all physical domains and their control and sublayer mappings; +- all generic and IEEE physical sublayers; +- all baseline controls with expanded Microsoft evidence; +- explicit limitations preventing the report from being interpreted as live hardware inspection or certification. + +The endpoint performs no external request and requires no paid service. Evidence metadata is stored in `compliance/assurance/physical_layer.json`, making assessments deterministic and reviewable in pull requests. + +## Maintaining evidence + +When Microsoft documentation changes, update the affected evidence entry's URL, `reviewed_at`, and `review_due_at` dates. Do not remove a domain, sublayer, PE control, or ISO A.11 control. The validator intentionally rejects incomplete catalogs, unknown references, insecure evidence URLs, non-Microsoft responsibility assignments, and claims of automated physical verification. diff --git a/docs/private-link-controls.md b/docs/private-link-controls.md new file mode 100644 index 00000000..f0ffb4d8 --- /dev/null +++ b/docs/private-link-controls.md @@ -0,0 +1,21 @@ +# Private Link and private DNS controls + +`AZ-NET-018` through `AZ-NET-021` implement the first delivery tranche of issue #253. They use the Azure Resource Manager Private Endpoint inventory, connection state, Private DNS zone groups, custom DNS configuration, and service-specific public-access properties. + +The controls distinguish four outcomes: a finding is `FAIL`; successful evaluation without a finding is `PASS`; empty Private Endpoint inventory is `NOT_APPLICABLE`; and API failures, unsupported target types, absent state, or incomplete DNS evidence are `UNKNOWN` and never create a finding. The current scanner persists failures rather than pass records, so `UNKNOWN` and `NOT_APPLICABLE` are emitted to scanner logs while evidence for failures is included in finding metadata. + +Public-access evaluation is deliberately limited to target types with authoritative service-specific management APIs: Storage accounts, Key Vaults, and Azure SQL logical servers. Other target types are unknown until a service collector is added. AZ-NET-021 evaluates only the `customDnsConfigs` returned for the Private Endpoint. Those values describe Azure's expected DNS configuration; they do not prove effective resolution from a workload VNet or on-premises resolver, whose DNS context may differ from the scanner host. + +Required permissions are `Microsoft.Network/privateEndpoints/read`, `Microsoft.Network/privateEndpoints/privateDnsZoneGroups/read`, and read permission on the target PaaS resource. Remediation playbooks require an operator to validate connectivity before disabling access or modifying DNS. + +## Enterprise perimeter controls + +The remaining issue controls are implemented by `AZ-NET-022` through `AZ-NET-027`: + +- Critical PaaS public exposure covers Storage accounts, Key Vaults, Azure SQL logical servers, PostgreSQL servers, and App Service. Exceptions are exact, case-insensitive resource IDs in `OPENSHIELD_PUBLIC_PAAS_EXCEPTIONS`; partial matches are never accepted. +- Azure Firewall threat intelligence must be `Deny`. +- An enabled Application Gateway WAF must use Prevention mode and export every diagnostic log category supported by its SKU. WAF v1 requires access, performance, and firewall logs; WAF_v2 requires access and firewall logs, while performance telemetry is supplied through Azure Monitor metrics. +- Application Gateway WAF policies must include OWASP 3.2 or Microsoft Default Rule Set 2.1 or later plus Microsoft Bot Manager Rule Set 1.0 or later. +- Public Application Gateways with WAF enabled must have an enabled `RateLimitRule` in the associated WAF policy. + +Each PaaS service inventory is independent. A permission failure for one service is `UNKNOWN` for that service and does not suppress valid findings from another. Application Gateway or WAF policy inventory failures similarly remain `UNKNOWN`. Empty service inventories are `NOT_APPLICABLE`. Rate limiting is deliberately limited to public Application Gateways because the repository currently has no authoritative inventory collector for Front Door, API Management, or third-party edge controls; those services remain unknown rather than being inferred from incomplete inventory. diff --git a/docs/release-security.md b/docs/release-security.md index 505674ac..3b9c56d3 100644 --- a/docs/release-security.md +++ b/docs/release-security.md @@ -2,10 +2,14 @@ ## Current process -Version tags trigger `.github/workflows/release.yml`, which creates GitHub -release notes. Published releases trigger `.github/workflows/sbom-release.yml`, -which generates and uploads a CycloneDX SBOM. Release actions are pinned to -specific commits. +Stable version tags trigger `.github/workflows/release.yml`, which verifies a +signed annotated tag and its commit's `main` ancestry, then creates and attests +the source archive, CycloneDX SBOM and checksum manifest before publication. +The dependent container workflow is owner-opt-in and scans the built image +before publishing a candidate, binds provenance/SBOM to its digest, and verifies +both before version promotion. Release actions are pinned to specific commits. +See [container release integrity](container-release-integrity.md) for the OWASP +registry transition, trust boundaries and first-release validation requirements. ## Required signing process diff --git a/docs/release-verification.md b/docs/release-verification.md new file mode 100644 index 00000000..7bc805ea --- /dev/null +++ b/docs/release-verification.md @@ -0,0 +1,57 @@ +# Verifying OpenShield Releases + +For the current OWASP-hosted container workflow, see +[container release integrity](container-release-integrity.md), including owner +opt-in, the new GHCR namespace and digest verification. The commands below retain +the historical source-artifact identity for releases from `openshield-org`; +for new OWASP-hosted source releases substitute `OWASP/openshield` in both +`--repo` and `--signer-workflow`. + +OpenShield release artifacts are produced only from a GitHub-verified signed +annotated tag. GitHub Actions generates a deterministic source archive, a +CycloneDX SBOM and SHA-256 checksums, then creates identity-bound Sigstore +provenance attestations before uploading the files to the release. + +## Verify checksums + +Download all release assets into one directory, then run: + +```bash +sha256sum --check SHA256SUMS +``` + +## Verify provenance + +Install the GitHub CLI and verify each artifact against this repository: + +```bash +gh attestation verify openshield-vX.Y.Z.tar.gz \ + --repo openshield-org/openshield \ + --signer-workflow openshield-org/openshield/.github/workflows/release.yml + +gh attestation verify openshield-vX.Y.Z-sbom.cyclonedx.json \ + --repo openshield-org/openshield \ + --signer-workflow openshield-org/openshield/.github/workflows/release.yml + +gh attestation verify SHA256SUMS \ + --repo openshield-org/openshield \ + --signer-workflow openshield-org/openshield/.github/workflows/release.yml +``` + +Successful verification proves that the artifact digest was attested by the +OpenShield release workflow for this public repository. It does not mean that +GitHub or Sigstore audited the source code. + +## Maintainer release procedure + +1. Confirm the release commit is on the approved `main` history and CI passes. +2. Create a signed annotated tag: `git tag -s vX.Y.Z -m "OpenShield vX.Y.Z"`. + SSH signing may be used when Git is configured with `gpg.format=ssh`. +3. Verify locally with `git tag -v vX.Y.Z` using the project's trusted signer + configuration. +4. Push only the tag: `git push origin vX.Y.Z`. +5. The workflow independently asks GitHub to verify the tag signature. A + lightweight or unverified tag fails before artifacts are produced. +6. After publication, download and verify every asset using the commands above. + +Existing historical lightweight tags are not retroactively described as signed. diff --git a/docs/rules-reference.md b/docs/rules-reference.md index 8b2b4f5d..1cfb1cb8 100644 --- a/docs/rules-reference.md +++ b/docs/rules-reference.md @@ -1,6 +1,6 @@ -# Rules Reference +# Rules Reference -OpenShield currently ships 51 Azure scan rules. This table is generated from the module-level constants in `scanner/rules/`. +OpenShield currently ships 90 Azure scan rules. This table is generated from the module-level constants in `scanner/rules/`. | Rule ID | Name | Severity | Category | CIS | NIST | ISO 27001 | |---|---|---|---|---|---|---| @@ -21,17 +21,27 @@ OpenShield currently ships 51 Azure scan rules. This table is generated from the | AZ-IDN-007 | Active User with No MFA Registered in Entra ID | HIGH | Identity | 1.1 | PR.AC-7 | A.9.4.2 | | AZ-IDN-008 | Custom RBAC Role with Wildcard Permissions at Subscription Scope | HIGH | Identity | 1.23 | PR.AC-4 | A.9.2.3 | | AZ-IDN-009 | No Activity Log Alert for Role Assignment Changes | MEDIUM | Identity | 5.2.1 | DE.CM-3 | A.12.4.1 | -| AZ-IDN-010 | App Registration Has No Owner | MEDIUM | Identity | TBD-IDN-010 | PR.AC-4 | A.9.2.1 | -| AZ-IDN-011 | App Registration Uses Insecure Redirect URI | HIGH | Identity | TBD-IDN-011 | PR.DS-2 | A.14.1.2 | -| AZ-IDN-012 | App Registration Enables OAuth Implicit Grant | MEDIUM | Identity | TBD-IDN-012 | PR.AC-3 | A.9.4.2 | -| AZ-IDN-013 | App Registration Uses Password Credentials | MEDIUM | Identity | TBD-IDN-013 | PR.AC-1 | A.9.4.3 | -| AZ-IDN-014 | Multi-Tenant App Registration Lacks Property Lock | HIGH | Identity | TBD-IDN-014 | PR.IP-1 | A.12.1.2 | -| AZ-IDN-015 | Managed Identity Has Privileged Subscription Role | HIGH | Identity | TBD-IDN-015 | PR.AC-4 | A.9.2.3 | -| AZ-KV-001 | Key Vault with Soft Delete Disabled | MEDIUM | KeyVault | 8.8 | PR.IP-4 | A.17.2.1 | +| AZ-IDN-010 | App Registration Has No Owner | MEDIUM | Identity | N/A-IDN-010 | PR.AC-4 | A.9.2.1 | +| AZ-IDN-011 | App Registration Uses Insecure Redirect URI | HIGH | Identity | N/A-IDN-011 | PR.DS-2 | A.14.1.2 | +| AZ-IDN-012 | App Registration Enables OAuth Implicit Grant | MEDIUM | Identity | N/A-IDN-012 | PR.AC-3 | A.9.4.2 | +| AZ-IDN-013 | App Registration Uses Password Credentials | MEDIUM | Identity | N/A-IDN-013 | PR.AC-1 | A.9.4.3 | +| AZ-IDN-014 | Multi-Tenant App Registration Lacks Property Lock | HIGH | Identity | N/A-IDN-014 | PR.IP-1 | A.12.1.2 | +| AZ-IDN-015 | Managed Identity Has Privileged Subscription Role | HIGH | Identity | N/A-IDN-015 | PR.AC-4 | A.9.2.3 | +| AZ-IDN-016 | Privileged User Missing Phishing-Resistant MFA | CRITICAL | Identity | N/A-IDN-016 | PR.AC-7 | A.9.4.2 | +| AZ-IDN-017 | Global Administrator Permanently Assigned Outside PIM | HIGH | Identity | N/A-IDN-017 | PR.AC-4 | A.9.2.3 | +| AZ-IDN-018 | Privileged Role Assigned Outside Privileged Identity Management | HIGH | Identity | N/A-IDN-018 | PR.AC-4 | A.9.2.3 | +| AZ-IDN-019 | Stale Privileged Account Retains Active Access | HIGH | Identity | N/A-IDN-019 | PR.AC-1 | A.9.2.5 | +| AZ-IDN-020 | Emergency Access Accounts Missing or Incorrectly Configured | HIGH | Identity | N/A-IDN-020 | PR.AC-4 | A.9.1.2 | +| AZ-IDN-021 | Legacy Authentication Not Blocked by Conditional Access | HIGH | Identity | N/A-IDN-021 | PR.AC-7 | A.9.4.2 | +| AZ-IDN-022 | Azure Management Not Protected by Conditional Access | HIGH | Identity | N/A-IDN-022 | PR.AC-4 | A.9.4.1 | +| AZ-IDN-023 | Identity Protection Risk Policies Not Enabled | MEDIUM | Identity | N/A-IDN-023 | DE.CM-3 | A.12.4.1 | +| AZ-IDN-024 | Workload Identities Excluded From Conditional Access Policies | MEDIUM | Identity | N/A-IDN-024 | PR.AC-4 | A.9.2.3 | +| AZ-IDN-025 | Privileged Role-Assignable Group Has No Owner | MEDIUM | Identity | N/A-IDN-025 | PR.AC-4 | A.9.2.5 | +| AZ-KV-001 | Key Vault with Soft Delete Disabled | MEDIUM | KeyVault | N/A-KV-001 | PR.IP-4 | A.17.2.1 | | AZ-KV-002 | Key Vault Allows Public Network Access Without Private Endpoint | HIGH | Key Vault | 8.7 | 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.6 | PR.IP-4 | A.17.2.1 | -| AZ-KV-005 | Key Vault Certificate Expiring Within 30 Days | MEDIUM | Key Vault | 8.5 | PR.MA-1 | A.10.1.2 | +| 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 | N/A-KV-005 | PR.MA-1 | 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 | @@ -43,10 +53,20 @@ OpenShield currently ships 51 Azure scan rules. This table is generated from the | 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.10 | SC-7 | A.13.1.1 | | AZ-NET-011 | Network Watcher Not Enabled in All Regions | LOW | Network | 6.5 | DE.CM-7 | A.12.4.1 | -| AZ-NET-012 | NSG Flow Logs Not Enabled | MEDIUM | Network | 6.7 | DE.CM-1 | A.12.4.1 | +| AZ-NET-012 | VNet Flow Logs Not Enabled | MEDIUM | Network | 6.7 | DE.CM-1 | A.12.4.1 | | AZ-NET-013 | Azure Firewall Not Enabled on Virtual Network | HIGH | Network | 6.4 | PR.AC-5 | A.13.1.1 | | AZ-NET-014 | VNet Peering Configured Without Gateway Transit Restrictions | MEDIUM | Network | 6.6 | PR.AC-5 | A.13.1.1 | | AZ-NET-015 | Public DNS Zone Exposes Internal Infrastructure Details | MEDIUM | Network | 9.8 | PR.AC-5 | A.13.1.1 | +| AZ-NET-018 | Private Endpoint Target Retains Public Network Access | HIGH | Network | N/A-NET-018 | PR.AC-3 | A.13.1.1 | +| AZ-NET-019 | Private Endpoint Connection Is Not Approved | HIGH | Network | N/A-NET-019 | PR.AC-5 | A.13.1.1 | +| AZ-NET-020 | Private Endpoint Lacks Private DNS Zone Association | HIGH | Network | N/A-NET-020 | PR.AC-5 | A.13.1.1 | +| AZ-NET-021 | Private Endpoint DNS Configuration Reports Only Public Addresses | HIGH | Network | N/A-NET-021 | PR.AC-5 | A.13.1.1 | +| AZ-NET-022 | Critical PaaS Resource Is Publicly Accessible Without Approved Exception | HIGH | Network | N/A-NET-022 | PR.AC-3 | A.13.1.1 | +| AZ-NET-023 | Azure Firewall Threat Intelligence Is Not in Deny Mode | HIGH | Network | N/A-NET-023 | DE.CM-1 | A.13.1.1 | +| AZ-NET-024 | Application Gateway WAF Is Not in Prevention Mode | HIGH | Network | N/A-NET-024 | PR.PT-4 | A.13.1.1 | +| AZ-NET-025 | Application Gateway WAF Diagnostic Logging Is Not Enabled | MEDIUM | Network | N/A-NET-025 | DE.CM-1 | A.12.4.1 | +| AZ-NET-026 | WAF Lacks Current Managed Rules or Bot Protection | HIGH | Network | N/A-NET-026 | PR.PT-4 | A.14.2.5 | +| AZ-NET-027 | Internet-Facing Application Gateway Lacks Approved Rate Limiting | HIGH | Network | N/A-NET-027 | PR.PT-4 | A.13.1.1 | | AZ-PQC-001 | TLS Using Classical Key Exchange Algorithm | HIGH | PostQuantum | 9.9 | PR.DS-2 | A.10.1.1 | | AZ-PQC-002 | Key Vault Key Using Non-Quantum-Safe Algorithm | HIGH | PostQuantum | 8.1 | PR.DS-2 | A.10.1.1 | | AZ-PQC-003 | Key Vault Certificate Using Non-Quantum-Safe Signature Algorithm | MEDIUM | PostQuantum | 8.9 | PR.DS-2 | A.10.1.1 | @@ -55,12 +75,45 @@ OpenShield currently ships 51 Azure scan rules. This table is generated from the | 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.3 | DE.CM-7 | A.12.4.1 | | AZ-STOR-005 | Storage Account Not Using Geo-Redundant Replication | MEDIUM | Storage | 3.8 | PR.IP-4 | A.17.2.1 | +| AZ-STOR-006 | Storage Account Shared-Key Authorization Enabled | HIGH | Storage | N/A-STOR-006 | N/A-STOR-006 | N/A-STOR-006 | +| AZ-STOR-007 | Storage Account Allows TLS Below 1.2 | HIGH | Storage | N/A-STOR-007 | N/A-STOR-007 | N/A-STOR-007 | +| AZ-STOR-008 | Required Storage Customer-Managed Key Protection Missing | HIGH | Storage | N/A-STOR-008 | N/A-STOR-008 | N/A-STOR-008 | +| AZ-STOR-009 | Required Blob Container Immutability Missing | HIGH | Storage | N/A-STOR-009 | N/A-STOR-009 | N/A-STOR-009 | +| AZ-DB-005 | SQL Server Microsoft Entra-Only Authentication Not Enforced | HIGH | Database | N/A-DB-005 | PR.AC-6 | A.9.4.2 | +| AZ-DB-006 | SQL Vulnerability Assessment Not Configured | HIGH | Database | N/A-DB-006 | DE.CM-8 | A.12.6.1 | +| AZ-DB-007 | SQL Auditing Retention Below Minimum | MEDIUM | Database | N/A-DB-007 | PR.PT-1 | A.12.4.1 | +| AZ-COSMOS-001 | Cosmos DB Local Authentication Enabled | HIGH | Database | N/A-COSMOS-001 | PR.AC-6 | A.9.4.2 | +| AZ-COSMOS-002 | Cosmos DB Public Network Access Enabled | HIGH | Network | N/A-COSMOS-002 | PR.AC-5 | A.13.1.1 | +| AZ-CACHE-001 | Managed Cache Public or Non-TLS Access | HIGH | Network | N/A-CACHE-001 | PR.AC-5 | A.13.1.1 | | AZ-AKS-001 | AKS Private Cluster Not Enabled | HIGH | Kubernetes | N/A-AKS-001 | PR.AC-3 | A.13.1.1 | | AZ-AKS-002 | AKS Local Accounts Enabled | HIGH | Kubernetes | N/A-AKS-002 | PR.AC-1 | A.9.2.1 | | AZ-AKS-003 | AKS Cluster Not Using Managed Identity | HIGH | Kubernetes | N/A-AKS-003 | PR.AC-1 | A.9.2.1 | | AZ-AKS-004 | AKS Workload Identity Not Fully Enabled | MEDIUM | Kubernetes | N/A-AKS-004 | PR.AC-4 | A.9.2.3 | | AZ-AKS-005 | AKS Azure Policy Add-on Not Enabled | MEDIUM | Kubernetes | N/A-AKS-005 | PR.IP-1 | A.12.1.2 | | AZ-AKS-006 | AKS Node OS Automatic Upgrades Disabled | HIGH | Kubernetes | N/A-AKS-006 | PR.IP-12 | A.12.6.1 | +| AZ-BAK-001 | Backup Soft Delete Disabled or Below 35 Days | CRITICAL | Backup | N/A-BAK-001 | PR.IP-4 | A.12.3.1 | +| AZ-BAK-002 | Backup Vault Immutability Disabled | HIGH | Backup | N/A-BAK-002 | PR.IP-4 | A.12.3.1 | +| AZ-BAK-004 | Backup Multiuser Authorization Missing | HIGH | Backup | N/A-BAK-004 | PR.AC-4 | A.9.2.3 | +| AZ-BAK-006 | Backup Security Monitoring Disabled | MEDIUM | Backup | N/A-BAK-006 | DE.CM-1 | A.12.4.1 | +| AZ-FUNC-001 | Function App HTTPS Only Disabled | HIGH | Serverless | N/A-FUNC-001 | PR.DS-2 | A.13.2.1 | +| AZ-FUNC-002 | Function App Minimum TLS Below 1.2 | HIGH | Serverless | N/A-FUNC-002 | PR.DS-2 | A.13.2.1 | +| AZ-FUNC-003 | Function App FTP Publishing Enabled | MEDIUM | Serverless | N/A-FUNC-003 | PR.AC-5 | A.13.1.1 | +| AZ-FUNC-004 | Function App Remote Debugging Enabled | HIGH | Serverless | N/A-FUNC-004 | PR.AC-5 | A.13.1.1 | +| AZ-FUNC-005 | Function App Managed Identity Missing | MEDIUM | Serverless | N/A-FUNC-005 | PR.AC-5 | A.13.1.1 | +| AZ-PE-001 | Storage Public Network Access Enabled | HIGH | Network | N/A-PE-001 | PR.AC-5 | A.13.1.1 | +| AZ-PE-002 | Azure SQL Public Network Access Enabled | HIGH | Network | N/A-PE-002 | PR.AC-5 | A.13.1.1 | +| AZ-PE-003 | PostgreSQL Public Network Access Enabled | HIGH | Network | N/A-PE-003 | PR.AC-5 | A.13.1.1 | +| AZ-PE-004 | Web or Function App Public Network Access Enabled | HIGH | Network | N/A-PE-004 | PR.AC-5 | A.13.1.1 | +| AZ-PE-005 | Recovery Vault Public Network Access Enabled | HIGH | Network | N/A-PE-005 | PR.AC-5 | A.13.1.1 | +| AZ-PE-006 | Private Endpoint Connection Not Approved | MEDIUM | Network | N/A-PE-006 | PR.AC-5 | A.13.1.1 | +| AZ-SC-001 | Container Registry Admin User Enabled | HIGH | Supply Chain | N/A-SC-001 | PR.AC-1 | A.9.2.1 | +| AZ-SC-002 | Container Registry Public Network Access Enabled | HIGH | Supply Chain | N/A-SC-002 | PR.AC-5 | A.13.1.1 | +| AZ-SC-003 | Container Registry Allows Anonymous Pull | HIGH | Supply Chain | N/A-SC-003 | PR.AC-1 | A.9.2.1 | +| AZ-SC-004 | Container Registry Missing Retention or Quarantine Policy | MEDIUM | Supply Chain | N/A-SC-004 | PR.IP-1 | A.12.1.2 | +| AZ-SC-005 | Terraform State Storage Container Publicly Readable | CRITICAL | Supply Chain | N/A-SC-005 | PR.AC-5 | A.13.1.1 | +| AZ-SC-006 | Terraform State Storage Account Missing Versioning or Soft Delete | HIGH | Supply Chain | N/A-SC-006 | PR.IP-4 | A.12.3.1 | +| AZ-SC-007 | Pipeline Service Connection Scoped to Subscription | HIGH | Supply Chain | N/A-SC-007 | PR.AC-4 | A.9.2.3 | +| AZ-SC-008 | Pipeline Service Connection Uses Password Instead of Federated Credential | MEDIUM | Supply Chain | N/A-SC-008 | PR.AC-1 | A.9.4.3 | SOC 2 mappings are maintained in `compliance/frameworks/soc2.json`. diff --git a/docs/security-operations-rules.md b/docs/security-operations-rules.md new file mode 100644 index 00000000..8ae7c65d --- /dev/null +++ b/docs/security-operations-rules.md @@ -0,0 +1,192 @@ +# Enterprise Security Operations Rules + +Issue #262 adds logging, Microsoft Defender for Cloud, and Microsoft Sentinel operational-health +controls. The foundation phase established policy and evidence collection without inventing +organisation-specific security requirements; this phase adds the ten evaluation rules built on +top of that foundation. + +## Coverage + +| Rule | Control | +|---|---| +| `AZ-SECOPS-001` | Subscription activity log not exported to an approved central destination | +| `AZ-SECOPS-002` | Required activity-log categories missing from the central export | +| `AZ-SECOPS-003` | Critical resource missing required diagnostic settings | +| `AZ-SECOPS-004` | Security logs have insufficient retention | +| `AZ-SECOPS-005` | Security logs stored only in a destination modifiable by workload administrators | +| `AZ-SECOPS-006` | Required Microsoft Defender for Cloud plan not enabled | +| `AZ-SECOPS-007` | High-risk Defender recommendation unresolved beyond SLA | +| `AZ-SECOPS-008` | Required Microsoft Sentinel data connector disconnected or unhealthy | +| `AZ-SECOPS-009` | Sentinel missing required high-severity analytics coverage | +| `AZ-SECOPS-010` | Security alerts have no monitored incident-response destination | + +## Policy boundary + +`config/security-operations-policy.example.json` documents every value that an organisation must +approve: critical resource types, central destinations, activity-log categories, retention, +Defender plans and SLA, Sentinel connectors and analytics coverage, and exclusions. The example is +not a production baseline and is never loaded automatically. + +Every AZ-SECOPS rule reads its policy from the file named by the +`OPENSHIELD_SECURITY_OPERATIONS_POLICY` environment variable. If the variable is unset, the +referenced file is missing, or the file fails `load_security_operations_policy`'s strict +validation, the rule logs a warning and returns no findings — an unapproved policy can never +produce a compliance claim in either direction. Operators must set this variable to their +organisation's own policy file (never the example file) before running these ten rules. + +## Sentinel scope: auto-discovered, not configured + +Microsoft Sentinel workspace scope is **discovered at scan time**, not named in the policy file. +Every Log Analytics workspace in the subscription is enumerated +(`LogAnalyticsManagementClient.workspaces.list()`, genuinely subscription-wide), and each +workspace's Sentinel onboarding state is checked individually +(`SecurityInsights.sentinel_onboarding_states.list(resource_group, workspace_name)`, which is not +subscription-wide — Sentinel's SDK requires a resource group and workspace name for every +operation). Only workspaces confirmed onboarded are queried for data connectors, analytics rules, +and automation rules. This means a newly onboarded Sentinel workspace is covered automatically on +the next scan with no policy change required. + +## Outcome model + +Issue #262 requires a four-state PASS/FAIL/UNKNOWN/NOT_APPLICABLE outcome per control, but +`scanner/engine.py` only consumes a findings list and treats *presence of a finding* as the sole +non-compliance signal (it feeds the list directly into severity-weighted scoring). All ten rules +therefore represent the outcomes as follows, matching the existing convention already used by +`az_dl_001`/`az_dl_002` and `az_kv_003`: + +- **PASS** — an empty list. No finding is emitted for a compliant resource. +- **NOT_APPLICABLE** — an empty list plus an `INFO`-level log line (e.g. no critical resources in + scope, no Sentinel-onboarded workspaces, no required plans/connectors/analytics configured). +- **UNKNOWN** — an empty list plus a `WARNING`-level log line. Any collector failure — a missing + policy, a permission error, an inaccessible workspace, a failed Defender/Sentinel API call — is + UNKNOWN and is never promoted to FAIL or silently treated as PASS. +- **FAIL** — the only outcome that produces a finding dict, and only with positive evidence of a + missing or unsafe control (e.g. an actual diagnostic setting missing an approved destination, an + actual disabled Defender plan, an actual disconnected connector). + +This is a real, structural limitation of the current engine contract: NOT_APPLICABLE and UNKNOWN +are not individually addressable as distinct dict rows the way FAIL findings are. The richer +per-finding metadata issue #262 also asks for — scope, category, destination, retention, +ownership, evidence timestamp, remediation, permissions required, severity, confidence, and an +`unknown_reason` field — is carried on every FAIL finding's `metadata` dict so downstream tooling +can still see "why," even though that metadata cannot currently attach to an outcome that produces +no finding at all. Changing `engine.py`'s contract to add first-class UNKNOWN/NOT_APPLICABLE rows +was out of scope for this change and would affect every existing rule, not just these ten. + +## Rule details + +### AZ-SECOPS-001 — Subscription activity log not exported + +Checks the subscription's `subscription_diagnostic_settings` for at least one setting whose +`workspace_id`, `storage_account_id`, or `event_hub_authorization_rule_id` matches +`approved_destination_ids`. No approved-destination match is a HIGH finding. + +### AZ-SECOPS-002 — Required activity-log categories missing + +Given at least one approved-destination export exists (otherwise AZ-SECOPS-001 owns the finding), +checks that every category in `required_activity_categories` appears as an *enabled* `LogSettings` +entry on an approved-destination setting. A disabled category counts as missing. + +### AZ-SECOPS-003 — Critical resource missing required diagnostic settings + +For every resource ID returned by `critical_resource_ids(policy)` (minus `approved_exclusions`), +checks `resource_diagnostic_settings` for at least one setting reaching an approved destination. + +### AZ-SECOPS-004 — Security logs have insufficient retention + +For each critical resource's diagnostic settings that target a Storage Account destination, +checks that every enabled log's `retention_policy` is enabled with `days >= minimum_retention_days`. +Log Analytics workspace retention is a workspace-level setting, not a `retention_policy` field on +the diagnostic setting, and is intentionally out of scope for this rule. + +### AZ-SECOPS-005 — Security logs stored only in a workload-administrator-modifiable destination + +For each critical resource with a diagnostic export that is **not** on the approved-destination +list, checks whether every configured destination's resource group matches the workload resource's +own resource group. If so, an administrator with Contributor/Owner on that resource group can +modify or delete the exported logs. This is a resource-group/ownership comparison — the Monitor +diagnostic-settings API does not expose a destination's own access-control or immutability +configuration, so this rule cannot verify an actual Storage immutability policy directly. + +### AZ-SECOPS-006 — Required Defender for Cloud plan not enabled + +Checks `Microsoft.Security/pricings` (subscription scope) for each plan named in +`required_defender_plans`; a plan whose `pricing_tier` is not `Standard` is a HIGH finding. Each +finding records the matching CIS Azure Foundations Benchmark leaf control for that specific plan +(e.g. 2.1.7 for StorageAccounts) in `metadata.cis_control_reference`, since the rule itself is +policy-driven across whichever plans the organisation names and has no single fixed CIS mapping. + +### AZ-SECOPS-007 — High-risk Defender recommendation unresolved beyond SLA + +Checks `Microsoft.Security/assessments` for entries with `metadata.severity == High` and +`status.code == Unhealthy`. Age is computed from a first-observed timestamp read defensively from +the assessment's `additional_data` map (the SDK's typed `AssessmentStatusResponse` model exposes +no timestamp field at all). An assessment with no recognised timestamp key is excluded from this +rule's findings — its age is genuinely unknown and is never assumed to be either compliant or +overdue. + +### AZ-SECOPS-008 — Required Sentinel data connector disconnected or unhealthy + +For each Sentinel-onboarded workspace, checks `data_connectors.list` for every connector kind in +`required_sentinel_connectors`. A connector is "connected" when present with at least one enabled +`data_types` entry (or when its connector kind exposes no typed `data_types` field at all, in +which case presence is the only available signal). Missing or fully-disabled connectors are HIGH +findings, distinguished as `missing` vs. `disconnected` in `metadata.connector_state`. + +### AZ-SECOPS-009 — Sentinel missing required high-severity analytics coverage + +For each Sentinel-onboarded workspace, checks `alert_rules.list` for an **enabled** scheduled rule +with `severity == High` whose `display_name` matches (case/separator-insensitive substring) each +use case in `required_high_severity_analytics`. A disabled rule, a wrong-severity rule, or no +matching rule at all is a HIGH finding. + +### AZ-SECOPS-010 — Security alerts have no monitored incident-response destination + +Checks Azure Monitor `action_groups.list_by_subscription_id()` for at least one **enabled** action +group with at least one receiver (email, SMS, voice, webhook, ITSM, Logic App, Automation +runbook, Azure Function, or Event Hub). If none exists, checks whether any Sentinel-onboarded +workspace has at least one automation rule (Sentinel's native incident-routing mechanism, +typically a `RunPlaybook` action) as an accepted alternative destination. Only when *neither* +mechanism exists is this a HIGH finding. + +## Collection behavior + +- Collection is read-only and retrieves configuration metadata only, never log, alert, or incident + content. +- A successful empty response is `COMPLETE` with no items. +- Permission, API, or transport failures are `FAILED`; they are never converted into an empty + inventory or compliance claim. +- Sentinel's per-workspace fan-out (onboarding checks, data connectors, alert rules, automation + rules) uses a third `PARTIAL` `CollectionStatus`: when some workspaces succeed and others fail, + the succeeded workspaces' evidence is still usable and the failed workspaces are listed in + `CollectionResult.failed_scopes` so rules can treat only the affected scope as UNKNOWN rather than + discarding evidence for every other workspace in the subscription. +- Resource diagnostic settings are collected only for policy-scoped critical resource IDs. + +## Minimum Azure permissions + +- `Microsoft.Insights/diagnosticSettings/read` — activity-log and resource diagnostic settings + (AZ-SECOPS-001 through 005). +- `Microsoft.Insights/actionGroups/read` — action group inventory (AZ-SECOPS-010). +- `Microsoft.Security/pricings/read` — Defender plan pricing tier (AZ-SECOPS-006). +- `Microsoft.Security/assessments/read` — Defender recommendations (AZ-SECOPS-007). +- `Microsoft.OperationalInsights/workspaces/read` — Log Analytics workspace enumeration used for + Sentinel auto-discovery. +- `Microsoft.SecurityInsights/onboardingStates/read`, `.../dataConnectors/read`, + `.../alertRules/read`, `.../automationRules/read` — Sentinel workspace and content collection + (AZ-SECOPS-008, 009, 010). +- Read access to the resource inventory used to select critical resources. + +No remediation or write permission is required by any of these ten rules; the accompanying +`playbooks/cli/fix_az_secops_*.sh` scripts run separately under an operator identity. + +## References + +- [CIS Microsoft Azure Foundations Benchmark v2.0.0](https://www.cisecurity.org/benchmark/azure) +- [Azure Monitor diagnostic settings](https://learn.microsoft.com/azure/azure-monitor/essentials/diagnostic-settings) +- [Azure Activity Log](https://learn.microsoft.com/azure/azure-monitor/essentials/activity-log) +- [Microsoft Defender for Cloud pricing](https://learn.microsoft.com/azure/defender-for-cloud/pricing) +- [Microsoft Defender for Cloud recommendations](https://learn.microsoft.com/azure/defender-for-cloud/review-security-recommendations) +- [Microsoft Sentinel data connectors](https://learn.microsoft.com/azure/sentinel/data-connectors-reference) +- [Microsoft Sentinel automation rules](https://learn.microsoft.com/azure/sentinel/automate-incident-handling-with-automation-rules) +- [Azure Monitor action groups](https://learn.microsoft.com/azure/azure-monitor/alerts/action-groups) diff --git a/docs/security-requirements.md b/docs/security-requirements.md index 5254935c..6dd4792d 100644 --- a/docs/security-requirements.md +++ b/docs/security-requirements.md @@ -47,3 +47,6 @@ The security policy, architecture, assurance case, automated test suite, SAST, secret scanning, dependency review, SBOM generation and container scanning form the public evidence for these requirements. Known defects must be tracked and resolved through GitHub issues or private advisories as appropriate. + +The reviewed input boundaries, limits, allowlists and intentional free-text +exceptions are recorded in `docs/input-validation-audit.md`. diff --git a/docs/severity-contract.md b/docs/severity-contract.md new file mode 100644 index 00000000..4a4239df --- /dev/null +++ b/docs/severity-contract.md @@ -0,0 +1,31 @@ +# Finding Severity Contract + +OpenShield uses [`contracts/severity.v1.json`](../contracts/severity.v1.json) as the single semantic source for finding severity. Scanner rules, persistence, API validation, scoring, resource risk, prioritization, Sentinel export, frontend filters, charts, and Tailwind colors consume this contract through the Python or JavaScript adapter. The frontend commits a byte-equivalent generated mirror under `frontend/src/generated/` because its Vercel project root is `frontend/`; `npm run test:severity` rejects any drift. + +## Version 1.0.0 + +| Severity | Rank | Posture deduction per finding | Matrix risk | Meaning | +|---|---:|---:|---:|---| +| `CRITICAL` | 4 | 20 | 10 | Immediate exploitation or catastrophic business-impact risk | +| `HIGH` | 3 | 10 | 8 | Direct, material security risk | +| `MEDIUM` | 2 | 5 | 5 | Indirect or partial security risk | +| `LOW` | 1 | 2 | 2 | Security hardening or best-practice gap | +| `INFO` | 0 | 0 | 1 | Informational evidence that does not reduce the posture score | + +`INFORMATIONAL` is accepted only as an input alias and is persisted and returned as `INFO`. Unknown values are rejected. `NONE` is a resource-view sentinel, not a finding severity. Evaluation states such as `PASS`, `UNKNOWN`, or `NOT_APPLICABLE` are also not severities. + +The v1 posture score is `max(0, 100 - sum(finding deductions))`. This model only describes severity arithmetic. Issue #263 tracks evidence completeness; a future coverage-aware score must not present incomplete collection or rule execution as a clean result. + +Prioritization may raise an impact label when many affected resources compound risk, but it must never lower the label below the finding's canonical severity. + +## Changing the contract + +Severity meaning is a public data contract. A change to an ID, alias, rank, weight, risk score, label, tone, or color requires all of the following in one coordinated release: + +1. Add a new immutable contract file (for example, `severity.v2.json`), run `npm run sync:severity` in `frontend/`, and update both adapters. Do not edit v1 semantics in place after release. +2. Add an Alembic migration that inventories existing values, explicitly maps supported legacy values, rejects unknown data, updates the database constraint, and backfills stored scores and the contract version. +3. Update scanner, API, Sentinel, frontend, documentation, and contract tests together. No consumer may maintain a fallback severity order or weight map. +4. Drain scanner workers and validate the migration against a production-sized staging copy before rollout. Document the expected score changes and rollback limitations. Contract provenance is nullable so any legacy worker result that lands during a rollout cannot be mislabeled as v1. +5. Deploy and wait for the migration-owning API before creating the worker deployment. Then verify scanner/database score parity plus every severity-facing API and dashboard view. + +Downgrades cannot truthfully restore scores that were previously calculated with incorrect semantics. Treat a score repair as an auditable data correction and retain the contract version on each scan. diff --git a/docs/storage-protection-controls.md b/docs/storage-protection-controls.md new file mode 100644 index 00000000..43ea8acb --- /dev/null +++ b/docs/storage-protection-controls.md @@ -0,0 +1,27 @@ +# Storage protection controls + +The enterprise Storage controls in issue #261 use explicit opt-in metadata for +requirements that depend on business criticality. OpenShield does not infer that +every account or container needs a customer-managed key or immutability policy. + +Use Azure resource tags as follows: + +- `oshield:cmk-required=true` enables `AZ-STOR-008` for a storage account. +- `oshield:immutability-required=true` enables `AZ-STOR-009` for a storage account; all containers under that account are checked for an immutability policy. +- `oshield:entra-only-required=true` enables `AZ-DB-005` for a SQL server. +- `oshield:sql-va-required=true` enables `AZ-DB-006` for a SQL server. +- `oshield:sql-audit-required=true` enables `AZ-DB-007` for a SQL server. +- `oshield:cosmos-local-auth-disabled=true` enables `AZ-COSMOS-001`. +- `oshield:cosmos-public-access-disabled=true` enables `AZ-COSMOS-002`. +- `oshield:cache-private-tls-required=true` enables `AZ-CACHE-001`. +- `oshield:exception-approved=true` suppresses either control only when the + organization has separately approved the exception. + +Missing tags are not treated as proof that protection is required. Missing +encryption properties, unknown key sources, or an unavailable container API are +indeterminate and produce no finding. A finding is emitted only when the +requirement is explicitly enabled and an unsafe state is positively observed. + +The tags are policy inputs, not evidence of approval by themselves; operators +must maintain the corresponding exception and criticality records outside the +scanner. diff --git a/docs/validation/FRONTEND_API_TESTING.md b/docs/validation/FRONTEND_API_TESTING.md index 4b3ba19d..09f98b4f 100644 --- a/docs/validation/FRONTEND_API_TESTING.md +++ b/docs/validation/FRONTEND_API_TESTING.md @@ -35,7 +35,7 @@ This guide validates the **frontend/API/database integration** of OpenShield. It | Frontend Page | API Helper Function | Backend Endpoint | Method | Auth Required | Expected Data Source | Current Status | Validation Notes | |---|---|---|---|---|---|---|---| | All (health check) | `api.health()` | `GET /health` | GET | No | None (static response) | Registered in `app.py` | Always returns `{"status":"ok"}` | -| Monitoring | `api.getScore()` | `GET /api/score` | GET | No (public GET) | Computed from findings | Registered (`score_bp`) | Score = 100 - (HIGH*10) - (MEDIUM*5) - (LOW*2) | +| Monitoring | `api.getScore()` | `GET /api/score` | GET | No (public GET) | Computed from findings | Registered (`score_bp`) | Contract v1 score = 100 - (CRITICAL*20) - (HIGH*10) - (MEDIUM*5) - (LOW*2) | | Monitoring | `api.getCVESummary()` | `GET /api/score/cve-summary` | GET | No (public GET) | DB + CVE correlation | Registered (`score_bp`) | Returns null on failure (try/catch) | | Monitoring, Discovery, Scan, AI | `api.getFindings()` | `GET /api/findings` | GET | No (public GET) | Database (findings+rules) | Registered (`findings_bp`) | Supports ?severity, ?category, ?rule_id filters | | DetailedScan | `api.getFinding(id)` | `GET /api/findings/:id` | GET | No (public GET) | Database | Registered (`findings_bp`) | Returns 404 if not found | diff --git a/docs/validation/SCANNER_VALIDATION.md b/docs/validation/SCANNER_VALIDATION.md index 10d93665..32a7f093 100644 --- a/docs/validation/SCANNER_VALIDATION.md +++ b/docs/validation/SCANNER_VALIDATION.md @@ -68,7 +68,7 @@ The expected finding fields are: |---|---| | `rule_id` | Stable OpenShield rule ID, for example `AZ-STOR-001` | | `rule_name` | Human-readable rule title | -| `severity` | Severity label such as `HIGH`, `MEDIUM`, `LOW`, or `INFO` | +| `severity` | Canonical severity: `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, or `INFO` | | `category` | Rule category such as `Storage`, `Network`, or `Key Vault` | | `resource_id` | Full Azure resource ID when available | | `resource_name` | Azure resource name | @@ -86,18 +86,20 @@ not already include them. The following matrix was verified from actual files in `scanner/rules`. -Total verified rule files: **44** +Total verified rule files: **64** | Category | Count | Rule IDs | |---|---:|---| | Compute | 4 | `AZ-CMP-001`, `AZ-CMP-002`, `AZ-CMP-003`, `AZ-CMP-004` | -| Database | 4 | `AZ-DB-001`, `AZ-DB-002`, `AZ-DB-003`, `AZ-DB-004` | -| Identity | 9 | `AZ-IDN-001`, `AZ-IDN-002`, `AZ-IDN-003`, `AZ-IDN-004`, `AZ-IDN-005`, `AZ-IDN-006`, `AZ-IDN-007`, `AZ-IDN-008`, `AZ-IDN-009` | +| Database | 7 | `AZ-DB-001`, `AZ-DB-002`, `AZ-DB-003`, `AZ-DB-004`, `AZ-DB-005`, `AZ-DB-006`, `AZ-DB-007` | +| Cosmos DB | 2 | `AZ-COSMOS-001`, `AZ-COSMOS-002` | +| Identity | 19 | `AZ-IDN-001`, `AZ-IDN-002`, `AZ-IDN-003`, `AZ-IDN-004`, `AZ-IDN-005`, `AZ-IDN-006`, `AZ-IDN-007`, `AZ-IDN-008`, `AZ-IDN-009`, `AZ-IDN-016`, `AZ-IDN-017`, `AZ-IDN-018`, `AZ-IDN-019`, `AZ-IDN-020`, `AZ-IDN-021`, `AZ-IDN-022`, `AZ-IDN-023`, `AZ-IDN-024`, `AZ-IDN-025` | | Key Vault | 4 | `AZ-KV-002`, `AZ-KV-003`, `AZ-KV-004`, `AZ-KV-005` | | KeyVault | 1 | `AZ-KV-001` | | Network | 14 | `AZ-NET-001`, `AZ-NET-002`, `AZ-NET-003`, `AZ-NET-004`, `AZ-NET-005`, `AZ-NET-006`, `AZ-NET-007`, `AZ-NET-008`, `AZ-NET-009`, `AZ-NET-010`, `AZ-NET-011`, `AZ-NET-012`, `AZ-NET-013`, `AZ-NET-014` | | PostQuantum | 3 | `AZ-PQC-001`, `AZ-PQC-002`, `AZ-PQC-003` | -| Storage | 5 | `AZ-STOR-001`, `AZ-STOR-002`, `AZ-STOR-003`, `AZ-STOR-004`, `AZ-STOR-005` | +| Storage | 9 | `AZ-STOR-001`, `AZ-STOR-002`, `AZ-STOR-003`, `AZ-STOR-004`, `AZ-STOR-005`, `AZ-STOR-006`, `AZ-STOR-007`, `AZ-STOR-008`, `AZ-STOR-009` | +| Managed Cache | 1 | `AZ-CACHE-001` | ## Initial Live Validation Candidates diff --git a/frontend/API_ENDPOINTS.txt b/frontend/API_ENDPOINTS.txt index b0433d38..a88052ed 100644 --- a/frontend/API_ENDPOINTS.txt +++ b/frontend/API_ENDPOINTS.txt @@ -1,1104 +1,397 @@ -================================================================================ - OPENSHIELD — BACKEND API ENDPOINTS REFERENCE - Frontend contract file | Last updated: 2026-06-01 -================================================================================ +OPENSHIELD FRONTEND API ENDPOINT REFERENCE +Last verified: 2026-08-18 +Finding severity source: contracts/severity.v1.json - Base URL : http://localhost:5001 (set via VITE_API_URL in .env.local) - Auth : Bearer (stored in localStorage key "jwt_token") - Format : JSON (Content-Type: application/json) +This file describes the API contract used by the React frontend. The source of +truth is the implementation in frontend/src/utils/api.js, +frontend/src/utils/aiApi.js, api/app.py, and api/routes/. - All protected endpoints need the Authorization header: - Authorization: Bearer dev-demo-token -================================================================================ +BASE URL +======== +Set VITE_API_URL to the API origin, without a trailing /api path. -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 1. HEALTH CHECK -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Direct Flask default: http://localhost:5000 + Local Compose profile: http://localhost:8000 + Production fallback: https://openshield-api.onrender.com + Example override: VITE_API_URL=https://api.example.com - What it does: - Simple ping to check if the backend server is running. - The frontend calls this automatically when you switch from Demo → Live mode. - If it fails, the app stays in Demo mode and shows an error popup. +The frontend appends /api for application endpoints and calls /health directly. +The Flask development server also defaults to port 5000 through PORT. - Request - ─────── - GET /health - (No authentication required) - Response - ──────── - { - "status": "ok" - } +AUTHENTICATION +============== +Default behavior +---------------- -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 2. SECURITY SCORE -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Every route except the always-public routes below requires a valid HS256 JWT: - What it does: - Returns the overall security score for the Azure environment. - Shown as the big number in the donut chart on the Monitoring page. - Score is 0–100. Lower is worse. Target is 80+. + Authorization: Bearer - Request - ─────── - GET /api/score - Authorization: Bearer +The frontend reads this token from localStorage key jwt_token. App.jsx first +uses VITE_JWT_TOKEN when it is configured. The API validates the token with +JWT_SECRET; an arbitrary string is not a valid JWT. - Response - ──────── - { - "score": 68, - "max_score": 100 - } +Always public: + GET / API metadata + GET /health process liveness + GET /ready database readiness + GET /metrics observability metrics + OPTIONS * CORS preflight -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 3. LIST ALL FINDINGS -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Public demo behavior +-------------------- - What it does: - Returns every security finding (misconfiguration or vulnerability) found - across all Azure resources. Used on the Scan page, AI page, and to count - issues per resource on the Discovery page. +When the API is started with OPENSHIELD_PUBLIC_DEMO=true, unauthenticated GET +requests are allowed. POST requests, including scan triggers and every AI +request, still require a valid JWT. Do not enable this setting for deployments +that contain real Azure scan data. - Request - ─────── - GET /api/findings - Authorization: Bearer +OPENSHIELD_PUBLIC_DEMO is a backend read-only access mode. The current frontend +does not have a Demo/Live data toggle, does not load frontend mock-data files, +and does not fall back to mock security data when the backend is unavailable. - Optional query parameters: - ?limit=100 How many findings to return (default 100) - ?offset=0 Skip this many findings (for pagination) - ?severity=HIGH Filter to HIGH, MEDIUM, or LOW only - ?category=Network Filter to one category (Storage, Compute, Network, etc.) - ?rule_id=AZ-NET-001 Filter to one specific rule - Examples: - GET /api/findings?severity=HIGH - GET /api/findings?limit=10&offset=20 - GET /api/findings?category=Storage&severity=HIGH +COMMON REQUEST AND ERROR RULES +============================== - Response - ──────── - { - "count": 25, - "limit": 100, - "offset": 0, - "findings": [ - { - "id": 1, - "rule_id": "AZ-STOR-001", - "rule_name": "Storage allows public blob access", - "severity": "HIGH", - "category": "Storage", - "resource_id": "/subscriptions/sub-123/resourceGroups/rg-prod/providers/Microsoft.Storage/storageAccounts/prod-storage-01", - "resource_name": "prod-storage-01", - "resource_type": "Microsoft.Storage/storageAccounts", - "description": "Storage account allows anonymous public read access", - "remediation": "Disable public blob access at the storage account level", - "detected_at": "2026-05-28T10:00:00Z" - }, - { - "id": 2, - "rule_id": "AZ-NET-001", - "rule_name": "NSG allows unrestricted SSH", - "severity": "HIGH", - "category": "Network", - "resource_id": "/subscriptions/sub-123/resourceGroups/rg-prod/providers/Microsoft.Network/networkSecurityGroups/nsg-web", - "resource_name": "nsg-web", - "resource_type": "Microsoft.Network/networkSecurityGroups", - "description": "Port 22 (SSH) open to 0.0.0.0/0", - "remediation": "Restrict SSH to specific trusted IP ranges", - "detected_at": "2026-05-28T13:00:00Z" - } - ] - } +JSON request bodies use Content-Type: application/json. Request bodies are +limited to 2 MiB. Validation failures return 400, missing or invalid auth +returns 401, unknown resources return 404, and unexpected server failures +return a safe JSON error response. Error responses generally have this shape: - Severity values: HIGH | MEDIUM | LOW - Category values: Storage | Compute | Network | Identity | Database | KeyVault | Monitoring + { "error": "", "request_id": "" } -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 4. SINGLE FINDING -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +FRONTEND-CONSUMED ENDPOINTS +=========================== - What it does: - Returns the details for one specific finding by its ID. - Called when a user clicks a finding on the Scan page to see - the full remediation playbook. +Method Path Frontend use +------ ------------------------------------ --------------------------------- +GET /health Header connection indicator +GET /api/score Monitoring security score +GET /api/score/cve-summary CVE summary and AI CVE panel +GET /api/findings Scan, monitoring, discovery, AI +GET /api/findings/ Exposed by the API service layer +GET /api/findings//playbook Detailed Scan remediation content +GET /api/scans Monitoring and scan history +GET /api/scans/ Scan status polling +POST /api/scans/trigger Start a scan +GET /api/resources Discovery inventory +GET /api/prioritization Prioritization page +GET /api/drift Drift page +GET /api/compliance/ Compliance page +POST /api/ai/ask AI question and answer +POST /api/ai/summary AI executive summary +POST /api/ai/insights AI insights/remediation plan +POST /api/ai/prioritise AI-ranked findings - Request - ─────── - GET /api/findings/1 - Authorization: Bearer +There is no GET /api/monitoring endpoint. The Monitoring page composes its data +from GET /api/score, GET /api/findings, and GET /api/scans. - Response - ──────── - { - "id": 1, - "rule_id": "AZ-STOR-001", - "rule_name": "Storage allows public blob access", - "severity": "HIGH", - "category": "Storage", - "resource_id": "/subscriptions/sub-123/resourceGroups/rg-prod/providers/Microsoft.Storage/storageAccounts/prod-storage-01", - "resource_name": "prod-storage-01", - "resource_type": "Microsoft.Storage/storageAccounts", - "description": "Storage account allows anonymous public read access", - "remediation": "Disable public blob access at the storage account level", - "detected_at": "2026-05-28T10:00:00Z" - } - Note: The frontend enriches this with portal steps and CLI commands - from its internal playbook library (scan.json). +HEALTH +====== +GET /health -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 5. SCAN HISTORY -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Authentication: always public. +Response: - What it does: - Returns the list of past scans that have been run. - Each scan has a start time, end time, and how many findings it found. + { "status": "ok" } - Request - ─────── - GET /api/scans - Authorization: Bearer - Response - ──────── - { - "count": 3, - "scans": [ - { - "scan_id": "scan-001-20260529", - "subscription_id": "sub-123", - "started_at": "2026-05-29T14:00:00Z", - "completed_at": "2026-05-29T14:05:23Z", - "total_findings": 25, - "status": "completed" - }, - { - "scan_id": "scan-002-20260528", - "subscription_id": "sub-123", - "started_at": "2026-05-28T10:00:00Z", - "completed_at": "2026-05-28T10:08:41Z", - "total_findings": 24, - "status": "completed" - } - ] - } +SCORE AND CVE SUMMARY +===================== - Status values: pending | running | completed | failed +GET /api/score +Returns a JSON integer from 0 to 100. The frontend normalizes this number to +{ score, max_score: 100 } for its components. -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 6. TRIGGER A NEW SCAN -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 82 - What it does: - Tells the backend to start scanning the Azure subscription right now. - Returns immediately with a scan ID. The scan runs in the background. - Poll GET /api/scans to check when it completes. +GET /api/score/cve-summary - Request - ─────── - POST /api/scans/trigger - Authorization: Bearer - Content-Type: application/json +Returns CVE enrichment status and summary values for the latest completed scan: - Body (optional — omit to scan the default subscription): { - "subscription_id": "sub-123" + "status": "COMPLETED", + "total_findings": 12, + "exploit_count": 2, + "max_cvss_score": 9.8, + "avg_cvss_score": 6.42, + "critical_cve_count": 1 } - Response - ──────── - { - "scan_id": "scan-new-20260601", - "subscription_id": "sub-123", - "started_at": "2026-06-01T10:00:00Z", - "completed_at": "2026-06-01T10:05:47Z", - "total_findings": 25, - "status": "completed" - } +When no completed scan exists, status is UNKNOWN, counts are zero, and score +values are null. + + +FINDINGS AND PLAYBOOKS +====================== +GET /api/findings -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 7. COMPLIANCE — CIS AZURE -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Supported query parameters (each may appear at most once): - What it does: - Returns how many CIS Azure Benchmark controls the environment passes or fails. - CIS is a widely-used security checklist published by the Center for Internet - Security. Each control maps to one of our rules (e.g. CIS 6.2 = AZ-NET-001). + severity CRITICAL, HIGH, MEDIUM, LOW, or INFO + category A supported rule category + rule_id A rule ID such as AZ-STOR-001 + scan_id Canonical scan UUID - Request - ─────── - GET /api/compliance/cis - Authorization: Bearer +Unknown, repeated, or invalid query parameters return 400. This endpoint does +not currently support limit or offset pagination parameters. + +Response: - Response - ──────── { - "framework": "CIS Microsoft Azure Foundations Benchmark", - "version": "2.0.0", - "score_percent": 74, - "passed": 7, - "failed": 6, - "total_controls": 13, - "controls": [ + "count": 1, + "findings": [ { - "control_id": "3.5", - "control_name": "Ensure public access is disabled on all storage accounts", + "id": 42, + "scan_id": "", "rule_id": "AZ-STOR-001", - "status": "FAIL" - }, - { - "control_id": "6.2", - "control_name": "Ensure SSH access is restricted from the internet", - "rule_id": "AZ-NET-001", - "status": "FAIL" - }, - { - "control_id": "1.1", - "control_name": "Ensure Security Defaults are enabled on Azure Active Directory", - "rule_id": null, - "status": "PASS" + "rule_name": "Public Blob Access Enabled", + "severity": "HIGH", + "category": "Storage", + "resource_id": "", + "resource_name": "example-storage", + "resource_type": "Microsoft.Storage/storageAccounts", + "description": "...", + "remediation": "...", + "detected_at": "" } ] } - Status values: PASS | FAIL +Finding records may also contain CVE, framework, metadata, and playbook fields +persisted by the scanner. +GET /api/findings/ -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 8. COMPLIANCE — NIST SP 800-53 -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +finding_id is a positive integer. Returns one finding record, or 404 with +{ "error": "Finding not found" }. - What it does: - Same as CIS but mapped to the NIST SP 800-53 framework instead. - NIST is the US government security standard used by federal agencies - and many enterprises. +GET /api/findings//playbook - Request - ─────── - GET /api/compliance/nist - Authorization: Bearer +The backend looks up the finding, loads the matching script from playbooks/cli, +and returns structured remediation content: - Response - ──────── { - "framework": "NIST SP 800-53 Rev 5", - "version": "5.0.0", - "score_percent": 68, - "passed": 17, - "failed": 8, - "total_controls": 25, - "controls": [ - { - "control_id": "AC-3", - "control_name": "Access Enforcement", - "rule_id": "AZ-STOR-001", - "status": "FAIL" - }, - { - "control_id": "SC-8", - "control_name": "Transmission Confidentiality and Integrity", - "rule_id": "AZ-STOR-002", - "status": "FAIL" - }, - { - "control_id": "IA-5", - "control_name": "Authenticator Management", - "rule_id": null, - "status": "PASS" - } - ] + "portal_steps": ["..."], + "cli_commands": ["..."], + "validation_steps": ["..."], + "references": ["..."] } +The frontend does not enrich findings from a mock scan.json file. -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 9. COMPLIANCE — ISO 27001 -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - What it does: - Same as CIS but mapped to the ISO 27001:2022 international standard. - ISO 27001 is the global benchmark for information security management. +SCANS +===== - Request - ─────── - GET /api/compliance/iso27001 - Authorization: Bearer +GET /api/scans - Response - ──────── - { - "framework": "ISO 27001:2022", - "version": "2022", - "score_percent": 81, - "passed": 18, - "failed": 4, - "total_controls": 22, - "controls": [ - { - "control_id": "A.10.1.1", - "control_name": "Policy on the use of cryptographic controls", - "rule_id": "AZ-STOR-002", - "status": "FAIL" - }, - { - "control_id": "A.12.3.1", - "control_name": "Information backup", - "rule_id": null, - "status": "PASS" - } - ] - } +Returns a JSON array containing at most the 100 most recent scan records. Common +fields include scan_id, subscription_id, status, started_at, completed_at, +total_findings, score, error_message, and cve_enrichment_status. +GET /api/scans/ -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 10. AI CHAT -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +scan_id must be a canonical UUID. Returns the scan record, or 404 when absent. - What it does: - Sends a question to the AI and gets back an answer. - The AI uses RAG (Retrieval-Augmented Generation) to look up relevant - findings from the database and answer in context. - Used on the AI Assistant page. +POST /api/scans/trigger - Request - ─────── - POST /api/ai/chat - Authorization: Bearer - Content-Type: application/json +Requires JWT even in public-demo mode. Accepts an optional JSON object: - Body: - { - "question": "How do I fix the SSH vulnerability on nsg-web?", - "context": { - "rule_id": "AZ-NET-001", - "resource_name": "nsg-web" - } - } + { "subscription_id": "" } - Note: "context" is optional. When omitted, the AI answers about the - full environment. When provided, it focuses on that specific finding. +If subscription_id is omitted, the API uses AZURE_SUBSCRIPTION_ID. If neither +is available, it returns 400. A successful request returns 202 immediately: - Response - ──────── { - "answer": "To fix the SSH vulnerability on nsg-web, delete the inbound rule allowing port 22 from 0.0.0.0/0 and replace it with a rule restricted to your VPN CIDR...", - "sources": [ - { "id": "AZ-NET-001", "resource": "nsg-web" } - ] + "scan_id": "", + "status": "pending", + "message": "Scan has been queued and will start shortly." } +Poll GET /api/scans/ for status. The frontend does not automatically +retry this state-changing request. + -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 11. AI EXECUTIVE SUMMARY -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +RESOURCES +========= - What it does: - Asks the AI to generate a short executive summary of the current - security posture. Returns the top 3 priorities and an estimate of - how long it would take to fix everything. Shown in the right panel - of the AI Assistant page. +GET /api/resources - Request - ─────── - GET /api/ai/summary - Authorization: Bearer +Returns unique Azure resources derived from findings in the latest scan that +has findings. The highest finding severity becomes each resource's risk value. - Response - ──────── { - "generated_at": "2026-06-01T06:00:00Z", - "risk_score": 68, - "trend": "improving", - "overview": "Your Azure environment has 25 open findings. The most critical exposures are internet-accessible SSH/RDP ports and an open SQL database firewall.", - "top_priorities": [ - { - "rank": 1, - "title": "Close SSH/RDP ports open to 0.0.0.0/0", - "impact": "CRITICAL", - "eta": "2 hours", - "rule_id": "AZ-NET-001", - "resource": "nsg-web, nsg-app" - }, + "summary": { + "total": 1, + "by_category": { "Storage": 1 }, + "by_risk_level": { "CRITICAL": 0, "HIGH": 1, "MEDIUM": 0, "LOW": 0, "INFO": 0, "NONE": 0 }, + "last_scan_at": "" + }, + "resources": [ { - "rank": 2, - "title": "Delete AllowAllIPs SQL firewall rule", - "impact": "CRITICAL", - "eta": "30 mins", - "rule_id": "AZ-DB-001", - "resource": "sql-dev-exposed" + "id": "", + "name": "example-storage", + "type": "Microsoft.Storage/storageAccounts", + "category": "Storage", + "resource_group": "example-rg", + "subscription_id": "", + "location": "", + "risk": "HIGH", + "discovered_at": "", + "config": {} } - ], - "estimated_remediation_time": "3-5 business days", - "compliance_status": { - "cis": 74, - "nist": 68, - "iso27001": 81 - } + ] } +With no qualifying scan, summary counts are empty/zero and resources is []. -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 12. AI CVE ANALYSIS -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - What it does: - Returns a list of known CVEs (Common Vulnerabilities and Exposures) - that affect resources in the environment. For example, if a VM is - running an unpatched Windows Server, this lists the specific CVE IDs - and their severity scores. Shown in the CVE Analysis panel on the AI page. +PRIORITIZATION +============== - Request - ─────── - GET /api/ai/cve-analysis - Authorization: Bearer +GET /api/prioritization - Response - ──────── - { - "last_updated": "2026-06-01T06:00:00Z", - "total": 5, - "cves": [ - { - "id": "CVE-2024-38077", - "name": "Windows RDL Remote Code Execution", - "description": "Critical RCE in Windows Remote Desktop Licensing Service. No authentication required.", - "cvss_score": 9.8, - "severity": "CRITICAL", - "affected_resources": ["vm-web-01"], - "affected_count": 1, - "patch_available": true, - "remediation": "Apply Microsoft security update KB5040442", - "nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2024-38077", - "published_date": "2024-07-09" - }, - { - "id": "CVE-2023-23397", - "name": "Microsoft Outlook NTLM Hash Leak", - "description": "Zero-click vulnerability, no user interaction required.", - "cvss_score": 9.8, - "severity": "CRITICAL", - "affected_resources": ["vm-web-01"], - "affected_count": 1, - "patch_available": true, - "remediation": "Apply Microsoft security update KB5023745", - "nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2023-23397", - "published_date": "2023-03-14" - } - ] - } +Groups findings from the latest scan by rule and ranks them using severity and +affected-resource count. Response keys are: + matrix Entries with id, rule_id, name, risk, effort, category, + severity, affected_resources, and resource. + rankings Up to 25 entries with rank, rule_id, name, score, severity, + category, effort, impact, and resource. + action_items Up to 10 entries with id, action, impact, effort, eta, rule_id, + and resource. + summary Finding counts, recommended action count, estimated fix time, + and top priority. -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 13. RESOURCE DISCOVERY -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +With no scan containing findings, the three lists and summary are empty. - Used by: Discovery page - What it does: - Returns every Azure resource that has been discovered across all - subscriptions and resource groups. Each resource has a risk level - (HIGH / MEDIUM / LOW / NONE) based on the worst finding attached to it. - The page lets users filter by category, risk, location, and resource group. +DRIFT +===== - Request - ─────── - GET /api/resources - Authorization: Bearer +GET /api/drift - Optional query parameters: - ?subscription_id=sub-123 Filter to one subscription - ?resource_group=rg-prod Filter to one resource group - ?category=Storage Filter by category - ?risk=HIGH Filter by risk level (HIGH|MEDIUM|LOW|NONE) - ?location=eastus Filter by Azure region +Compares the two most recent scans that contain findings. ADDED means a +(rule_id, resource_id) pair exists only in the latest scan; REMOVED means it +exists only in the previous scan. The current implementation reports modified +as zero. - Response - ──────── { "summary": { - "total": 17, - "by_category": { - "Storage": 4, - "Compute": 3, - "Network": 4, - "Identity": 1, - "Database": 3, - "KeyVault": 1, - "Monitoring": 1 - }, - "by_risk_level": { - "HIGH": 7, - "MEDIUM": 4, - "LOW": 4, - "NONE": 2 - }, - "last_scan_at": "2026-05-29T18:00:00Z" + "total": 1, + "added": 1, + "removed": 0, + "modified": 0, + "last_checked": "" }, - "resources": [ - { - "id": "/subscriptions/sub-123/resourceGroups/rg-prod/providers/Microsoft.Storage/storageAccounts/prod-storage-01", - "name": "prod-storage-01", - "type": "Microsoft.Storage/storageAccounts", - "category": "Storage", - "resource_group": "rg-prod", - "subscription_id": "sub-123", - "location": "eastus", - "risk": "HIGH", - "discovered_at": "2026-05-28T10:00:00Z" - }, + "events": [ { - "id": "/subscriptions/sub-123/resourceGroups/rg-prod/providers/Microsoft.Network/networkSecurityGroups/nsg-web", - "name": "nsg-web", - "type": "Microsoft.Network/networkSecurityGroups", - "category": "Network", - "resource_group": "rg-prod", - "subscription_id": "sub-123", - "location": "eastus", - "risk": "HIGH", - "discovered_at": "2026-05-28T10:10:00Z" + "id": 1, + "type": "ADDED", + "severity": "HIGH", + "resource_name": "example-nsg", + "resource_type": "Microsoft.Network/networkSecurityGroups", + "resource_group": "example-rg", + "field": "security_policy", + "old_value": null, + "new_value": "HIGH", + "changed_by": "azure-policy-scan", + "changed_at": "", + "rule_violated": "AZ-NET-001" } ] } - Risk values: HIGH | MEDIUM | LOW | NONE - Category values: Storage | Compute | Network | Identity | Database | KeyVault | Monitoring - +With fewer than two qualifying scans, events is [] and summary counts are zero. -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 14. FINDING REMEDIATION PLAYBOOK -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - Used by: Detailed Scan page, AI Assistant page +COMPLIANCE +========== - What it does: - Returns the full step-by-step remediation guide for one specific finding. - This includes portal steps (what to click in Azure Portal), CLI commands - (az commands to copy-paste), validation steps (how to confirm the fix - worked), and compliance references. +GET /api/compliance/ - The Detailed Scan page shows this as a tabbed panel: Portal Steps | CLI | Validation. +Supported framework values: - Request - ─────── - GET /api/findings/:id/playbook - Authorization: Bearer + cis, nist, iso27001, soc2, ncsc_pqc, enisa_pqc - Example: - GET /api/findings/4/playbook - - Response - ──────── - { - "finding_id": 4, - "rule_id": "AZ-NET-001", - "rule_name": "NSG allows unrestricted SSH", - "resource_name": "nsg-web", - "resource_group": "rg-prod", - "portal_steps": [ - "Open the Azure Portal and navigate to Network Security Groups", - "Select 'nsg-web' and click 'Inbound security rules'", - "Find the rule allowing port 22 from source 0.0.0.0/0", - "Change the Source from 'Any' to your VPN CIDR (e.g. 10.0.0.0/8)", - "Click Save — change takes effect within seconds" - ], - "cli_commands": [ - "az network nsg rule delete --resource-group rg-prod --nsg-name nsg-web --name Allow-SSH-Any", - "az network nsg rule create --resource-group rg-prod --nsg-name nsg-web --name Allow-SSH-VPN --priority 200 --source-address-prefixes 10.0.0.0/8 --destination-port-ranges 22 --access Allow --protocol Tcp" - ], - "validation_steps": [ - "Run: az network nsg rule list --nsg-name nsg-web --resource-group rg-prod", - "Confirm no rule shows Source: * and Port: 22", - "Test SSH from an IP outside your allowed range — connection should time out" - ], - "references": [ - "CIS Azure 6.2", - "NIST SP 800-53 AC-17" - ] - } +The current Compliance page requests cis, nist, iso27001, and soc2. A response +contains framework, version, total_controls, passed, failed, score_percent, and +a controls array. Each control includes control_id, control_name, rule_id, and +status (PASS or FAIL), with additional finding metadata where available. - Frontend behaviour: - The frontend calls GET /api/findings/:id/playbook every time a user selects - a finding on the Scan page. If the endpoint returns an error or doesn't exist - yet, it automatically falls back to the internal playbook library (scan.json). - Once you implement this endpoint, it takes over with no frontend changes needed. +An unsupported framework returns 400 and a supported-values list. -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 15. RISK PRIORITIZATION -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +AI REQUESTS +=========== - Used by: Prioritization page +All AI endpoints are POST requests and require JWT, including in public-demo +mode. They also require bring-your-own provider credentials in the JSON body. +Common fields are: - What it does: - Returns all findings ranked by a priority score that factors in both - risk (how dangerous is it?) and effort (how hard is it to fix?). - High risk + low effort = fix first. Also returns a matrix of all findings - plotted on risk vs effort axes, and a list of concrete action items. + provider Supported provider identifier (required) + api_key Provider API key (required) + model Provider model override (optional) + findings Current finding objects (optional or required by the operation) + question User question (required by /ask; optional for /insights) - Request - ─────── - GET /api/prioritization - Authorization: Bearer +POST /api/ai/ask + Requires question. Returns answer, sources, provider, and model. - Optional query parameters: - ?category=Network Filter to one category - ?severity=HIGH Filter to one severity level +POST /api/ai/summary + Accepts findings. Returns summary, sources, provider, and model. - Response - ──────── - { - "matrix": [ - { - "id": 1, - "rule_id": "AZ-STOR-001", - "name": "Storage allows public blob access", - "risk": 9, - "effort": 1, - "category": "Storage", - "severity": "HIGH", - "resource": "prod-storage-01" - }, - { - "id": 4, - "rule_id": "AZ-NET-001", - "name": "NSG allows unrestricted SSH", - "risk": 9, - "effort": 2, - "category": "Network", - "severity": "HIGH", - "resource": "nsg-web" - } - ], - "rankings": [ - { - "rank": 1, - "rule_id": "AZ-NET-001", - "name": "SSH (port 22) open to 0.0.0.0/0 on nsg-web", - "score": 98, - "severity": "HIGH", - "category": "Network", - "effort": 2, - "impact": "CRITICAL", - "resource": "nsg-web" - }, - { - "rank": 2, - "rule_id": "AZ-DB-001", - "name": "SQL database fully public on sql-dev-exposed", - "score": 97, - "severity": "HIGH", - "category": "Database", - "effort": 1, - "impact": "CRITICAL", - "resource": "sql-dev-exposed" - } - ], - "action_items": [ - { - "id": 1, - "action": "Restrict SSH/RDP NSG rules to VPN CIDR on nsg-web and nsg-app", - "impact": "HIGH", - "effort": "LOW", - "eta": "1 hour", - "rule_id": "AZ-NET-001", - "resource": "nsg-web" - }, - { - "id": 2, - "action": "Delete AllowAllIPs firewall rule on sql-dev-exposed", - "impact": "HIGH", - "effort": "LOW", - "eta": "30 mins", - "rule_id": "AZ-DB-001", - "resource": "sql-dev-exposed" - } - ] - } +POST /api/ai/insights + Requires findings. Returns executive_summary and remediation_plan, plus + answer when question was supplied. - risk field: 1–10 (10 = most dangerous) - effort field: 1–5 (1 = easiest to fix, 5 = hardest) - score field: 0–100 overall priority score - impact values: CRITICAL | HIGH | MEDIUM | LOW +POST /api/ai/prioritise + Accepts findings. Returns prioritised_findings, sources, provider, and model. +The implemented question endpoint is /api/ai/ask, not /api/ai/chat. AI summary +is POST, not GET. CVE dashboard data comes from GET /api/score/cve-summary; +there is no GET /api/ai/cve-analysis route. -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 16. CONFIGURATION DRIFT -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - Used by: Drift page +OTHER REGISTERED BACKEND ROUTES +=============================== - What it does: - Returns a timeline of all configuration changes detected in the Azure - environment. Each event shows what changed, on which resource, who made - the change, and whether it violated a security rule (creating a new finding). +These routes are implemented and registered but are not called by the current +frontend API utilities: - Typical drift events: - - Someone opened a port in an NSG (MODIFIED) - - A new VM was spun up without a security policy (ADDED) - - A storage account was deleted (REMOVED) +Method Path +------ ------------------------------------------ +POST /api/scans//enrich +POST /api/ai/threat-simulation +GET /api/assurance/physical-layer +GET /api/assurance/data-link-layer +GET /api/assurance/network-layer +GET /api/cbom +GET /api/cbom/summary +GET /api/cbom/migration-roadmap - Request - ─────── - GET /api/drift - Authorization: Bearer +They follow the same authentication rule: GET is JWT-protected by default and +becomes unauthenticated only with OPENSHIELD_PUBLIC_DEMO=true; POST always +requires JWT. Consult the matching module under api/routes/ for specialized +request and response details. - Optional query parameters: - ?type=MODIFIED Filter by change type (ADDED|REMOVED|MODIFIED) - ?severity=HIGH Filter by severity of the change - ?resource_group=rg-prod Filter to one resource group - ?from=2026-05-28T00:00:00Z Changes after this timestamp - ?to=2026-05-30T00:00:00Z Changes before this timestamp - Response - ──────── - { - "summary": { - "total": 10, - "added": 3, - "removed": 2, - "modified": 5, - "last_checked": "2026-05-29T18:00:00Z" - }, - "events": [ - { - "id": 1, - "type": "MODIFIED", - "severity": "HIGH", - "resource_name": "prod-storage-01", - "resource_type": "Microsoft.Storage/storageAccounts", - "resource_group": "rg-prod", - "field": "allowBlobPublicAccess", - "old_value": "false", - "new_value": "true", - "changed_by": "john.doe@company.com", - "changed_at": "2026-05-29T14:32:00Z", - "rule_violated": "AZ-STOR-001" - }, - { - "id": 2, - "type": "MODIFIED", - "severity": "HIGH", - "resource_name": "nsg-web", - "resource_type": "Microsoft.Network/networkSecurityGroups", - "resource_group": "rg-prod", - "field": "inboundRules[Allow-SSH].sourceAddressPrefix", - "old_value": "10.0.0.0/8", - "new_value": "0.0.0.0/0", - "changed_by": "jane.smith@company.com", - "changed_at": "2026-05-29T11:15:00Z", - "rule_violated": "AZ-NET-001" - }, - { - "id": 5, - "type": "REMOVED", - "severity": "LOW", - "resource_name": "nsg-legacy-dev", - "resource_type": "Microsoft.Network/networkSecurityGroups", - "resource_group": "rg-dev", - "field": "resource", - "old_value": "existed", - "new_value": null, - "changed_by": "terraform-automation@company.com", - "changed_at": "2026-05-28T18:00:00Z", - "rule_violated": null - } - ] - } +IMPLEMENTATION STATUS +===================== - type values: ADDED | REMOVED | MODIFIED - severity values: HIGH | MEDIUM | LOW - rule_violated: rule ID if this change created a finding, null if safe change - - -================================================================================ - QUICK REFERENCE + IMPLEMENTATION STATUS -================================================================================ - - STATUS KEY: - ✅ Frontend wired up — calls real endpoint, falls back to mock if it fails - 🔶 Mock only — no backend endpoint defined yet, uses internal mock data - 📄 Mock file ready — api.*.json exists and matches expected response format - - METHOD ENDPOINT AUTH STATUS WHAT IT RETURNS - ────── ───────────────────────── ───── ────── ──────────────────────────────── - GET /health No ✅ 📄 { status: "ok" } - GET /api/score Yes ✅ 📄 Overall security score 0-100 - GET /api/resources Yes ✅ All Azure resources + summary - GET /api/findings Yes ✅ 📄 All findings (paginated + filters) - GET /api/findings/:id Yes ✅ 📄 One finding by ID - GET /api/findings/:id/playbook Yes ✅ Portal steps, CLI, validation - GET /api/scans Yes ✅ 📄 History of past scans - GET /api/scans/:id Yes ✅ Status of one specific scan - POST /api/scans/trigger Yes ✅ 📄 Start a new scan + poll for result - GET /api/prioritization Yes ✅ Risk-ranked findings + matrix - GET /api/drift Yes ✅ Configuration change timeline - GET /api/compliance/cis Yes ✅ 📄 CIS Azure controls pass/fail - GET /api/compliance/nist Yes ✅ 📄 NIST SP 800-53 controls pass/fail - GET /api/compliance/iso27001 Yes ✅ 📄 ISO 27001 controls pass/fail - POST /api/ai/chat Yes ✅ AI answer to a question - GET /api/ai/summary Yes ✅ AI-generated executive summary - GET /api/ai/cve-analysis Yes ✅ CVEs affecting your environment - GET /api/monitoring — 🔶 Score trend + category breakdown - (no endpoint defined — uses mock) - - 📄 = mock file in frontend/src/mockData/api.*.json matches exact response format - ✅ = wired up in frontend/src/utils/api.js with real fetch + mock fallback - - -================================================================================ - FIELD NAMING CONVENTION -================================================================================ - - Backend returns snake_case. Frontend converts to camelCase automatically. - - snake_case (backend) camelCase (frontend) - ───────────────────── ──────────────────────── - rule_id → ruleId - rule_name → ruleName - resource_id → resourceId - resource_name → resourceName - resource_type → resourceType - resource_group → resourceGroup - subscription_id → subscription - detected_at → detectedAt - discovered_at → discoveredAt - - portal_steps → portalSteps - cli_commands → cliCommands - validation_steps → validationSteps - - action_items → actionItems - affected_resources → affectedResources - - old_value → oldValue - new_value → newValue - changed_by → changedBy - changed_at → changedAt - rule_violated → ruleViolated - last_checked → lastChecked - - score_percent → score (renamed for display) - total_controls → totalControls - by_category → byCategory - by_risk_level → byRiskLevel - last_scan_at → lastScanAt - - cvss_score → cvssScore - affected_count → affectedCount - patch_available → patchAvailable - published_date → publishedDate - nvd_url → nvdUrl - - generated_at → generatedAt - risk_score → riskScore - top_priorities → topPriorities - estimated_remediation_time → estimatedRemediationTime - compliance_status → complianceStatus - last_updated → lastUpdated - - -================================================================================ - BACKEND IMPLEMENTATION GUIDE (feat/flask-api branch) -================================================================================ - - This section maps each endpoint to a backend task, database table, and - answers the question: "does this data live in the DB or is it computed?" - - Branch: feat/flask-api - Stack: Flask + PostgreSQL (Render free tier) - -──────────────────────────────────────────────────────────────────────────────── - SPRINT SCOPE — BUILD THESE NOW -──────────────────────────────────────────────────────────────────────────────── - - These are the endpoints explicitly listed in the backend task. - The frontend is already wired to call them (falls back to mock until live). - - ┌──────────────────────────────┬──────────────────────┬──────────────────────┐ - │ Endpoint │ File │ Database │ - ├──────────────────────────────┼──────────────────────┼──────────────────────┤ - │ GET /health │ api/app.py │ None — returns "ok" │ - │ GET /api/score │ api/routes/score.py │ READ findings │ - │ GET /api/findings │ api/routes/ │ READ findings+rules │ - │ GET /api/findings/:id │ findings.py │ READ findings+rules │ - │ GET /api/scans │ api/routes/scans.py │ READ scans │ - │ GET /api/scans/:id │ api/routes/scans.py │ READ scans │ - │ POST /api/scans/trigger │ api/routes/scans.py │ WRITE scans │ - │ GET /api/compliance/cis │ api/routes/ │ READ findings+rules │ - │ GET /api/compliance/nist │ compliance.py │ READ findings+rules │ - │ GET /api/compliance/iso27001│ │ READ findings+rules │ - └──────────────────────────────┴──────────────────────┴──────────────────────┘ - -──────────────────────────────────────────────────────────────────────────────── - DEFERRED — DO NOT BUILD YET -──────────────────────────────────────────────────────────────────────────────── - - These endpoints exist in the API contract and the frontend calls them, - but they are NOT part of the current sprint. Frontend falls back to mock. - - Endpoint Why deferred - ────────────────────────────── ──────────────────────────────────────────── - GET /api/resources Needs a `resources` table (not in schema yet) - GET /api/findings/:id/playbook Needs a `playbooks` table (not in schema yet) - GET /api/prioritization Computed endpoint — build after core is done - GET /api/drift Needs a `drift_events` table (not in schema) - POST /api/ai/chat AI service — separate task entirely - GET /api/ai/summary AI service — separate task entirely - GET /api/ai/cve-analysis AI service — separate task entirely - -──────────────────────────────────────────────────────────────────────────────── - DATABASE SCHEMA (what to create in PostgreSQL) -──────────────────────────────────────────────────────────────────────────────── - - Table: findings - ─────────────── - id SERIAL PRIMARY KEY - rule_id VARCHAR(20) NOT NULL e.g. "AZ-STOR-001" - severity VARCHAR(10) NOT NULL HIGH | MEDIUM | LOW | INFO - resource_id TEXT NOT NULL Full Azure resource path - resource_name VARCHAR(100) NOT NULL e.g. "prod-storage-01" - resource_type VARCHAR(100) e.g. "Microsoft.Storage/storageAccounts" - resource_group VARCHAR(50) e.g. "rg-prod" - category VARCHAR(30) Storage | Compute | Network | etc. - description TEXT - remediation TEXT - status VARCHAR(20) DEFAULT 'open' open | resolved | suppressed - detected_at TIMESTAMP DEFAULT NOW() - scan_id INTEGER REFERENCES scans(id) - - Table: rules - ──────────── - rule_id VARCHAR(20) PRIMARY KEY e.g. "AZ-STOR-001" - name VARCHAR(200) NOT NULL e.g. "Storage allows public blob access" - severity VARCHAR(10) NOT NULL HIGH | MEDIUM | LOW - category VARCHAR(30) NOT NULL Storage | Network | etc. - description TEXT - remediation TEXT - frameworks JSONB { "CIS": "3.5", "NIST": "AC-3" } - - Table: scans - ──────────── - id SERIAL PRIMARY KEY - scan_id VARCHAR(50) UNIQUE e.g. "scan-001-20260529" - subscription_id VARCHAR(50) - started_at TIMESTAMP DEFAULT NOW() - completed_at TIMESTAMP - total_findings INTEGER DEFAULT 0 - status VARCHAR(20) DEFAULT 'running' running | completed | failed - -──────────────────────────────────────────────────────────────────────────────── - DATA SOURCES — what comes from DB vs what is computed -──────────────────────────────────────────────────────────────────────────────── - - FROM DATABASE (straightforward SELECT queries) - ─────────────────────────────────────────────── - GET /api/findings → SELECT * FROM findings JOIN rules ON findings.rule_id = rules.rule_id - GET /api/findings/:id → SELECT * FROM findings JOIN rules WHERE findings.id = :id - GET /api/scans → SELECT * FROM scans ORDER BY started_at DESC - GET /api/scans/:id → SELECT * FROM scans WHERE scan_id = :id - POST /api/scans/trigger → INSERT INTO scans ... (then run scanner async) - - COMPUTED — derived from database rows, not stored - ────────────────────────────────────────────────── - GET /api/score - Formula: - total = COUNT(*) FROM findings WHERE status = 'open' - high = COUNT(*) FROM findings WHERE severity = 'HIGH' AND status = 'open' - medium = COUNT(*) FROM findings WHERE severity = 'MEDIUM' AND status = 'open' - score = MAX(0, 100 - (high * 10) - (medium * 3)) - max_score = 100 - Returns: { "score": 72, "max_score": 100 } - - GET /api/compliance/cis (and /nist, /iso27001) - Step 1: Look up which rule_ids map to this framework's controls - (stored in rules.frameworks JSONB column) - Step 2: For each control, check if any open finding has that rule_id - → if yes: status = FAIL - → if no: status = PASS - Step 3: score_percent = (passed / total_controls) * 100 - Note: The mapping between rule_ids and control IDs is in the rules.frameworks - column — the scanner team populates this when inserting rules. - - NOT IN DATABASE (no table, no endpoint yet) - ──────────────────────────────────────────── - /api/resources → needs its own `resources` table (future sprint) - /api/drift → needs its own `drift_events` table (future sprint) - /api/prioritization → computed from findings; build after findings is stable - /api/findings/:id/playbook → stored as static content, not in DB (future) - /api/ai/* → calls external AI service, not DB (separate task) - -──────────────────────────────────────────────────────────────────────────────── - SUGGESTED IMPLEMENTATION ORDER -──────────────────────────────────────────────────────────────────────────────── - - 1. api/app.py Flask factory, CORS, JWT middleware, blueprints - 2. api/models/finding.py DatabaseManager + Finding/Rule/Scan models - 3. GET /api/findings Simplest read — confirms DB connection works - 4. GET /api/findings/:id Same table, single row - 5. GET /api/scans Scan history - 6. POST /api/scans/trigger Insert scan + trigger async scanner - 7. GET /api/score Computed from findings count - 8. GET /api/compliance/* Computed from findings + rules.frameworks - - Tip: Seed the `rules` table first with all 25 rule definitions from the - mock data (api.findings.json has all rule_ids, names, descriptions). - Without rules in the DB, compliance mapping won't work. - -──────────────────────────────────────────────────────────────────────────────── - SEED DATA FOR RULES TABLE -──────────────────────────────────────────────────────────────────────────────── - - Run this once after creating the schema to populate the rules table. - Copy the rule definitions from the frontend mock data in: - frontend/src/mockData/api.findings.json (has rule_id, description, etc.) - - Minimum set of rules the compliance endpoint needs to work: - - rule_id name severity category CIS NIST ISO - ───────────── ──────────────────────────────── ──────── ───────── ───── ────── ────── - AZ-STOR-001 Storage allows public blob access HIGH Storage 3.5 AC-3 A.10.1.1 - AZ-STOR-002 Storage does not enforce HTTPS HIGH Storage 3.1 SC-8 A.10.1.1 - AZ-NET-001 NSG allows unrestricted SSH HIGH Network 6.2 AC-17 A.13.1.1 - AZ-NET-007 NSG allows unrestricted RDP HIGH Network 6.3 AC-17 A.13.1.1 - AZ-DB-001 SQL database publicly accessible HIGH Database 4.1 SC-7 — - AZ-IDN-001 Service Principal over-privileged HIGH Identity 1.20 AC-6 A.9.4.1 - AZ-CMP-001 VM operating system outdated HIGH Compute 7.3 SI-2 A.12.6.1 - AZ-KV-001 Key Vault purge protection missing MEDIUM KeyVault 8.5 — A.18.1.3 - AZ-KV-002 Key Vault network ACLs disabled MEDIUM KeyVault 8.1 — — - - Full list of 25 rules: see frontend/src/mockData/api.findings.json - -================================================================================ - DEMO MODE vs LIVE MODE -================================================================================ - - Demo Mode (default, amber badge in header) - All data comes from mock JSON files in frontend/src/mockData/api.*.json. - No network calls are made. Safe to use without a backend. - - Live Mode (green badge in header) - Calls the real backend at VITE_API_URL. - Requires the backend server to be running on port 5001. - If the backend is unreachable, the app shows an error and falls back - to Demo Mode automatically. - - To switch: - Click the DEMO / LIVE badge in the top-right of the header. - The app will test the connection first before switching to Live. - - Environment variable: - VITE_API_URL=http://localhost:5001 (in frontend/.env.local) - - -================================================================================ +All frontend-consumed endpoints listed in this file are implemented and +registered. Resources, prioritization, drift, finding playbooks, and the AI +routes are not deferred. The frontend uses backend responses only; it does not +silently substitute mock posture data when a request fails. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cab1e076..98f9bb12 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,11 +9,11 @@ "version": "0.0.0", "dependencies": { "autoprefixer": "^10.5.0", - "postcss": "^8.5.15", - "react": "^19.2.6", - "react-dom": "^19.2.6", + "postcss": "^8.5.18", + "react": "^19.2.7", + "react-dom": "^19.2.7", "react-icons": "^5.6.0", - "react-router-dom": "^7.16.0", + "react-router": "^8.3.0", "recharts": "^3.8.1" }, "devDependencies": { @@ -27,6 +27,9 @@ "globals": "^17.6.0", "tailwindcss": "^3.4.19", "vite": "^8.0.16" + }, + "engines": { + "node": ">=22.22.0" } }, "node_modules/@alloc/quick-lru": { @@ -715,9 +718,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -735,9 +735,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -755,9 +752,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -775,9 +769,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -795,9 +786,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -815,9 +803,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1163,9 +1148,10 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", - "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" }, @@ -1186,15 +1172,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -1210,9 +1197,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "funding": [ { "type": "opencollective", @@ -1227,12 +1214,13 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -1251,9 +1239,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "funding": [ { "type": "opencollective", @@ -1267,7 +1255,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/chokidar": { "version": "3.6.0", @@ -1328,17 +1317,11 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -1532,9 +1515,10 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.5.364", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", - "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==" + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", + "license": "ISC" }, "node_modules/es-errors": { "version": "1.3.0", @@ -1558,6 +1542,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", "engines": { "node": ">=6" } @@ -2512,15 +2497,16 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -2535,9 +2521,10 @@ "dev": true }, "node_modules/node-releases": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", - "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "license": "MIT", "engines": { "node": ">=18" } @@ -2676,9 +2663,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "funding": [ { "type": "opencollective", @@ -2693,8 +2680,9 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2812,10 +2800,11 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, + "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -2868,22 +2857,24 @@ ] }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.6" + "react": "^19.2.8" } }, "node_modules/react-icons": { @@ -2923,19 +2914,19 @@ } }, "node_modules/react-router": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz", - "integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz", + "integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==", + "license": "MIT", "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" + "cookie-es": "^3.1.1" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.22.0" }, "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" + "react": ">=19.2.7", + "react-dom": ">=19.2.7" }, "peerDependenciesMeta": { "react-dom": { @@ -2943,21 +2934,6 @@ } } }, - "node_modules/react-router-dom": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz", - "integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==", - "dependencies": { - "react-router": "7.16.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - } - }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -3140,11 +3116,6 @@ "semver": "bin/semver.js" } }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3327,9 +3298,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "funding": [ { "type": "opencollective", @@ -3344,6 +3315,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" diff --git a/frontend/package.json b/frontend/package.json index 631146ac..2f37c61b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,19 +3,26 @@ "private": true, "version": "0.0.0", "type": "module", + "engines": { + "node": ">=22.22.0" + }, "scripts": { "dev": "vite", "build": "vite build", "lint": "eslint . --max-warnings=0", + "test:i18n": "node src/i18n/messages.test.mjs", + "test:a11y": "node scripts/accessibility-check.mjs", + "sync:severity": "node scripts/sync-severity-contract.mjs", + "test:severity": "node scripts/sync-severity-contract.mjs --check && node --test src/utils/severity.test.mjs", "preview": "vite preview" }, "dependencies": { "autoprefixer": "^10.5.0", - "postcss": "^8.5.15", - "react": "^19.2.6", - "react-dom": "^19.2.6", + "postcss": "^8.5.18", + "react": "^19.2.7", + "react-dom": "^19.2.7", "react-icons": "^5.6.0", - "react-router-dom": "^7.16.0", + "react-router": "^8.3.0", "recharts": "^3.8.1" }, "devDependencies": { diff --git a/frontend/scripts/accessibility-check.mjs b/frontend/scripts/accessibility-check.mjs new file mode 100644 index 00000000..35f8150f --- /dev/null +++ b/frontend/scripts/accessibility-check.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = path.resolve(import.meta.dirname, '..'); +const sourceRoot = path.join(root, 'src'); + +function filesUnder(directory) { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(directory, entry.name); + return entry.isDirectory() ? filesUnder(full) : [full]; + }); +} + +const html = fs.readFileSync(path.join(root, 'index.html'), 'utf8'); +assert.match(html, /]+lang="[a-z]{2}"/i, 'frontend HTML must declare a language'); + +for (const file of filesUnder(sourceRoot).filter((item) => /\.(jsx?|html)$/.test(item))) { + const source = fs.readFileSync(file, 'utf8'); + assert.doesNotMatch(source, /tabIndex=["'{]?[1-9]/, `${file} uses a positive tab order`); + assert.doesNotMatch(source, /<(div|span)\b[^>]*\bonClick=/, `${file} uses a non-semantic clickable element`); + for (const image of source.matchAll(/]*>/g)) { + assert.match(image[0], /\balt=/, `${file} contains an image without alt text`); + } +} + +console.log('accessibility static checks passed'); diff --git a/frontend/scripts/sync-severity-contract.mjs b/frontend/scripts/sync-severity-contract.mjs new file mode 100644 index 00000000..45ab797f --- /dev/null +++ b/frontend/scripts/sync-severity-contract.mjs @@ -0,0 +1,17 @@ +import { readFile, writeFile } from 'node:fs/promises'; + +const canonicalUrl = new URL('../../contracts/severity.v1.json', import.meta.url); +const generatedUrl = new URL('../src/generated/severity.v1.json', import.meta.url); +const canonical = JSON.parse(await readFile(canonicalUrl, 'utf8')); +const expected = `${JSON.stringify(canonical, null, 2)}\n`; + +if (process.argv.includes('--check')) { + const generated = await readFile(generatedUrl, 'utf8'); + if (generated !== expected) { + throw new Error( + 'frontend severity contract is stale; run npm run sync:severity and commit the result', + ); + } +} else { + await writeFile(generatedUrl, expected, 'utf8'); +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 206e84f5..0964a2ca 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router'; import { DarkModeProvider } from './contexts/DarkModeContext'; +import { I18nProvider } from './contexts/I18nContext'; import { api } from './utils/api'; import Layout from './components/layout/Layout'; import Discovery from './pages/Discovery'; @@ -25,8 +26,9 @@ export default function App() { return ( - - + + + }> } /> } /> @@ -37,8 +39,9 @@ export default function App() { } /> } /> - - + + + ); } diff --git a/frontend/src/components/compliance/ComplianceTable.jsx b/frontend/src/components/compliance/ComplianceTable.jsx index f1d18e0e..528c8b8e 100644 --- a/frontend/src/components/compliance/ComplianceTable.jsx +++ b/frontend/src/components/compliance/ComplianceTable.jsx @@ -1,4 +1,4 @@ -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { FiCheckCircle, FiXCircle, FiMinusCircle, FiArrowRight } from 'react-icons/fi'; import SeverityBadge from '../shared/SeverityBadge'; @@ -28,7 +28,9 @@ export default function ComplianceTable({ controls }) { {c.id} {c.name} - + + {c.severity ? : } + {c.category}
diff --git a/frontend/src/components/discovery/ResourceFilter.jsx b/frontend/src/components/discovery/ResourceFilter.jsx index acb1651f..6f1c38f0 100644 --- a/frontend/src/components/discovery/ResourceFilter.jsx +++ b/frontend/src/components/discovery/ResourceFilter.jsx @@ -1,18 +1,37 @@ import { FiSearch, FiX, FiLayers, FiList } from 'react-icons/fi'; +import { SEVERITY_DEFINITIONS } from '../../utils/severity'; + +const RISK_TEXT_STYLES = { + critical: 'text-severity-critical', + danger: 'text-severity-high', + warning: 'text-severity-medium', + success: 'text-severity-low', + neutral: 'text-severity-info', +}; + +const RISK_ACTIVE_TONE_STYLES = { + critical: 'bg-severity-critical text-white', + danger: 'bg-severity-high text-white', + warning: 'bg-severity-medium text-white', + success: 'bg-severity-low text-white', + neutral: 'bg-severity-info text-white', +}; const RISK_PILLS = [ { value: 'ACTIVE', label: 'Active Issues', color: 'text-text-primary dark:text-text-dark-primary' }, - { value: 'HIGH', label: 'HIGH', color: 'text-severity-high' }, - { value: 'MEDIUM', label: 'MEDIUM', color: 'text-severity-medium' }, - { value: 'LOW', label: 'LOW', color: 'text-severity-low' }, + ...SEVERITY_DEFINITIONS.map((level) => ({ + value: level.id, + label: level.id, + color: RISK_TEXT_STYLES[level.tone], + })), { value: 'CLEAN', label: 'Clean Only', color: 'text-brand-primary' }, ]; const RISK_ACTIVE_STYLES = { ACTIVE: 'bg-text-primary dark:bg-text-dark-primary text-bg-primary dark:text-bg-dark-primary', - HIGH: 'bg-severity-high text-white', - MEDIUM: 'bg-severity-medium text-white', - LOW: 'bg-severity-low text-white', + ...Object.fromEntries( + SEVERITY_DEFINITIONS.map((level) => [level.id, RISK_ACTIVE_TONE_STYLES[level.tone]]), + ), CLEAN: 'bg-brand-primary text-white', }; diff --git a/frontend/src/components/discovery/ResourceSummary.jsx b/frontend/src/components/discovery/ResourceSummary.jsx index 2c3de8a8..d5ee3735 100644 --- a/frontend/src/components/discovery/ResourceSummary.jsx +++ b/frontend/src/components/discovery/ResourceSummary.jsx @@ -17,6 +17,7 @@ export default function ResourceSummary({ summary, activeCategory, onCategoryCli const stats = [ { label: 'Total Resources', value: summary.total, sub: 'across all categories' }, + { label: 'Critical Risk', value: summary.byRiskLevel?.CRITICAL || 0, sub: 'require immediate action', color: 'text-severity-critical' }, { label: 'High Risk', value: summary.byRiskLevel?.HIGH || 0, sub: 'require immediate action', color: 'text-severity-high' }, { label: 'Medium Risk', value: summary.byRiskLevel?.MEDIUM || 0, sub: 'need attention', color: 'text-severity-medium' }, { label: 'Clean', value: summary.byRiskLevel?.NONE || 0, sub: 'no issues found', color: 'text-brand-primary' }, @@ -24,7 +25,7 @@ export default function ResourceSummary({ summary, activeCategory, onCategoryCli return (
-
+
{stats.map(({ label, value, sub, color }) => (

{label}

diff --git a/frontend/src/components/drift/DriftFilters.jsx b/frontend/src/components/drift/DriftFilters.jsx index 0a53ef00..a3354e59 100644 --- a/frontend/src/components/drift/DriftFilters.jsx +++ b/frontend/src/components/drift/DriftFilters.jsx @@ -1,6 +1,8 @@ +import { SEVERITY_IDS } from '../../utils/severity'; + const TYPES = ['All', 'ADDED', 'REMOVED', 'MODIFIED']; -const SEVERITIES = ['All', 'HIGH', 'MEDIUM', 'LOW']; +const SEVERITIES = ['All', ...SEVERITY_IDS]; export default function DriftFilters({ filters, onChange }) { const set = (key, val) => onChange({ ...filters, [key]: val }); diff --git a/frontend/src/components/layout/Header.jsx b/frontend/src/components/layout/Header.jsx index feb635f2..97d9ed34 100644 --- a/frontend/src/components/layout/Header.jsx +++ b/frontend/src/components/layout/Header.jsx @@ -1,27 +1,24 @@ import { useEffect, useRef, useState } from 'react'; -import { useLocation } from 'react-router-dom'; +import { useLocation } from 'react-router'; import { FiMenu, FiAlertTriangle, FiX, FiLoader, FiZap, FiCheckCircle, FiAlertCircle, FiClock, } from 'react-icons/fi'; import { api } from '../../utils/api'; +import { pollScan } from '../../utils/scanPolling'; +import { useI18n } from '../../i18n/I18nState'; -const PAGE_TITLES = { - '/monitoring': { title: 'Security Monitoring', subtitle: 'Overall health score and trends' }, - '/discovery': { title: 'Resource Discovery', subtitle: 'All resources across your Azure environment' }, - '/prioritization': { title: 'Risk Prioritization', subtitle: 'What to fix first based on risk and effort' }, - '/scan': { title: 'Detailed Scan', subtitle: 'Findings with step-by-step remediation playbooks' }, - '/compliance': { title: 'Compliance', subtitle: 'Framework tracking and control status' }, - '/drift': { title: 'Configuration Drift', subtitle: 'Detect unexpected changes to your environment' }, - '/ai': { title: 'AI Assistant', subtitle: 'Ask questions about your security posture' }, +const PAGE_KEYS = { + '/monitoring': 'monitoring', '/discovery': 'discovery', '/prioritization': 'prioritization', + '/scan': 'scan', '/compliance': 'compliance', '/drift': 'drift', '/ai': 'ai', }; // ── Connection-error popup ───────────────────────────────────────────────── function ConnectionErrorPopup({ apiBase, onClose }) { return ( <> -
-
+
@@ -68,7 +65,7 @@ function ScanToast({ result, error, onClose }) { const isSuccess = !!result; return ( -
+
-
@@ -126,10 +123,10 @@ function ScanInputPopover({ onConfirm, onCancel }) { return ( <> -
-
+ @@ -268,6 +267,15 @@ export default function Header({ onMenuToggle }) { {/* Right: controls */}
+ + {/* Run Scan button + popover wrapper */}
@@ -282,7 +290,7 @@ export default function Header({ onMenuToggle }) { : } - {scanning ? `Scanning… ${elapsed}s` : 'Run Scan'} + {scanning ? t('scan.scanning', { seconds: elapsed }) : t('scan.run')} @@ -298,12 +306,14 @@ export default function Header({ onMenuToggle }) { {lastScanAt && isLive && (
- Last scanned: {lastScanAt} + {t('scan.last', { date: lastScanAt })}
)} {/* Live / Reconnecting status dot */}
@@ -316,7 +326,7 @@ export default function Header({ onMenuToggle }) { )} - {isLive ? 'Live' : 'Reconnecting'} + {isLive ? t('status.live') : t('status.reconnecting')}
diff --git a/frontend/src/components/layout/Layout.jsx b/frontend/src/components/layout/Layout.jsx index 01a23c40..fc1d99aa 100644 --- a/frontend/src/components/layout/Layout.jsx +++ b/frontend/src/components/layout/Layout.jsx @@ -1,16 +1,23 @@ import { useState } from 'react'; -import { Outlet } from 'react-router-dom'; +import { Outlet } from 'react-router'; import Sidebar from './Sidebar'; import Header from './Header'; +import { useI18n } from '../../i18n/I18nState'; export default function Layout() { const [sidebarOpen, setSidebarOpen] = useState(false); + const { t } = useI18n(); return (
+ + {t('skip.content')} + {/* Mobile overlay */} {sidebarOpen && ( -
setSidebarOpen(false)} /> @@ -20,7 +27,7 @@ export default function Layout() {
setSidebarOpen((v) => !v)} /> -
+
diff --git a/frontend/src/components/layout/Sidebar.jsx b/frontend/src/components/layout/Sidebar.jsx index e79074ab..f11e1acf 100644 --- a/frontend/src/components/layout/Sidebar.jsx +++ b/frontend/src/components/layout/Sidebar.jsx @@ -1,34 +1,36 @@ -import { NavLink } from 'react-router-dom'; +import { NavLink } from 'react-router'; import { FiActivity, FiSearch, FiTarget, FiZap, FiShield, FiGitBranch, FiCpu, FiSun, FiMoon, FiX, } from 'react-icons/fi'; import { useDarkMode } from '../../contexts/DarkModeContext'; +import { useI18n } from '../../i18n/I18nState'; import Logo from '../shared/Logo'; const navItems = [ - { path: '/monitoring', label: 'Monitor', Icon: FiActivity }, - { path: '/discovery', label: 'Discover', Icon: FiSearch }, - { path: '/prioritization', label: 'Prioritize', Icon: FiTarget }, - { path: '/scan', label: 'Scan', Icon: FiZap }, - { path: '/compliance', label: 'Comply', Icon: FiShield }, - { path: '/drift', label: 'Drift', Icon: FiGitBranch }, - { path: '/ai', label: 'AI', Icon: FiCpu }, + { path: '/monitoring', key: 'monitoring', Icon: FiActivity }, + { path: '/discovery', key: 'discovery', Icon: FiSearch }, + { path: '/prioritization', key: 'prioritization', Icon: FiTarget }, + { path: '/scan', key: 'scan', Icon: FiZap }, + { path: '/compliance', key: 'compliance', Icon: FiShield }, + { path: '/drift', key: 'drift', Icon: FiGitBranch }, + { path: '/ai', key: 'ai', Icon: FiCpu }, ]; export default function Sidebar({ isOpen, onClose }) { const { isDark, toggle } = useDarkMode(); + const { t } = useI18n(); return ( <> {/* ── Desktop sidebar (always visible on lg+) ── */} -
{/* Drawer nav */} -
diff --git a/frontend/src/components/monitoring/ResourceGroupChart.jsx b/frontend/src/components/monitoring/ResourceGroupChart.jsx index 4e840d17..879ca933 100644 --- a/frontend/src/components/monitoring/ResourceGroupChart.jsx +++ b/frontend/src/components/monitoring/ResourceGroupChart.jsx @@ -3,7 +3,9 @@ import { Tooltip, Legend, ResponsiveContainer, } from 'recharts'; -const COLORS = { HIGH: '#ef4444', MEDIUM: '#f97316', LOW: '#10b981' }; +import { SEVERITY_DEFINITIONS } from '../../utils/severity'; + +const DISPLAY_LEVELS = SEVERITY_DEFINITIONS.filter((level) => level.score_weight > 0); const CustomTooltip = ({ active, payload, label }) => { if (!active || !payload?.length) return null; @@ -33,9 +35,17 @@ export default function ResourceGroupChart({ data }) { } /> - - - + {DISPLAY_LEVELS.map((level, index) => ( + + ))} ); diff --git a/frontend/src/components/monitoring/StatCards.jsx b/frontend/src/components/monitoring/StatCards.jsx index a1812da9..e33bfaf1 100644 --- a/frontend/src/components/monitoring/StatCards.jsx +++ b/frontend/src/components/monitoring/StatCards.jsx @@ -4,13 +4,14 @@ import Card from '../shared/Card'; export default function StatCards({ stats }) { const cards = [ { label: 'Total Findings', value: stats.totalFindings, Icon: FiLayers, color: 'text-status-info', bg: 'bg-blue-50 dark:bg-blue-900/20' }, - { label: 'Critical Issues', value: stats.criticalIssues, Icon: FiAlertCircle, color: 'text-severity-high', bg: 'bg-red-50 dark:bg-red-900/20' }, + { label: 'Critical Issues', value: stats.criticalIssues, Icon: FiAlertCircle, color: 'text-severity-critical', bg: 'bg-red-100 dark:bg-red-950/40' }, + { label: 'High Risk', value: stats.highRisk, Icon: FiAlertCircle, color: 'text-severity-high', bg: 'bg-red-50 dark:bg-red-900/20' }, { label: 'Medium Risk', value: stats.mediumRisk, Icon: FiAlertTriangle, color: 'text-severity-medium', bg: 'bg-orange-50 dark:bg-orange-900/20' }, { label: 'Low Priority', value: stats.lowPriority, Icon: FiInfo, color: 'text-severity-low', bg: 'bg-green-50 dark:bg-green-900/20' }, ]; return ( -
+
{cards.map(({ label, value, Icon, color, bg }) => (
diff --git a/frontend/src/components/prioritization/PriorityFilters.jsx b/frontend/src/components/prioritization/PriorityFilters.jsx index c564771c..40ca1c3a 100644 --- a/frontend/src/components/prioritization/PriorityFilters.jsx +++ b/frontend/src/components/prioritization/PriorityFilters.jsx @@ -1,6 +1,8 @@ +import { SEVERITY_IDS } from '../../utils/severity'; + const CATEGORIES = ['All', 'Storage', 'Compute', 'Network', 'Identity', 'Database', 'KeyVault']; -const SEVERITIES = ['All', 'HIGH', 'MEDIUM', 'LOW']; +const SEVERITIES = ['All', ...SEVERITY_IDS]; export default function PriorityFilters({ filters, onChange }) { const set = (key, val) => onChange({ ...filters, [key]: val }); diff --git a/frontend/src/components/prioritization/PriorityMatrix.jsx b/frontend/src/components/prioritization/PriorityMatrix.jsx index bcc5ad01..67822d70 100644 --- a/frontend/src/components/prioritization/PriorityMatrix.jsx +++ b/frontend/src/components/prioritization/PriorityMatrix.jsx @@ -3,7 +3,7 @@ import { Tooltip, ResponsiveContainer, ReferenceLine, ReferenceArea, } from 'recharts'; -const SEVERITY_COLORS = { HIGH: '#ef4444', MEDIUM: '#f97316', LOW: '#10b981', INFO: '#6b7280' }; +import { SEVERITY_DEFINITIONS, severityColor } from '../../utils/severity'; const CustomTooltip = ({ active, payload }) => { if (!active || !payload?.length) return null; @@ -68,7 +68,7 @@ export default function PriorityMatrix({ items, selectedId, onSelect }) { const { cx, cy, payload } = props; const isSelected = payload.ruleId === selectedId; const isDimmed = hasSelection && !isSelected; - const color = SEVERITY_COLORS[payload.severity] || '#6b7280'; + const color = severityColor(payload.severity); return ( {isSelected && ( @@ -92,9 +92,9 @@ export default function PriorityMatrix({ items, selectedId, onSelect }) { {/* Legend */}
- {[['HIGH', '#ef4444'], ['MEDIUM', '#f97316'], ['LOW', '#10b981']].map(([s, c]) => ( - - {s} + {SEVERITY_DEFINITIONS.map((level) => ( + + {level.id} ))} diff --git a/frontend/src/components/prioritization/QuickRemediation.jsx b/frontend/src/components/prioritization/QuickRemediation.jsx index c4e5ab43..813c44cb 100644 --- a/frontend/src/components/prioritization/QuickRemediation.jsx +++ b/frontend/src/components/prioritization/QuickRemediation.jsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { FiLayout, FiTerminal, FiClock, FiArrowRight, FiTool, FiAlertTriangle } from 'react-icons/fi'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; const EFFORT_ETA = { 1: '15–30 mins', 2: '1–2 hours', 3: '2–4 hours', 4: '~1 day', 5: '2–3 days' }; diff --git a/frontend/src/components/scan/AskAIButton.jsx b/frontend/src/components/scan/AskAIButton.jsx index fbd89889..8102c950 100644 --- a/frontend/src/components/scan/AskAIButton.jsx +++ b/frontend/src/components/scan/AskAIButton.jsx @@ -1,5 +1,5 @@ import { FiCpu } from 'react-icons/fi'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; export default function AskAIButton({ finding }) { const navigate = useNavigate(); diff --git a/frontend/src/components/shared/Card.jsx b/frontend/src/components/shared/Card.jsx index bafe013b..363fb708 100644 --- a/frontend/src/components/shared/Card.jsx +++ b/frontend/src/components/shared/Card.jsx @@ -1,10 +1,15 @@ export default function Card({ children, className = '', onClick }) { + const classes = `rounded-2xl border border-border-light dark:border-border-dark bg-bg-primary dark:bg-bg-dark-secondary p-6 shadow-soft hover:shadow-soft-lg transition-all duration-200 ${onClick ? 'cursor-pointer' : ''} ${className}`; + if (onClick) { + return ( + + ); + } return ( -
+
{children}
); diff --git a/frontend/src/components/shared/ErrorState.jsx b/frontend/src/components/shared/ErrorState.jsx new file mode 100644 index 00000000..fd8cd273 --- /dev/null +++ b/frontend/src/components/shared/ErrorState.jsx @@ -0,0 +1,30 @@ +import { FiAlertCircle, FiRefreshCw } from 'react-icons/fi'; +import Button from './Button'; + +export default function ErrorState({ + title = 'Unable to load this page', + description = 'Check your connection and try again.', + onRetry, +}) { + return ( +
+
+
+

+ {title} +

+

+ {description} +

+ +
+ ); +} diff --git a/frontend/src/components/shared/RiskBadge.jsx b/frontend/src/components/shared/RiskBadge.jsx index 1e831460..ab18635d 100644 --- a/frontend/src/components/shared/RiskBadge.jsx +++ b/frontend/src/components/shared/RiskBadge.jsx @@ -1,15 +1,23 @@ +import { normalizeRisk, severityDefinition } from '../../utils/severity'; + const styles = { - HIGH: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', - MEDIUM: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', - LOW: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + critical: 'bg-red-200 text-red-900 dark:bg-red-950/50 dark:text-red-300', + danger: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', + warning: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', + success: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + neutral: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400', NONE: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400', }; export default function RiskBadge({ risk }) { + const normalized = normalizeRisk(risk || 'NONE'); + const style = normalized === 'NONE' + ? styles.NONE + : styles[severityDefinition(normalized).tone]; return ( - - {risk || 'NONE'} + + {normalized} ); } diff --git a/frontend/src/components/shared/SeverityBadge.jsx b/frontend/src/components/shared/SeverityBadge.jsx index 3b56fbd1..1d9679ad 100644 --- a/frontend/src/components/shared/SeverityBadge.jsx +++ b/frontend/src/components/shared/SeverityBadge.jsx @@ -1,16 +1,19 @@ +import { severityDefinition } from '../../utils/severity'; + const styles = { - HIGH: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', - MEDIUM: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', - LOW: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', - INFO: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400', - NONE: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400', + critical: 'bg-red-200 text-red-900 dark:bg-red-950/50 dark:text-red-300', + danger: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', + warning: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', + success: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + neutral: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400', }; export default function SeverityBadge({ severity }) { + const definition = severityDefinition(severity); return ( - - {severity || 'INFO'} + + {definition.id} ); } diff --git a/frontend/src/contexts/I18nContext.jsx b/frontend/src/contexts/I18nContext.jsx new file mode 100644 index 00000000..f56e34f0 --- /dev/null +++ b/frontend/src/contexts/I18nContext.jsx @@ -0,0 +1,36 @@ +import { useEffect, useMemo, useState } from 'react'; +import { DEFAULT_LOCALE, messages, translate } from '../i18n/messages'; +import { I18nState } from '../i18n/I18nState'; +const STORAGE_KEY = 'openshield.locale'; + +function initialLocale() { + const stored = window.localStorage.getItem(STORAGE_KEY); + if (stored && messages[stored]) return stored; + const browserLocale = window.navigator.language?.split('-')[0]; + return messages[browserLocale] ? browserLocale : DEFAULT_LOCALE; +} + +export function I18nProvider({ children }) { + const [locale, setLocaleState] = useState(initialLocale); + + const setLocale = (nextLocale) => { + const supported = messages[nextLocale] ? nextLocale : DEFAULT_LOCALE; + window.localStorage.setItem(STORAGE_KEY, supported); + setLocaleState(supported); + }; + + useEffect(() => { + document.documentElement.lang = locale; + }, [locale]); + + const value = useMemo(() => ({ + locale, + locales: Object.keys(messages), + setLocale, + t: (key, values) => translate(locale, key, values), + formatDate: (value, options) => new Intl.DateTimeFormat(locale, options).format(new Date(value)), + formatNumber: (value, options) => new Intl.NumberFormat(locale, options).format(value), + }), [locale]); + + return {children}; +} diff --git a/frontend/src/generated/severity.v1.json b/frontend/src/generated/severity.v1.json new file mode 100644 index 00000000..344a14a9 --- /dev/null +++ b/frontend/src/generated/severity.v1.json @@ -0,0 +1,54 @@ +{ + "contract": "openshield.finding-severity", + "version": "1.0.0", + "aliases": { + "INFORMATIONAL": "INFO" + }, + "levels": [ + { + "id": "CRITICAL", + "rank": 4, + "score_weight": 20, + "risk_score": 10, + "label": "Critical", + "color": "#b91c1c", + "tone": "critical" + }, + { + "id": "HIGH", + "rank": 3, + "score_weight": 10, + "risk_score": 8, + "label": "High", + "color": "#ef4444", + "tone": "danger" + }, + { + "id": "MEDIUM", + "rank": 2, + "score_weight": 5, + "risk_score": 5, + "label": "Medium", + "color": "#f97316", + "tone": "warning" + }, + { + "id": "LOW", + "rank": 1, + "score_weight": 2, + "risk_score": 2, + "label": "Low", + "color": "#10b981", + "tone": "success" + }, + { + "id": "INFO", + "rank": 0, + "score_weight": 0, + "risk_score": 1, + "label": "Info", + "color": "#6b7280", + "tone": "neutral" + } + ] +} diff --git a/frontend/src/hooks/usePageData.js b/frontend/src/hooks/usePageData.js new file mode 100644 index 00000000..f264e66a --- /dev/null +++ b/frontend/src/hooks/usePageData.js @@ -0,0 +1,69 @@ +import { useEffect, useMemo, useReducer } from 'react'; + +export const initialPageDataState = { + status: 'loading', + data: null, + error: null, +}; + +export function pageDataReducer(state, action) { + switch (action.type) { + case 'loading': + return initialPageDataState; + case 'success': + return { status: 'success', data: action.data, error: null }; + case 'error': + return { status: 'error', data: null, error: action.error }; + default: + return state; + } +} + +export function createPageDataLoader(load, dispatch) { + let requestId = 0; + let inFlight = false; + + return { + async retry() { + if (inFlight) return; + inFlight = true; + const currentRequest = ++requestId; + dispatch({ type: 'loading' }); + + try { + const data = await load(); + if (currentRequest === requestId) dispatch({ type: 'success', data }); + } catch (error) { + if (currentRequest === requestId) dispatch({ type: 'error', error }); + } finally { + if (currentRequest === requestId) inFlight = false; + } + }, + + cancel() { + requestId++; + inFlight = false; + }, + }; +} + +export function schedulePageDataLoad(loader) { + let active = true; + queueMicrotask(() => { + if (active) loader.retry(); + }); + + return () => { + active = false; + loader.cancel(); + }; +} + +export default function usePageData(load) { + const [state, dispatch] = useReducer(pageDataReducer, initialPageDataState); + const loader = useMemo(() => createPageDataLoader(load, dispatch), [load]); + + useEffect(() => schedulePageDataLoad(loader), [loader]); + + return { ...state, retry: loader.retry }; +} diff --git a/frontend/src/hooks/usePageData.test.mjs b/frontend/src/hooks/usePageData.test.mjs new file mode 100644 index 00000000..4b8925eb --- /dev/null +++ b/frontend/src/hooks/usePageData.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import { + createPageDataLoader, + initialPageDataState, + pageDataReducer, + schedulePageDataLoad, +} from './usePageData.js'; + +function createHarness(load) { + let state = initialPageDataState; + const transitions = []; + const dispatch = (action) => { + state = pageDataReducer(state, action); + transitions.push(state); + }; + return { + loader: createPageDataLoader(load, dispatch), + getState: () => state, + transitions, + }; +} + +const tests = []; +function test(description, fn) { tests.push({ description, fn }); } + +test('a successful populated load transitions from loading to success', async () => { + const data = [{ id: 1 }]; + const harness = createHarness(async () => data); + await harness.loader.retry(); + assert.deepEqual(harness.transitions.map(({ status }) => status), ['loading', 'success']); + assert.equal(harness.getState().data, data); +}); + +test('a successful empty load remains distinct from loading', async () => { + const harness = createHarness(async () => []); + await harness.loader.retry(); + assert.equal(harness.getState().status, 'success'); + assert.deepEqual(harness.getState().data, []); +}); + +test('a rejected load transitions to an observable error state', async () => { + const error = new Error('backend unavailable'); + const harness = createHarness(async () => { throw error; }); + await harness.loader.retry(); + assert.equal(harness.getState().status, 'error'); + assert.equal(harness.getState().error, error); + assert.equal(harness.getState().data, null); +}); + +test('retry clears the failure and can transition to populated success', async () => { + let attempt = 0; + const harness = createHarness(async () => { + attempt++; + if (attempt === 1) throw new Error('temporary failure'); + return [{ id: 'recovered' }]; + }); + await harness.loader.retry(); + await harness.loader.retry(); + assert.deepEqual( + harness.transitions.map(({ status }) => status), + ['loading', 'error', 'loading', 'success'], + ); + assert.deepEqual(harness.getState().data, [{ id: 'recovered' }]); +}); + +test('retry can recover to a successful empty response', async () => { + let attempt = 0; + const harness = createHarness(async () => { + attempt++; + if (attempt === 1) throw new Error('temporary failure'); + return []; + }); + await harness.loader.retry(); + await harness.loader.retry(); + assert.equal(harness.getState().status, 'success'); + assert.deepEqual(harness.getState().data, []); +}); + +test('rapid retry attempts do not start duplicate concurrent requests', async () => { + let resolveLoad; + let calls = 0; + const harness = createHarness(() => { + calls++; + return new Promise((resolve) => { resolveLoad = resolve; }); + }); + const first = harness.loader.retry(); + await harness.loader.retry(); + assert.equal(calls, 1); + resolveLoad('done'); + await first; + assert.equal(harness.getState().status, 'success'); +}); + +test('cancelled loads cannot overwrite state after unmount', async () => { + let resolveLoad; + const harness = createHarness(() => new Promise((resolve) => { resolveLoad = resolve; })); + const request = harness.loader.retry(); + harness.loader.cancel(); + resolveLoad('stale'); + await request; + assert.deepEqual(harness.transitions.map(({ status }) => status), ['loading']); +}); + +test('Strict Mode effect replay starts only one initial request', async () => { + let calls = 0; + let resolveLoad; + const harness = createHarness(() => { + calls++; + return new Promise((resolve) => { resolveLoad = resolve; }); + }); + + const cancelFirstSetup = schedulePageDataLoad(harness.loader); + cancelFirstSetup(); + const cancelSecondSetup = schedulePageDataLoad(harness.loader); + await new Promise((resolve) => queueMicrotask(resolve)); + + assert.equal(calls, 1); + cancelSecondSetup(); + resolveLoad('stale'); + await new Promise((resolve) => queueMicrotask(resolve)); + assert.deepEqual(harness.transitions.map(({ status }) => status), ['loading']); +}); + +let failures = 0; +for (const { description, fn } of tests) { + try { + await fn(); + console.log(`PASS: ${description}`); + } catch (error) { + failures++; + console.error(`FAIL: ${description}\n ${error.stack || error.message}`); + } +} + +if (failures > 0) { + console.error(`\n${failures} test(s) failed`); + process.exit(1); +} +console.log(`\nAll ${tests.length} page data tests passed`); diff --git a/frontend/src/i18n/I18nState.js b/frontend/src/i18n/I18nState.js new file mode 100644 index 00000000..5a4283fd --- /dev/null +++ b/frontend/src/i18n/I18nState.js @@ -0,0 +1,9 @@ +import { createContext, useContext } from 'react'; + +export const I18nState = createContext(null); + +export function useI18n() { + const context = useContext(I18nState); + if (!context) throw new Error('useI18n must be used inside I18nProvider'); + return context; +} diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js new file mode 100644 index 00000000..dfe1ebe4 --- /dev/null +++ b/frontend/src/i18n/messages.js @@ -0,0 +1,41 @@ +export const DEFAULT_LOCALE = 'en'; + +export const messages = { + en: { + 'nav.monitoring': 'Monitor', 'nav.discovery': 'Discover', 'nav.prioritization': 'Prioritize', + 'nav.scan': 'Scan', 'nav.compliance': 'Comply', 'nav.drift': 'Drift', 'nav.ai': 'AI', + 'theme.dark': 'Dark mode', 'theme.light': 'Light mode', 'theme.toggle': 'Toggle colour theme', + 'menu.open': 'Open menu', 'menu.close': 'Close menu', 'nav.primary': 'Primary navigation', + 'language.label': 'Language', 'language.en': 'English', 'language.es': 'Español', + 'page.monitoring.title': 'Security Monitoring', 'page.monitoring.subtitle': 'Overall health score and trends', + 'page.discovery.title': 'Resource Discovery', 'page.discovery.subtitle': 'All resources across your Azure environment', + 'page.prioritization.title': 'Risk Prioritization', 'page.prioritization.subtitle': 'What to fix first based on risk and effort', + 'page.scan.title': 'Detailed Scan', 'page.scan.subtitle': 'Findings with step-by-step remediation playbooks', + 'page.compliance.title': 'Compliance', 'page.compliance.subtitle': 'Framework tracking and control status', + 'page.drift.title': 'Configuration Drift', 'page.drift.subtitle': 'Detect unexpected changes to your environment', + 'page.ai.title': 'AI Assistant', 'page.ai.subtitle': 'Ask questions about your security posture', + 'scan.run': 'Run Scan', 'scan.scanning': 'Scanning… {seconds}s', 'scan.last': 'Last scanned: {date}', + 'status.live': 'Live', 'status.reconnecting': 'Reconnecting', 'skip.content': 'Skip to main content', + }, + es: { + 'nav.monitoring': 'Monitorear', 'nav.discovery': 'Descubrir', 'nav.prioritization': 'Priorizar', + 'nav.scan': 'Escanear', 'nav.compliance': 'Cumplimiento', 'nav.drift': 'Cambios', 'nav.ai': 'IA', + 'theme.dark': 'Modo oscuro', 'theme.light': 'Modo claro', 'theme.toggle': 'Cambiar tema de color', + 'menu.open': 'Abrir menú', 'menu.close': 'Cerrar menú', 'nav.primary': 'Navegación principal', + 'language.label': 'Idioma', 'language.en': 'English', 'language.es': 'Español', + 'page.monitoring.title': 'Monitoreo de seguridad', 'page.monitoring.subtitle': 'Puntuación general y tendencias', + 'page.discovery.title': 'Descubrimiento de recursos', 'page.discovery.subtitle': 'Recursos del entorno de Azure', + 'page.prioritization.title': 'Priorización de riesgos', 'page.prioritization.subtitle': 'Qué corregir primero según riesgo y esfuerzo', + 'page.scan.title': 'Escaneo detallado', 'page.scan.subtitle': 'Hallazgos y guías de corrección', + 'page.compliance.title': 'Cumplimiento', 'page.compliance.subtitle': 'Controles y marcos de cumplimiento', + 'page.drift.title': 'Cambios de configuración', 'page.drift.subtitle': 'Cambios inesperados del entorno', + 'page.ai.title': 'Asistente de IA', 'page.ai.subtitle': 'Preguntas sobre la postura de seguridad', + 'scan.run': 'Ejecutar escaneo', 'scan.scanning': 'Escaneando… {seconds}s', 'scan.last': 'Último escaneo: {date}', + 'status.live': 'En línea', 'status.reconnecting': 'Reconectando', 'skip.content': 'Saltar al contenido principal', + }, +}; + +export function translate(locale, key, values = {}) { + const template = messages[locale]?.[key] ?? messages[DEFAULT_LOCALE][key] ?? key; + return Object.entries(values).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), template); +} diff --git a/frontend/src/i18n/messages.test.mjs b/frontend/src/i18n/messages.test.mjs new file mode 100644 index 00000000..5d090335 --- /dev/null +++ b/frontend/src/i18n/messages.test.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { DEFAULT_LOCALE, messages, translate } from './messages.js'; + +const referenceKeys = Object.keys(messages[DEFAULT_LOCALE]).sort(); +for (const [locale, catalog] of Object.entries(messages)) { + assert.deepEqual(Object.keys(catalog).sort(), referenceKeys, `${locale} must contain the complete message catalog`); +} +assert.equal(translate('es', 'nav.monitoring'), 'Monitorear'); +assert.equal(translate('unknown', 'nav.monitoring'), 'Monitor'); +assert.equal(translate('en', 'scan.scanning', { seconds: 12 }), 'Scanning… 12s'); +console.log('i18n catalogs valid'); diff --git a/frontend/src/pages/AILayer.jsx b/frontend/src/pages/AILayer.jsx index ed378406..6efa514d 100644 --- a/frontend/src/pages/AILayer.jsx +++ b/frontend/src/pages/AILayer.jsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { useLocation } from 'react-router-dom'; +import { useLocation } from 'react-router'; import { FiCpu, FiX, FiAlertCircle, FiKey, FiCheckCircle } from 'react-icons/fi'; import { api } from '../utils/api'; import { aiApi, aiSettings } from '../utils/aiApi'; diff --git a/frontend/src/pages/Compliance.jsx b/frontend/src/pages/Compliance.jsx index 78d04f1a..6251e3e9 100644 --- a/frontend/src/pages/Compliance.jsx +++ b/frontend/src/pages/Compliance.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; import { api } from '../utils/api'; import FrameworkCards from '../components/compliance/FrameworkCards'; import ComplianceTable from '../components/compliance/ComplianceTable'; @@ -6,30 +6,48 @@ import ComparisonChart from '../components/compliance/ComparisonChart'; import ExportButton from '../components/compliance/ExportButton'; import Card from '../components/shared/Card'; import Loader from '../components/shared/Loader'; +import EmptyState from '../components/shared/EmptyState'; +import ErrorState from '../components/shared/ErrorState'; +import usePageData from '../hooks/usePageData'; export default function Compliance() { - const [data, setData] = useState(null); const [selectedFw, setSelectedFw] = useState(null); const [statusFilter, setStatusFilter] = useState('All'); + const loadCompliance = useCallback(() => api.getCompliance(), []); + const { status, data, retry } = usePageData(loadCompliance); - useEffect(() => { - api.getCompliance().then((d) => { - setData(d); - setSelectedFw(d.frameworks[0]); - }); - }, []); + if (status === 'loading') return ; + if (status === 'error') return ( + + ); + if (data.frameworks.length === 0 && data.controls.length === 0) return ( + + ); - if (!data) return ; + const activeFramework = data.frameworks.find((framework) => framework.name === selectedFw?.name) + ?? data.frameworks[0] + ?? null; const filteredControls = data.controls.filter((c) => { - if (selectedFw && c.framework !== selectedFw.name) return false; + if (activeFramework && c.framework !== activeFramework.name) return false; if (statusFilter !== 'All' && c.status !== statusFilter) return false; return true; }); return (
- +

Framework Score Trends

diff --git a/frontend/src/pages/DetailedScan.jsx b/frontend/src/pages/DetailedScan.jsx index 41c8fee0..711f5f0d 100644 --- a/frontend/src/pages/DetailedScan.jsx +++ b/frontend/src/pages/DetailedScan.jsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useCallback, useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router'; import { FiArrowLeft, FiX, FiAlertTriangle } from 'react-icons/fi'; import { api } from '../utils/api'; import FindingHeader from '../components/scan/FindingHeader'; @@ -8,6 +8,9 @@ import AskAIButton from '../components/scan/AskAIButton'; import SeverityBadge from '../components/shared/SeverityBadge'; import Card from '../components/shared/Card'; import Loader from '../components/shared/Loader'; +import EmptyState from '../components/shared/EmptyState'; +import ErrorState from '../components/shared/ErrorState'; +import usePageData from '../hooks/usePageData'; const IMPACT_COLORS = { CRITICAL: 'text-red-600 dark:text-red-400', @@ -16,6 +19,13 @@ const IMPACT_COLORS = { LOW: 'text-green-600 dark:text-green-400', }; +const EMPTY_PLAYBOOK = { + portalSteps: [], + cliCommands: [], + validationSteps: [], + references: [], +}; + function FromPrioritizationBanner({ state, finding, onDismiss }) { const navigate = useNavigate(); const issueName = state.issueName || finding?.ruleName || 'Selected finding'; @@ -77,36 +87,51 @@ function FromPrioritizationBanner({ state, finding, onDismiss }) { export default function DetailedScan() { const location = useLocation(); - const [findings, setFindings] = useState([]); - const [selected, setSelected] = useState(null); - const [playbook, setPlaybook] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [playbookState, setPlaybookState] = useState({ findingId: null, data: null }); const [showBanner, setShowBanner] = useState(!!location.state?.fromPrioritization); const preSelectRuleId = location.state?.ruleId; - - // Load findings on mount + const loadFindings = useCallback(() => api.getFindings(), []); + const { status, data: findings, retry } = usePageData(loadFindings); + + const preselected = preSelectRuleId + ? findings?.find((finding) => finding.ruleId === preSelectRuleId) + : null; + const selected = findings?.find((finding) => finding.id === selectedId) + ?? preselected + ?? findings?.[0] + ?? null; + const playbook = playbookState.findingId === selected?.id ? playbookState.data : null; + + // Fetch the playbook for the derived initial finding or a user selection. useEffect(() => { - api.getFindings().then((data) => { - setFindings(data); - const initial = preSelectRuleId - ? (data.find((f) => f.ruleId === preSelectRuleId) ?? data[0]) - : data[0]; - selectFinding(initial); + if (!selected?.id) return undefined; + let active = true; + api.getPlaybook(selected.id).then((data) => { + if (active) setPlaybookState({ findingId: selected.id, data }); + }).catch(() => { + if (active) setPlaybookState({ findingId: selected.id, data: EMPTY_PLAYBOOK }); }); - }, [preSelectRuleId]); - - // Fetch playbook whenever selected finding changes - async function selectFinding(f) { - setSelected(f); - setPlaybook(null); - if (!f?.id) return; - const pb = await api.getPlaybook(f.id); - setPlaybook(pb); - } + return () => { active = false; }; + }, [selected?.id]); // Merge playbook data into the selected finding for rendering const enriched = selected ? { ...selected, ...(playbook || {}) } : null; - if (!findings.length) return ; + if (status === 'loading') return ; + if (status === 'error') return ( + + ); + if (findings.length === 0) return ( + + ); const fromState = location.state?.fromPrioritization ? location.state : null; @@ -134,7 +159,7 @@ export default function DetailedScan() { return ( - - - - - - - - -
- - - - - - GitHub - -
- - -
- - - -
- -
- - -
- - -
-
-
- - 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/package-lock.json b/website/package-lock.json new file mode 100644 index 00000000..a03955e2 --- /dev/null +++ b/website/package-lock.json @@ -0,0 +1,4414 @@ +{ + "name": "openshield-website", + "version": "0.4.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openshield-website", + "version": "0.4.0", + "dependencies": { + "@astrojs/rss": "^4.0.12", + "@astrojs/sitemap": "^3.5.0", + "@fontsource-variable/schibsted-grotesk": "^5.2.0", + "@fontsource/dm-mono": "^5.2.0", + "astro": "^7.3.1", + "three": "^0.180.0" + } + }, + "node_modules/@astrojs/compiler-binding": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.4.0.tgz", + "integrity": "sha512-x2RjDUuWfwLNtc3mjAdSRInwqh/rqbLar9cm/5FOMbHvmYZB7yfKewzSclAxWjIZsypJDXv1lhaP2WG+P8TK3g==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@astrojs/compiler-binding-darwin-arm64": "0.4.0", + "@astrojs/compiler-binding-darwin-x64": "0.4.0", + "@astrojs/compiler-binding-linux-arm64-gnu": "0.4.0", + "@astrojs/compiler-binding-linux-arm64-musl": "0.4.0", + "@astrojs/compiler-binding-linux-x64-gnu": "0.4.0", + "@astrojs/compiler-binding-linux-x64-musl": "0.4.0", + "@astrojs/compiler-binding-wasm32-wasi": "0.4.0", + "@astrojs/compiler-binding-win32-arm64-msvc": "0.4.0", + "@astrojs/compiler-binding-win32-x64-msvc": "0.4.0" + } + }, + "node_modules/@astrojs/compiler-binding-darwin-arm64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-arm64/-/compiler-binding-darwin-arm64-0.4.0.tgz", + "integrity": "sha512-ZVUwHundaQyFNjE6uoa0usaC0WOCitDCLS/4mdb4rOiJXwVUuKJBMxI5WMzXLWmamsXtK/Z//ifLXvV5Yeh4Hw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-darwin-x64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-x64/-/compiler-binding-darwin-x64-0.4.0.tgz", + "integrity": "sha512-FI6G8AY8u6fR1SI/QRR5yGMwtvZwP34CDmZpZ5HwJGa50UM1VISTLhqkhV4a476pmgd25X1Aur2dqw6hUnrlKA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-arm64-gnu": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-gnu/-/compiler-binding-linux-arm64-gnu-0.4.0.tgz", + "integrity": "sha512-lB9gLFJK7m82EnjaU8nlRBEfcwGNeHidW3sSjODTUjMNaoewVuUz9fwwdY5M4jiSXIqWLH3yl6TX8FTDKA74Sw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-arm64-musl": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-musl/-/compiler-binding-linux-arm64-musl-0.4.0.tgz", + "integrity": "sha512-HPbvWqbxFxyaoQJhLxCaSjtYBx9KBo7JGVzEFZCmMl968a2PsSH0UfiODYgYPXofTOIsIH2aoCcrHXML0IA3ig==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-x64-gnu": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-gnu/-/compiler-binding-linux-x64-gnu-0.4.0.tgz", + "integrity": "sha512-tQKolMxoJ/+0AmLWm1PmJ/i+z3i10ZU1bNuVjEDulCf48azEMtUNjTZgHJ5MPtpYRNc7dlETr8QujUfduzoC7Q==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-x64-musl": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-musl/-/compiler-binding-linux-x64-musl-0.4.0.tgz", + "integrity": "sha512-5v5YymudsxMHp3NBLCS8BUlu5CRqeLtWD9cKS/4nIhIEHCbpz9okmVV6I0HWqmBAPhWYcDa3vw/vltYPrOQCTA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-wasm32-wasi": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-wasm32-wasi/-/compiler-binding-wasm32-wasi-0.4.0.tgz", + "integrity": "sha512-m/phuH3x3PREvv1OnkM44NoPh4MatUadix1fB1u5SvMLCyDTUZykDJbKnWf1cjnYmHdlB8HcjTjl6JrCqAIXcw==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.2.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@astrojs/compiler-binding-win32-arm64-msvc": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.4.0.tgz", + "integrity": "sha512-B9zYf3okEY83kM8gydlpH2BHP00w4ifxPqlYlWrgTwuD6wnkrJDCwBlgy1q31cERjCJRXN1lrE2VmkLvFjv/6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-win32-x64-msvc": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-x64-msvc/-/compiler-binding-win32-x64-msvc-0.4.0.tgz", + "integrity": "sha512-zB0Nrv0dGc0zZWPGDRmmETTPhDRqyZjAjk+gWMlVrJX5U89obpB3VUUE1ZiHxOCN5LQojeLK6O8L/dnoHolvNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-rs": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-rs/-/compiler-rs-0.4.0.tgz", + "integrity": "sha512-koVikeon1kreEy+/JzLQRy3vzHHQVOjycs4degg4vFufKApZOwMZvSSAEztYNhmcQVfNVsVZZI4cEge3cexAbQ==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler-binding": "0.4.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.11.0.tgz", + "integrity": "sha512-3rzxJ+xbo0+8YyqOzLziIN32wmsHdCjEVz2sGOpRxJ+Ben/KiLph4ItxBy1abEL+E8fkRzqjg0rfXmaHJGw9JA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.3.0", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" + } + }, + "node_modules/@astrojs/markdown-satteri": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.4.0.tgz", + "integrity": "sha512-wykOOW9KsUVcZweOpY/CeXpdKcCKZy6fQbdcteWFuI75+sQCiqxYM7VKsGa5b+aGl3cYQscFY37rsbbyal5MRw==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.11.0", + "@astrojs/prism": "4.0.2", + "github-slugger": "^2.0.0", + "satteri": "^0.10.3" + } + }, + "node_modules/@astrojs/prism": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", + "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/rss": { + "version": "4.0.19", + "resolved": "https://registry.npmjs.org/@astrojs/rss/-/rss-4.0.19.tgz", + "integrity": "sha512-e+z5wYeYtffQdHQO8c2tkSd2JEBdAuRXJV4ZEU5IxkYeE6e39woDd7nw1PH1Kk2tEYNCYuKdylnnbhGmt61awA==", + "license": "MIT", + "dependencies": { + "fast-xml-parser": "^5.5.7", + "piccolore": "^0.1.3", + "zod": "^4.3.6" + } + }, + "node_modules/@astrojs/sitemap": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.4.tgz", + "integrity": "sha512-LbKNC24bdUWcQf/pThB6qLlSqHojxGjZDURIzFocY8rlWnAn2t74nnhnK6S5x0NHriHoAduLEpVjRykmeGiVvA==", + "license": "MIT", + "dependencies": { + "sitemap": "^9.0.0", + "zod": "^4.3.6" + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.3.tgz", + "integrity": "sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==", + "license": "MIT", + "dependencies": { + "ci-info": "^4.4.0", + "dset": "^3.1.4", + "is-docker": "^4.0.0", + "package-manager-detector": "^1.6.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bruits/satteri-darwin-arm64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz", + "integrity": "sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@bruits/satteri-darwin-x64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.10.5.tgz", + "integrity": "sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@bruits/satteri-linux-arm64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.10.5.tgz", + "integrity": "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-arm64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.10.5.tgz", + "integrity": "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-x64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.10.5.tgz", + "integrity": "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-x64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.10.5.tgz", + "integrity": "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-wasm32-wasi": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.10.5.tgz", + "integrity": "sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.2.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-win32-arm64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.10.5.tgz", + "integrity": "sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@bruits/satteri-win32-x64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.10.5.tgz", + "integrity": "sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@capsizecss/unpack": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.1.tgz", + "integrity": "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@clack/core": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.3.tgz", + "integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.7.0.tgz", + "integrity": "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.4.3", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fontsource-variable/schibsted-grotesk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/schibsted-grotesk/-/schibsted-grotesk-5.3.0.tgz", + "integrity": "sha512-ndS6H/KICWANchlHF3q4UdZD+xrZ3Ji0rUobIuXlgvDUzDfqZDsVuXPXZb636nJZMHE9zZ9Fmwgqazm8hMB50Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/dm-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/dm-mono/-/dm-mono-5.3.0.tgz", + "integrity": "sha512-OINjI8C1S/wpchhQxl7njZdMn4+hnDCpQ4YtvvOpKNARo+0J8O1x1IcrChxNjHOhfVv1by8C/FQoy3hXK+C1Ug==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@shikijs/core": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", + "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz", + "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz", + "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz", + "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz", + "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz", + "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", + "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/nlcst": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", + "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/sax": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "license": "ISC" + }, + "node_modules/am-i-vibing": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz", + "integrity": "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==", + "license": "MIT", + "dependencies": { + "process-ancestry": "^0.1.0" + }, + "bin": { + "am-i-vibing": "dist/cli.mjs" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/astro": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/astro/-/astro-7.3.1.tgz", + "integrity": "sha512-A/bJYHtc6n0UAdROY9W948fW6lX0pnUA+JWKW+IGCXRyDIGN2MsXC0OlS8onZGJjr+sv8htemwOc9W5PBRXCkw==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler-rs": "^0.4.0", + "@astrojs/internal-helpers": "0.11.0", + "@astrojs/markdown-satteri": "0.4.0", + "@astrojs/telemetry": "3.3.3", + "@capsizecss/unpack": "^4.0.0", + "@clack/prompts": "^1.1.0", + "@oslojs/encoding": "^1.1.0", + "am-i-vibing": "^0.4.0", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "ci-info": "^4.4.0", + "clsx": "^2.1.1", + "common-ancestor-path": "^2.0.0", + "cookie": "^2.0.1", + "devalue": "^5.8.1", + "diff": "^9.0.0", + "dset": "^3.1.4", + "es-module-lexer": "^2.0.0", + "esbuild": "^0.28.0", + "find-proc": "0.1.0", + "flattie": "^1.1.1", + "fontace": "~0.4.1", + "get-tsconfig": "5.0.0-beta.4", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.2.0", + "js-yaml": "^4.3.0", + "jsonc-parser": "^3.3.1", + "magic-string": "^1.0.0", + "magicast": "^0.5.2", + "mrmime": "^2.0.1", + "neotraverse": "^1.0.1", + "obug": "^2.1.1", + "p-limit": "^7.3.0", + "p-queue": "^9.1.0", + "package-manager-detector": "^1.6.0", + "piccolore": "^0.1.3", + "picomatch": "^4.0.4", + "semver": "^7.7.4", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "svgo": "^4.0.1", + "tinyclip": "^0.1.12", + "tinyexec": "^1.0.4", + "tinyglobby": "^0.2.15", + "ultrahtml": "^1.6.0", + "unifont": "~0.7.5", + "unstorage": "^1.17.5", + "vite": "^8.0.13", + "vitefu": "^1.1.2", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^22.0.0", + "zod": "^4.5.4" + }, + "bin": { + "astro": "bin/astro.mjs" + }, + "engines": { + "node": ">=22.12.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.35.4" + }, + "peerDependencies": { + "@astrojs/markdown-remark": "^7.3.0" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-remark": { + "optional": true + } + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/common-ancestor-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", + "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", + "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", + "license": "MIT" + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-select": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", + "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^7.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "nth-check": "^2.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", + "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz", + "integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz", + "integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-proc": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/find-proc/-/find-proc-0.1.0.tgz", + "integrity": "sha512-OaOpEYv2PiQ7SQ5LIrl+deA1XaWcxEjnpM6VuWXTUvn+teIXxeFTLDmu18/zDQpFmHN4o3oDBX+BT0AGwEhemg==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/flattie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fontace": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", + "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.2" + } + }, + "node_modules/fontkitten": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", + "integrity": "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/get-tsconfig": { + "version": "5.0.0-beta.4", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz", + "integrity": "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "engines": { + "node": ">=20.20.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, + "node_modules/h3": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", + "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.3", + "crossws": "^0.3.5", + "defu": "^6.1.6", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-docker": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-4.0.0.tgz", + "integrity": "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", + "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neotraverse": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-1.0.1.tgz", + "integrity": "sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.5.tgz", + "integrity": "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", + "license": "MIT" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/p-limit": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.2.tgz", + "integrity": "sha512-Ll0w3fU24vYpXoZmjjZIee6bJQDgG0oAyo1PdmFYI8UDwJJddaHAypxIH9avUu+t+lSsAwKVsb1jDCMIIChliw==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "9.3.3", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz", + "integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "license": "MIT" + }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/piccolore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", + "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/process-ancestry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/process-ancestry/-/process-ancestry-0.1.0.tgz", + "integrity": "sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rolldown": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.148.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, + "node_modules/satteri": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", + "integrity": "sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.5", + "@types/hast": "^3.0.5", + "@types/mdast": "^4.0.4", + "@types/unist": "^3.0.3" + }, + "optionalDependencies": { + "@bruits/satteri-darwin-arm64": "0.10.5", + "@bruits/satteri-darwin-x64": "0.10.5", + "@bruits/satteri-linux-arm64-gnu": "0.10.5", + "@bruits/satteri-linux-arm64-musl": "0.10.5", + "@bruits/satteri-linux-x64-gnu": "0.10.5", + "@bruits/satteri-linux-x64-musl": "0.10.5", + "@bruits/satteri-wasm32-wasi": "0.10.5", + "@bruits/satteri-win32-arm64-msvc": "0.10.5", + "@bruits/satteri-win32-x64-msvc": "0.10.5" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/shiki": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", + "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.4.3", + "@shikijs/engine-javascript": "4.4.3", + "@shikijs/engine-oniguruma": "4.4.3", + "@shikijs/langs": "4.4.3", + "@shikijs/themes": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/sitemap": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz", + "integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^24.9.2", + "@types/sax": "^1.2.1", + "arg": "^5.0.0", + "sax": "^1.4.1" + }, + "bin": { + "sitemap": "dist/esm/cli.js" + }, + "engines": { + "node": ">=20.19.5", + "npm": ">=10.8.2" + } + }, + "node_modules/smol-toml": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strnum": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/svgo": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz", + "integrity": "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^6.0.0", + "css-tree": "^3.0.1", + "css-what": "^7.0.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "1.6.1" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/three": { + "version": "0.180.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", + "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", + "license": "MIT" + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyclip": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.15.tgz", + "integrity": "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A==", + "license": "MIT", + "engines": { + "node": "^16.14.0 || >= 17.3.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/ultrahtml": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.7.0.tgz", + "integrity": "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==", + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unifont": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.5.tgz", + "integrity": "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.1.0", + "ohash": "^2.0.11", + "undici": "^8.0.0" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/website/package.json b/website/package.json new file mode 100644 index 00000000..c93cf068 --- /dev/null +++ b/website/package.json @@ -0,0 +1,22 @@ +{ + "name": "openshield-website", + "version": "0.4.0", + "private": true, + "type": "module", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "configure:cms": "node scripts/configure-cms.mjs", + "verify": "node scripts/verify-site.mjs", + "check": "npm run build && DECAP_GITHUB_APP_ID= npm run configure:cms && npm run verify && npm run build && DECAP_GITHUB_APP_ID=0123456789LOCALTEST npm run configure:cms && npm run verify" + }, + "dependencies": { + "@astrojs/rss": "^4.0.12", + "@astrojs/sitemap": "^3.5.0", + "@fontsource-variable/schibsted-grotesk": "^5.2.0", + "@fontsource/dm-mono": "^5.2.0", + "astro": "^7.3.1", + "three": "^0.180.0" + } +} diff --git a/website/public/admin/config.yml b/website/public/admin/config.yml new file mode 100644 index 00000000..569fe65e --- /dev/null +++ b/website/public/admin/config.yml @@ -0,0 +1,71 @@ +# Decap CMS configuration for the OpenShield website. +# +# One-time setup (see website/README.md): +# 1. Register a GitHub OAuth App: +# https://github.com/settings/applications/new +# Homepage URL: https://owasp.github.io/openshield/admin/ +# Authorization callback: https://api.netlify.com/auth/done +# 2. Store the public Client ID in the DECAP_GITHUB_APP_ID repository +# variable. The deployment pipeline injects it into the built artifact. +# +# Publishing flow (compatible with branch protection on dev and main): +# author writes in /admin -> editorial_workflow opens a PR from cms/ +# against dev, DCO-signed -> maintainer reviews and merges -> the change is +# published after promotion to main through the repository release process. + +site_url: https://owasp.github.io/openshield/ +display_url: https://owasp.github.io/openshield/ + +backend: + name: github + repo: OWASP/openshield + branch: dev + auth_type: pkce + # The deployment build inserts app_id from the repository variable. + # Never put an OAuth client secret in this public configuration. + +publish_mode: editorial_workflow +open_authoring: true + +media_folder: website/public/uploads +public_folder: /openshield/uploads + +commit_messages: + create: "content(blog): create {{slug}}\n\nSigned-off-by: {{author-name}} <{{author-email}}>" + update: "content(blog): update {{slug}}\n\nSigned-off-by: {{author-name}} <{{author-email}}>" + delete: "content(blog): delete {{slug}}\n\nSigned-off-by: {{author-name}} <{{author-email}}>" + uploadMedia: "content(uploads): add {{filename}}\n\nSigned-off-by: {{author-name}} <{{author-email}}>" + deleteMedia: "content(uploads): remove {{filename}}\n\nSigned-off-by: {{author-name}} <{{author-email}}>" + +collections: + - name: blog + label: Blog posts + label_singular: Blog post + folder: website/src/content/blog + create: true + slug: "{{slug}}" + extension: md + format: frontmatter + summary: "{{title}} ({{year}}-{{month}}-{{day}})" + sortable_fields: ["pubDate", "title"] + editor: + preview: true + fields: + - { name: title, label: Title } + - { + name: description, + label: Description, + hint: "One sentence shown on cards, RSS and search results.", + } + - { + name: pubDate, + label: Publish date, + widget: datetime, + date_format: "YYYY-MM-DD", + time_format: false, + format: "YYYY-MM-DD", + } + - { name: author, label: Author, default: "OpenShield Maintainers" } + - { name: tags, label: Tags, widget: list, required: false } + - { name: draft, label: Draft, widget: boolean, default: false, required: false } + - { name: body, label: Body, widget: markdown } diff --git a/website/public/admin/index.html b/website/public/admin/index.html new file mode 100644 index 00000000..ad66e10b --- /dev/null +++ b/website/public/admin/index.html @@ -0,0 +1,12 @@ + + + + + +OpenShield CMS + + + + + + diff --git a/website/public/diagrams/compliance-map.svg b/website/public/diagrams/compliance-map.svg new file mode 100644 index 00000000..cc500f9c --- /dev/null +++ b/website/public/diagrams/compliance-map.svg @@ -0,0 +1,41 @@ + + + + + + + + + AZ-STOR-001 + Public Blob Access Enabled + on Storage Account + + HIGH + one finding, declared once + + + CIS AZURE + 3.5 + storage access + + + NIST CSF + PR.AC-3 + protect access + + + ISO 27001 + A.9.4.1 + access control + + + SOC 2 + CC6.1 + logical access + + + + + + + diff --git a/website/public/diagrams/rule-engine.svg b/website/public/diagrams/rule-engine.svg new file mode 100644 index 00000000..24e25b7d --- /dev/null +++ b/website/public/diagrams/rule-engine.svg @@ -0,0 +1,49 @@ + + + + + + + + + 1 / THE FILE + az_stor_001.py + scanner/rules/ + one rule, one file + + + 2 / LOAD + Dynamic import + engine scans folder + no engine edits + + + 3 / CONTRACT + Metadata + scan() + RULE_ID SEVERITY + FRAMEWORKS + + + 4 / FINDING + Finding record + resource_id severity + frameworks playbook + + + 5 / FIX + Remediation + PLAYBOOK points at + fix_az_*.sh + + + + + + + + AzureClient + typed accessors, unified auth, cache-backed in tests. Rules never instantiate an SDK client. + + + + diff --git a/website/public/diagrams/scan-pipeline.svg b/website/public/diagrams/scan-pipeline.svg new file mode 100644 index 00000000..ae36d399 --- /dev/null +++ b/website/public/diagrams/scan-pipeline.svg @@ -0,0 +1,47 @@ + + + + + + + + + Azure tenant + Reader role, read-only + + + OpenShield scanner + scanner/engine.py + + + Repository rules + scanner/rules/az_*.py + + + Findings report + score + severities + + + Playbooks + playbooks/cli/*.sh + + + read-only + + findings + + fixes + + loads + + RULE DEFINITIONS MAP TO + + CIS Azure v2.0 + + NIST CSF + + ISO 27001 + + SOC 2 + + diff --git a/website/public/diagrams/sentinel-flow.svg b/website/public/diagrams/sentinel-flow.svg new file mode 100644 index 00000000..5d7b43f3 --- /dev/null +++ b/website/public/diagrams/sentinel-flow.svg @@ -0,0 +1,42 @@ + + + + + + + + + OpenShield scan + posture score + findings + + + findings.json + list or findings array + + + sentinel/ingest.py + normalises + signs + + + Log Analytics + Data Collector API + + + OpenShieldFindings_CL + custom log table + + + Sentinel analytics + 4 KQL rules, alerts + workbooks + + + + + POST + + + KQL + + SENTINEL_WORKSPACE_ID + SENTINEL_SHARED_KEY SIGN EVERY BATCH + + diff --git a/website/public/favicon.svg b/website/public/favicon.svg new file mode 100644 index 00000000..0ce635c2 --- /dev/null +++ b/website/public/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/website/public/robots.txt b/website/public/robots.txt new file mode 100644 index 00000000..c4257d2b --- /dev/null +++ b/website/public/robots.txt @@ -0,0 +1,5 @@ +User-agent: * +Allow: / +Disallow: /openshield/admin/ + +Sitemap: https://owasp.github.io/openshield/sitemap-index.xml diff --git a/website/public/rss.xsl b/website/public/rss.xsl new file mode 100644 index 00000000..2c36d1ff --- /dev/null +++ b/website/public/rss.xsl @@ -0,0 +1,53 @@ + + + + + + + + +<xsl:value-of select="channel/title"/> / RSS + + + +
+
RSS 2.0 / machine-readable feed
+

+

+

entries / paste this page's URL into any feed reader

+
+ + + + + + + + +
+

You are reading the styled view of an XML feed. The raw XML is what your reader consumes. Back to the site

+
+ + +
+
diff --git a/website/script.js b/website/script.js deleted file mode 100644 index a54b0c0c..00000000 --- a/website/script.js +++ /dev/null @@ -1,1108 +0,0 @@ -/** - * 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.textContent = ''; - const session = sessions[currentSession]; - - const cmdRow = document.createElement('div'); - cmdRow.className = 'flex items-start'; - cmdRow.textContent = ''; - 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.textContent = ` - ${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.textContent = 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; - -const EMBED_ALLOWED_HOSTS = new Set(['www.youtube.com', 'youtube.com', 'player.vimeo.com']); - -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]}`; - - // Already-an-embed-URL fallback: verify the actual origin instead of a - // substring check, which a crafted string (e.g. containing - // "youtube.com/embed" anywhere but hosted elsewhere) can bypass and - // break out of the iframe's src="..." attribute when interpolated. - try { - const parsed = new URL(raw); - if (parsed.protocol === 'https:' && EMBED_ALLOWED_HOSTS.has(parsed.hostname)) { - // Return the canonicalised href, not the raw input: a valid host - // still lets an attribute-injection payload (e.g. a literal - // double-quote) through on an allowed origin. parsed.href - // percent-encodes quotes/spaces/brackets so the value is safe to - // interpolate into the iframe src="..." attribute. - return parsed.href; - } - } catch { - // Not a valid absolute URL — fall through to reject below. - } - 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.textContent = ` - ${imageHtml} -
-
- Blog Preview - | - ${escapeHTML(date)} -
-

${escapeHTML(title)}

-

By ${escapeHTML(author)}

-
- ${videoHtml} -
- ${(() => { - const html = marked.parse(dedent(content)); - const temp = document.createElement('div'); - temp.textContent = 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.textContent = ` -
-
- 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.textContent = ` -
-
- 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.textContent = ` -
-
- ${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.textContent = 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.textContent = ` -
-

No rules match your search criteria.

-
- `; - return; - } - - container.textContent = 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.textContent = 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.textContent = rawHtml; - tempDiv.querySelectorAll('pre').forEach(pre => pre.classList.add('not-prose')); - - container.textContent = ` - ${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.textContent = 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.textContent = ` -
-

No upcoming events. Stay tuned!

-
- `; - return; - } - - container.textContent = 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.textContent = 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.textContent = 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.textContent = 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.textContent = 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.textContent = 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.textContent = ' Running...'; - terminal.textContent = '
$ openshield scan --env ' + document.getElementById('pg-env').value + ' --pkg ' + document.getElementById('pg-framework').value + '
'; - feed.textContent = ''; - 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.textContent = ` -
- ${event.id} - ${event.sev} -
-
${event.name}
-

${event.desc}

- `; - feed.prepend(card); - } - } - - btn.disabled = false; - btn.textContent = ' 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.textContent = 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/scripts/configure-cms.mjs b/website/scripts/configure-cms.mjs new file mode 100644 index 00000000..0b60d7bc --- /dev/null +++ b/website/scripts/configure-cms.mjs @@ -0,0 +1,33 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const configPath = path.join(root, 'dist', 'admin', 'config.yml'); +const clientId = process.env.DECAP_GITHUB_APP_ID?.trim(); + +if (!clientId) { + fs.rmSync(path.dirname(configPath), { recursive: true, force: true }); + console.log('DECAP_GITHUB_APP_ID is not configured; omitted the optional CMS from the generated site.'); + process.exit(0); +} + +if (!/^[A-Za-z0-9]{12,128}$/.test(clientId)) { + console.error('DECAP_GITHUB_APP_ID must be a 12 to 128 character alphanumeric OAuth Client ID.'); + process.exit(1); +} + +if (!fs.existsSync(configPath)) { + console.error('CMS config was not found in dist. Run npm run build first.'); + process.exit(1); +} + +const config = fs.readFileSync(configPath, 'utf8'); +const insertionPoint = ' auth_type: pkce\n'; +if (!config.includes(insertionPoint) || /^\s*app_id:/m.test(config)) { + console.error('CMS config cannot be safely configured: expected one auth_type entry and no existing app_id.'); + process.exit(1); +} + +fs.writeFileSync(configPath, config.replace(insertionPoint, `${insertionPoint} app_id: ${clientId}\n`)); +console.log('Configured the generated CMS artifact with the GitHub OAuth Client ID.'); diff --git a/website/scripts/verify-site.mjs b/website/scripts/verify-site.mjs new file mode 100644 index 00000000..61a61cdb --- /dev/null +++ b/website/scripts/verify-site.mjs @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const dist = path.join(root, 'dist'); +const failures = []; + +function filesUnder(directory) { + if (!fs.existsSync(directory)) return []; + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(directory, entry.name); + return entry.isDirectory() ? filesUnder(full) : [full]; + }); +} + +const sourceFiles = [ + path.join(root, 'src'), + path.join(root, 'public'), + path.join(root, 'README.md'), + path.join(root, 'astro.config.mjs'), +] + .flatMap((entry) => fs.statSync(entry).isDirectory() ? filesUnder(entry) : [entry]) + .filter((file) => /\.(astro|css|html|js|json|md|mjs|svg|ts|xml|xsl)$/.test(file)); + +for (const file of sourceFiles) { + const source = fs.readFileSync(file, 'utf8'); + if (source.includes('\u2014')) failures.push(`${path.relative(root, file)} contains an em dash`); + if (source.includes('openshield-org')) failures.push(`${path.relative(root, file)} contains the pre-OWASP repository identity`); +} + +const htmlFiles = filesUnder(dist).filter((file) => file.endsWith('.html') && !file.includes(`${path.sep}admin${path.sep}`)); +if (!htmlFiles.length) failures.push('dist contains no HTML pages; run npm run build first'); + +for (const file of htmlFiles) { + const html = fs.readFileSync(file, 'utf8'); + const relative = path.relative(dist, file); + if (!html.includes('
file.endsWith('.js')); +const largestJs = jsFiles.reduce((largest, file) => Math.max(largest, fs.statSync(file).size), 0); +const jsBudget = 520 * 1024; +if (largestJs > jsBudget) failures.push(`largest JavaScript asset is ${Math.ceil(largestJs / 1024)} KiB; budget is 520 KiB`); + +if (failures.length) { + console.error(`Website verification failed:\n- ${failures.join('\n- ')}`); + process.exit(1); +} + +console.log(`Website verification passed for ${htmlFiles.length} HTML pages. Largest JavaScript asset: ${Math.ceil(largestJs / 1024)} KiB.`); diff --git a/website/src/assets/logo-dark.png b/website/src/assets/logo-dark.png new file mode 100644 index 00000000..2d4a1538 Binary files /dev/null and b/website/src/assets/logo-dark.png differ diff --git a/website/src/assets/logo-light.png b/website/src/assets/logo-light.png new file mode 100644 index 00000000..5d679adc Binary files /dev/null and b/website/src/assets/logo-light.png differ diff --git a/website/src/components/BlogSection.astro b/website/src/components/BlogSection.astro new file mode 100644 index 00000000..31eb4e9f --- /dev/null +++ b/website/src/components/BlogSection.astro @@ -0,0 +1,46 @@ +--- +import { getCollection } from 'astro:content'; +import { url } from '../lib/base'; + +const posts = (await getCollection('blog', ({ data }) => !data.draft)).sort( + (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(), +).slice(0, 3); + +function fmtDate(d: Date): string { + return d + .toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + .toUpperCase(); +} + +function readingTime(body: string): number { + const words = body.split(/\s+/).filter(Boolean).length; + return Math.max(1, Math.round(words / 200)); +} +--- + +
+
+
+

From the blog.

+ Read all articles → +
+
+ {posts.map((post, i) => { + const tag = post.data.tags[0] ?? 'Post'; + return ( +
+
+ POST/{String(i + 1).padStart(2, '0')} + {tag} +
+
+

{post.data.title}

+

{post.data.description}

+ {fmtDate(post.data.pubDate)} / {readingTime(post.body)} MIN READ +
+
+ ); + })} +
+
+
diff --git a/website/src/components/DemoSection.astro b/website/src/components/DemoSection.astro new file mode 100644 index 00000000..c9aa5678 --- /dev/null +++ b/website/src/components/DemoSection.astro @@ -0,0 +1,102 @@ +--- +import { url } from '../lib/base'; +import { repoData } from '../lib/repoData'; +const { ruleCount, sampleScan } = repoData; +--- + +
+
+
+

One scan to see your posture.

+ Read the docs → +
+
+
+
+ + + +
+
+
scanner @ prod-01bash
+
+$ git clone https://github.com/OWASP/openshield.git +$ python -m scanner.run --subscription prod-01 +loading {ruleCount} rule modules from scanner/rules/ ... +  +AZ-STOR-001  HIGH    Public blob access enabled    stor-acct-prod-01 +AZ-NET-001   HIGH    Unrestricted inbound SSH     nsg-web-tier +AZ-KV-001    MEDIUM  Soft delete disabled         kv-payments +{sampleScan.passing} checks passing +  +SCORE {sampleScan.score}/100  {sampleScan.high} HIGH / {sampleScan.medium} MEDIUM / {sampleScan.passing} PASSING +
+ + +
+

Illustrative output. The values above are a stable example for learning the interface, not a live customer scan. Repository-derived coverage counts remain live at build time.

+
+
+
01

Detect

The engine imports every rule module in scanner/rules/ and runs it against your subscription.

+
02

Map

Each finding cites the CIS, NIST, ISO 27001 or SOC 2 control it violates, straight from the rule definition.

+
03

Prioritize

Severity plus CVE enrichment from the NVD API, with drift detection between consecutive scans.

+
04

Remediate

Every failing check ships with a playbook: the exact CLI command that fixes it.

+
+
+
+
+ + diff --git a/website/src/components/Hero.astro b/website/src/components/Hero.astro new file mode 100644 index 00000000..dfb472c0 --- /dev/null +++ b/website/src/components/Hero.astro @@ -0,0 +1,55 @@ +--- +import { url } from '../lib/base'; +import { repoData, orbRules, domainOrder } from '../lib/repoData'; + +const GITHUB = 'https://github.com/OWASP/openshield'; +const { ruleCount, domainCount } = repoData; +--- + +
+
+
+
OpenShield / CSPM for Azure
+

Open source security posture for Azure. Written in the open.

+
+
{ruleCount} misconfiguration rules across {domainCount} Azure domains, each one a plain Python file you can read, audit and extend.
+
Rule definitions carry CIS, NIST, ISO 27001 and SOC 2 mappings beside the detection logic.
+
Azure collection uses the built-in Reader role. Self-host the data layer when posture data must stay in infrastructure you control.
+
+ +
+
+ +
{ruleCount} RULE NODES / WEBGL UNAVAILABLE IN THIS BROWSER
+
AZURE SUBSCRIPTION / {domainCount} DOMAINS / {ruleCount} RULESDrag or use the controls to inspect
+
+ + + +
+
+
+
+ diff --git a/website/src/components/JourneySection.astro b/website/src/components/JourneySection.astro new file mode 100644 index 00000000..34d5de13 --- /dev/null +++ b/website/src/components/JourneySection.astro @@ -0,0 +1,20 @@ +--- +import { url } from '../lib/base'; +const GITHUB = 'https://github.com/OWASP/openshield'; +--- + +
+ +
diff --git a/website/src/components/MetricsSection.astro b/website/src/components/MetricsSection.astro new file mode 100644 index 00000000..7b563252 --- /dev/null +++ b/website/src/components/MetricsSection.astro @@ -0,0 +1,23 @@ +--- +import { repoData } from '../lib/repoData'; +const { ruleCount, domainCount, playbookCount, sampleScan, domains } = repoData; +const domainList = domains.slice(0, 6).map((d) => d.label.toLowerCase()).join(', '); +--- + +
+
+
Posture at a glance
+
+
{ruleCount}
Rules

Across {domainCount} domains: {domainList} and more.

+106 since v0.1.0
+
4
Frameworks

CIS, NIST CSF, ISO 27001 and SOC 2 mappings are stored in rule definitions.

auditable in source
+
{playbookCount}
Playbooks

A fix_az_*.sh remediation script for every rule, ready to run.

+48 since v0.1.0
+
{sampleScan.score}/100
Example output

{sampleScan.high} high, {sampleScan.medium} medium, {sampleScan.passing} passing in the illustrative interface fixture.

clearly labelled sample
+
+
+ reader role only + python 3.11 + react dashboard + sentinel export +
+
+
diff --git a/website/src/components/RoadmapSection.astro b/website/src/components/RoadmapSection.astro new file mode 100644 index 00000000..b7da628d --- /dev/null +++ b/website/src/components/RoadmapSection.astro @@ -0,0 +1,50 @@ +--- +import { repoData } from '../lib/repoData'; +import { url } from '../lib/base'; +const { ruleCount, domainCount } = repoData; +const GITHUB = 'https://github.com/OWASP/openshield'; +--- + +
+
+
+

How it works, and where it goes.

+ Explore the architecture → +
+
+
+
MAY 2026 / V0.1.0First light

20 rules across 6 domains, four framework maps, Sentinel export, SBOM on release.

+
JUL 2026 / V0.3.0Goes async

Background scan worker, Docker deploys, identity and post-quantum rules, GHCR images.

+
NOWEnterprise pack

{ruleCount} rules across {domainCount} domains, evidence APIs, CBOM quantum scoring, OpenSSF Passing.

+ + +
+
+
+
+
Azure metadata
READER ACCESS
+ +
Scanner
{ruleCount} RULE MODULES
+ +
PostgreSQL
FINDINGS + SCANS
+ +
Flask API
AUTHENTICATED ACCESS
+ +
Dashboard + exports
OPERATOR VIEWS
+
+
+
+ + + + + + + + + + +
CapabilityOpenShieldTypical enterprise CSPM
Open source rule library✓ every rule readable in the repo✗ closed checks
Self-hosted findings store✓ your PostgreSQL, your tenant✗ vendor cloud
Framework mapping✓ CIS, NIST, ISO 27001, SOC 2✓ comparable
Remediation guidance✓ repository playbooks~ implementation varies
SIEM export✓ Microsoft Sentinel~ varies
Cost✓ MIT, free forever✗ enterprise licence
+
+
+
diff --git a/website/src/components/RulesSection.astro b/website/src/components/RulesSection.astro new file mode 100644 index 00000000..a92919cc --- /dev/null +++ b/website/src/components/RulesSection.astro @@ -0,0 +1,52 @@ +--- +import { url } from '../lib/base'; +import { repoData } from '../lib/repoData'; +import type { Rule } from '../lib/repoData'; + +const { rules, ruleCount } = repoData; + +const SEV_TEXT: Record = { HIGH: '#a03a1e', MEDIUM: '#7a5310', LOW: '#3d5a75' }; +const SEV_DOT: Record = { HIGH: '#c73a17', MEDIUM: '#a06f10', LOW: '#5d6c80' }; + +/* representative picks, one per pastel swatch; fall back to the first rule + of the domain so the section never renders an empty card */ +const picks = ['AZ-STOR-001', 'AZ-NET-001', 'AZ-IDN-002', 'AZ-KV-001', 'AZ-CMP-004', 'AZ-NET-005']; +function byId(id: string): Rule { + const found = rules.find((r) => r.id === id); + if (found) return found; + const domain = id.split('-')[1]?.toLowerCase() ?? ''; + return rules.find((r) => r.domain === domain) ?? rules[0]; +} +const featured: { rule: Rule; swatch: string }[] = picks.map((id, i) => ({ + rule: byId(id), + swatch: `r${i + 1}`, +})); +--- + +
+
+
+

Start with the rule book.

+ Browse all {ruleCount} rules → +
+
+ {featured.map(({ rule, swatch }) => ( +
+
+ + {rule.severity === 'HIGH' ? 'High' : rule.severity === 'MEDIUM' ? 'Medium' : 'Low'} + + {rule.id} +
+
+

{rule.name}

+
+ {rule.category.toLowerCase()} + {rule.frameworks.CIS && CIS {rule.frameworks.CIS}} +
+
+
+ ))} +
+
+
diff --git a/website/src/components/RunSection.astro b/website/src/components/RunSection.astro new file mode 100644 index 00000000..990d102d --- /dev/null +++ b/website/src/components/RunSection.astro @@ -0,0 +1,16 @@ +--- +import { url } from '../lib/base'; +--- + +
+
+
+

Run it yourself, on your own data.

+
+
+
Self-hosted scan

Clone and point it at a subscription

Azure collection uses Reader permissions. Choose and operate the PostgreSQL deployment that stores your findings.

Read the quickstart
+
React dashboard

Explore the posture workflow

Resources, drift, prioritization and playbooks are wired to the backend. Availability depends on the hosted API.

Open the hosted dashboard
+
Sentinel integration

Feed findings into your SIEM

Pipe OpenShield posture data straight into Microsoft Sentinel for unified visibility.

Read the guide
+
+
+
diff --git a/website/src/components/TrustStrip.astro b/website/src/components/TrustStrip.astro new file mode 100644 index 00000000..ea7eff85 --- /dev/null +++ b/website/src/components/TrustStrip.astro @@ -0,0 +1,17 @@ +--- +import { repoData } from '../lib/repoData'; +const { contributorCount } = repoData; +--- + +
+
+ MIT LICENCE + {contributorCount} CONTRIBUTORS + CIS AZURE v2.0 + NIST CSF + ISO 27001 + SOC 2 + SELF-HOSTED POSTGRESQL + OPENSSF PASSING +
+
diff --git a/website/src/components/WhySection.astro b/website/src/components/WhySection.astro new file mode 100644 index 00000000..aa0942a8 --- /dev/null +++ b/website/src/components/WhySection.astro @@ -0,0 +1,23 @@ +--- +import { repoData } from '../lib/repoData'; +const { ruleCount, domainCount, contributorCount } = repoData; +--- + +
+
+
+
+
Why OpenShield exists
+

Cloud posture should be inspectable before it becomes expensive.

+

Smaller teams often have to choose between limited visibility and enterprise tooling. A public storage container, an overprivileged identity or an open network rule can remain unnoticed. OpenShield makes the detection logic readable, testable and extendable.

+
Built by security engineers and students who believe cloud security tooling should be accessible to everyone.
+
FREE FOREVER / MIT LICENCE / OPENSSF PASSING / {contributorCount} CONTRIBUTORS
+
+
+
The gap

Priced out of posture

Security posture management is sold as an enterprise licence. Teams without budget inherit blind spots instead.

+
The threat

Harvest now, decrypt later

Adversaries record encrypted Azure traffic today to break it with tomorrow's quantum machines. RSA and ECC assets need migration before that day arrives.

+
The answer

Open rules, visible evidence

{ruleCount} auditable Python rules across {domainCount} Azure domains, backed by repository-tracked mappings and remediation playbooks. MIT licensed and open source.

+
+
+
+
diff --git a/website/src/content.config.ts b/website/src/content.config.ts new file mode 100644 index 00000000..fc95b3c7 --- /dev/null +++ b/website/src/content.config.ts @@ -0,0 +1,16 @@ +import { defineCollection, z } from 'astro:content'; +import { glob } from 'astro/loaders'; + +const blog = defineCollection({ + loader: glob({ pattern: '**/*.md', base: './src/content/blog' }), + schema: z.object({ + title: z.string(), + description: z.string(), + pubDate: z.coerce.date(), + author: z.string().default('OpenShield Maintainers'), + tags: z.array(z.string()).default([]), + draft: z.boolean().default(false), + }), +}); + +export const collections = { blog }; diff --git a/website/src/content/blog/evidence-without-invented-metrics.md b/website/src/content/blog/evidence-without-invented-metrics.md new file mode 100644 index 00000000..9ebc91d5 --- /dev/null +++ b/website/src/content/blog/evidence-without-invented-metrics.md @@ -0,0 +1,38 @@ +--- +title: "Project evidence without invented metrics" +description: "How the website derives useful project evidence from source files, Git history, releases and documented boundaries." +pubDate: 2026-09-05 +author: "OpenShield Maintainers" +tags: ["Evidence", "Open source"] +draft: false +--- + +Security projects lose credibility when presentation runs ahead of evidence. A polished number is still misleading if nobody can trace where it came from. + +OpenShield now separates repository-derived facts, illustrative interface output and manually recorded service status. + +## What the build can prove + +The website reads the current checkout during every production build. It counts rule files and remediation playbooks, groups rules by Azure domain, reads tagged and commit-linked versions from `CHANGELOG.md`, and derives contributor activity from Git history. + +Those values change when their underlying repository sources change. The deployment workflow also watches those source paths, so updates to rules, playbooks, documentation and the changelog trigger a fresh website build. + +## What a configured check does not prove + +The repository contains CI, CodeQL, dependency review, DCO and signed-release workflows. Their presence proves that the controls are configured. It does not prove that every current run passes. + +The evidence page therefore links directly to each workflow and labels it as configured. Current pass or failure state belongs in the GitHub run history, where the execution record exists. + +## Why sample output needs a label + +Stable sample findings are useful for explaining a CLI or API response. They are not live customer data and should never be presented as such. + +The website labels its terminal score as illustrative output. Repository coverage numbers remain separate from the sample scan fixture. + +## Boundaries are evidence too + +`ROADMAP.md` explicitly excludes automatic remediation, certification claims, workload-content collection, guaranteed detection and premature multi-cloud parity. Those limits are displayed alongside the roadmap direction. + +Publishing a limitation is not a weakness. It tells operators where professional judgment and additional controls are still required. + +The complete [project evidence page](/openshield/evidence/) brings these sources together without adding customer logos, adoption counts or testimonials that the repository cannot support. diff --git a/website/src/content/blog/how-a-finding-moves-through-openshield.md b/website/src/content/blog/how-a-finding-moves-through-openshield.md new file mode 100644 index 00000000..2c24a5bb --- /dev/null +++ b/website/src/content/blog/how-a-finding-moves-through-openshield.md @@ -0,0 +1,44 @@ +--- +title: "How a finding moves through OpenShield" +description: "Trace the path from Azure configuration metadata through rule execution, persistence and the operator-facing API." +pubDate: 2026-09-06 +author: "OpenShield Maintainers" +tags: ["Architecture", "Scanner"] +draft: false +--- + +OpenShield is easier to evaluate when its boundaries are visible. The core path is deliberately small: collect Azure configuration metadata, run repository rules, store normalized findings and expose those records through authenticated API routes. + +![OpenShield scan pipeline](/openshield/diagrams/scan-pipeline.svg) + +*The core scan path. Optional integrations sit outside rule execution.* + +## Start with Azure metadata + +The scanner uses the shared `AzureClient` abstraction rather than creating an SDK client inside every rule. This keeps authentication and Azure API access in one place. The documented baseline is the built-in Reader role, although identity checks can require additional Microsoft Graph permissions. + +The scanner assesses configuration metadata. It is not designed to collect workload contents, secrets or customer files. + +## Load rules through one engine + +Each file under `scanner/rules/` declares a rule identifier, name, severity, category, framework mappings and a `scan()` function. The engine discovers those modules and runs them against the shared client. + +That structure gives reviewers a direct path from a finding to the code that produced it. It also keeps extension work local: adding a rule does not require rewriting the engine. + +## Persist before presenting + +Scan and finding records are stored in PostgreSQL. Async scan state also lives in the database, so an API process restart does not erase the job record. + +The Flask API then reads the stored evidence for findings, scores, resources, prioritization, drift and playbook routes. The React dashboard is a consumer of those contracts, not the source of the posture data. + +## Keep optional systems explicit + +CVE enrichment can query NVD after rule execution. AI providers and Microsoft Sentinel are separate integrations. A core scan does not depend on either one. + +This distinction matters for deployment planning. Every external connection adds a trust boundary, an availability dependency and a configuration decision. The [interactive architecture map](/openshield/architecture/) exposes those stages individually. + +## Remediation remains an operator decision + +Findings can reference remediation playbooks, but OpenShield does not automatically execute them against Azure resources. Operators review the command, scope and validation steps before making a change. + +That boundary is intentional. Detection and explanation can be automated safely. Cloud mutation still needs explicit authority and context. diff --git a/website/src/content/blog/rule-engine-deep-dive.md b/website/src/content/blog/rule-engine-deep-dive.md new file mode 100644 index 00000000..890a8a6a --- /dev/null +++ b/website/src/content/blog/rule-engine-deep-dive.md @@ -0,0 +1,57 @@ +--- +title: "Under the Hood: Engineering a Dynamic Rule Orchestration Engine" +description: "A technical deep-dive into how OpenShield uses Python dynamic imports and SDK abstraction to scale security coverage." +pubDate: 2026-05-28 +author: "OpenShield Engineering" +tags: ["engineering"] +draft: false +--- + +When we designed the OpenShield scanner, we knew that hardcoding security rules into the core engine was a recipe for technical debt. We needed a system where a security researcher could drop a new `.py` file into a folder and have it immediately active. + +## One file, one rule + +Adding coverage never touches the engine. A rule is a small contract: metadata constants plus one `scan()` function. Here is the shape of AZ-STOR-001, the rule that flags public blob access (abridged; the shipped rule emits the full finding record): + +```python +RULE_ID = "AZ-STOR-001" +RULE_NAME = "Public Blob Access Enabled on Storage Account" +SEVERITY = "HIGH" +CATEGORY = "Storage" +FRAMEWORKS = {"CIS": "3.5", "NIST": "PR.AC-3", "ISO27001": "A.9.4.1"} + +def scan(azure_client, subscription_id): + return [ + finding + for account in azure_client.get_storage_accounts() + if getattr(account, "allow_blob_public_access", False) + ] +``` + +The engine walks `scanner/rules/`, dynamically imports every `az_*.py` file and calls `scan()`. Nothing anywhere in the core lists the rules by name, so a pull request that adds a rule is exactly one new file. + +## The AzureClient Abstraction + +Rules shouldn't deal with the complexities of Azure's many SDKs. We built the `AzureClient` wrapper in `scanner/azure_client.py` to provide typed accessors and unified auth. A rule author calls `get_storage_accounts()` and never instantiates an SDK client, never reads an environment variable and never handles a token refresh. + +The abstraction pays for itself again in tests: the validation suite points the scanner at cached inventory instead of a live tenant, so every rule is exercised in CI without any Azure subscription. + +![How a rule file becomes a finding: dynamic import, metadata contract, AzureClient accessors, finding record, playbook reference](/openshield/diagrams/rule-engine.svg) + +*Fig. 1: the engine never hardcodes a rule. AzureClient sits under the contract and the finding.* + +## Severity and compliance are data + +`SEVERITY` follows the shared severity contract documented in `docs/severity-contract.md`, so HIGH means the same thing in every rule and in every report. `FRAMEWORKS` maps the rule to real control identifiers, which is how a single finding can answer four auditors at once. Neither is inferred by heuristics; both are declared by the rule author and reviewed like code, because they are code. + +## From finding to fix + +A finding that ends at "you have a problem" is only half a product. Rules may declare a `PLAYBOOK` path, and `playbooks/cli/` ships a `fix_az_*.sh` script per remediation: idempotent, printed with the exact `az` command, and safe to dry-run. The report links the two, so the path from finding to fixed state is one hop. + +## Adding your own rule + +1. Create `scanner/rules/az__.py` following `docs/adding-a-rule.md`. +2. Run the scanner against test inventory and confirm your rule fires and stays quiet on clean inventory. +3. Open a pull request with a DCO sign-off; CI compiles every rule and runs the validation suite. + +That is the entire contribution surface. One file in, one pull request, and every OpenShield deployment in the world gets your coverage on the next merge. diff --git a/website/src/content/blog/sentinel-automation.md b/website/src/content/blog/sentinel-automation.md new file mode 100644 index 00000000..e079e9c6 --- /dev/null +++ b/website/src/content/blog/sentinel-automation.md @@ -0,0 +1,76 @@ +--- +title: "Automating Microsoft Sentinel with OpenShield Findings" +description: "Learn how to feed OpenShield's security posture data directly into Azure's enterprise SIEM for unified visibility." +pubDate: 2026-05-20 +author: "OpenShield Engineering" +tags: ["integration"] +draft: false +--- + +Security posture data is most valuable when it's integrated into your existing SOC workflows. OpenShield's Sentinel connector allows you to ingest findings into Log Analytics with a single command, so misconfiguration data lives next to your alerts instead of in a forgotten JSON file. + +## Why Sentinel + +A scan report answers "how are we doing right now", but a SOC runs on streams. Pushing findings into Log Analytics means posture data participates in the same KQL queries, dashboards and incident workflows as every other signal your team already trusts. It also gives findings a retention policy and an audit trail for free. + +## The pipeline + +![Findings flow from an OpenShield scan through ingest.py into the Log Analytics Data Collector API, land in the OpenShieldFindings_CL table and feed Sentinel analytics rules](/openshield/diagrams/sentinel-flow.svg) + +*Fig. 1: ingest.py normalises each record and signs every batch with the workspace shared key before posting.* + +The ingestion client is `sentinel/ingest.py`. It accepts either a JSON list of findings or an object with a `findings` array, normalises the records, and posts them to the Data Collector API under the `OpenShieldFindings` log type. + +## Setup in four commands + +Create a workspace and read back its credentials: + +```bash +az monitor log-analytics workspace create \ + --resource-group openshield-rg \ + --workspace-name openshield-laws \ + --location uksouth \ + --retention-time 30 +``` + +Export the two variables the client reads: + +```bash +export SENTINEL_WORKSPACE_ID="your-workspace-id" +export SENTINEL_SHARED_KEY="your-shared-key" +``` + +Then push a scan. With no arguments the client defaults to `scanner/output/test_findings.json` and mints a scan ID from the UTC timestamp: + +```bash +python3 sentinel/ingest.py scanner/output/test_findings.json scan-001 +``` + +The full walkthrough, including the Sentinel onboarding commands, lives in [docs/sentinel-setup.md](https://github.com/OWASP/openshield/blob/dev/docs/sentinel-setup.md). + +## Verify with KQL + +In Log Analytics, run: + +```kql +OpenShieldFindings_CL | take 10 +``` + +If rows appear, ingestion is working and every future scan is one command away from the same table. + +## Analytics rules that ship with the repo + +`sentinel/rules/` contains KQL analytics rules ready to deploy in Sentinel or Defender XDR, one scheduled query each: + +| Rule file | Severity | Schedule | +|---|---|---| +| `high_severity_finding.kql` | High | Every 1 hour | +| `misconfiguration_wave.kql` | High | Every 2 hours | +| `persistent_misconfiguration.kql` | Medium | Every 24 hours | +| `new_resource_type_critical.kql` | Critical | Every 1 hour | + +Set the alert threshold above zero and you have detection-as-code for posture: the same repository that finds the misconfiguration ships the alert that fires if it lingers. + +## What good looks like + +A fresh scan lands in the workspace, the analytics rules evaluate on their schedules, and the SOC sees a misconfiguration incident in the same queue as every other alert. No bespoke dashboards, no polling scripts, and nothing about the flow leaves Azure. Questions and improvements are welcome on the [community page](/openshield/community/). diff --git a/website/src/content/blog/why-openshield.md b/website/src/content/blog/why-openshield.md new file mode 100644 index 00000000..46989fb5 --- /dev/null +++ b/website/src/content/blog/why-openshield.md @@ -0,0 +1,43 @@ +--- +title: "Why We Built OpenShield: Solving the Cloud Security Accessibility Gap" +description: "Cloud security shouldn't be a luxury reserved for the Fortune 500. We're democratizing CSPM for startups and researchers." +pubDate: 2026-06-02 +author: "OpenShield Maintainers" +tags: ["announcement"] +draft: false +--- + +The modern cloud landscape is a double-edged sword. While it provides unprecedented agility, it also introduces a massive surface area for catastrophic errors. A single unchecked checkbox in the Azure Portal can expose a terabyte of PII to the public internet. + +## The "Zero Visibility" Problem + +Startups, SMEs, and academic teams often operate in a security vacuum. They don't have the budget for enterprise tooling, yet they handle sensitive data that requires rigorous protection. Commercial CSPM platforms price per asset, which means the teams with the least money routinely get the least visibility. + +OpenShield was born to bridge this gap. One service principal with the built-in Reader role, one scan command, and a report you can read end to end: a posture score, every finding ranked by severity, and the exact command that fixes it. + +![The OpenShield scan pipeline: read-only Azure access feeds the scanner, rules load from plain Python files, and findings flow to reports and playbooks](/openshield/diagrams/scan-pipeline.svg) + +*Fig. 1: the whole pipeline. The scanner only ever reads, and every output is a file you can inspect.* + +## What one scan gives you + +- A posture score from 0 to 100 for the subscription, so trend lines mean something over time. +- Findings grouped by severity, each with the affected resource ID and a plain-language description. +- A compliance mapping per finding, declared in the rule itself, covering CIS Azure, NIST CSF, ISO 27001 and SOC 2. +- A remediation playbook reference, so the distance between "found" and "fixed" is one shell script. + +![One finding mapped to four frameworks: CIS 3.5, NIST PR.AC-3, ISO 27001 A.9.4.1 and SOC 2 CC6.1](/openshield/diagrams/compliance-map.svg) + +*Fig. 2: compliance evidence is data on the rule, not a sales deck.* + +## Built in the open + +Every rule is a plain Python file under `scanner/rules/`. Every fix is a shell script under `playbooks/cli/`. There is no proprietary rule language, no opaque scoring model and no telemetry phone-home. If you disagree with a rule, you can read it, fork it and fix it, and the review happens in public where everyone learns from it. + +> Built by security engineers and students who believe cloud security tooling should be accessible to everyone. + +That is also why the licence is MIT and why the contributor list on our [community page](/openshield/community/) keeps growing: security tooling earns trust by being readable. + +## The Road Ahead + +Release v0.3.0 shipped live data wiring, a React dashboard, CVE enrichment and drift detection. The public roadmap goes further: deeper DevOps coverage, more Azure domains and richer SIEM integrations. Every milestone is tracked in the open, and every one of them is open to contributors. Come build it with us. diff --git a/website/src/content/blog/why-remediation-stays-explicit.md b/website/src/content/blog/why-remediation-stays-explicit.md new file mode 100644 index 00000000..7dda49be --- /dev/null +++ b/website/src/content/blog/why-remediation-stays-explicit.md @@ -0,0 +1,41 @@ +--- +title: "Why remediation stays explicit" +description: "Detection can be automated, but changing cloud resources requires authority, context and a deliberate operator decision." +pubDate: 2026-09-04 +author: "OpenShield Maintainers" +tags: ["Remediation", "Security design"] +draft: false +--- + +A security scanner can identify a risky configuration without knowing every operational reason behind it. That gap is why OpenShield provides remediation guidance without automatically changing Azure resources. + +## A finding is evidence, not authority + +A rule can detect that public access is enabled, a network path is broad or an identity assignment is privileged. It cannot infer every availability requirement, exception approval or migration dependency attached to that resource. + +Automatic mutation would turn a detection error into an operational incident. A false positive could interrupt a legitimate workload before a person has reviewed its context. + +## Playbooks make the proposed change inspectable + +Remediation playbooks live under `playbooks/cli/` and are referenced from rule metadata. This lets an operator inspect the command beside the detection logic and framework mapping. + +The playbook is a starting point. Scope, resource identifiers and validation steps still need review for the target environment. + +## Separate read permission from write permission + +The scanner's documented Azure baseline is read-only. Remediation needs separate operator credentials and explicit authorization. Keeping those permissions apart limits the impact of a compromised scanner process or incorrect rule. + +This also makes the trust model easier to audit: detection gathers configuration evidence, while remediation is a distinct administrative action. + +## Validate after the change + +A remediation workflow should include four visible decisions: + +1. Confirm that the finding applies to the intended resource. +2. Review the command and its scope. +3. Apply the change through an authorized operator path. +4. Run the relevant validation or scan again. + +OpenShield's roadmap keeps automatic remediation out of scope for this period. That is a safety boundary, not an unfinished button. + +Read the [rule book](/openshield/rules/) to inspect current rules and their repository-linked playbooks. diff --git a/website/src/layouts/Base.astro b/website/src/layouts/Base.astro new file mode 100644 index 00000000..ff9f1f60 --- /dev/null +++ b/website/src/layouts/Base.astro @@ -0,0 +1,224 @@ +--- +import { url } from '../lib/base'; +import { repoData } from '../lib/repoData'; +import logoLight from '../assets/logo-light.png'; +import logoDark from '../assets/logo-dark.png'; + +import '@fontsource-variable/schibsted-grotesk'; +import '@fontsource/dm-mono/300.css'; +import '@fontsource/dm-mono/400.css'; +import '@fontsource/dm-mono/500.css'; +import '../styles/global.css'; + +interface Props { + title?: string; + description?: string; + /** which nav tab is active: start | rules | architecture | docs | blog | community | evidence */ + active?: string; + /** page-level structured data (SoftwareApplication, BlogPosting, ...) */ + jsonLd?: Record; + ogType?: 'website' | 'article'; +} +const { + title = 'OpenShield · Open source security posture for Azure', + description = 'OpenShield scans Azure subscriptions for misconfigurations and connects findings to compliance mappings and remediation guidance. MIT licensed and open source.', + active = 'start', + jsonLd, + ogType = 'website', +} = Astro.props; + +const canonical = new URL(Astro.url.pathname, Astro.site); +const GITHUB = 'https://github.com/OWASP/openshield'; +const year = new Date().getFullYear(); + +/* Structured data: Organization + WebSite on every page. Read by search + engines only; renders nothing visible. */ +const siteRoot = new URL(url('/'), Astro.site).href; +const socialImage = new URL(url('/diagrams/scan-pipeline.svg'), Astro.site).href; +const pathParts = Astro.url.pathname.split('/').filter((part) => part !== 'openshield'); +const breadcrumbs = pathParts.length ? { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'OpenShield', item: siteRoot }, + ...pathParts.map((part, index) => ({ + '@type': 'ListItem', + position: index + 2, + name: part.replace(/-/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase()), + item: new URL(url(`/${pathParts.slice(0, index + 1).join('/')}/`), Astro.site).href, + })), + ], +} : null; +const siteJsonLd = { + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'Organization', + name: 'OpenShield', + url: siteRoot, + logo: new URL(url('/favicon.svg'), Astro.site).href, + sameAs: [GITHUB], + }, + { + '@type': 'WebSite', + name: 'OpenShield', + url: siteRoot, + }, + ], +}; +--- + + + + + + +{title} + + + + + + + + + + + + + + + + + + + + +{jsonLd && } +{breadcrumbs && } + + + + + + + + + + + +
+ +
+ +
+
+
Get started
+

Open source security posture for Azure.

+ +
+
+ + + + + + + diff --git a/website/src/lib/base.ts b/website/src/lib/base.ts new file mode 100644 index 00000000..877fd267 --- /dev/null +++ b/website/src/lib/base.ts @@ -0,0 +1,7 @@ +export const base = import.meta.env.BASE_URL.replace(/\/$/, ''); + +/** Prefix an absolute site path with the configured base (Pages path prefix). */ +export function url(path: string): string { + if (!path.startsWith('/')) return path; + return `${base}${path}`; +} diff --git a/website/src/lib/immersive.ts b/website/src/lib/immersive.ts new file mode 100644 index 00000000..9dc38bba --- /dev/null +++ b/website/src/lib/immersive.ts @@ -0,0 +1,75 @@ +/** + * Immersive layer for the homepage: metric count-up, terminal type-in and + * the hero glow parallax. Every effect is guarded for reduced motion and + * missing elements so the script is safe to load on any page. + */ +const reduce = !!window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +function animateNum(el: HTMLElement): void { + const target = parseInt(el.textContent ?? '', 10); + if (Number.isNaN(target)) return; + let node: ChildNode | null = el.firstChild; + if (!(node && node.nodeType === Node.TEXT_NODE)) { + node = document.createTextNode(el.textContent ?? ''); + el.insertBefore(node, el.firstChild); + } + const textNode = node as Text; + let t0: number | null = null; + const dur = 900; + const tick = (now: number) => { + if (t0 === null) t0 = now; + const p = Math.min(1, (now - t0) / dur); + const eased = 1 - Math.pow(1 - p, 3); + textNode.nodeValue = String(Math.round(target * eased)); + if (p < 1) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); +} + +export function initImmersive(): void { + if (!reduce && 'IntersectionObserver' in window) { + const io1 = new IntersectionObserver( + (entries) => { + entries.forEach((en) => { + if (en.isIntersecting) { + animateNum(en.target as HTMLElement); + io1.unobserve(en.target); + } + }); + }, + { threshold: 0.4 }, + ); + document.querySelectorAll('.metric .num').forEach((el) => io1.observe(el)); + } + + /* each terminal types itself in the first time it scrolls into view */ + if (!reduce && 'IntersectionObserver' in window) { + const io2 = new IntersectionObserver( + (entries) => { + entries.forEach((en) => { + if (en.isIntersecting) { + en.target.classList.add('anim'); + io2.unobserve(en.target); + } + }); + }, + { threshold: 0.35 }, + ); + document.querySelectorAll('.term').forEach((el) => { + if (!(el as HTMLElement).hidden) io2.observe(el); + }); + } + + const hero = document.querySelector('.hero') as HTMLElement | null; + if (hero && !reduce) { + hero.addEventListener('pointermove', (e) => { + const r = hero.getBoundingClientRect(); + hero.style.setProperty('--px', ((e.clientX - r.left) / r.width - 0.5).toFixed(3)); + hero.style.setProperty('--py', ((e.clientY - r.top) / r.height - 0.5).toFixed(3)); + }); + hero.addEventListener('pointerleave', () => { + hero.style.setProperty('--px', '0'); + hero.style.setProperty('--py', '0'); + }); + } +} diff --git a/website/src/lib/orbScene.ts b/website/src/lib/orbScene.ts new file mode 100644 index 00000000..934f89fa --- /dev/null +++ b/website/src/lib/orbScene.ts @@ -0,0 +1,399 @@ +/** + * Interactive 3D map of an Azure subscription: a central core connects one + * hub per domain, each hub fans out to its rule nodes, and a scan pulse + * sweeps the estate. Drag to rotate, click a node to focus a rule. + */ +import * as THREE from 'three'; + +interface OrbRule { + id: string; + name: string; + severity: string; + domain: string; +} +interface OrbData { + /** [id, severity, name] triples, see orbRules in repoData */ + rules: [string, string, string][]; + domains: string[]; +} + +function domainOf(ruleId: string): string { + const parts = ruleId.split('-'); + return (parts[1] || '').toLowerCase(); +} + +export function initOrb(): void { + const canvas = document.getElementById('heroCanvas') as HTMLCanvasElement | null; + const fallback = document.getElementById('heroFallback') as HTMLElement | null; + const info = document.getElementById('ruleInfo'); + const legend = document.getElementById('heroLegend'); + const previous = document.getElementById('orbPrevious') as HTMLButtonElement | null; + const next = document.getElementById('orbNext') as HTMLButtonElement | null; + const motion = document.getElementById('orbMotion') as HTMLButtonElement | null; + const dataEl = document.getElementById('orb-data'); + if (!canvas || !info || !legend || !dataEl) return; + + let data: OrbData; + try { + data = JSON.parse(dataEl.textContent || '{}') as OrbData; + } catch { + return; + } + const RULES: OrbRule[] = (data.rules || []).map(([id, severity, name]) => ({ + id, + severity, + name, + domain: domainOf(id), + })); + const DOMAINS = data.domains || []; + if (!RULES.length || !DOMAINS.length) return; + + const reduce = !!window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + let renderer: THREE.WebGLRenderer; + try { + renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); + } catch { + canvas.style.display = 'none'; + if (fallback) fallback.style.display = 'flex'; + return; + } + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100); + camera.position.set(0, 0, 4.9); + const group = new THREE.Group(); + scene.add(group); + + const domIndex: Record = {}; + DOMAINS.forEach((d, i) => { + domIndex[d] = i; + }); + const counts: Record = {}; + RULES.forEach((r) => { + const d = domainOf(r.id); + counts[d] = (counts[d] || 0) + 1; + }); + const domHue = (i: number) => (i * 137.508) % 360; + const domColor = (i: number) => { + const c = new THREE.Color(); + c.setHSL(domHue(i) / 360, 0.5, 0.62); + return c; + }; + + legend.replaceChildren( + ...DOMAINS.map((d, i) => { + const span = document.createElement('span'); + span.className = 'lg'; + const dot = document.createElement('i'); + dot.style.background = `hsl(${Math.round(domHue(i))},50%,62%)`; + span.appendChild(dot); + span.appendChild(document.createTextNode(`${d.toUpperCase()} ${counts[d] || 0}`)); + return span; + }), + ); + + const N = RULES.length; + const D = DOMAINS.length; + const R = 1.5; + const golden = Math.PI * (3 - Math.sqrt(5)); + const centroids: number[][] = []; + for (let d = 0; d < D; d++) { + const cy = 1 - (d / (D - 1)) * 2; + const cr = Math.sqrt(Math.max(0, 1 - cy * cy)); + centroids.push([Math.cos(golden * d) * cr, cy, Math.sin(golden * d) * cr]); + } + let seed = 7; + const rnd = () => { + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed / 4294967296; + }; + + const positions = new Float32Array(N * 3); + const colorsArr = new Float32Array(N * 3); + const nodeDom = new Array(N); + RULES.forEach((r, i) => { + const di = domIndex[domainOf(r.id)] ?? 0; + nodeDom[i] = di; + const c = centroids[di]; + const spread = 0.2 + (counts[DOMAINS[di]] || 1) * 0.006; + const x = c[0] + (rnd() * 2 - 1) * spread; + const y = c[1] + (rnd() * 2 - 1) * spread; + const z = c[2] + (rnd() * 2 - 1) * spread; + const len = Math.sqrt(x * x + y * y + z * z); + positions[i * 3] = (x / len) * R; + positions[i * 3 + 1] = (y / len) * R; + positions[i * 3 + 2] = (z / len) * R; + const col = domColor(di); + colorsArr[i * 3] = col.r; + colorsArr[i * 3 + 1] = col.g; + colorsArr[i * 3 + 2] = col.b; + }); + + const nodeGeo = new THREE.BufferGeometry(); + nodeGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + nodeGeo.setAttribute('color', new THREE.BufferAttribute(colorsArr, 3)); + const ptsMat = new THREE.PointsMaterial({ + size: 0.055, + vertexColors: true, + transparent: true, + opacity: 0.95, + sizeAttenuation: true, + }); + const points = new THREE.Points(nodeGeo, ptsMat); + group.add(points); + + // constellation lines within each domain cluster + const seg: number[] = []; + for (let a = 0; a < N; a++) { + for (let b = a + 1; b < N; b++) { + if (nodeDom[a] !== nodeDom[b]) continue; + const dx = positions[a * 3] - positions[b * 3]; + const dy = positions[a * 3 + 1] - positions[b * 3 + 1]; + const dz = positions[a * 3 + 2] - positions[b * 3 + 2]; + if (Math.sqrt(dx * dx + dy * dy + dz * dz) < 0.5) { + seg.push( + positions[a * 3], positions[a * 3 + 1], positions[a * 3 + 2], + positions[b * 3], positions[b * 3 + 1], positions[b * 3 + 2], + ); + } + } + } + const lineGeo = new THREE.BufferGeometry(); + lineGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(seg), 3)); + const lineMat = new THREE.LineBasicMaterial({ color: new THREE.Color('#a7a7b0'), transparent: true, opacity: 0.10 }); + group.add(new THREE.LineSegments(lineGeo, lineMat)); + + const PAPER = new THREE.Color('#f2f0ec'); + const wireMat = new THREE.MeshBasicMaterial({ color: PAPER, wireframe: true, transparent: true, opacity: 0.16 }); + const core = new THREE.Mesh(new THREE.IcosahedronGeometry(0.74, 1), wireMat); + group.add(core); + const ringMatA = new THREE.MeshBasicMaterial({ color: new THREE.Color('#ff5a33'), transparent: true, opacity: 0.45, side: THREE.DoubleSide }); + const ringA = new THREE.Mesh(new THREE.TorusGeometry(1.8, 0.006, 8, 140), ringMatA); + ringA.rotation.x = Math.PI / 2.25; + group.add(ringA); + const ringMatB = new THREE.MeshBasicMaterial({ color: new THREE.Color('#a7a7b0'), transparent: true, opacity: 0.2, side: THREE.DoubleSide }); + const ringB = new THREE.Mesh(new THREE.TorusGeometry(1.98, 0.005, 8, 140), ringMatB); + ringB.rotation.x = Math.PI / 1.7; + ringB.rotation.y = 0.5; + group.add(ringB); + + // core -> hub -> nodes wiring + const byDom: number[][] = []; + for (let i = 0; i < D; i++) byDom.push([]); + for (let i = 0; i < N; i++) byDom[nodeDom[i]].push(i); + const hubSeg: number[] = []; + const nodeSeg: number[] = []; + for (let h = 0; h < D; h++) { + const hc = centroids[h]; + const hx = hc[0] * R, hy = hc[1] * R, hz = hc[2] * R; + hubSeg.push(0, 0, 0, hx, hy, hz); + for (const ni of byDom[h]) { + nodeSeg.push(hx, hy, hz, positions[ni * 3], positions[ni * 3 + 1], positions[ni * 3 + 2]); + } + const hubMesh = new THREE.Mesh( + new THREE.OctahedronGeometry(0.055), + new THREE.MeshBasicMaterial({ color: domColor(h), wireframe: true, transparent: true, opacity: 0.95 }), + ); + hubMesh.position.set(hx, hy, hz); + group.add(hubMesh); + } + const hubGeo = new THREE.BufferGeometry(); + hubGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(hubSeg), 3)); + group.add(new THREE.LineSegments(hubGeo, new THREE.LineBasicMaterial({ color: PAPER, transparent: true, opacity: 0.14 }))); + const nodeSegGeo = new THREE.BufferGeometry(); + nodeSegGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(nodeSeg), 3)); + group.add(new THREE.LineSegments(nodeSegGeo, new THREE.LineBasicMaterial({ color: PAPER, transparent: true, opacity: 0.07 }))); + + // one bridge per neighboring domain pair: a single connected map + const bridge: number[] = []; + for (let b = 0; b < D - 1; b++) { + const bA = byDom[b]; + const bB = byDom[b + 1]; + let bestD = Infinity, bI = -1, bJ = -1; + for (const ia of bA) { + for (const jb of bB) { + const dx = positions[ia * 3] - positions[jb * 3]; + const dy = positions[ia * 3 + 1] - positions[jb * 3 + 1]; + const dz = positions[ia * 3 + 2] - positions[jb * 3 + 2]; + const dd = dx * dx + dy * dy + dz * dz; + if (dd < bestD) { bestD = dd; bI = ia; bJ = jb; } + } + } + if (bI >= 0) { + bridge.push( + positions[bI * 3], positions[bI * 3 + 1], positions[bI * 3 + 2], + positions[bJ * 3], positions[bJ * 3 + 1], positions[bJ * 3 + 2], + ); + } + } + const bridgeGeo = new THREE.BufferGeometry(); + bridgeGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(bridge), 3)); + group.add(new THREE.LineSegments(bridgeGeo, new THREE.LineBasicMaterial({ color: PAPER, transparent: true, opacity: 0.32 }))); + + // scan pulse + const pulseMat = new THREE.MeshBasicMaterial({ color: 0xff5a33, wireframe: true, transparent: true, opacity: 0 }); + const pulse = new THREE.Mesh(new THREE.SphereGeometry(1, 20, 14), pulseMat); + group.add(pulse); + + const marker = new THREE.Mesh( + new THREE.SphereGeometry(0.105, 16, 16), + new THREE.MeshBasicMaterial({ color: 0xff5a33, wireframe: true, transparent: true, opacity: 0.9 }), + ); + marker.visible = false; + group.add(marker); + + let focus = -1; + const sevCol: Record = { HIGH: '#ff5a33', MEDIUM: '#e0b356', LOW: '#a7a7b0' }; + const setFocus = (i: number) => { + focus = i; + const r = RULES[i]; + marker.position.set(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]); + (marker.material as THREE.MeshBasicMaterial).color.set(sevCol[r.severity] || '#f2f0ec'); + marker.visible = true; + const idEl = document.createElement('b'); + idEl.textContent = r.id; + const sevEl = document.createElement('em'); + sevEl.style.color = sevCol[r.severity] || '#a7a7b0'; + sevEl.textContent = r.severity; + info.replaceChildren(idEl, document.createTextNode(` / ${r.name} / `), sevEl); + renderer.render(scene, camera); + }; + + previous?.addEventListener('click', () => setFocus(((focus < 0 ? 0 : focus) - 1 + N) % N)); + next?.addEventListener('click', () => setFocus((focus + 1) % N)); + + const ray = new THREE.Raycaster(); + ray.params.Points = { threshold: 0.14 }; + const ndc = new THREE.Vector2(); + const pick = (e: PointerEvent) => { + const rect = canvas.getBoundingClientRect(); + ndc.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; + ndc.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; + ray.setFromCamera(ndc, camera); + const hits = ray.intersectObject(points); + return hits.length && hits[0].index !== undefined ? hits[0].index : -1; + }; + + let ry = 0.6, rx = 0.3, tRy = 0.6, tRx = 0.3; + let dragging = false, lastX = 0, lastY = 0, downX = 0, downY = 0, hoverX = 0, hoverY = 0; + const clamp1 = (v: number) => Math.max(-1, Math.min(1, v)); + + canvas.addEventListener('pointerdown', (e) => { + dragging = true; + lastX = downX = e.clientX; + lastY = downY = e.clientY; + try { canvas.setPointerCapture(e.pointerId); } catch { /* ignore */ } + e.preventDefault(); + }); + window.addEventListener('pointermove', (e) => { + if (dragging) { + tRy += (e.clientX - lastX) * 0.006; + tRx = Math.max(-1.2, Math.min(1.2, tRx + (e.clientY - lastY) * 0.004)); + lastX = e.clientX; + lastY = e.clientY; + } else { + const rect = canvas.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) { + hoverY = clamp1((e.clientX - (rect.left + rect.width / 2)) / rect.width); + hoverX = clamp1((e.clientY - (rect.top + rect.height / 2)) / rect.height); + canvas.style.cursor = pick(e) >= 0 ? 'pointer' : 'grab'; + } + } + }); + window.addEventListener('pointerup', (e) => { + if (!dragging) return; + dragging = false; + if (Math.hypot(e.clientX - downX, e.clientY - downY) < 6) { + const i = pick(e); + setFocus(i >= 0 ? i : (focus + 1) % N); + } + }); + + const resize = () => { + const w = canvas.clientWidth || canvas.parentElement?.clientWidth || 600; + const h = canvas.clientHeight || 440; + renderer.setSize(w, h, false); + camera.aspect = w / h; + camera.updateProjectionMatrix(); + const half = Math.tan((camera.fov * Math.PI) / 360); + camera.position.z = (2.06 / half) / Math.min(1, camera.aspect) * 1.02; + }; + window.addEventListener('resize', resize); + resize(); + + const start = performance.now(); + let motionPaused = reduce; + let inView = true; + let frameId = 0; + + const setMotionLabel = () => { + if (!motion) return; + motion.setAttribute('aria-pressed', String(motionPaused)); + motion.textContent = motionPaused ? 'Resume motion' : 'Pause motion'; + }; + setMotionLabel(); + + const frame = (now: number) => { + frameId = 0; + const t = now - start; + if (focus >= 0 && !dragging) { + const px = positions[focus * 3], py = positions[focus * 3 + 1], pz = positions[focus * 3 + 2]; + const ty = Math.atan2(-px, pz); + const tx = Math.atan2(py, Math.hypot(px, pz)); + let dY = ty - tRy; + dY = Math.atan2(Math.sin(dY), Math.cos(dY)); + tRy += dY * 0.09; + tRx += (tx - tRx) * 0.09; + } else if (!dragging && !motionPaused) { + tRy += 0.0024; + } + ry += (tRy - ry) * 0.12; + rx += (tRx - rx) * 0.12; + group.rotation.set(rx + (motionPaused ? 0 : hoverX * 0.08), ry + (motionPaused ? 0 : hoverY * 0.1), 0); + if (marker.visible) marker.scale.setScalar(1 + Math.sin(t * 0.005) * 0.14); + const period = 4200; + const pp = (t % period) / period; + pulse.scale.setScalar(0.78 + pp * 1.4); + pulseMat.opacity = motionPaused ? 0 : 0.20 * (1 - pp); + if (!motionPaused) { + ringA.rotation.z += 0.0006; + ringB.rotation.z -= 0.0004; + core.scale.setScalar(1 + 0.025 * Math.sin(t * 0.0024)); + } + group.position.y = motionPaused ? 0 : Math.sin(t * 0.00055) * 0.07; + renderer.render(scene, camera); + if (!motionPaused && inView && !document.hidden) frameId = requestAnimationFrame(frame); + }; + + const startFrames = () => { + if (!frameId && !motionPaused && inView && !document.hidden) frameId = requestAnimationFrame(frame); + }; + motion?.addEventListener('click', () => { + motionPaused = !motionPaused; + setMotionLabel(); + if (motionPaused && frameId) { + cancelAnimationFrame(frameId); + frameId = 0; + renderer.render(scene, camera); + } else { + startFrames(); + } + }); + document.addEventListener('visibilitychange', startFrames); + if ('IntersectionObserver' in window) { + const observer = new IntersectionObserver((entries) => { + inView = entries[0]?.isIntersecting ?? true; + if (!inView && frameId) { + cancelAnimationFrame(frameId); + frameId = 0; + } else { + startFrames(); + } + }); + observer.observe(canvas); + } + renderer.render(scene, camera); + startFrames(); +} diff --git a/website/src/lib/repoData.ts b/website/src/lib/repoData.ts new file mode 100644 index 00000000..ab0edf08 --- /dev/null +++ b/website/src/lib/repoData.ts @@ -0,0 +1,385 @@ +/** + * Build-time extraction of live repository data. + * + * Everything the site shows about the project (rule counts, domains, + * playbooks, contributors, latest release, docs index) is derived from the + * repository itself at build time, so nothing here ever needs a manual edit + * when the codebase moves on. The Pages workflow checks out full git history + * (fetch-depth: 0) so contributor counting works in CI. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +/** + * Locate the repository root. Astro 7 bundles static entrypoints into + * dist/.prerender before running them, so import.meta.url is not reliable + * here; instead walk up from the working directory (and from this file) + * until the marker files of the repository root appear. + */ +function isRepoRoot(dir: string): boolean { + return ( + fs.existsSync(path.join(dir, 'scanner', 'rules')) && + fs.existsSync(path.join(dir, 'CHANGELOG.md')) + ); +} + +function findRepoRoot(): string { + if (process.env.REPO_ROOT) return path.resolve(process.env.REPO_ROOT); + const starts = [process.cwd(), path.dirname(fileURLToPath(import.meta.url))]; + for (const start of starts) { + let dir = start; + for (let depth = 0; depth < 8; depth++) { + if (isRepoRoot(dir)) return dir; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + } + // Fallback to the classic layout: website/ sits directly under the root. + return path.resolve(process.cwd(), '..'); +} + +export const repoRoot = findRepoRoot(); + +/* ------------------------------------------------------------------ */ +/* Rules */ +/* ------------------------------------------------------------------ */ + +export interface Rule { + id: string; + name: string; + severity: 'HIGH' | 'MEDIUM' | 'LOW'; + domain: string; + category: string; + frameworks: Record; + description: string; + remediation: string; + playbook: string; +} + +const DOMAIN_LABELS: Record = { + net: 'Network', + idn: 'Identity', + secops: 'Security Operations', + stor: 'Storage', + sc: 'Supply Chain', + db: 'Database', + pe: 'Private Endpoint', + kv: 'Key Vault', + aks: 'AKS', + func: 'Serverless', + cmp: 'Compute', + bak: 'Backup', + pqc: 'Post-Quantum', + dl: 'Data Link', + cosmos: 'Cosmos DB', + cache: 'Cache', +}; + +/** Repo sources occasionally contain em dashes; site copy never does. */ +function clean(value: string): string { + return value.replace(/\s*\u2014\s*/g, ', '); +} + +function quoted(source: string, field: string): string { + const m = source.match(new RegExp(`${field}\\s*=\\s*"((?:[^"\\\\]|\\\\.)*)"`)); + return m ? m[1] : ''; +} + +function multiline(source: string, field: string): string { + const m = source.match(new RegExp(`${field}\\s*=\\s*\\(([\\s\\S]*?)\\n\\)`)); + if (!m) return quoted(source, field); + const parts = [...m[1].matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((p) => p[1]); + return parts.join(''); +} + +function parseFrameworks(source: string): Record { + const m = source.match(/FRAMEWORKS\s*=\s*\{([^}]*)\}/); + const out: Record = {}; + if (!m) return out; + for (const pair of m[1].matchAll(/"([^"]+)"\s*:\s*"([^"]*)"/g)) { + out[pair[1]] = pair[2]; + } + return out; +} + +function parseRules(): Rule[] { + const dir = path.join(repoRoot, 'scanner', 'rules'); + const rules: Rule[] = []; + for (const file of fs.readdirSync(dir).sort()) { + if (!file.startsWith('az_') || !file.endsWith('.py') || file.startsWith('_')) continue; + const source = fs.readFileSync(path.join(dir, file), 'utf8'); + const id = quoted(source, 'RULE_ID'); + if (!id) continue; + const domain = file.replace(/^az_/, '').replace(/_\d+\.py$/, ''); + rules.push({ + id, + name: clean(quoted(source, 'RULE_NAME')), + severity: (quoted(source, 'SEVERITY') || 'LOW') as Rule['severity'], + domain, + category: clean(quoted(source, 'CATEGORY')) || DOMAIN_LABELS[domain] || domain, + frameworks: parseFrameworks(source), + description: clean(multiline(source, 'DESCRIPTION')), + remediation: clean(multiline(source, 'REMEDIATION')), + playbook: quoted(source, 'PLAYBOOK'), + }); + } + return rules; +} + +/* ------------------------------------------------------------------ */ +/* Playbooks, contributors, releases, docs */ +/* ------------------------------------------------------------------ */ + +function countPlaybooks(): number { + const dir = path.join(repoRoot, 'playbooks', 'cli'); + return fs.readdirSync(dir).filter((f) => f.startsWith('fix_az_') && f.endsWith('.sh')).length; +} + +/** Historical aliases merged so one person is not counted several times. */ +const ALIASES: Record = { + 'ritik sah': 'Ritik Sah', + ritiksah141: 'Ritik Sah', + ritiksah141: 'Ritik Sah', + 'vishnu ajith': 'Vishnu Ajith', + vishnu2707: 'Vishnu Ajith', + 'tanvir farhad': 'Tanvir Farhad', + tft444: 'Tanvir Farhad', + 'parth j rohit': 'Parth Rohit', + 'parth rohit': 'Parth Rohit', + parthrohit22: 'Parth Rohit', + 'safid nadaf': 'Safid Nadaf', + safidnadaf: 'Safid Nadaf', + 'sharique ahmad': 'Sharique Ahmad', + shariqueahmad108: 'Sharique Ahmad', + 'shaurya k sharma': 'Shaurya K Sharma', + shauryaksharma24: 'Shaurya K Sharma', + 'prayas gautam': 'Prayas Gautam', + vogonprayas: 'Prayas Gautam', +}; + +const GITHUB_PROFILES: Record = { + 'Ritik Sah': 'ritiksah141', + 'Vishnu Ajith': 'Vishnu2707', + 'Tanvir Farhad': 'tft444', + 'Parth Rohit': 'parthrohit22', + 'Safid Nadaf': 'safidnadaf', + 'Sharique Ahmad': 'shariqueahmad108', + 'Shaurya K Sharma': 'shauryaksharma24', + 'Prayas Gautam': 'vogonPrayas', + 'Muhammad Ibrahim': 'm-khan-97', +}; + +export function contributorGithub(name: string): string | undefined { + return GITHUB_PROFILES[name]; +} + +function listContributors(): string[] { + let raw: string; + try { + raw = execSync('git log --format=%aN', { cwd: repoRoot, encoding: 'utf8' }); + } catch { + return []; + } + const seen = new Map(); + for (const name of raw.split('\n')) { + const trimmed = name.trim(); + if (!trimmed || trimmed.endsWith('[bot]')) continue; + const canonical = ALIASES[trimmed.toLowerCase()] ?? trimmed; + seen.set(canonical.toLowerCase(), canonical); + } + return [...seen.values()].sort((a, b) => a.localeCompare(b)); +} + +export interface ContributorActivity { + name: string; + commits: number; + github?: string; +} + +function listContributorActivity(): ContributorActivity[] { + let raw: string; + try { + raw = execSync('git log --format=%aN', { cwd: repoRoot, encoding: 'utf8' }); + } catch { + return []; + } + const counts = new Map(); + for (const value of raw.split('\n')) { + const trimmed = value.trim(); + if (!trimmed || trimmed.endsWith('[bot]')) continue; + const name = ALIASES[trimmed.toLowerCase()] ?? trimmed; + const key = name.toLowerCase(); + const current = counts.get(key) ?? { name, commits: 0 }; + current.commits++; + counts.set(key, current); + } + return [...counts.values()] + .map(({ name, commits }) => ({ name, commits, github: contributorGithub(name) })) + .sort((a, b) => b.commits - a.commits || a.name.localeCompare(b.name)); +} + +function latestRelease(): { tag: string; date: string } { + const changelog = fs.readFileSync(path.join(repoRoot, 'CHANGELOG.md'), 'utf8'); + const m = changelog.match(/^##\s*\[(\d+\.\d+\.\d+)\]\s*-\s*(\d{4}-\d{2}-\d{2})/m); + return m ? { tag: `v${m[1]}`, date: m[2] } : { tag: 'v0.0.0', date: '' }; +} + +export interface ReleaseEntry { + tag: string; + date: string; + href: string; +} + +function releaseHistory(): ReleaseEntry[] { + const changelog = fs.readFileSync(path.join(repoRoot, 'CHANGELOG.md'), 'utf8'); + const references = new Map( + [...changelog.matchAll(/^\[(\d+\.\d+\.\d+)\]:\s*(\S+)/gm)] + .map((match) => [match[1], match[2]]), + ); + return [...changelog.matchAll(/^##\s*\[(\d+\.\d+\.\d+)\]\s*-\s*(\d{4}-\d{2}-\d{2})/gm)] + .map((match) => { + const version = match[1]; + return { + tag: `v${version}`, + date: match[2], + href: references.get(version) ?? `https://github.com/OWASP/openshield/releases/tag/v${version}`, + }; + }); +} + +export interface RoadmapPeriod { + period: string; + items: string[]; +} + +function roadmapData(): { periods: RoadmapPeriod[]; limitations: string[] } { + const source = fs.readFileSync(path.join(repoRoot, 'ROADMAP.md'), 'utf8'); + const periods: RoadmapPeriod[] = []; + let limitations: string[] = []; + let heading = ''; + let items: string[] = []; + const flush = () => { + if (!heading) return; + if (heading.toLowerCase().includes('out of scope')) limitations = items; + else if (/\d{4}/.test(heading)) periods.push({ period: heading, items }); + }; + for (const line of source.split('\n')) { + const nextHeading = line.match(/^##\s+(.+)/); + if (nextHeading) { + flush(); + heading = nextHeading[1].trim(); + items = []; + continue; + } + const bullet = line.match(/^-\s+(.+)/); + if (bullet) { + items.push(bullet[1].trim()); + continue; + } + if (/^\s{2,}\S/.test(line) && items.length) { + items[items.length - 1] += ` ${line.trim()}`; + } + } + flush(); + return { periods, limitations }; +} + +export interface DocEntry { + file: string; + title: string; + section: string; +} + +function docTitle(file: string): string { + const stem = file.replace(/\.md$/, '').replace(/[-_]/g, ' '); + return stem + .split(' ') + .map((w) => (w.length > 2 && w === w.toUpperCase() ? w : w.charAt(0).toUpperCase() + w.slice(1))) + .join(' '); +} + +/** doc subfolders shown after the top-level guides, with their own heading */ +const DOC_SUBFOLDERS: { dir: string; section: string }[] = [ + { dir: 'deployment', section: 'Deployment' }, + { dir: 'validation', section: 'Validation reports' }, +]; + +function listDocs(): DocEntry[] { + const docsDir = path.join(repoRoot, 'docs'); + const entries: DocEntry[] = []; + for (const file of fs.readdirSync(docsDir).sort()) { + if (file.endsWith('.md') && !file.startsWith('_')) { + entries.push({ file, title: docTitle(file), section: 'Guides and references' }); + } + } + for (const { dir, section } of DOC_SUBFOLDERS) { + const full = path.join(docsDir, dir); + if (!fs.existsSync(full)) continue; + for (const file of fs.readdirSync(full).sort()) { + if (file.endsWith('.md') && !file.startsWith('_')) { + entries.push({ file: `${dir}/${file}`, title: docTitle(file), section }); + } + } + } + return entries; +} + +/* ------------------------------------------------------------------ */ +/* Assembled dataset */ +/* ------------------------------------------------------------------ */ + +const rules = parseRules(); +const contributors = listContributors(); +const contributorActivity = listContributorActivity(); +const roadmap = roadmapData(); + +const domainCounts = new Map(); +for (const r of rules) domainCounts.set(r.domain, (domainCounts.get(r.domain) ?? 0) + 1); +const domains = [...domainCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([key, count]) => ({ key, count, label: DOMAIN_LABELS[key] ?? key })); + +export interface RepoData { + rules: Rule[]; + domains: { key: string; count: number; label: string }[]; + ruleCount: number; + domainCount: number; + playbookCount: number; + contributors: string[]; + contributorActivity: ContributorActivity[]; + contributorCount: number; + release: { tag: string; date: string }; + releases: ReleaseEntry[]; + roadmap: RoadmapPeriod[]; + limitations: string[]; + docs: DocEntry[]; + /** demo scan: 6 high + 3 medium failing, everything else passing */ + sampleScan: { score: number; high: number; medium: number; passing: number }; +} + +export const repoData: RepoData = { + rules, + domains, + ruleCount: rules.length, + domainCount: domains.length, + playbookCount: countPlaybooks(), + contributors, + contributorCount: contributors.length, + contributorActivity, + release: latestRelease(), + releases: releaseHistory(), + roadmap: roadmap.periods, + limitations: roadmap.limitations, + docs: listDocs(), + sampleScan: { score: 62, high: 6, medium: 3, passing: Math.max(rules.length - 9, 0) }, +}; + +/** Compact [id, severity, name] triples in domain order, for the 3D hero. */ +export const orbRules: [string, string, string][] = domains + .flatMap((d) => rules.filter((r) => r.domain === d.key).sort((a, b) => a.id.localeCompare(b.id))) + .map((r) => [r.id, r.severity, r.name]); + +export const domainOrder: string[] = domains.map((d) => d.key); diff --git a/website/src/pages/404.astro b/website/src/pages/404.astro new file mode 100644 index 00000000..e35db3cd --- /dev/null +++ b/website/src/pages/404.astro @@ -0,0 +1,14 @@ +--- +import Base from '../layouts/Base.astro'; +import { url } from '../lib/base'; +--- + + +
+
+
404
+

This check did not pass. The page you are looking for does not exist.

+ Back to the start +
+
+ diff --git a/website/src/pages/architecture.astro b/website/src/pages/architecture.astro new file mode 100644 index 00000000..b44a0eaa --- /dev/null +++ b/website/src/pages/architecture.astro @@ -0,0 +1,162 @@ +--- +import Base from '../layouts/Base.astro'; +import { repoData } from '../lib/repoData'; +import { url } from '../lib/base'; + +const GITHUB = 'https://github.com/OWASP/openshield'; +const stages = [ + { + id: 'azure', + label: 'Azure metadata', + eyebrow: 'Trust boundary 01', + title: 'Collect configuration, not workload contents.', + description: 'The scanner uses Azure management APIs and Microsoft Graph accessors to read resource configuration. Reader is the documented baseline, with extra directory permissions required for specific identity checks.', + evidence: 'scanner/azure_client.py', + href: `${GITHUB}/blob/dev/scanner/azure_client.py`, + }, + { + id: 'engine', + label: 'Scan engine', + eyebrow: 'Execution 02', + title: `Load ${repoData.ruleCount} repository rules through one engine.`, + description: 'Each Python module declares identity, severity, framework mappings and a scan function. The engine isolates rule execution and produces normalized findings.', + evidence: 'scanner/engine.py', + href: `${GITHUB}/blob/dev/scanner/engine.py`, + }, + { + id: 'enrichment', + label: 'CVE enrichment', + eyebrow: 'External boundary 03', + title: 'Attach NVD context when requested.', + description: 'CVE enrichment can query NVD after rule execution. This external boundary is not required to run the rules. Configured AI providers are separate API integrations and do not participate in core detection.', + evidence: 'docs/cve_correlation_feature.md', + href: `${GITHUB}/blob/dev/docs/cve_correlation_feature.md`, + }, + { + id: 'database', + label: 'PostgreSQL', + eyebrow: 'Persistence 04', + title: 'Persist scans and findings in the operator deployment.', + description: 'The database stores scan identity, status, score and normalized finding records. Async jobs survive API process restarts because queue state is persisted rather than held in memory.', + evidence: 'docs/async-scan-architecture.md', + href: `${GITHUB}/blob/dev/docs/async-scan-architecture.md`, + }, + { + id: 'api', + label: 'Flask API', + eyebrow: 'Application boundary 05', + title: 'Expose the latest stored evidence through authenticated routes.', + description: 'The API serves findings, scores, resources, prioritization, drift and playbook data. State-changing routes require authentication and role checks.', + evidence: 'docs/api-reference.md', + href: `${GITHUB}/blob/dev/docs/api-reference.md`, + }, + { + id: 'consumers', + label: 'Operator surfaces', + eyebrow: 'Consumption 06', + title: 'Review findings in the dashboard or export them to Sentinel.', + description: 'The React dashboard consumes API contracts. Sentinel ingestion is an optional export path. Neither surface changes Azure resources without a separate operator action.', + evidence: 'docs/architecture.md', + href: `${GITHUB}/blob/dev/docs/architecture.md`, + }, +]; +--- + + +
+
+ Interactive system map +

Follow one finding through OpenShield.

+

Select each stage to inspect its responsibility, boundary and repository evidence. Optional services are identified rather than folded into the core scanner.

+
+
+ +
+
+
+ {stages.map((stage, index) => ( + <> + + {index < stages.length - 1 && } + + ))} +
+ +
+ {stages.map((stage, index) => ( + + ))} +
+ +
+
Core path

Azure to stored findings

Azure metadata, rule execution, PostgreSQL persistence and the authenticated API form the primary system path.

+
Optional path

NVD, AI and Sentinel

External enrichment and export systems are configuration-dependent integrations with their own trust boundaries.

+
Operator action

Remediation stays explicit

Playbooks provide commands and validation steps. They do not run automatically against Azure resources.

+
+ + +
+
+ + + diff --git a/website/src/pages/blog/[...slug].astro b/website/src/pages/blog/[...slug].astro new file mode 100644 index 00000000..0b7b808d --- /dev/null +++ b/website/src/pages/blog/[...slug].astro @@ -0,0 +1,89 @@ +--- +import { getCollection, render } from 'astro:content'; +import Base from '../../layouts/Base.astro'; +import { url } from '../../lib/base'; + +export async function getStaticPaths() { + const posts = await getCollection('blog', ({ data }) => !data.draft); + return posts.map((post) => ({ + params: { slug: post.id }, + props: { post }, + })); +} + +const { post } = Astro.props; +const { Content, headings } = await render(post); + +function fmtDate(d: Date): string { + return d + .toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + .toUpperCase(); +} + +function readingTime(body: string): number { + const words = body.split(/\s+/).filter(Boolean).length; + return Math.max(1, Math.round(words / 200)); +} + +const all = (await getCollection('blog', ({ data }) => !data.draft)).sort( + (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(), +); +const idx = all.findIndex((p) => p.id === post.id); +const newer = idx > 0 ? all[idx - 1] : null; +const older = idx < all.length - 1 ? all[idx + 1] : null; + +const toc = headings.filter((h) => h.depth === 2 || h.depth === 3); + +const postUrl = new URL(url(`/blog/${post.id}/`), Astro.site).href; +const postJsonLd = { + '@context': 'https://schema.org', + '@type': 'BlogPosting', + headline: post.data.title, + description: post.data.description, + datePublished: post.data.pubDate.toISOString(), + dateModified: post.data.pubDate.toISOString(), + author: { '@type': 'Person', name: post.data.author }, + publisher: { '@type': 'Organization', name: 'OpenShield' }, + mainEntityOfPage: postUrl, + url: postUrl, + ...(post.data.tags.length ? { keywords: post.data.tags.join(', ') } : {}), +}; +--- + + +
+
+
{(post.data.tags[0] ?? 'Post').toUpperCase()}
+

{post.data.title}

+

{post.data.description}

+ +
+ {post.data.tags.map((t) => {t})} +
+
+
+ +
+
+
+ +
+ {toc.length > 0 && ( + + )} +
+
+ {older ? ( + ← {older.data.title} + ) : } + {newer ? ( + {newer.data.title} → + ) : All articles →} +
+
+ diff --git a/website/src/pages/blog/index.astro b/website/src/pages/blog/index.astro new file mode 100644 index 00000000..de0f929c --- /dev/null +++ b/website/src/pages/blog/index.astro @@ -0,0 +1,49 @@ +--- +import { getCollection } from 'astro:content'; +import Base from '../../layouts/Base.astro'; +import { url } from '../../lib/base'; + +const posts = (await getCollection('blog', ({ data }) => !data.draft)).sort( + (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(), +); + +function fmtDate(d: Date): string { + return d + .toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + .toUpperCase(); +} + +function readingTime(body: string): number { + const words = body.split(/\s+/).filter(Boolean).length; + return Math.max(1, Math.round(words / 200)); +} +--- + + +
+
+

From the blog.

+

Release notes, engineering deep-dives and integration guides. Written by the maintainers, published as it ships.

+
+
+
+ {posts.map((post, i) => { + const tag = post.data.tags[0] ?? 'Post'; + return ( +
+
+ POST/{String(i + 1).padStart(2, '0')} + {tag} +
+
+

{post.data.title}

+

{post.data.description}

+ {fmtDate(post.data.pubDate)} / {readingTime(post.body)} MIN READ +
+
+ ); + })} +
+
+
+ diff --git a/website/src/pages/community.astro b/website/src/pages/community.astro new file mode 100644 index 00000000..ef22c9af --- /dev/null +++ b/website/src/pages/community.astro @@ -0,0 +1,85 @@ +--- +import Base from '../layouts/Base.astro'; +import { repoData } from '../lib/repoData'; + +const GITHUB = 'https://github.com/OWASP/openshield'; +const contributors = repoData.contributorActivity; + +const LEAD = new Set(['Vishnu Ajith']); +const MAINTAINERS = new Set(['Ritik Sah', 'Tanvir Farhad', 'Parth Rohit']); + +function role(name: string): string { + if (LEAD.has(name)) return 'Project lead'; + if (MAINTAINERS.has(name)) return 'Maintainer'; + return 'Contributor'; +} + +function initials(name: string): string { + const parts = name.split(/\s+/).filter(Boolean); + return ((parts[0]?.[0] ?? '') + (parts[parts.length - 1]?.[0] ?? '')).toUpperCase(); +} +--- + + +
+
+

Community.

+

+ {repoData.contributorCount} people have committed to OpenShield. Rules, playbooks and + docs are written in the open, reviewed in the open and released in the open. +

+
+ +
+
+ {contributors.map((contributor, i) => { + const { name, commits, github } = contributor; + const content = ( + <> + {initials(name)} +
+

{name}

+ {role(name)} / {commits} {commits === 1 ? 'commit' : 'commits'} +
+ {github && GitHub ↗} + + ); + return github ? ( + + {content} + + ) : ( +
{content}
+ ); + })} +
+

+ Counted from git history at build time. Profile links appear only where a repository-recorded identity is available. See the full log on + GitHub contributors → +

+
+ +
+
+

Join in.

+
+
+
+ Write a rule +

One Python file in scanner/rules/ is one rule. The contributing guide walks you from fork to merged rule.

+ Read CONTRIBUTING.md +
+
+ Report a gap +

Found a misconfiguration the scanner misses? Open an issue and a maintainer will triage it with you.

+ Open an issue +
+
+ Review a PR +

Security tooling earns trust by being read. Reviews from newcomers are as welcome as reviews from maintainers.

+ Browse pull requests +
+
+
+
+ diff --git a/website/src/pages/docs.astro b/website/src/pages/docs.astro new file mode 100644 index 00000000..c03dedd1 --- /dev/null +++ b/website/src/pages/docs.astro @@ -0,0 +1,112 @@ +--- +import Base from '../layouts/Base.astro'; +import { url } from '../lib/base'; +import { repoData } from '../lib/repoData'; + +const { docs } = repoData; +const GITHUB = 'https://github.com/OWASP/openshield'; + +const sections = ['Guides and references', 'Deployment', 'Validation reports']; +const grouped = sections + .map((section) => ({ section, items: docs.filter((d) => d.section === section) })) + .filter((g) => g.items.length > 0); +--- + + +
+
+ From access to evidence +

Run your first scan.

+

+ Start with read-only Azure access, inspect an illustrative result, then use the full repository reference when you need more detail. +

+
+ +
+
+
+ About 10 minutes +

A short path to useful evidence.

+
+

OpenShield reads Azure configuration through the Reader role. The commands below are a guided starting point. Review the complete setup guide before using a production subscription.

+
+
    +
  1. + 01 +

    Clone and create an environment

    git clone https://github.com/OWASP/openshield.git
    +cd openshield
    +python -m venv .venv
    +source .venv/bin/activate
    +pip install -r requirements.txt
    +
  2. +
  3. + 02 +

    Authenticate with read-only access

    az login
    +export AZURE_SUBSCRIPTION_ID="your-subscription-id"

    Follow the Azure setup guide for service-principal variables and least-privilege scope.

    +
  4. +
  5. + 03 +

    Run and inspect

    python -m scanner.run \
    +  --subscription "$AZURE_SUBSCRIPTION_ID"

    Use the rule book to trace each rule back to its source, framework mapping and playbook.

    +
  6. +
+ + +
+ +
+
+
Repository reference

Go deeper when you need to.

+ Browse all {docs.length} documents → +
+
+ + + {docs.length} documents +
+ {grouped.map((group) => ( +
+

{group.section}

+
+ {group.items.map((doc) => ( + + {doc.title} + + + ))} +
+
+ ))} + +
+
+ + + diff --git a/website/src/pages/evidence.astro b/website/src/pages/evidence.astro new file mode 100644 index 00000000..bdfa2a5b --- /dev/null +++ b/website/src/pages/evidence.astro @@ -0,0 +1,79 @@ +--- +import Base from '../layouts/Base.astro'; +import { repoData } from '../lib/repoData'; + +const GITHUB = 'https://github.com/OWASP/openshield'; +const maxDomainCount = Math.max(...repoData.domains.map((domain) => domain.count)); +const assurance = [ + ['Continuous integration', '.github/workflows/ci.yml'], + ['CodeQL analysis', '.github/workflows/codeql.yml'], + ['Dependency review', '.github/workflows/dependency-review.yml'], + ['DCO verification', '.github/workflows/dco.yml'], + ['Signed release workflow', '.github/workflows/release.yml'], + ['Security assurance case', 'docs/security-assurance-case.md'], +]; +--- + + +
+
+ No invented adoption metrics +

Project evidence.

+

This page is populated from repository rules, Git history, the changelog and the roadmap. Configured checks are not presented as passing unless current execution evidence proves it.

+
+ +
+
Current checkout

Rule coverage by Azure domain.

Inspect rule source →
+
+ {repoData.domains.map((domain) => ( +
+ {domain.label} +
+ {domain.count} +
+ ))} +
+

Bars encode rule-file count, not security completeness or certification coverage.

+
+ +
+
CHANGELOG.md

Published version history.

Open releases →
+
+ {repoData.releases.map((release) => ( + {release.tag}{release.date} + ))} +
+
+ +
+
Configured controls

Public assurance surfaces.

+
+ {assurance.map(([label, file]) => ( + Configured{label}{file} + ))} +
+
+ +
+
+ Maintainer status / 06 Sep 2026 +

Hosted demo availability.

+
Hosted API unavailable

The free hosting tier has expired. Use the self-hosted quickstart for a working deployment path. The dashboard depends on that API and is not presented as a live demo.

+
+
+ ROADMAP.md +

Documented boundaries.

+
    {repoData.limitations.map((limitation) =>
  • {limitation}
  • )}
+
+
+ +
+
Direction, not a delivery guarantee

Implementation roadmap.

Read the source →
+
+ {repoData.roadmap.map((period) => ( +

{period.period}

    {period.items.map((item) =>
  • {item}
  • )}
+ ))} +
+
+
+ diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro new file mode 100644 index 00000000..d3efb9f3 --- /dev/null +++ b/website/src/pages/index.astro @@ -0,0 +1,48 @@ +--- +import Base from '../layouts/Base.astro'; +import Hero from '../components/Hero.astro'; +import TrustStrip from '../components/TrustStrip.astro'; +import WhySection from '../components/WhySection.astro'; +import MetricsSection from '../components/MetricsSection.astro'; +import DemoSection from '../components/DemoSection.astro'; +import RulesSection from '../components/RulesSection.astro'; +import RunSection from '../components/RunSection.astro'; +import JourneySection from '../components/JourneySection.astro'; +import RoadmapSection from '../components/RoadmapSection.astro'; +import BlogSection from '../components/BlogSection.astro'; +import { url } from '../lib/base'; +import { repoData } from '../lib/repoData'; + +const appJsonLd = { + '@context': 'https://schema.org', + '@type': 'SoftwareApplication', + name: 'OpenShield', + description: + 'Open source security posture for Azure: misconfiguration scanning, compliance mapping to CIS, NIST, ISO 27001 and SOC 2, and one-command remediation playbooks.', + applicationCategory: 'SecurityApplication', + operatingSystem: 'Linux, macOS, Windows', + url: new URL(url('/'), Astro.site).href, + license: 'https://opensource.org/license/mit', + softwareVersion: repoData.release.tag.replace(/^v/, ''), + offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, + sameAs: ['https://github.com/OWASP/openshield'], +}; +--- + + + + + + + + + + + + + + + diff --git a/website/src/pages/rss.xml.ts b/website/src/pages/rss.xml.ts new file mode 100644 index 00000000..a27c440e --- /dev/null +++ b/website/src/pages/rss.xml.ts @@ -0,0 +1,26 @@ +import rss from '@astrojs/rss'; +import { getCollection } from 'astro:content'; +import type { APIContext } from 'astro'; +import { url } from '../lib/base'; + +export async function GET(context: APIContext) { + const posts = (await getCollection('blog', ({ data }) => !data.draft)).sort( + (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(), + ); + const siteRoot = context.site ?? new URL('https://owasp.github.io'); + return rss({ + title: 'OpenShield Blog', + description: + 'Release notes, engineering deep-dives and integration guides from the OpenShield maintainers.', + site: new URL(url('/'), siteRoot), + stylesheet: url('/rss.xsl'), + items: posts.map((post) => ({ + title: post.data.title, + pubDate: post.data.pubDate, + description: post.data.description, + author: post.data.author, + categories: post.data.tags, + link: url(`/blog/${post.id}/`), + })), + }); +} diff --git a/website/src/pages/rules.astro b/website/src/pages/rules.astro new file mode 100644 index 00000000..0b88bd50 --- /dev/null +++ b/website/src/pages/rules.astro @@ -0,0 +1,159 @@ +--- +import Base from '../layouts/Base.astro'; +import { repoData, domainOrder } from '../lib/repoData'; + +const { rules, ruleCount, domainCount, playbookCount, domains } = repoData; +const GITHUB = 'https://github.com/OWASP/openshield'; + +const SEV_TEXT: Record = { HIGH: '#a03a1e', MEDIUM: '#7a5310', LOW: '#3d5a75' }; +const SEV_DOT: Record = { HIGH: '#c73a17', MEDIUM: '#a06f10', LOW: '#5d6c80' }; +const SEV_LABEL: Record = { HIGH: 'High', MEDIUM: 'Medium', LOW: 'Low' }; + +/* stable pastel per domain so one domain reads as one color */ +const domainSwatch: Record = {}; +domainOrder.forEach((d, i) => { + domainSwatch[d] = `r${(i % 6) + 1}`; +}); + +const labelOf: Record = {}; +domains.forEach((d) => { + labelOf[d.key] = d.label; +}); +--- + + +
+
+

The rule book.

+

+ All {ruleCount} misconfiguration rules across {domainCount} Azure domains, with {playbookCount} remediation playbooks. + Every rule is a plain Python file in scanner/rules/, + extracted here at build time. +

+
+ +
+
+ + + + + + + +
+
SHOWING {ruleCount} OF {ruleCount} RULES
+ +
+ {rules.map((rule) => { + const hay = `${rule.id} ${rule.name} ${rule.category} ${Object.entries(rule.frameworks).map(([k, v]) => `${k} ${v}`).join(' ')}`.toLowerCase(); + return ( +
+
+ + {SEV_LABEL[rule.severity]} + + {rule.id} +
+
+

{rule.name}

+
+ {(labelOf[rule.domain] ?? rule.domain).toLowerCase()} + {rule.frameworks.CIS && CIS {rule.frameworks.CIS}} + {rule.frameworks.NIST && NIST {rule.frameworks.NIST}} +
+
+
+ Details + + {rule.description &&

{rule.description}

} + {rule.remediation &&

Fix: {rule.remediation}

} +
+ {Object.entries(rule.frameworks).map(([k, v]) => {k}{v ? ` ${v}` : ''})} +
+ {rule.playbook && ( + + {rule.playbook.split('/').pop()} + + )} +
+
+ ); + })} +
+ +
+
+ + + diff --git a/website/src/styles/global.css b/website/src/styles/global.css new file mode 100644 index 00000000..1a5285da --- /dev/null +++ b/website/src/styles/global.css @@ -0,0 +1,481 @@ + +/* Single committed world, superlinked-style: warm paper base with navy + sections mixed in proportionally. No theme toggle, no second palette. */ +:root{ + --bg:#f2f0ec; --surface:#ffffff; --surface-2:#faf9f6; + --ink:#050505; --dim:#636366; --hairline:#d3d2d2; --hairline-strong:#b0b0b0; + --accent:#f3441d; --accent-hi:#ff5a33; + --blush:#fde4dd; --peach:#feccbe; --sand:#e6d7bf; --mist:#d7e0e7; --sage:#d9e0d6; --sky:#d9e3ec; + --chip-ink:#050505; + --sev-hi:#c73a17; --sev-med:#a06f10; --sev-lo:#5d6c80; + --navy:#1f1f2c; --navy-2:#24242f; --navy-3:#282831; + --navy-ink:#f2f0ec; --navy-dim:#a7a7b0; --navy-hairline:#34343f; + --code-bg:#15151f; --code-ink:#e8e6e1; --code-dim:#8e8e93; + --lift:0 10px 28px -14px rgba(20,20,30,.22); +} +*{box-sizing:border-box;margin:0;padding:0} +html{scroll-behavior:smooth} +html{scroll-padding-top:72px} +body{background:var(--bg);color:var(--ink);font-family:'Schibsted Grotesk Variable','Schibsted Grotesk',system-ui,sans-serif;font-size:16px;line-height:1.6;-webkit-font-smoothing:antialiased} +.mono{font-family:'DM Mono',ui-monospace,monospace} +a{color:inherit;text-decoration:none} +:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:6px} +.skip-link{position:fixed;top:8px;left:12px;z-index:100;background:var(--accent);color:#fff;padding:10px 16px;border-radius:8px;transform:translateY(-160%);font-weight:700} +.skip-link:focus{transform:translateY(0)} +.wrap{max-width:1320px;margin:0 auto;padding:0 24px} +section{padding:52px 0;scroll-margin-top:70px} +.sec-rule{border-top:1px solid var(--hairline)} +h1,h2,h3{font-weight:700;letter-spacing:-.03em;text-wrap:balance} +h2{font-size:31px;line-height:1.14} +h3{font-size:19px;line-height:1.3} +.sec-head{display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin-bottom:22px;flex-wrap:wrap} +.sec-link{font-family:'DM Mono',monospace;font-size:13px;color:var(--accent);white-space:nowrap} +.sec-link:hover{text-decoration:underline;text-underline-offset:3px} +.kicker{font-family:'DM Mono',monospace;font-size:12px;letter-spacing:.08em;color:var(--dim);text-transform:uppercase;margin-bottom:12px} +@media (prefers-reduced-motion: reduce){ + html{scroll-behavior:auto} + *,*::before,*::after{transition:none!important;animation:none!important} + .term.anim .ln{opacity:1} +} + +/* nav */ +.nav{border-bottom:1px solid var(--hairline);background:var(--bg);position:sticky;top:0;z-index:50} +.nav .wrap{display:flex;align-items:center;gap:26px;height:56px} +.wordmark{display:flex;align-items:center;flex:none} +.wordmark img{height:22px;width:auto;display:block} +.navlinks{display:flex;gap:22px;margin-left:auto;padding-left:22px;border-left:1px solid var(--hairline);overflow-x:auto;scrollbar-width:none} +.navlinks::-webkit-scrollbar{display:none} +.navlinks a{font-size:14px;font-weight:500;color:var(--dim);white-space:nowrap;padding:4px 0;border-bottom:2px solid transparent} +.navlinks a:hover{color:var(--ink)} +.navlinks a[aria-current]{color:var(--ink);border-color:var(--accent)} +.nav-right{margin-left:0;display:flex;align-items:center;gap:10px;flex:none} +.nav-toggle{display:none;border:1px solid var(--hairline-strong);background:var(--surface);color:var(--ink);border-radius:99px;min-height:44px;padding:0 15px;font:600 14px inherit;align-items:center;gap:9px} +.star-btn{font-family:'DM Mono',monospace;font-size:12.5px;border:1px solid var(--hairline-strong);border-radius:99px;padding:6px 14px;color:var(--ink);background:var(--surface);transition:border-color .18s,transform .18s} +.star-btn:hover{border-color:var(--ink);transform:translateY(-1px)} +@media(max-width:780px){ + .nav .wrap{height:60px;gap:12px;position:relative} + .nav-toggle{display:inline-flex;margin-left:auto} + .nav-right{display:none} + .navlinks{display:none;position:absolute;top:60px;left:16px;right:16px;margin:0;padding:10px;background:var(--surface);border:1px solid var(--hairline);border-radius:14px;box-shadow:var(--lift);overflow:visible;flex-direction:column;gap:2px} + .navlinks.open{display:flex} + .navlinks a{padding:10px 12px;border:0;border-radius:8px;min-height:44px} + .navlinks a[aria-current]{background:var(--blush);color:var(--ink)} +} + +/* buttons */ +.btn{display:inline-flex;align-items:center;gap:8px;border-radius:99px;padding:11px 22px;font-size:14.5px;font-weight:600;letter-spacing:-.01em;border:1px solid transparent;cursor:pointer;transition:transform .18s,opacity .18s,background .18s,border-color .18s} +.btn:hover{transform:translateY(-1px)} +.btn:active{transform:translateY(0)} +.btn .ar{transition:transform .18s} +.btn:hover .ar{transform:translateX(3px)} +.btn-dark{background:var(--ink);color:var(--bg)} +.btn-dark:hover{opacity:.85} +.btn-line{border-color:var(--hairline-strong);color:var(--ink);background:transparent} +.btn-line:hover{border-color:var(--ink)} +.btn-accent{background:var(--accent);color:#fff} +.btn-accent:hover{background:var(--accent-hi)} +.btn-ghostline{border-color:rgba(242,240,236,.35);color:var(--navy-ink);background:transparent} +.btn-ghostline:hover{border-color:var(--navy-ink)} + +/* hero: full navy band, differentiated from the paper page */ +.hero{background:var(--navy);color:var(--navy-ink);padding:38px 0 42px;position:relative;overflow:hidden;border-top:1px solid var(--navy-hairline)} +.hero::before{content:"";position:absolute;top:-260px;right:-180px;width:720px;height:720px;border-radius:50%;background:radial-gradient(circle,rgba(243,68,29,.22),transparent 62%);pointer-events:none} +.hero::after{content:"";position:absolute;bottom:-320px;left:-160px;width:640px;height:640px;border-radius:50%;background:radial-gradient(circle,rgba(134,161,188,.12),transparent 60%);pointer-events:none} +.hero .wrap{display:grid;grid-template-columns:0.98fr 1.02fr;gap:44px;align-items:center;position:relative} +.hero .kicker{color:var(--navy-dim)} +.hero h1{font-size:46px;line-height:1.05;font-weight:800;max-width:15ch;color:var(--navy-ink)} +.hero h1 .alt{color:var(--navy-dim)} +.claims{margin-top:22px;display:flex;flex-direction:column;gap:9px;max-width:640px} +.claim{display:flex;gap:14px;align-items:baseline;font-size:16px;color:rgba(242,240,236,.88)} +.claim .ar{color:var(--accent-hi);font-family:'DM Mono',monospace;flex:none} +.hero-cta{margin-top:26px;display:flex;gap:12px;flex-wrap:wrap} +.hero-panel{border:1px solid rgba(242,240,236,.14);border-radius:16px;background:rgba(255,255,255,.03);overflow:hidden} +#heroCanvas{display:block;width:100%;height:330px;cursor:grab;touch-action:pan-y} +#heroCanvas:active{cursor:grabbing} +.hero-cap{display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;padding:10px 16px;border-top:1px solid rgba(242,240,236,.12);font-family:'DM Mono',monospace;font-size:10.5px;letter-spacing:.06em;color:var(--navy-dim)} +.hero-cap b{color:var(--navy-ink);font-weight:400} +.hero-cap em{color:var(--accent-hi);font-style:normal} +.orb-controls{display:flex;gap:8px;padding:10px 16px;border-top:1px solid rgba(242,240,236,.12)} +.orb-controls button{min-height:44px;border:1px solid rgba(242,240,236,.25);border-radius:99px;background:transparent;color:var(--navy-ink);padding:8px 13px;font:400 11px 'DM Mono',monospace;cursor:pointer} +.orb-controls button:hover{border-color:var(--accent-hi);color:var(--accent-hi)} +.hero-legend{display:flex;flex-wrap:wrap;gap:6px 12px;padding:9px 16px;border-top:1px solid rgba(242,240,236,.12);font-family:'DM Mono',monospace;font-size:10px;letter-spacing:.05em;color:var(--navy-dim)} +.hero-legend .lg{display:inline-flex;align-items:center;gap:5px} +.hero-legend .lg i{width:7px;height:7px;border-radius:2px;display:inline-block} +.hero-fallback{display:none;height:330px;align-items:center;justify-content:center;text-align:center;padding:24px;color:var(--navy-dim);font-family:'DM Mono',monospace;font-size:12px} +@media(max-width:960px){.hero .wrap{grid-template-columns:1fr}.hero h1{font-size:38px}} +@media(max-width:520px){#heroCanvas,.hero-fallback{height:250px}.hero{padding:30px 0 34px}.hero h1{font-size:32px}.orb-controls{display:grid;grid-template-columns:1fr 1fr}.orb-controls button:nth-child(2){grid-column:1/-1;grid-row:2}} + +/* trust strip */ +.trust{border-bottom:1px solid var(--hairline);padding:13px 0;background:var(--surface)} +.trust .wrap{display:flex;gap:16px;flex-wrap:wrap;align-items:center;justify-content:space-between} +.trust span{font-family:'DM Mono',monospace;font-size:12px;letter-spacing:.05em;color:var(--dim)} +.trust b{color:var(--ink);font-weight:400} +.trust .hot b{color:var(--accent)} + +/* metrics */ +.metric-grid{display:grid;grid-template-columns:repeat(4,1fr);border:1px solid var(--hairline);border-radius:14px;overflow:hidden;background:var(--surface)} +.metric{padding:20px 22px;border-right:1px solid var(--hairline);transition:background .18s} +.metric:hover{background:var(--surface-2)} +.metric:last-child{border-right:none} +.metric .num{font-size:38px;font-weight:800;letter-spacing:-.04em;line-height:1;font-variant-numeric:tabular-nums} +.metric .num .unit{font-size:17px;color:var(--dim);font-weight:600} +.metric .lbl{font-family:'DM Mono',monospace;font-size:11.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--accent);margin-top:9px} +.metric p{font-size:13px;color:var(--dim);margin-top:5px;line-height:1.5} +.metric .delta{display:inline-block;font-family:'DM Mono',monospace;font-size:10.5px;color:var(--chip-ink);background:var(--sage);border-radius:99px;padding:2px 9px;margin-top:9px} +.badges{margin-top:16px;display:flex;gap:10px;flex-wrap:wrap} +.badge{font-family:'DM Mono',monospace;font-size:11.5px;border-radius:99px;padding:4px 12px;color:var(--chip-ink)} +.b1{background:var(--blush)} .b2{background:var(--sand)} .b3{background:var(--mist)} .b4{background:var(--sage)} +@media(max-width:880px){.metric-grid{grid-template-columns:1fr 1fr}.metric:nth-child(2){border-right:none}.metric:nth-child(-n+2){border-bottom:1px solid var(--hairline)}} +@media(max-width:520px){.metric-grid{grid-template-columns:1fr}.metric{border-right:none;border-bottom:1px solid var(--hairline)}.metric:last-child{border-bottom:none}} + +/* demo */ +.demo-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:34px;align-items:start} +.tabs{display:flex;gap:4px;border-bottom:1px solid var(--hairline);margin-bottom:18px} +.tab{font-family:'DM Mono',monospace;font-size:13px;padding:7px 14px;color:var(--dim);border-bottom:2px solid transparent;margin-bottom:-1px} +.tab.on{color:var(--ink);border-color:var(--accent)} +.term-card{border-radius:14px;overflow:hidden;border:1px solid var(--navy-hairline);box-shadow:var(--lift)} +.term-bar{display:flex;align-items:center;gap:7px;background:var(--navy-3);padding:9px 14px} +.term-bar i{width:9px;height:9px;border-radius:50%;background:var(--navy-hairline)} +.term-bar i:first-child{background:var(--accent)} +.term-bar .title{margin-left:8px;font-family:'DM Mono',monospace;font-size:11px;color:var(--navy-dim);letter-spacing:.05em} +.term-bar .lang{margin-left:auto;font-family:'DM Mono',monospace;font-size:10px;color:var(--navy-dim)} +.term{background:var(--code-bg);padding:18px;font-family:'DM Mono',monospace;font-size:12px;line-height:1.65;color:var(--code-ink);overflow-x:auto} +.term .dim{color:var(--code-dim)} +.term .acc{color:#ff8a6b} +.term .grn{color:#9ec795} +.term .amb{color:#e0b356} +.term .path{color:#9db8f0} +.steps{display:flex;flex-direction:column} +.step{display:flex;gap:16px;padding:14px 0;border-bottom:1px solid var(--hairline)} +.step:last-child{border-bottom:none} +.step .no{font-family:'DM Mono',monospace;font-size:13px;color:var(--accent);flex:none;padding-top:2px} +.step h3{font-size:16px;margin-bottom:2px} +.step p{font-size:14px;color:var(--dim);line-height:1.5} +.demo-note{margin-top:16px;padding:13px 16px;border-left:3px solid var(--accent);background:var(--surface);color:var(--dim);font-size:14px} +.demo-note strong{color:var(--ink)} +@media(max-width:900px){.demo-grid{grid-template-columns:1fr}} + +/* rules */ +.rule-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px} +.rcard{border:1px solid var(--hairline);border-radius:14px;background:var(--surface);overflow:hidden;display:flex;flex-direction:column;transition:transform .18s,border-color .18s,box-shadow .18s} +.rcard:hover{transform:translateY(-3px);border-color:var(--hairline-strong);box-shadow:var(--lift)} +.rcard .swatch{padding:14px 18px;color:var(--chip-ink)} +.rcard .rid{font-family:'DM Mono',monospace;font-size:13px;display:block} +.rcard .sev{float:right;font-family:'DM Mono',monospace;font-size:11px;letter-spacing:.06em;text-transform:uppercase;display:flex;align-items:center;gap:6px} +.rcard .sev i{width:7px;height:7px;border-radius:50%;display:inline-block} +.r1{background:var(--blush)} .r2{background:var(--sand)} .r3{background:var(--mist)} .r4{background:var(--sage)} .r5{background:var(--peach)} .r6{background:var(--sky)} +.rcard .body{padding:15px 18px 16px;display:flex;flex-direction:column;gap:8px;flex:1} +.rcard .body h3{font-size:15.5px;letter-spacing:-.01em} +.rcard .meta{font-family:'DM Mono',monospace;font-size:11px;color:var(--dim);margin-top:auto;display:flex;gap:14px;flex-wrap:wrap} +@media(max-width:960px){.rule-grid{grid-template-columns:1fr 1fr}} +@media(max-width:600px){.rule-grid{grid-template-columns:1fr}} + +/* run options */ +.run-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px} +.run{border:1px solid var(--hairline);border-radius:14px;background:var(--surface);padding:20px 22px 18px;display:flex;flex-direction:column;gap:9px;transition:transform .18s,border-color .18s,box-shadow .18s} +.run:hover{transform:translateY(-3px);border-color:var(--hairline-strong);box-shadow:var(--lift)} +.run .tag{font-family:'DM Mono',monospace;font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--accent)} +.run p{font-size:14px;color:var(--dim);flex:1} +.run a.go{font-family:'DM Mono',monospace;font-size:13px;color:var(--ink)} +.run a.go .ar{color:var(--accent);transition:transform .18s;display:inline-block} +.run a.go:hover .ar{transform:translateX(3px)} +@media(max-width:880px){.run-grid{grid-template-columns:1fr}} + +/* architecture */ +.pipe-wrap{overflow-x:auto;margin-bottom:26px} +.pipe{display:flex;align-items:stretch;width:100%;min-width:900px} +.pnode{border:1px solid var(--hairline-strong);border-radius:12px;background:var(--surface);padding:13px 18px;flex:1;min-width:0;text-align:center;transition:transform .18s,border-color .18s} +.pnode:hover{transform:translateY(-2px);border-color:var(--ink)} +.pnode .t{font-weight:700;font-size:14.5px;letter-spacing:-.01em} +.pnode .s{font-family:'DM Mono',monospace;font-size:10.5px;color:var(--dim);margin-top:2px;letter-spacing:.04em} +.parrow{align-self:center;flex:none;font-family:'DM Mono',monospace;color:var(--accent);padding:0 10px} +.matrix-wrap{overflow-x:auto;border:1px solid var(--hairline);border-radius:14px;background:var(--surface)} +table.matrix{width:100%;border-collapse:collapse;min-width:640px} +.matrix th,.matrix td{padding:11px 18px;text-align:left;border-bottom:1px solid var(--hairline);font-size:14px} +.matrix tbody tr{transition:background .15s} +.matrix tbody tr:hover{background:var(--surface-2)} +.matrix thead th{font-family:'DM Mono',monospace;font-size:11.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--dim)} +.matrix thead th.us{color:var(--accent)} +.matrix tbody tr:last-child td{border-bottom:none} +.matrix td.yes{color:#4c6147;font-family:'DM Mono',monospace} +.matrix td.no{color:var(--dim);font-family:'DM Mono',monospace} +.matrix td.part{color:var(--sev-med);font-family:'DM Mono',monospace} + +/* interactive architecture page */ +.architecture-hero{background:var(--navy);color:var(--navy-ink);padding:34px 0 38px} +.architecture-hero h1{font-size:42px;line-height:1.08;max-width:18ch} +.architecture-hero p{color:var(--navy-dim);max-width:66ch;margin-top:12px} +.architecture-stage{padding-top:34px} +.architecture-map{display:flex;align-items:center;width:100%;overflow-x:auto;padding:6px 2px 18px;scrollbar-width:thin} +.architecture-node{flex:1 1 0;min-width:150px;min-height:86px;display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:5px;text-align:left;border:1px solid var(--hairline-strong);border-radius:12px;background:var(--surface);padding:13px 15px;color:var(--ink);cursor:pointer;transition:border-color .18s,background .18s,transform .18s} +.architecture-node span{font:11px 'DM Mono',monospace;color:var(--accent)} +.architecture-node strong{font-size:14px;line-height:1.25} +.architecture-node:hover{border-color:var(--ink);transform:translateY(-2px)} +.architecture-node.active{background:var(--navy);border-color:var(--navy);color:var(--navy-ink)} +.architecture-edge{flex:0 1 34px;min-width:24px;text-align:center;color:var(--accent);font-family:'DM Mono',monospace} +.architecture-panels{border:1px solid var(--hairline);border-radius:16px;background:var(--surface);overflow:hidden} +.architecture-panel{min-height:220px;grid-template-columns:minmax(0,1fr) auto;gap:34px;align-items:end;padding:28px 30px;background:linear-gradient(120deg,var(--surface) 65%,var(--surface-2))} +.architecture-panel:not([hidden]){display:grid} +.architecture-panel h2{font-size:28px;max-width:26ch} +.architecture-panel p{color:var(--dim);max-width:68ch;margin-top:12px} +.architecture-panel>a{font:12px 'DM Mono',monospace;color:var(--accent);white-space:nowrap} +.boundary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:20px} +.boundary-grid article{border-top:3px solid var(--hairline-strong);background:var(--surface);padding:18px;border-radius:0 0 12px 12px} +.boundary-grid article:nth-child(2){border-color:var(--accent)} +.boundary-grid span{font:11px 'DM Mono',monospace;color:var(--dim)} +.boundary-grid h3{font-size:16px;margin-top:5px} +.boundary-grid p{font-size:13.5px;color:var(--dim);margin-top:5px} +.architecture-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:22px} +@media(max-width:760px){.architecture-hero h1{font-size:34px}.architecture-panel:not([hidden]){display:block}.architecture-panel>a{display:inline-block;margin-top:20px}.boundary-grid{grid-template-columns:1fr}} + +/* evidence page */ +.evidence-head .sub{max-width:72ch} +.evidence-section{border-top:1px solid var(--hairline)} +.coverage-chart{display:grid;gap:10px;border:1px solid var(--hairline);border-radius:16px;background:var(--surface);padding:20px} +.coverage-row{display:grid;grid-template-columns:170px minmax(80px,1fr) 36px;gap:14px;align-items:center} +.coverage-row>span{font-size:14px} +.coverage-row>strong{font:12px 'DM Mono',monospace;text-align:right} +.coverage-track{height:12px;border-radius:99px;background:var(--surface-2);overflow:hidden;border:1px solid var(--hairline)} +.coverage-track i{display:block;height:100%;min-width:4px;background:var(--accent);border-radius:inherit} +.chart-note{font:11.5px 'DM Mono',monospace;color:var(--dim);margin-top:12px} +.release-strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px} +.release-strip a{display:flex;justify-content:space-between;align-items:center;gap:12px;border:1px solid var(--hairline);border-radius:12px;background:var(--surface);padding:15px} +.release-strip a:hover{border-color:var(--accent)} +.release-strip strong{font:500 13px 'DM Mono',monospace;color:var(--accent)} +.release-strip span{font-size:12px;color:var(--dim)} +.assurance-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px} +.assurance-grid a{display:flex;flex-direction:column;border:1px solid var(--hairline);border-radius:12px;background:var(--surface);padding:17px;min-height:130px} +.assurance-grid a:hover{border-color:var(--ink)} +.assurance-grid span{font:10.5px 'DM Mono',monospace;color:#4c6147} +.assurance-grid strong{font-size:15px;margin-top:8px} +.assurance-grid small{font:10.5px/1.45 'DM Mono',monospace;color:var(--dim);margin-top:auto;padding-top:12px;overflow-wrap:anywhere} +.evidence-split{display:grid;grid-template-columns:1fr 1fr;gap:42px} +.service-status{display:flex;gap:14px;margin-top:18px;border:1px solid var(--hairline);border-radius:14px;background:var(--surface);padding:18px} +.service-status i{width:10px;height:10px;border-radius:50%;background:var(--sev-hi);margin-top:7px;flex:none} +.service-status p{font-size:14px;color:var(--dim);margin-top:5px} +.limitation-list{margin:18px 0 0 20px;color:var(--dim);font-size:14px} +.limitation-list li{margin-bottom:8px;padding-left:3px} +.roadmap-board{display:grid;grid-template-columns:repeat(2,1fr);gap:12px} +.roadmap-board article{border:1px solid var(--hairline);border-radius:14px;background:var(--surface);padding:19px} +.roadmap-board h3{font-size:16px;color:var(--accent)} +.roadmap-board ul{margin:12px 0 0 18px;color:var(--dim);font-size:13.5px} +.roadmap-board li{margin-bottom:7px} +@media(max-width:820px){.assurance-grid{grid-template-columns:1fr 1fr}.evidence-split{grid-template-columns:1fr}.roadmap-board{grid-template-columns:1fr}} +@media(max-width:560px){.coverage-row{grid-template-columns:1fr 34px}.coverage-track{grid-column:1/-1;grid-row:2}.assurance-grid{grid-template-columns:1fr}} + +/* blog */ +.blog-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px} +.post{border:1px solid var(--hairline);border-radius:14px;background:var(--surface);overflow:hidden;display:flex;flex-direction:column;transition:transform .18s,border-color .18s,box-shadow .18s} +.post:hover{transform:translateY(-3px);border-color:var(--hairline-strong);box-shadow:var(--lift)} +.post .swatch{height:92px;position:relative} +.post .ghost{position:absolute;top:10px;right:14px;font-family:'DM Mono',monospace;font-size:11px;letter-spacing:.08em;color:var(--chip-ink);opacity:.5} +.post .swatch .tag{position:absolute;left:16px;bottom:10px;font-family:'DM Mono',monospace;font-size:11px;letter-spacing:.06em;text-transform:uppercase;background:var(--surface);color:var(--ink);border-radius:99px;padding:3px 11px} +.post .body{padding:16px 18px 18px;display:flex;flex-direction:column;gap:9px;flex:1} +.post h3{font-size:17px;letter-spacing:-.015em;line-height:1.3} +.post h3 a:hover{color:var(--accent)} +.post p{font-size:13.5px;color:var(--dim);flex:1} +.post .date{font-family:'DM Mono',monospace;font-size:11px;color:var(--dim)} +@media(max-width:960px){.blog-grid{grid-template-columns:1fr}} + +/* cta: navy bookend matching the hero */ +.cta{background:var(--navy);color:var(--navy-ink);text-align:left;padding:20px 0;position:relative;overflow:hidden;border-top:1px solid var(--navy-hairline)} +.cta::before{content:"";position:absolute;top:-190px;left:-120px;width:480px;height:480px;border-radius:50%;background:radial-gradient(circle,rgba(243,68,29,.18),transparent 62%);pointer-events:none} +.cta .wrap{position:relative;display:flex;align-items:center;justify-content:space-between;gap:18px;flex-wrap:wrap} +.cta .kicker{display:none} +.cta h2{font-size:21px;max-width:none;color:var(--navy-ink)} +.cta .hero-cta{margin-top:0;flex:none} +@media(max-width:720px){.cta h2{font-size:19px}} + +/* footer */ +footer{background:var(--navy);color:var(--navy-ink);padding:40px 0 26px;border-top:1px solid var(--navy-hairline)} +.f-grid{display:grid;grid-template-columns:1.4fr 1fr 1fr 1.2fr;gap:30px} +.f-logo{height:26px;width:auto;display:block} +.f-tag{color:var(--navy-dim);font-size:13.5px;margin-top:12px;max-width:34ch} +.f-col h4{font-family:'DM Mono',monospace;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--navy-dim);margin-bottom:11px;font-weight:400} +.f-col a{display:block;font-size:14px;color:var(--navy-ink);opacity:.85;margin-bottom:8px;transition:opacity .15s,color .15s} +.f-col a:hover{opacity:1;color:var(--accent-hi)} +footer .sub{display:flex;margin-top:10px;border:1px solid var(--navy-hairline);border-radius:99px;overflow:hidden;background:var(--navy-2)} +.sub-links a{flex:1;display:flex;align-items:center;justify-content:center;gap:6px;padding:10px 14px;color:var(--navy-ink);font-size:13px;text-decoration:none;transition:background .18s} +.sub-links a:hover{background:var(--navy-3)} +.sub-links a+a{background:var(--accent);color:#fff;font-family:'DM Mono',monospace;font-size:12px;letter-spacing:.04em} +.sub-links a+a:hover{background:var(--accent-hi)} +.f-bottom{margin-top:28px;padding-top:16px;border-top:1px solid var(--navy-hairline);display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;font-family:'DM Mono',monospace;font-size:11px;color:var(--navy-dim)} +@media(max-width:880px){.f-grid{grid-template-columns:1fr 1fr}} + +/* immersive layer */ +.nav{transition:box-shadow .2s} +.nav.scrolled{box-shadow:0 8px 20px -14px rgba(20,20,30,.3)} +@keyframes rise{from{opacity:0;transform:translateY(16px)}} +.hero-copy>*{animation:rise .55s cubic-bezier(.2,.7,.2,1) both} +.hero-copy>*:nth-child(2){animation-delay:.07s} +.hero-copy>*:nth-child(3){animation-delay:.14s} +.hero-copy>*:nth-child(4){animation-delay:.21s} +.hero-panel{animation:rise .6s .24s cubic-bezier(.2,.7,.2,1) both} +.hero::before,.hero::after{transform:translate(calc(var(--px,0)*16px),calc(var(--py,0)*12px));transition:transform .35s ease-out} +.metric .num{transition:color .2s} +.metric:hover .num{color:var(--accent)} +.term .ln{display:block;min-height:1.6em} +.term.anim .ln{opacity:0;animation:lnIn .3s forwards;animation-delay:calc(var(--i)*.17s)} +@keyframes lnIn{to{opacity:1}} + +/* story */ +.story-h{font-size:30px;line-height:1.15;max-width:none;font-weight:800} +.story-p{color:var(--dim);max-width:none;margin-top:16px;font-size:15.5px} +.story-quote{border-left:2px solid var(--accent);padding-left:16px;margin-top:28px;font-size:16.5px;font-weight:600;line-height:1.5;letter-spacing:-.01em} +.story-proof{margin-top:16px;font-family:'DM Mono',monospace;font-size:11px;letter-spacing:.06em;color:var(--dim)} +.story-grid{display:grid;grid-template-columns:1.04fr .96fr;gap:44px;align-items:center} +.story-cards{display:flex;flex-direction:column;gap:12px} +.story-card{border:1px solid var(--hairline);border-top:3px solid var(--hairline);border-radius:14px;padding:16px 18px;background:var(--surface);display:flex;flex-direction:column;gap:6px;transition:transform .18s,box-shadow .18s} +.story-card:hover{transform:translateY(-3px);box-shadow:var(--lift)} +.story-card .tag{font-family:'DM Mono',monospace;font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--accent)} +.story-card p{font-size:14px;color:var(--dim);line-height:1.5} +.sc-gap{border-top-color:var(--peach)} +.sc-threat{border-top-color:var(--blush)} +.sc-answer{border-top-color:var(--sage)} +#why{padding-bottom:18px} +#metrics{padding-top:26px} +@media(max-width:880px){.story-grid{grid-template-columns:1fr;gap:24px}.story-h{font-size:26px}} +/* journey */ +.journey{display:grid;grid-template-columns:repeat(auto-fit,minmax(215px,1fr));gap:12px} +.jcard{border:1px solid var(--hairline);border-radius:14px;background:var(--surface);padding:18px;display:flex;flex-direction:column;gap:7px;transition:transform .18s,border-color .18s,box-shadow .18s} +.jcard:hover{transform:translateY(-3px);border-color:var(--hairline-strong);box-shadow:var(--lift)} +.jcard .jno{font-family:'DM Mono',monospace;font-size:12px;color:var(--accent)} +.jcard h3{font-size:15.5px;letter-spacing:-.01em} +.jcard p{font-size:13px;color:var(--dim);line-height:1.45;flex:1} +.jcard .jlink{font-family:'DM Mono',monospace;font-size:12px;color:var(--ink)} +.jcard .jlink .ar{color:var(--accent);display:inline-block;transition:transform .18s} +.jcard:hover .jlink .ar{transform:translateX(3px)} +/* release timeline */ +.tl-wrap{overflow-x:auto;margin-bottom:18px} +.tl{display:flex;gap:12px;min-width:900px} +.tl-item{flex:1;border:1px solid var(--hairline);border-radius:12px;background:var(--surface);padding:13px 16px} +.tl-item .tl-q{font-family:'DM Mono',monospace;font-size:10.5px;letter-spacing:.07em;color:var(--dim)} +.tl-item b{display:block;font-size:15px;margin:4px 0 3px;letter-spacing:-.01em} +.tl-item p{font-size:12.5px;color:var(--dim);line-height:1.45} +.tl-item.now{border-color:var(--accent);background:var(--blush)} +.tl-item.now .tl-q{color:var(--accent)} +.tl-item.next{border-style:dashed;border-color:var(--hairline-strong)} + +/* subscribe hint line (footer) */ +.sub-status{margin-top:9px;font-size:10.5px;letter-spacing:.05em;color:var(--navy-dim);min-height:1.2em} + +/* rules page: toolbar + full rule book */ +.rules-toolbar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:20px} +.rules-toolbar input[type="search"]{flex:1;min-width:220px;border:1px solid var(--hairline-strong);border-radius:99px;background:var(--surface);padding:9px 18px;font-family:inherit;font-size:14px;color:var(--ink)} +.rules-toolbar input[type="search"]::placeholder{color:var(--dim)} +.rules-toolbar select{border:1px solid var(--hairline-strong);border-radius:99px;background:var(--surface);padding:9px 14px;font-family:'DM Mono',monospace;font-size:12.5px;color:var(--ink);cursor:pointer} +.sevbtn{font-family:'DM Mono',monospace;font-size:12px;border:1px solid var(--hairline-strong);border-radius:99px;background:var(--surface);color:var(--dim);padding:8px 15px;cursor:pointer;transition:border-color .15s,color .15s,background .15s} +.sevbtn:hover{border-color:var(--ink);color:var(--ink)} +.sevbtn[aria-pressed="true"]{background:var(--ink);border-color:var(--ink);color:var(--bg)} +.clearbtn{min-height:40px;border:0;background:transparent;color:var(--accent);padding:6px 10px;font:500 13px 'DM Mono',monospace;cursor:pointer} +.clearbtn:hover{text-decoration:underline;text-underline-offset:3px} +.rules-count{font-family:'DM Mono',monospace;font-size:12px;color:var(--dim);margin-bottom:14px;letter-spacing:.05em} +.rules-empty{text-align:center;border:1px dashed var(--hairline-strong);border-radius:16px;background:var(--surface);padding:54px 20px} +.rules-empty p{color:var(--dim);margin:8px 0 20px} +.rcard details{padding:0 18px 16px;display:flex;flex-direction:column;gap:8px} +.rcard summary{font-family:'DM Mono',monospace;font-size:12px;color:var(--accent);cursor:pointer;list-style:none;padding:0 18px 4px} +.rcard summary::-webkit-details-marker{display:none} +.rcard summary:hover{text-decoration:underline;text-underline-offset:3px} +.rcard details p{font-size:13px;color:var(--dim);line-height:1.5} +.rcard details .fw{display:flex;gap:6px;flex-wrap:wrap} +.rcard details .fw span{font-family:'DM Mono',monospace;font-size:10.5px;border:1px solid var(--hairline);border-radius:99px;padding:2px 9px;color:var(--dim)} +.rcard details a.pb{font-family:'DM Mono',monospace;font-size:12px;color:var(--ink)} +.rcard details a.pb .ar{color:var(--accent);display:inline-block;transition:transform .18s} +.rcard details a.pb:hover .ar{transform:translateX(3px)} + +/* docs index */ +.page-step{display:block;font-family:'DM Mono',monospace;font-size:12px;letter-spacing:.06em;color:var(--accent);margin-bottom:8px} +.quickstart{padding-top:26px} +.quickstart-intro{display:grid;grid-template-columns:1fr 1fr;gap:36px;align-items:end;margin-bottom:22px} +.quickstart-intro p{color:var(--dim);max-width:60ch} +.quickstart-steps{list-style:none;border:1px solid var(--hairline);border-radius:16px;background:var(--surface);overflow:hidden} +.quickstart-steps li{display:grid;grid-template-columns:60px minmax(0,1fr);gap:16px;padding:22px;border-bottom:1px solid var(--hairline)} +.quickstart-steps li:last-child{border-bottom:0} +.qs-number{font-family:'DM Mono',monospace;color:var(--accent);font-size:13px;padding-top:3px} +.quickstart-steps h3{margin-bottom:10px} +.quickstart-steps p{color:var(--dim);font-size:14px;margin-top:10px} +.quickstart pre{background:var(--code-bg);color:var(--code-ink);padding:15px 17px;border-radius:10px;overflow-x:auto;font:12.5px/1.7 'DM Mono',monospace} +.quickstart-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:18px} +.evidence-note{display:grid;grid-template-columns:180px 1fr;gap:18px;margin-top:24px;padding:18px 20px;background:var(--sage);border-radius:14px;font-size:14px} +.evidence-note span{color:#465044} +.docs-library{border-top:1px solid var(--hairline);margin-top:14px} +.docs-filter{display:grid;grid-template-columns:auto minmax(220px,1fr) auto;align-items:center;gap:12px;margin-bottom:24px} +.docs-filter label{font-weight:700;font-size:14px} +.docs-filter input{width:100%;min-height:44px;border:1px solid var(--hairline-strong);border-radius:99px;background:var(--surface);padding:9px 16px;font:inherit;color:var(--ink)} +.docs-filter span{font:12px 'DM Mono',monospace;color:var(--dim)} +.docs-empty{padding:28px;border:1px dashed var(--hairline-strong);border-radius:14px;background:var(--surface);text-align:center;color:var(--dim)} +@media(max-width:720px){.quickstart-intro,.evidence-note{grid-template-columns:1fr}.quickstart-steps li{grid-template-columns:40px minmax(0,1fr);padding:18px 14px;gap:8px}} +@media(max-width:600px){.docs-filter{grid-template-columns:1fr}.docs-filter span{grid-row:1;justify-self:end}.docs-filter label{grid-row:1}.docs-filter input{grid-column:1/-1}} +.doc-group{margin-bottom:26px} +.doc-group h3{font-family:'DM Mono',monospace;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--accent);margin-bottom:10px;font-weight:400} +.doc-list{display:grid;grid-template-columns:repeat(3,1fr);gap:10px} +.doc-list a{border:1px solid var(--hairline);border-radius:12px;background:var(--surface);padding:13px 16px;font-size:14px;display:flex;justify-content:space-between;gap:10px;align-items:center;transition:transform .15s,border-color .15s,box-shadow .15s} +.doc-list a:hover{transform:translateY(-2px);border-color:var(--hairline-strong);box-shadow:var(--lift)} +.doc-list a .ar{color:var(--accent);font-family:'DM Mono',monospace;font-size:12px;flex:none} +@media(max-width:880px){.doc-list{grid-template-columns:1fr 1fr}} +@media(max-width:560px){.doc-list{grid-template-columns:1fr}} + +/* blog index + post pages */ +.page-head{padding:30px 0 4px} +.page-head h1{font-size:38px;line-height:1.08;font-weight:800;letter-spacing:-.03em} +.page-head .sub{color:var(--dim);margin-top:10px;font-size:15.5px;max-width:62ch} +.post-hero{background:var(--navy);color:var(--navy-ink);padding:34px 0 30px;position:relative;overflow:hidden;border-top:1px solid var(--navy-hairline)} +.post-hero::before{content:"";position:absolute;top:-240px;right:-160px;width:680px;height:680px;border-radius:50%;background:radial-gradient(circle,rgba(243,68,29,.2),transparent 62%);pointer-events:none} +.post-hero .wrap{position:relative} +.post-hero .ph-kicker{font-family:'DM Mono',monospace;font-size:12px;letter-spacing:.08em;color:var(--accent-hi);text-transform:uppercase} +.post-hero h1{font-size:42px;line-height:1.1;font-weight:800;letter-spacing:-.03em;max-width:24ch;margin-top:12px;color:var(--navy-ink)} +.post-hero .lede{margin-top:14px;color:var(--navy-dim);font-size:16.5px;max-width:64ch} +.post-hero .byline{font-family:'DM Mono',monospace;font-size:12px;color:var(--navy-dim);margin-top:16px;letter-spacing:.05em} +.post-hero .chips{margin-top:16px;display:flex;gap:8px;flex-wrap:wrap} +.post-hero .chip{font-family:'DM Mono',monospace;font-size:11px;letter-spacing:.06em;text-transform:uppercase;border:1px solid rgba(242,240,236,.28);border-radius:99px;padding:4px 12px} +@media(max-width:960px){.post-hero h1{font-size:32px}} +.post-layout{display:grid;grid-template-columns:minmax(0,1fr) 240px;gap:40px;align-items:start;padding:40px 0 8px} +.post-toc{position:sticky;top:76px;border-left:1px solid var(--hairline);padding-left:18px} +.post-toc h4{font-family:'DM Mono',monospace;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--dim);margin-bottom:10px;font-weight:400} +.post-toc a{display:block;font-size:13px;color:var(--dim);margin-bottom:7px;line-height:1.4} +.post-toc a:hover{color:var(--accent)} +.post-toc a.h3{padding-left:12px} +@media(max-width:1000px){.post-layout{grid-template-columns:1fr}.post-toc{display:none}} +.post-layout .prose{max-width:none;min-width:0} +.post-layout .prose>p,.post-layout .prose>ul,.post-layout .prose>ol,.post-layout .prose>blockquote{max-width:82ch} +.prose h2{font-size:23px;margin:30px 0 10px} +.prose h3{font-size:18px;margin:26px 0 8px} +.prose p{color:#3c3c40;font-size:16px;margin-bottom:16px} +.prose code{font-family:'DM Mono',monospace;font-size:13.5px;background:var(--surface);border:1px solid var(--hairline);border-radius:6px;padding:1px 6px} +.prose pre{background:var(--code-bg);color:var(--code-ink);border-radius:12px;padding:16px 18px;overflow-x:auto;margin:0 0 18px} +.prose pre code{background:none;border:none;padding:0;font-size:12.5px;line-height:1.7;color:inherit} +.prose a{color:var(--accent);text-decoration:underline;text-underline-offset:3px} +.prose ul,.prose ol{margin:0 0 16px 22px;color:#3c3c40} +.prose li{margin-bottom:6px} +.prose blockquote{border-left:2px solid var(--accent);padding-left:16px;margin:22px 0;font-size:17px;font-weight:600;line-height:1.5;letter-spacing:-.01em} +.prose img{display:block;width:100%;border:1px solid var(--hairline);border-radius:14px;background:var(--surface);margin:4px 0 8px} +.prose p>em:only-child{display:block;font-style:normal;font-family:'DM Mono',monospace;font-size:11.5px;letter-spacing:.05em;color:var(--dim);text-align:center;margin:0 0 22px} +.prose table{width:100%;border-collapse:collapse;border:1px solid var(--hairline);background:var(--surface);margin:0 0 18px} +.prose th,.prose td{padding:10px 14px;text-align:left;border-bottom:1px solid var(--hairline);font-size:14px} +.prose thead th{font-family:'DM Mono',monospace;font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--dim)} +.prose tbody tr:last-child td{border-bottom:none} +.post-nav{margin:34px 0 60px;padding-top:18px;border-top:1px solid var(--hairline);display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap} +.post-nav a{font-family:'DM Mono',monospace;font-size:13px;color:var(--ink)} +.post-nav a .ar{color:var(--accent)} +.post-nav a:hover{color:var(--accent)} + +/* community */ +.contrib-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px} +.contrib{display:flex;gap:12px;align-items:center;border:1px solid var(--hairline);border-radius:14px;background:var(--surface);padding:14px 16px;transition:transform .15s,border-color .15s,box-shadow .15s} +.contrib:hover{transform:translateY(-2px);border-color:var(--hairline-strong);box-shadow:var(--lift)} +.contrib .avatar{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-family:'DM Mono',monospace;font-size:14px;letter-spacing:.04em;color:var(--chip-ink);flex:none} +.contrib h3{font-size:15px;letter-spacing:-.01em} +.contrib-copy{min-width:0;flex:1} +.profile-link{font:10.5px 'DM Mono',monospace;color:var(--accent);white-space:nowrap} +.contrib .role{font-family:'DM Mono',monospace;font-size:10.5px;letter-spacing:.07em;text-transform:uppercase;color:var(--dim)} +.contrib-note{margin-top:18px;font-size:11.5px;color:var(--dim);letter-spacing:.05em} +.contrib-note a{color:var(--accent)} +.contrib-note a:hover{text-decoration:underline;text-underline-offset:3px} +@media(max-width:960px){.contrib-grid{grid-template-columns:1fr 1fr}} +@media(max-width:560px){.contrib-grid{grid-template-columns:1fr}} + +/* 404 */ +.notfound{padding:90px 0;text-align:center} +.notfound .code{font-size:84px;font-weight:800;letter-spacing:-.04em;line-height:1} +.notfound .code span{color:var(--accent)} +.notfound p{color:var(--dim);margin:14px 0 26px} diff --git a/website/styles.css b/website/styles.css deleted file mode 100644 index c2dc7c11..00000000 --- a/website/styles.css +++ /dev/null @@ -1,80 +0,0 @@ -/* 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/test_toEmbedUrl.mjs b/website/test_toEmbedUrl.mjs deleted file mode 100644 index 39332a15..00000000 --- a/website/test_toEmbedUrl.mjs +++ /dev/null @@ -1,149 +0,0 @@ -// Minimal, dependency-free test for toEmbedUrl() in script.js (issue #179). -// -// website/ is a plain static site with no build step and no existing test -// framework, so this loads the real script.js source via Node's built-in vm -// module (no duplication of the function under test) with just enough DOM -// stubbing for the file's top-level statements to execute without crashing. -// -// Run with: node website/test_toEmbedUrl.mjs - -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import path from 'node:path'; -import vm from 'node:vm'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const source = readFileSync(path.join(__dirname, 'script.js'), 'utf8'); - -function stubElement() { - return { - addEventListener() {}, - classList: { contains: () => false, add() {}, remove() {}, toggle() {} }, - style: {}, - value: '', - }; -} - -const sandbox = { - window: { - addEventListener() {}, - requestAnimationFrame() {}, - location: { hash: '' }, - history: { pushState() {} }, - lucide: null, - }, - document: { - createElement: () => stubElement(), - getElementById: () => null, - querySelectorAll: () => [], - documentElement: { classList: { contains: () => false } }, - addEventListener() {}, - }, - localStorage: { - getItem: () => null, - setItem() {}, - removeItem() {}, - }, - siteContent: { blog: [], terminal: [] }, - marked: { parse: (s) => s }, - console, - URL, -}; -sandbox.window.document = sandbox.document; -vm.createContext(sandbox); -vm.runInContext(source, sandbox, { filename: 'script.js' }); - -const { toEmbedUrl } = sandbox; -assert.equal(typeof toEmbedUrl, 'function', 'toEmbedUrl must be defined at top level of script.js'); - -const cases = [ - // [input, expected output, description] - ['https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'https://www.youtube.com/embed/dQw4w9WgXcQ', 'youtube watch URL'], - ['https://youtu.be/dQw4w9WgXcQ', 'https://www.youtube.com/embed/dQw4w9WgXcQ', 'youtu.be short URL'], - ['https://vimeo.com/12345678', 'https://player.vimeo.com/video/12345678', 'vimeo URL'], - ['https://www.youtube.com/embed/dQw4w9WgXcQ', 'https://www.youtube.com/embed/dQw4w9WgXcQ', 'already-embed youtube URL'], - ['https://player.vimeo.com/video/12345678', 'https://player.vimeo.com/video/12345678', 'already-embed vimeo URL'], - ['', '', 'empty input'], - [null, '', 'null input'], -]; - -const rejected = [ - // Inputs that must be rejected (return '') because they are not a genuine - // youtube.com/youtu.be/vimeo.com URL, even though some contain the - // substring "youtube.com/embed" or "player.vimeo.com" somewhere. - '">//youtube.com/embed', - 'https://evil.com/?x=youtube.com/embed', - 'https://youtube.com.evil.com/embed', - 'https://notyoutube.com/embed/xyz//player.vimeo.com', - 'javascript:alert(1)', - 'not a url at all but contains youtube.com/embed', -]; - -// Inputs on an ALLOWED host that still carry an attribute-injection payload. -// The host passes the allowlist, so the earlier "rejected" cases don't cover -// this — the returned value is interpolated into an iframe src="..." attribute, -// so it must never contain a raw double-quote that could break out of it. -// The fix returns the canonicalised URL.href (which percent-encodes quotes and -// spaces) instead of the raw input. -const sanitizedPassthrough = [ - [ - 'https://www.youtube.com/embed/abc" onload="alert(1)', - 'https://www.youtube.com/embed/abc%22%20onload=%22alert(1)', - 'attribute-injection payload on allowed host is percent-encoded', - ], - [ - 'https://player.vimeo.com/video/1">', - 'https://player.vimeo.com/video/1%22%3E%3Cscript%3Ealert(1)%3C/script%3E', - 'script-injection payload on allowed vimeo host is percent-encoded', - ], -]; - -let failures = 0; - -function assertNoDoubleQuote(value, description) { - assert.ok( - !String(value).includes('"'), - `${description}: return value must not contain a raw double-quote (iframe src breakout): ${JSON.stringify(value)}`, - ); -} - -for (const [input, expected, description] of cases) { - const actual = toEmbedUrl(input); - try { - assert.equal(actual, expected); - console.log(`PASS: ${description}`); - } catch { - failures++; - console.error(`FAIL: ${description} — input=${JSON.stringify(input)} got=${JSON.stringify(actual)} want=${JSON.stringify(expected)}`); - } -} - -for (const input of rejected) { - const actual = toEmbedUrl(input); - try { - assert.equal(actual, ''); - console.log(`PASS: rejects bypass attempt (${JSON.stringify(input.slice(0, 40))}...)`); - } catch { - failures++; - console.error(`FAIL: bypass NOT rejected — input=${JSON.stringify(input)} got=${JSON.stringify(actual)}`); - } -} - -for (const [input, expected, description] of sanitizedPassthrough) { - const actual = toEmbedUrl(input); - try { - assert.equal(actual, expected); - assertNoDoubleQuote(actual, description); - console.log(`PASS: ${description}`); - } catch (err) { - failures++; - console.error(`FAIL: ${description} — input=${JSON.stringify(input)} got=${JSON.stringify(actual)}\n ${err.message}`); - } -} - -if (failures > 0) { - console.error(`\n${failures} test(s) failed`); - process.exit(1); -} -console.log('\nAll toEmbedUrl tests passed'); diff --git a/website/tsconfig.json b/website/tsconfig.json new file mode 100644 index 00000000..adbbd073 --- /dev/null +++ b/website/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/website/vercel.json b/website/vercel.json deleted file mode 100644 index b12e58a0..00000000 --- a/website/vercel.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "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';" - } - ] - } - ] -}