Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
400 changes: 0 additions & 400 deletions .github/scripts/update_learn_page.py

This file was deleted.

207 changes: 207 additions & 0 deletions .github/scripts/update_readme_stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Refresh the rule and playbook counts hardcoded in README.md.

README.md states the rule and playbook counts in its feature table and in two
nodes of its Mermaid architecture diagram. This script recomputes both counts
from scanner/rules/ and playbooks/cli/ and rewrites those four spots in place.

The website (including the Learn page) no longer needs this: it derives every
count from the repository at build time (website/src/lib/repoData.ts), so it
cannot drift. README.md is plain Markdown rendered by GitHub, so it is the one
place a number still has to be written down.

Usage:
python .github/scripts/update_readme_stats.py # rewrite README.md
python .github/scripts/update_readme_stats.py --check # report drift only

Nothing commits the result automatically: `dev` only accepts changes through
pull requests. The workflow runs --check after each merge and surfaces drift
as a warning, and whoever next touches README.md (or a maintainer) runs the
script and includes the diff in their PR.

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.
"""

import argparse
import re
import sys
from pathlib import Path
from typing import List, Tuple

REPO_ROOT = Path(__file__).resolve().parents[2]
RULES_DIR = REPO_ROOT / "scanner" / "rules"
PLAYBOOKS_DIR = REPO_ROOT / "playbooks" / "cli"
README_PATH = REPO_ROOT / "README.md"

# Rule modules are named az_<category>_<number>.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"

RULE_ID_PATTERN = re.compile(r"^RULE_ID\s*=\s*[\"']([^\"']+)[\"']", re.MULTILINE)

# Exit code for --check when README.md is stale, distinct from real errors.
EXIT_STALE = 3


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/ would also pick 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_<rule filename stem>.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 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_readme(content: str, rule_count: int, playbook_count: int) -> Tuple[str, List[str]]:
"""Return (updated_content, failed_pattern_names) for README.md."""
# Anchored on the row label and the unit that follows the number, not on
# the full prose. The wording of these rows (the category list, how the
# scripts are described) is edited independently of the counts, and pinning
# the whole sentence made this script fail on every unrelated reword while
# the counts silently went stale. Losing the row itself, or the "Azure
# security rules"/"(N playbooks)" shape, still fails loudly.
feature_row = r"(\| \*\*Misconfiguration Scanner\*\* \| Runs )\d+( Azure security rules)"
playbook_row = r"(\| \*\*Remediation Playbooks\*\* \|[^|]*\()\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(argv: List[str] | None = None) -> int:
"""Rewrite (or, with --check, verify) README.md counts; return an exit code."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--check",
action="store_true",
help=f"do not write; exit {EXIT_STALE} if README.md counts are stale",
)
args = parser.parse_args(argv)

if not README_PATH.is_file():
print(f"Error: {README_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.
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"Note: {len(orphan_playbooks)} playbook file(s) in playbooks/cli/ are not any rule's "
f"matching playbook and are excluded from the count (expected for shared helpers "
f"other playbooks exec into): {', '.join(orphan_playbooks)}",
file=sys.stderr,
)

original = README_PATH.read_text(encoding="utf-8")
updated, failures = render_readme(original, rule_count, playbook_count)

if failures:
print(
"Error: the following README.md 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

if updated == original:
print(f"README.md statistics already current (rules: {rule_count}, playbooks: {playbook_count}).")
return 0

if args.check:
print(
f"README.md statistics are stale; repository now has {rule_count} rules and "
f"{playbook_count} playbooks. Run: python .github/scripts/update_readme_stats.py"
)
return EXIT_STALE

README_PATH.write_text(updated, encoding="utf-8")
print(f"Updated README.md - rules: {rule_count}, playbooks: {playbook_count}")
return 0


if __name__ == "__main__":
sys.exit(main())
61 changes: 61 additions & 0 deletions .github/workflows/readme-stats.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: README Stats Drift

# The website (Learn page included) computes its statistics at build time from
# website/src/lib/repoData.ts, so it never needs a commit. README.md is the one
# file that still states counts, and `dev` only accepts changes through pull
# requests, so this workflow never pushes: it reports drift as a warning and
# the fix lands in an ordinary PR (python .github/scripts/update_readme_stats.py).
on:
push:
branches: [dev]
paths:
- "scanner/rules/**"
- "playbooks/cli/**"
- "README.md"
- ".github/scripts/update_readme_stats.py"
- ".github/workflows/readme-stats.yml"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: readme-stats-${{ github.ref }}
cancel-in-progress: true

jobs:
readme-stats:
name: Check README statistics
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false

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

- name: Check README statistics
run: |
set +e
output="$(python .github/scripts/update_readme_stats.py --check 2>&1)"
status=$?
set -e
echo "$output"
if [ "$status" -eq 3 ]; then
# Stale counts are a docs follow-up, not a broken build.
message="$(echo "$output" | tail -n 1)"
echo "::warning title=README statistics are stale::${message}"
{
echo "### README statistics are stale"
echo
echo "${message}"
echo
echo "Open a PR with the output of \`python .github/scripts/update_readme_stats.py\`."
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
exit "$status"
67 changes: 0 additions & 67 deletions .github/workflows/update-learn-page.yml

This file was deleted.

1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ OpenShield uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixed

- Learn page statistics are computed at site build time, and the post-merge stats workflow no longer pushes to the protected `dev` branch (it failed on every merge); README count drift is now reported as a warning
- High-severity CodeQL findings in Python and JavaScript code
- Security findings identified during Semgrep analysis
- Sensitive identity metadata removed from scanner debug logging
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,10 @@ Findings map to NIST FIPS 203 (ML-KEM), FIPS 204 (ML-DSA), and FIPS 205 (SLH-DSA

| Feature | Description |
|---|---|
| **Misconfiguration Scanner** | Runs 95 Azure security rules across storage, network, identity, database, compute, Key Vault, AKS, Kubernetes workloads, post-quantum cryptography, backup, serverless, private endpoint, and supply chain posture |
| **Misconfiguration Scanner** | Runs 144 Azure security rules across storage, network, identity, database, compute, Key Vault, AKS, Kubernetes workloads, 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 documented rule ships with a matching review-gated remediation script (95 playbooks) |
| **Remediation Playbooks** | Every documented rule ships with a matching review-gated remediation script (144 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 [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 |
Expand Down Expand Up @@ -119,11 +119,11 @@ Project policies and assurance evidence:
flowchart TD
A["React Dashboard\nVercel · Live"]
B["Flask REST API\nJWT · CORS · Blueprints"]
C["Scanner Engine\n95 Python rules"]
C["Scanner Engine\n144 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\n95 remediation scripts"]
G["Azure CLI Playbooks\n144 remediation scripts"]
H["sentinel/ingest.py\nNormalise + HMAC upload"]
I["Microsoft Sentinel\nOpenShieldFindings_CL · KQL rules"]

Expand Down Expand Up @@ -330,7 +330,7 @@ Learn OpenShield covers:
- Contributor onboarding
- Documentation navigation

Live Learning Portal: https://openshieldlearn.netlify.app/learn/
Live Learning Portal: https://owasp.github.io/openshield/learn/
Full documentation, the security rules gallery, architecture guide, evidence guide, and blog are available at the project website:

**[owasp.github.io/openshield](https://owasp.github.io/openshield/)**
Expand Down
1 change: 0 additions & 1 deletion docs/_redirects

This file was deleted.

2 changes: 1 addition & 1 deletion docs/ci-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ There is no standing bypass. If a production incident requires merging without t

CI and CodeQL also run on every push to `dev` and `main`, so each merged commit has its own result instead of relying only on a PR run against an older base. Post-merge runs are never cancelled.

The **Update Learn Page and README Stats** workflow no longer pushes to `dev`. When statistics change it force-updates the `docs/refresh-learn-page-stats` branch and opens (or refreshes) a pull request, which goes through the same protected flow. Pull requests opened with the default `GITHUB_TOKEN` do not start workflows, so administrators should provide a `STATS_BOT_TOKEN` secret (GitHub App token or fine-grained token with contents and pull-requests write) and allow GitHub Actions to create pull requests in repository settings.
Nothing pushes statistics to `dev` or opens bot pull requests. The website, including the Learn page, computes every rule, playbook, severity and category count at build time from `website/src/lib/repoData.ts`, so it cannot drift. `README.md` is the only file that still states counts: the **README Stats Drift** workflow (`readme-stats.yml`, `contents: read`) runs `python .github/scripts/update_readme_stats.py --check` after rule or playbook changes land and reports stale counts as a warning with a job summary. The fix is an ordinary PR with the script's output, reviewed through the same protected flow. No `STATS_BOT_TOKEN` secret is needed.

---

Expand Down
Loading
Loading