From 74528eceb83c669b940e6c6455667e0fa4d103e1 Mon Sep 17 00:00:00 2001 From: elprofesoriqo Date: Sun, 20 Sep 2026 15:15:59 +0200 Subject: [PATCH 1/6] Add issue, PR, and contributor templates for the gym. Keep MAPPO/QMIX out of tree as a checklist item so PRs stay gym-scoped. Co-authored-by: Cursor --- .github/ISSUE_TEMPLATE/01-bug-report.yml | 79 +++++++++++ .github/ISSUE_TEMPLATE/02-feature-request.yml | 59 ++++++++ .github/ISSUE_TEMPLATE/config.yml | 14 ++ .github/ISSUE_TEMPLATE/good_first_issue.yml | 22 +++ .github/ISSUE_TEMPLATE/research.yml | 20 +++ .github/PULL_REQUEST_TEMPLATE.md | 15 ++ CODE_OF_CONDUCT.md | 129 ++++++++++++++++++ CONTRIBUTING.md | 26 ++++ 8 files changed, 364 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/01-bug-report.yml create mode 100644 .github/ISSUE_TEMPLATE/02-feature-request.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/good_first_issue.yml create mode 100644 .github/ISSUE_TEMPLATE/research.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md diff --git a/.github/ISSUE_TEMPLATE/01-bug-report.yml b/.github/ISSUE_TEMPLATE/01-bug-report.yml new file mode 100644 index 0000000..059ded3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01-bug-report.yml @@ -0,0 +1,79 @@ +name: Bug report +description: Report a crash, incorrect metric, API break, or other reproducible problem +title: "[Bug] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a NetForge issue. For usage questions, use Discussions. + - type: checkboxes + id: checks + attributes: + label: Before submitting + options: + - label: I searched existing issues and did not find the same problem. + required: true + - label: I can reproduce this on the latest `main` branch. + required: true + - type: textarea + id: problem + attributes: + label: What happened? + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Minimal reproduction + description: `netforge …` or a few lines of PettingZoo / Gymnasium code. + render: shell + validations: + required: true + - type: input + id: version + attributes: + label: NetForge version / commit + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - Environment / PettingZoo + - Gymnasium facade + - Arena / questions / submissions + - Diagnostics / agent cards + - JAX backend + - SIEM / NLP + - Datasets / replay + - Docs + - Other + validations: + required: true + - type: input + id: os + attributes: + label: Operating system + placeholder: "Windows 11 / Ubuntu 24.04 / macOS 15" + validations: + required: true + - type: input + id: python + attributes: + label: Python version + placeholder: "3.12.8" + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs + render: shell diff --git a/.github/ISSUE_TEMPLATE/02-feature-request.yml b/.github/ISSUE_TEMPLATE/02-feature-request.yml new file mode 100644 index 0000000..0aecee4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02-feature-request.yml @@ -0,0 +1,59 @@ +name: Feature request +description: Propose gym infrastructure — not a new exploit family +title: "[Proposal] " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Prefer wrappers, metrics, packs, probes, and docs. Algorithms belong in + their own repos and should be *evaluated on* NetForge. If the design is + not obvious, use the Research template. + - type: checkboxes + id: checks + attributes: + label: Before submitting + options: + - label: I searched existing issues and Discussions for a similar proposal. + required: true + - label: This is gym/benchmark infrastructure, not a vendored trainer. + required: true + - type: dropdown + id: category + attributes: + label: Proposal type + options: + - Scenario pack + - Metric / agent card + - Capability probe + - Wrapper (reward, constraint, comm) + - Dataset / Minari + - Docs + - Interop (PettingZoo, Gymnasium, JaxMARL) + - Other + validations: + required: true + - type: textarea + id: problem + attributes: + label: What problem does this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Smallest useful implementation. + validations: + required: true + - type: textarea + id: out_of_scope + attributes: + label: Out of scope + - type: textarea + id: validation + attributes: + label: How should this be validated? + description: Tests, Arena seeds, a named `netforge questions` entry. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..2742d81 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: true +contact_links: + - name: Ask a question + url: https://github.com/reforcemind/NetForge_RL/discussions + about: Usage or architecture questions. Use Research issues for scoped design work. + - name: Documentation + url: https://reforcemind.github.io/NetForge_RL/ + about: Getting started, Arena, questions, API. + - name: Research backlog + url: https://github.com/reforcemind/NetForge_RL/blob/main/RESEARCH.md + about: Public research issues and good first contributions + - name: Arena spec + url: https://github.com/reforcemind/NetForge_RL/blob/main/docs/arena.md + about: Frozen benchmark contract (splits, metrics, hidden tests) diff --git a/.github/ISSUE_TEMPLATE/good_first_issue.yml b/.github/ISSUE_TEMPLATE/good_first_issue.yml new file mode 100644 index 0000000..4608db1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/good_first_issue.yml @@ -0,0 +1,22 @@ +name: Good first issue +description: Small gym/infrastructure task for new contributors +title: "[good first issue] " +labels: ["good first issue"] +body: + - type: markdown + attributes: + value: "Prefer scenario packs, metrics, probes, docs, or wrappers — not new exploits." + - type: textarea + id: task + attributes: + label: Task + description: What should land in the gym? + validations: + required: true + - type: dropdown + id: kind + attributes: + label: Kind + options: [scenario pack, metric, capability probe, wrapper, docs, replay, dataset] + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/research.yml b/.github/ISSUE_TEMPLATE/research.yml new file mode 100644 index 0000000..338d072 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/research.yml @@ -0,0 +1,20 @@ +name: Research +description: A research question the gym should make easy to answer +title: "[research] " +labels: ["research"] +body: + - type: markdown + attributes: + value: "Algorithms belong in their own repos and should be *evaluated on* NetForge." + - type: textarea + id: question + attributes: + label: Question + description: Generalization, safety, adaptation, POMDP, temporal, self-play, … + validations: + required: true + - type: textarea + id: protocol + attributes: + label: Arena protocol + description: Split, seeds, metrics, baselines. Hidden tests if it is a leaderboard claim. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..a3b9537 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Summary + + +## Evidence + + +```text + +``` + +## Checklist +- [ ] One issue, one PR +- [ ] Tests added or updated +- [ ] Did not vendor a training algorithm (MAPPO / QMIX / CT-GMARL stay out of tree) +- [ ] User-path docs updated only if the user path changed diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..e270b8b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,129 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported through a +[private GitHub security advisory](https://github.com/reforcemind/NetForge_RL/security/advisories/new). +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. + +[homepage]: https://www.contributor-covenant.org +[Mozilla CoC]: https://github.com/mozilla/diversity diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e856d34 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,26 @@ +# Contributing + +NetForge is a cybersecurity RL gym. Prefer packs, metrics, wrappers, probes, +datasets, docs. MAPPO / QMIX stay in other repos. Blue trains on SIEM, not +ground-truth compromise. + +[Code of Conduct](CODE_OF_CONDUCT.md). +[Discussions](https://github.com/reforcemind/NetForge_RL/discussions) if issues are locked. + +- YAML pack +- Probe in `netforge_rl/arena/questions/` +- Reward variant +- HTML replay +- Capability probe + +Issue → one PR from `main` → tests for behavior changes. + +```bash +pip install -e ".[dev,docs]" +ruff check . && ruff format . +pytest tests/ -m fast +sphinx-build -b html -W --keep-going docs docs/_build/html +``` + +Gate: `scripts/verify_all.sh` or `scripts/verify_local.ps1`. +GitHub Actions is ruff + pytest + Sphinx. `docs.yml` deploys GitHub Pages on `main`. From 2724774af6bf8b1f1cb263a714b6ef58d73d561d Mon Sep 17 00:00:00 2001 From: elprofesoriqo Date: Sun, 20 Sep 2026 15:15:59 +0200 Subject: [PATCH 2/6] Build Sphinx in CI and deploy GitHub Pages from docs.yml. Replace the old python-app workflow so PRs lint, test, and compile the site, and main publishes Pages. Co-authored-by: Cursor --- .github/workflows/ci.yml | 57 ++++++++++++++++++++++++++++++++ .github/workflows/docs.yml | 40 ++++++++++++++++++++++ .github/workflows/python-app.yml | 51 ---------------------------- 3 files changed, 97 insertions(+), 51 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docs.yml delete mode 100644 .github/workflows/python-app.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bd0d467 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install --upgrade pip + - run: pip install ruff + - run: ruff format --check . + - run: ruff check . + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install --upgrade pip + - run: pip install -e ".[dev]" + - run: pytest tests/ --cov=netforge_rl --cov-report=xml --cov-fail-under=50 + - uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install --upgrade pip + - run: pip install -e ".[docs]" + - run: sphinx-build -b html -W --keep-going docs docs/_build/html + - run: touch docs/_build/html/.nojekyll + - uses: actions/upload-artifact@v4 + with: + name: docs-html + path: docs/_build/html diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..65d89cb --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,40 @@ +name: Docs + +on: + push: + branches: [main] + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install --upgrade pip + - run: pip install -e ".[docs]" + - run: sphinx-build -b html -W --keep-going docs docs/_build/html + - run: touch docs/_build/html/.nojekyll + - uses: actions/upload-pages-artifact@v3 + with: + path: docs/_build/html + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - id: deploy + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml deleted file mode 100644 index 74909be..0000000 --- a/.github/workflows/python-app.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: CI Pipeline - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -permissions: - contents: read - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Python 3.12 - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install ruff - - name: Check formatting with ruff - run: ruff format --check . - - name: Lint with ruff - run: ruff check . - - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Python 3.12 - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e . - pip install pytest pytest-cov scikit-learn gymnasium pettingzoo networkx pyyaml - - name: Run tests with pytest - run: | - pytest tests/ -v --cov=netforge_rl --cov-report=xml --cov-fail-under=50 - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4 - with: - token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: false - From e10e2856fe8b3a797fedbdf01e0b47d35ceda557 Mon Sep 17 00:00:00 2001 From: elprofesoriqo Date: Sun, 20 Sep 2026 15:15:59 +0200 Subject: [PATCH 3/6] Add local verify scripts that match the GitHub gate. Document that ruff, fast pytest, and Sphinx -W are the merge bar. Co-authored-by: Cursor --- AGENTS.md | 12 ++++++++++++ scripts/verify_all.sh | 8 ++++++++ scripts/verify_local.ps1 | 7 +++++++ 3 files changed, 27 insertions(+) create mode 100644 AGENTS.md create mode 100755 scripts/verify_all.sh create mode 100644 scripts/verify_local.ps1 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..52b56c7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,12 @@ +# Agent notes + +Verification is local. GitHub Actions (`ci.yml`) runs ruff + pytest + Sphinx. +`docs.yml` deploys the HTML site to GitHub Pages on push to `main`. + +- Full gate: `scripts/verify_all.sh` (Linux/Git Bash) or `scripts/verify_local.ps1` (Windows) +- User CLI: `python -m netforge` / `netforge` +- Docs: `sphinx-build -b html -W --keep-going docs docs/_build/html` +- Contract: `docs/guides/verification.md` + +Sister project docs kit: [FlowEdge](https://github.com/reforcemind/FlowEdge). +Do not vendor MAPPO/QMIX/CT-GMARL into this tree. diff --git a/scripts/verify_all.sh b/scripts/verify_all.sh new file mode 100755 index 0000000..5265d54 --- /dev/null +++ b/scripts/verify_all.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +python -m ruff check . +python -m ruff format --check . +python -m pytest tests/ -m fast +python -m sphinx -b html -W --keep-going docs docs/_build/html +echo "ok" diff --git a/scripts/verify_local.ps1 b/scripts/verify_local.ps1 new file mode 100644 index 0000000..18e7619 --- /dev/null +++ b/scripts/verify_local.ps1 @@ -0,0 +1,7 @@ +$ErrorActionPreference = "Stop" +Set-Location (Split-Path -Parent $PSScriptRoot) +python -m ruff check . +python -m ruff format --check . +python -m pytest tests/ -m fast +python -m sphinx -b html -W --keep-going docs docs/_build/html +Write-Host "ok" From a62542c812b57db3830e0e127ff1ea28412cb528 Mon Sep 17 00:00:00 2001 From: elprofesoriqo Date: Sun, 20 Sep 2026 15:21:48 +0200 Subject: [PATCH 4/6] Make ruff format happy on the example submission and render test. Unindent the evaluate command in the docstring and construct FrameRecorder before asserting empty save. Co-authored-by: Cursor --- examples/submissions/heuristic_blue.py | 2 +- tests/render/test_render_pipeline.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/submissions/heuristic_blue.py b/examples/submissions/heuristic_blue.py index a8ac9a9..8584b1a 100644 --- a/examples/submissions/heuristic_blue.py +++ b/examples/submissions/heuristic_blue.py @@ -1,6 +1,6 @@ """CAGE-style ``make_blue`` example. - netforge evaluate examples/submissions/heuristic_blue.py --seeds 0 --max-ticks 20 +netforge evaluate examples/submissions/heuristic_blue.py --seeds 0 --max-ticks 20 """ NAME = 'heuristic-blue' diff --git a/tests/render/test_render_pipeline.py b/tests/render/test_render_pipeline.py index 75bf41c..daf722d 100644 --- a/tests/render/test_render_pipeline.py +++ b/tests/render/test_render_pipeline.py @@ -68,6 +68,8 @@ def test_frame_recorder_append_and_len() -> None: def test_frame_recorder_save_requires_frames(tmp_path) -> None: pytest.importorskip('moviepy') from netforge_rl.render.recorder import FrameRecorder + + rec = FrameRecorder(fps=4) with pytest.raises(ValueError): rec.save(tmp_path / 'empty.gif') From 825bb59768b060659284c0ae9a06d1b1b4ac71fb Mon Sep 17 00:00:00 2001 From: elprofesoriqo Date: Sun, 20 Sep 2026 15:25:00 +0200 Subject: [PATCH 5/6] Pin ruff lint to pycodestyle and pyflakes so CI matches the old default. Ruff 0.16 treats a bare ignore list as a much larger rule set, which fails on the existing simulator files. Co-authored-by: Cursor --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 09d5284..a22807c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,6 +122,7 @@ docstring-code-line-length = "dynamic" "netforge_rl/semantic/clients/*.py" = ["F401"] "netforge_rl/render/replay_html.py" = ["E501"] [tool.ruff.lint] +select = ["E4", "E7", "E9", "F"] ignore = ["E701", "E402", "E702"] [tool.setuptools.packages.find] From 80affeb249b35846afaab728c9b3d10766bf619e Mon Sep 17 00:00:00 2001 From: elprofesoriqo Date: Sun, 20 Sep 2026 15:25:04 +0200 Subject: [PATCH 6/6] Format Python snippets in docs so ruff format --check passes. Quote style is single, including fenced examples and the figure builder. Co-authored-by: Cursor --- README.md | 4 +- docs/_static/figures/build.py | 839 +++++++++++++++++++++++----------- docs/getting-started.md | 2 +- docs/index.md | 4 +- docs/interop/pettingzoo.md | 4 +- docs/interop/soc_export.md | 4 +- docs/python-quickstart.md | 4 +- docs/submissions.md | 4 +- docs/training/rllib.md | 12 +- docs/training/scenarios.md | 4 +- 10 files changed, 584 insertions(+), 297 deletions(-) diff --git a/README.md b/README.md index 25421a7..e8824c1 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,9 @@ import netforge_rl import gymnasium as gym from pettingzoo import make -env = make("parallel", "netforge/ransomware-v4", max_ticks=80) +env = make('parallel', 'netforge/ransomware-v4', max_ticks=80) obs, infos = env.reset(seed=0) -blue = gym.make("NetForge/Blue-v4", max_ticks=80) +blue = gym.make('NetForge/Blue-v4', max_ticks=80) ```

diff --git a/docs/_static/figures/build.py b/docs/_static/figures/build.py index 3d98970..6163fa5 100644 --- a/docs/_static/figures/build.py +++ b/docs/_static/figures/build.py @@ -7,25 +7,25 @@ OUT = Path(__file__).resolve().parent -INK = "#2B2118" -TITLE = "#1B4B6B" -CANVAS = "#FFF6DC" -EDGE = "#E8D7A8" -SHADOW = "#E8C98A" -YEL = "#FFD166" -TEAL = "#2EC4B6" -PINK = "#FF8FAB" -ORANGE = "#FF9F43" -PURPLE = "#7B6CF6" -GREEN = "#6BCB77" -CREAM = "#FFF1C2" -BLUE = "#8EC5FF" -RED = "#FF6B6B" -FONT = "Trebuchet MS, Segoe UI, sans-serif" +INK = '#2B2118' +TITLE = '#1B4B6B' +CANVAS = '#FFF6DC' +EDGE = '#E8D7A8' +SHADOW = '#E8C98A' +YEL = '#FFD166' +TEAL = '#2EC4B6' +PINK = '#FF8FAB' +ORANGE = '#FF9F43' +PURPLE = '#7B6CF6' +GREEN = '#6BCB77' +CREAM = '#FFF1C2' +BLUE = '#8EC5FF' +RED = '#FF6B6B' +FONT = 'Trebuchet MS, Segoe UI, sans-serif' def header(title: str, w: int, h: int, mid: str | None = None) -> str: - extra = "" + extra = '' if mid: extra = ( f' str: def _slug(title: str) -> str: - return "".join(ch if ch.isalnum() else "-" for ch in title.lower()).strip("-")[:24] + return ''.join(ch if ch.isalnum() else '-' for ch in title.lower()).strip('-')[:24] def close() -> str: - return "\n" + return '\n' -def card(x: float, y: float, w: float, h: float, fill: str, rot: float, inner: str) -> str: +def card( + x: float, y: float, w: float, h: float, fill: str, rot: float, inner: str +) -> str: cx = x + w / 2 cy = y + h / 2 return ( f'' f'' f'' - f"{inner}\n" + f'{inner}\n' ) def xml_escape(text: str) -> str: - return text.replace("&", "&").replace("<", "<").replace(">", ">") - - -def t(x: float, y: float, size: int, weight: str, fill: str, text: str, anchor: str = "middle") -> str: + return text.replace('&', '&').replace('<', '<').replace('>', '>') + + +def t( + x: float, + y: float, + size: int, + weight: str, + fill: str, + text: str, + anchor: str = 'middle', +) -> str: return ( f'{xml_escape(text)}' ) -def chip(x: float, y: float, w: float, h: float, fill: str, rot: float, label: str) -> str: +def chip( + x: float, y: float, w: float, h: float, fill: str, rot: float, label: str +) -> str: cx = x + w / 2 cy = y + h / 2 return ( @@ -81,7 +93,9 @@ def chip(x: float, y: float, w: float, h: float, fill: str, rot: float, label: s ) -def arrow(x1: float, y1: float, x2: float, y2: float, title: str, qy: float | None = None) -> str: +def arrow( + x1: float, y1: float, x2: float, y2: float, title: str, qy: float | None = None +) -> str: mid = (x1 + x2) / 2 q = y1 if qy is None else qy return ( @@ -92,8 +106,8 @@ def arrow(x1: float, y1: float, x2: float, y2: float, title: str, qy: float | No def write(name: str, body: str) -> None: path = OUT / name - path.write_text(body, encoding="utf-8") - print("wrote", path.name, path.stat().st_size) + path.write_text(body, encoding='utf-8') + print('wrote', path.name, path.stat().st_size) # --------------------------------------------------------------------------- @@ -103,144 +117,145 @@ def write(name: str, body: str) -> None: _EID = 0 -def eid(prefix: str = "e") -> str: +def eid(prefix: str = 'e') -> str: global _EID _EID += 1 - return f"{prefix}{_EID:04d}" + return f'{prefix}{_EID:04d}' def e_rect(x, y, w, h, bg, angle=0.0, seed=1): return { - "id": eid("r"), - "type": "rectangle", - "x": x, - "y": y, - "width": w, - "height": h, - "angle": angle, - "strokeColor": INK, - "backgroundColor": bg, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "groupIds": [], - "frameId": None, - "roundness": {"type": 3}, - "seed": seed, - "version": 1, - "versionNonce": seed + 7, - "isDeleted": False, - "boundElements": None, - "updated": 1, - "link": None, - "locked": False, + 'id': eid('r'), + 'type': 'rectangle', + 'x': x, + 'y': y, + 'width': w, + 'height': h, + 'angle': angle, + 'strokeColor': INK, + 'backgroundColor': bg, + 'fillStyle': 'solid', + 'strokeWidth': 2, + 'strokeStyle': 'solid', + 'roughness': 1, + 'opacity': 100, + 'groupIds': [], + 'frameId': None, + 'roundness': {'type': 3}, + 'seed': seed, + 'version': 1, + 'versionNonce': seed + 7, + 'isDeleted': False, + 'boundElements': None, + 'updated': 1, + 'link': None, + 'locked': False, } -def e_text(x, y, w, h, text, color=INK, size=20, align="center", seed=2): +def e_text(x, y, w, h, text, color=INK, size=20, align='center', seed=2): return { - "id": eid("t"), - "type": "text", - "x": x, - "y": y, - "width": w, - "height": h, - "angle": 0, - "strokeColor": color, - "backgroundColor": "transparent", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "solid", - "roughness": 0, - "opacity": 100, - "groupIds": [], - "frameId": None, - "roundness": None, - "seed": seed, - "version": 1, - "versionNonce": seed + 3, - "isDeleted": False, - "boundElements": None, - "updated": 1, - "link": None, - "locked": False, - "text": text, - "fontSize": size, - "fontFamily": 2, - "textAlign": align, - "verticalAlign": "middle", - "containerId": None, - "originalText": text, - "autoResize": True, - "lineHeight": 1.25, + 'id': eid('t'), + 'type': 'text', + 'x': x, + 'y': y, + 'width': w, + 'height': h, + 'angle': 0, + 'strokeColor': color, + 'backgroundColor': 'transparent', + 'fillStyle': 'solid', + 'strokeWidth': 1, + 'strokeStyle': 'solid', + 'roughness': 0, + 'opacity': 100, + 'groupIds': [], + 'frameId': None, + 'roundness': None, + 'seed': seed, + 'version': 1, + 'versionNonce': seed + 3, + 'isDeleted': False, + 'boundElements': None, + 'updated': 1, + 'link': None, + 'locked': False, + 'text': text, + 'fontSize': size, + 'fontFamily': 2, + 'textAlign': align, + 'verticalAlign': 'middle', + 'containerId': None, + 'originalText': text, + 'autoResize': True, + 'lineHeight': 1.25, } def e_arrow(x, y, w, h, seed=3): return { - "id": eid("a"), - "type": "arrow", - "x": x, - "y": y, - "width": w, - "height": h, - "angle": 0, - "strokeColor": TITLE, - "backgroundColor": "transparent", - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "groupIds": [], - "frameId": None, - "roundness": {"type": 2}, - "seed": seed, - "version": 1, - "versionNonce": seed + 9, - "isDeleted": False, - "boundElements": None, - "updated": 1, - "link": None, - "locked": False, - "startBinding": None, - "endBinding": None, - "lastCommittedPoint": None, - "startArrowhead": None, - "endArrowhead": "arrow", - "points": [[0, 0], [w, h]], + 'id': eid('a'), + 'type': 'arrow', + 'x': x, + 'y': y, + 'width': w, + 'height': h, + 'angle': 0, + 'strokeColor': TITLE, + 'backgroundColor': 'transparent', + 'fillStyle': 'solid', + 'strokeWidth': 2, + 'strokeStyle': 'solid', + 'roughness': 1, + 'opacity': 100, + 'groupIds': [], + 'frameId': None, + 'roundness': {'type': 2}, + 'seed': seed, + 'version': 1, + 'versionNonce': seed + 9, + 'isDeleted': False, + 'boundElements': None, + 'updated': 1, + 'link': None, + 'locked': False, + 'startBinding': None, + 'endBinding': None, + 'lastCommittedPoint': None, + 'startArrowhead': None, + 'endArrowhead': 'arrow', + 'points': [[0, 0], [w, h]], } def scene(elements: list, w: int = 1180, h: int = 400) -> dict: return { - "type": "excalidraw", - "version": 2, - "source": "https://excalidraw.com", - "elements": elements, - "appState": { - "gridSize": None, - "viewBackgroundColor": CANVAS, - "currentItemFontFamily": 2, - "width": w, - "height": h, + 'type': 'excalidraw', + 'version': 2, + 'source': 'https://excalidraw.com', + 'elements': elements, + 'appState': { + 'gridSize': None, + 'viewBackgroundColor': CANVAS, + 'currentItemFontFamily': 2, + 'width': w, + 'height': h, }, - "files": {}, + 'files': {}, } def write_ex(name: str, elements: list, w: int = 1180, h: int = 400) -> None: path = OUT / name - path.write_text(json.dumps(scene(elements, w, h), indent=2), encoding="utf-8") - print("wrote", path.name, path.stat().st_size) + path.write_text(json.dumps(scene(elements, w, h), indent=2), encoding='utf-8') + print('wrote', path.name, path.stat().st_size) # --------------------------------------------------------------------------- # Diagrams # --------------------------------------------------------------------------- + def mark_svg() -> str: return f''' NetForge @@ -256,58 +271,117 @@ def mark_svg() -> str: def purpose(): - title = "the match" - parts = [header(title, 1180, 400, "Red attacks - Blue defends - SIEM in the middle")] + title = 'the match' + parts = [ + header(title, 1180, 400, 'Red attacks - Blue defends - SIEM in the middle') + ] parts.append( card( - 36, 78, 350, 220, RED, -0.08, - t(211, 156, 24, "800", TITLE, "Red") - + t(211, 188, 16, "600", INK, "discover, exploit") - + t(211, 208, 16, "600", INK, "escalate, exfil") - + t(211, 228, 16, "600", INK, "fog of war") - + t(211, 266, 14, "600", TITLE, "starts blind"), + 36, + 78, + 350, + 220, + RED, + -0.08, + t(211, 156, 24, '800', TITLE, 'Red') + + t(211, 188, 16, '600', INK, 'discover, exploit') + + t(211, 208, 16, '600', INK, 'escalate, exfil') + + t(211, 228, 16, '600', INK, 'fog of war') + + t(211, 266, 14, '600', TITLE, 'starts blind'), ) ) parts.append( card( - 414, 78, 350, 220, TEAL, 0.24, - t(589, 156, 24, "800", TITLE, "Network") - + t(589, 188, 16, "600", INK, "hosts, subnets") - + t(589, 208, 16, "600", INK, "firewalls, decoys") - + t(589, 228, 16, "600", INK, "durative actions") - + t(589, 266, 14, "600", TITLE, "the gym world"), + 414, + 78, + 350, + 220, + TEAL, + 0.24, + t(589, 156, 24, '800', TITLE, 'Network') + + t(589, 188, 16, '600', INK, 'hosts, subnets') + + t(589, 208, 16, '600', INK, 'firewalls, decoys') + + t(589, 228, 16, '600', INK, 'durative actions') + + t(589, 266, 14, '600', TITLE, 'the gym world'), ) ) parts.append( card( - 792, 78, 350, 220, BLUE, 0.10, - t(967, 156, 24, "800", TITLE, "Blue") - + t(967, 188, 16, "600", INK, "isolate, restore") - + t(967, 208, 16, "600", INK, "decoys, EDR") - + t(967, 228, 16, "600", INK, "reads SIEM only") - + t(967, 266, 14, "600", TITLE, "no oracle"), + 792, + 78, + 350, + 220, + BLUE, + 0.10, + t(967, 156, 24, '800', TITLE, 'Blue') + + t(967, 188, 16, '600', INK, 'isolate, restore') + + t(967, 208, 16, '600', INK, 'decoys, EDR') + + t(967, 228, 16, '600', INK, 'reads SIEM only') + + t(967, 266, 14, '600', TITLE, 'no oracle'), ) ) - parts.append(chip(70, 312, 148, 36, YEL, 0.9, "ransomware")) - parts.append(chip(470, 312, 174, 36, ORANGE, -1.4, "APT / cloud / OT")) - parts.append(chip(848, 312, 174, 36, PINK, -1.4, "1 Red + 3 Blue")) - parts.append(t(590, 378, 15, "800", TITLE, "PettingZoo parallel -> or Gymnasium Blue-v4")) + parts.append(chip(70, 312, 148, 36, YEL, 0.9, 'ransomware')) + parts.append(chip(470, 312, 174, 36, ORANGE, -1.4, 'APT / cloud / OT')) + parts.append(chip(848, 312, 174, 36, PINK, -1.4, '1 Red + 3 Blue')) + parts.append( + t(590, 378, 15, '800', TITLE, 'PettingZoo parallel -> or Gymnasium Blue-v4') + ) parts.append(close()) - write("purpose.svg", "".join(parts)) + write('purpose.svg', ''.join(parts)) write_ex( - "purpose.excalidraw", + 'purpose.excalidraw', [ - e_text(28, 12, 280, 32, "the match", TITLE, 28, "left", 11), - e_text(240, 44, 700, 24, "Red attacks - Blue defends - SIEM in the middle", TITLE, 18, "center", 12), + e_text(28, 12, 280, 32, 'the match', TITLE, 28, 'left', 11), + e_text( + 240, + 44, + 700, + 24, + 'Red attacks - Blue defends - SIEM in the middle', + TITLE, + 18, + 'center', + 12, + ), e_rect(36, 78, 350, 220, RED, -0.01, 21), - e_text(36, 150, 350, 40, "Red", TITLE, 28, "center", 22), - e_text(36, 190, 350, 80, "discover, exploit\nescalate, exfil\nstarts blind", INK, 18, "center", 23), + e_text(36, 150, 350, 40, 'Red', TITLE, 28, 'center', 22), + e_text( + 36, + 190, + 350, + 80, + 'discover, exploit\nescalate, exfil\nstarts blind', + INK, + 18, + 'center', + 23, + ), e_rect(414, 78, 350, 220, TEAL, 0.004, 31), - e_text(414, 150, 350, 40, "Network", TITLE, 28, "center", 32), - e_text(414, 190, 350, 80, "hosts, subnets\nfirewalls, decoys\ndurative actions", INK, 18, "center", 33), + e_text(414, 150, 350, 40, 'Network', TITLE, 28, 'center', 32), + e_text( + 414, + 190, + 350, + 80, + 'hosts, subnets\nfirewalls, decoys\ndurative actions', + INK, + 18, + 'center', + 33, + ), e_rect(792, 78, 350, 220, BLUE, 0.002, 41), - e_text(792, 150, 350, 40, "Blue", TITLE, 28, "center", 42), - e_text(792, 190, 350, 80, "isolate, restore\nreads SIEM only\nno oracle", INK, 18, "center", 43), + e_text(792, 150, 350, 40, 'Blue', TITLE, 28, 'center', 42), + e_text( + 792, + 190, + 350, + 80, + 'isolate, restore\nreads SIEM only\nno oracle', + INK, + 18, + 'center', + 43, + ), ], 1180, 400, @@ -315,223 +389,432 @@ def purpose(): def workflow(): - title = "one tick" + title = 'one tick' parts = [header(title, 980, 270)] boxes = [ - (24, 78, 155, 100, YEL, -0.13, "reset", "seeded net"), - (210, 78, 165, 100, TEAL, -0.07, "observe", "SIEM + mask"), - (406, 78, 185, 100, PURPLE, -0.15, "act", "Red / Blue"), - (622, 78, 155, 100, ORANGE, -0.25, "resolve", "conflicts"), - (808, 78, 148, 100, GREEN, -0.17, "log", "Sysmon-like"), + (24, 78, 155, 100, YEL, -0.13, 'reset', 'seeded net'), + (210, 78, 165, 100, TEAL, -0.07, 'observe', 'SIEM + mask'), + (406, 78, 185, 100, PURPLE, -0.15, 'act', 'Red / Blue'), + (622, 78, 155, 100, ORANGE, -0.25, 'resolve', 'conflicts'), + (808, 78, 148, 100, GREEN, -0.17, 'log', 'Sysmon-like'), ] for x, y, w, h, fill, rot, a, b in boxes: cx = x + w / 2 parts.append( - card(x, y, w, h, fill, rot, t(cx, 118, 16, "800", INK, a) + t(cx, 140, 14, "600", INK, b)) + card( + x, + y, + w, + h, + fill, + rot, + t(cx, 118, 16, '800', INK, a) + t(cx, 140, 14, '600', INK, b), + ) ) parts.append(arrow(179, 128, 210, 128, title, 133)) parts.append(arrow(375, 128, 406, 128, title, 126)) parts.append(arrow(591, 128, 622, 128, title, 128)) parts.append(arrow(777, 128, 808, 128, title, 129)) - parts.append(chip(220, 200, 527, 36, BLUE, -1.5, "exploits and isolates take time - logs can be late")) + parts.append( + chip( + 220, + 200, + 527, + 36, + BLUE, + -1.5, + 'exploits and isolates take time - logs can be late', + ) + ) parts.append(close()) - write("workflow.svg", "".join(parts)) - els = [e_text(28, 12, 280, 32, "one tick", TITLE, 28, "left", 61)] + write('workflow.svg', ''.join(parts)) + els = [e_text(28, 12, 280, 32, 'one tick', TITLE, 28, 'left', 61)] xs = [24, 210, 406, 622, 808] fills = [YEL, TEAL, PURPLE, ORANGE, GREEN] - labels = [("reset", "seeded net"), ("observe", "SIEM + mask"), ("act", "Red / Blue"), ("resolve", "conflicts"), ("log", "Sysmon-like")] + labels = [ + ('reset', 'seeded net'), + ('observe', 'SIEM + mask'), + ('act', 'Red / Blue'), + ('resolve', 'conflicts'), + ('log', 'Sysmon-like'), + ] ws = [155, 165, 185, 155, 148] for i, (x, fill, (a, b), w) in enumerate(zip(xs, fills, labels, ws)): els += [ e_rect(x, 78, w, 100, fill, 0, 70 + i), - e_text(x, 100, w, 28, a, INK, 20, "center", 80 + i), - e_text(x, 132, w, 24, b, INK, 16, "center", 90 + i), + e_text(x, 100, w, 28, a, INK, 20, 'center', 80 + i), + e_text(x, 132, w, 24, b, INK, 16, 'center', 90 + i), ] if i: - els.append(e_arrow(xs[i - 1] + ws[i - 1], 128, x - (xs[i - 1] + ws[i - 1]), 0, 100 + i)) + els.append( + e_arrow( + xs[i - 1] + ws[i - 1], 128, x - (xs[i - 1] + ws[i - 1]), 0, 100 + i + ) + ) els += [ e_rect(220, 200, 527, 36, BLUE, -0.026, 110), - e_text(220, 202, 527, 32, "exploits and isolates take time - logs can be late", INK, 16, "center", 111), + e_text( + 220, + 202, + 527, + 32, + 'exploits and isolates take time - logs can be late', + INK, + 16, + 'center', + 111, + ), ] - write_ex("workflow.excalidraw", els, 980, 270) + write_ex('workflow.excalidraw', els, 980, 270) def why_this(): - title = "what Blue actually sees" + title = 'what Blue actually sees' rows = [ - (YEL, "true compromise map", "simulator state", TEAL, "SIEM belief graph", "alerts + inventory"), - (PINK, "instant telemetry", "every exploit is obvious", GREEN, "delayed / noisy logs", "log_latency, drop, decoys"), - (ORANGE, "one giant isolate", "kill the network to win", PURPLE, "SLA vs security", "keep hosts online"), - (RED, "scripted campaign only", "one attacker forever", TEAL, "Red population", "random / heuristic / kill-chain"), - (PINK, "discrete instant acts", "done in one step", GREEN, "durative actions", "ticks until complete"), + ( + YEL, + 'true compromise map', + 'simulator state', + TEAL, + 'SIEM belief graph', + 'alerts + inventory', + ), + ( + PINK, + 'instant telemetry', + 'every exploit is obvious', + GREEN, + 'delayed / noisy logs', + 'log_latency, drop, decoys', + ), + ( + ORANGE, + 'one giant isolate', + 'kill the network to win', + PURPLE, + 'SLA vs security', + 'keep hosts online', + ), + ( + RED, + 'scripted campaign only', + 'one attacker forever', + TEAL, + 'Red population', + 'random / heuristic / kill-chain', + ), + ( + PINK, + 'discrete instant acts', + 'done in one step', + GREEN, + 'durative actions', + 'ticks until complete', + ), ] parts = [header(title, 980, 570)] y = 72 for i, (lc, lt, ls, rc, rt, rs) in enumerate(rows): parts.append( card( - 36, y, 380, 70, lc, 0.04 if i % 2 == 0 else -0.12, - t(226, y + 25, 16, "800", INK, lt) + t(226, y + 47, 14, "600", INK, ls), + 36, + y, + 380, + 70, + lc, + 0.04 if i % 2 == 0 else -0.12, + t(226, y + 25, 16, '800', INK, lt) + t(226, y + 47, 14, '600', INK, ls), ) ) parts.append(arrow(428, y + 35, 534, y + 35, title, y + 32)) parts.append( card( - 544, y, 400, 70, rc, -0.08 if i % 2 == 0 else 0.06, - t(744, y + 25, 16, "800", INK, rt) + t(744, y + 47, 14, "600", INK, rs), + 544, + y, + 400, + 70, + rc, + -0.08 if i % 2 == 0 else 0.06, + t(744, y + 25, 16, '800', INK, rt) + t(744, y + 47, 14, '600', INK, rs), ) ) y += 78 - parts.append(chip(36, 472, 180, 36, CREAM, -1.4, "SOC view")) - parts.append(chip(248, 472, 200, 36, CREAM, -0.8, "not god-mode")) - parts.append(chip(532, 472, 226, 36, YEL, -1.2, "keep the lights on")) - parts.append(t(490, 540, 14, "800", TITLE, "Blue trains on logs - Red trains on recon - the rest is hidden")) + parts.append(chip(36, 472, 180, 36, CREAM, -1.4, 'SOC view')) + parts.append(chip(248, 472, 200, 36, CREAM, -0.8, 'not god-mode')) + parts.append(chip(532, 472, 226, 36, YEL, -1.2, 'keep the lights on')) + parts.append( + t( + 490, + 540, + 14, + '800', + TITLE, + 'Blue trains on logs - Red trains on recon - the rest is hidden', + ) + ) parts.append(close()) - write("why-this.svg", "".join(parts)) - els = [e_text(28, 12, 520, 32, "what Blue actually sees", TITLE, 26, "left", 200)] + write('why-this.svg', ''.join(parts)) + els = [e_text(28, 12, 520, 32, 'what Blue actually sees', TITLE, 26, 'left', 200)] y = 72 for i, (lc, lt, ls, rc, rt, rs) in enumerate(rows): els += [ e_rect(36, y, 380, 70, lc, 0, 210 + i), - e_text(36, y + 8, 380, 28, lt, INK, 18, "center", 220 + i), - e_text(36, y + 36, 380, 24, ls, INK, 14, "center", 230 + i), + e_text(36, y + 8, 380, 28, lt, INK, 18, 'center', 220 + i), + e_text(36, y + 36, 380, 24, ls, INK, 14, 'center', 230 + i), e_arrow(416, y + 35, 128, 0, 240 + i), e_rect(544, y, 400, 70, rc, 0, 250 + i), - e_text(544, y + 8, 400, 28, rt, INK, 18, "center", 260 + i), - e_text(544, y + 36, 400, 24, rs, INK, 14, "center", 270 + i), + e_text(544, y + 8, 400, 28, rt, INK, 18, 'center', 260 + i), + e_text(544, y + 36, 400, 24, rs, INK, 14, 'center', 270 + i), ] y += 78 - write_ex("why-this.excalidraw", els, 980, 570) + write_ex('why-this.excalidraw', els, 980, 570) def status(): - title = "in the gym" + title = 'in the gym' parts = [header(title, 980, 520)] - def pill(x: float, y: float, w: float, fill: str, mark: str, name: str, rot: float) -> None: - parts.append(card(x, y, w, 56, fill, rot, "")) + def pill( + x: float, y: float, w: float, fill: str, mark: str, name: str, rot: float + ) -> None: + parts.append(card(x, y, w, 56, fill, rot, '')) parts.append( f'' ) - parts.append(t(x + 18, y + 33, 13, "800", INK, mark)) - parts.append(t(x + w / 2 + 10, y + 32, 13, "800", TITLE, name)) - - parts.append(t(36, 68, 16, "800", TITLE, "APIs", "start")) - pill(36, 80, 220, GREEN, "+", "PettingZoo", 0.11) - pill(268, 80, 250, GREEN, "+", "Gymnasium Blue-v4", 0.15) - pill(530, 80, 200, GREEN, "+", "AEC wrapper", 0.08) - - parts.append(t(36, 160, 16, "800", TITLE, "World", "start")) - pill(36, 172, 200, GREEN, "+", "SIEM logs", 0.25) - pill(248, 172, 210, GREEN, "+", "belief graphs", 0.06) - pill(470, 172, 200, GREEN, "+", "decoys / OT", -0.05) - pill(682, 172, 190, TEAL, "+", "HTML replay", -0.20) - - parts.append(t(36, 252, 16, "800", TITLE, "Agents", "start")) - pill(36, 264, 200, GREEN, "+", "heuristics", 0.14) - pill(248, 264, 220, GREEN, "+", "kill-chain Red", 0.02) - pill(480, 264, 210, TEAL, "+", "scripted Blue", -0.11) - - parts.append(t(36, 344, 16, "800", TITLE, "Your code", "start")) - pill(36, 356, 160, CREAM, "x", "MAPPO", 0.11) - pill(208, 356, 150, CREAM, "x", "QMIX", -0.21) - pill(370, 356, 190, CREAM, "x", "GNN trainer", -0.18) - pill(572, 356, 200, CREAM, "x", "LLM agent", -0.08) + parts.append(t(x + 18, y + 33, 13, '800', INK, mark)) + parts.append(t(x + w / 2 + 10, y + 32, 13, '800', TITLE, name)) + + parts.append(t(36, 68, 16, '800', TITLE, 'APIs', 'start')) + pill(36, 80, 220, GREEN, '+', 'PettingZoo', 0.11) + pill(268, 80, 250, GREEN, '+', 'Gymnasium Blue-v4', 0.15) + pill(530, 80, 200, GREEN, '+', 'AEC wrapper', 0.08) + + parts.append(t(36, 160, 16, '800', TITLE, 'World', 'start')) + pill(36, 172, 200, GREEN, '+', 'SIEM logs', 0.25) + pill(248, 172, 210, GREEN, '+', 'belief graphs', 0.06) + pill(470, 172, 200, GREEN, '+', 'decoys / OT', -0.05) + pill(682, 172, 190, TEAL, '+', 'HTML replay', -0.20) + + parts.append(t(36, 252, 16, '800', TITLE, 'Agents', 'start')) + pill(36, 264, 200, GREEN, '+', 'heuristics', 0.14) + pill(248, 264, 220, GREEN, '+', 'kill-chain Red', 0.02) + pill(480, 264, 210, TEAL, '+', 'scripted Blue', -0.11) + + parts.append(t(36, 344, 16, '800', TITLE, 'Your code', 'start')) + pill(36, 356, 160, CREAM, 'x', 'MAPPO', 0.11) + pill(208, 356, 150, CREAM, 'x', 'QMIX', -0.21) + pill(370, 356, 190, CREAM, 'x', 'GNN trainer', -0.18) + pill(572, 356, 200, CREAM, 'x', 'LLM agent', -0.08) parts.append( - t(36, 448, 14, "800", TITLE, "entry: gym.make / pettingzoo.make - netforge run / evaluate", "start") + t( + 36, + 448, + 14, + '800', + TITLE, + 'entry: gym.make / pettingzoo.make - netforge run / evaluate', + 'start', + ) + ) + parts.append( + t( + 36, + 478, + 14, + '800', + TITLE, + '+ ships in this repo x train elsewhere, run here', + 'start', + ) ) - parts.append(t(36, 478, 14, "800", TITLE, "+ ships in this repo x train elsewhere, run here", "start")) parts.append(close()) - write("status.svg", "".join(parts)) - els = [e_text(28, 12, 280, 32, "in the gym", TITLE, 26, "left", 300)] + write('status.svg', ''.join(parts)) + els = [e_text(28, 12, 280, 32, 'in the gym', TITLE, 26, 'left', 300)] rows_e = [ - (80, "APIs", [(36, 220, "PettingZoo", GREEN), (268, 250, "Gymnasium Blue-v4", GREEN), (530, 200, "AEC wrapper", GREEN)]), - (172, "World", [(36, 200, "SIEM logs", GREEN), (248, 210, "belief graphs", GREEN), (470, 200, "decoys / OT", GREEN), (682, 190, "HTML replay", TEAL)]), - (264, "Agents", [(36, 200, "heuristics", GREEN), (248, 220, "kill-chain Red", GREEN), (480, 210, "scripted Blue", TEAL)]), - (356, "Your code", [(36, 160, "MAPPO", CREAM), (208, 150, "QMIX", CREAM), (370, 190, "GNN trainer", CREAM), (572, 200, "LLM agent", CREAM)]), + ( + 80, + 'APIs', + [ + (36, 220, 'PettingZoo', GREEN), + (268, 250, 'Gymnasium Blue-v4', GREEN), + (530, 200, 'AEC wrapper', GREEN), + ], + ), + ( + 172, + 'World', + [ + (36, 200, 'SIEM logs', GREEN), + (248, 210, 'belief graphs', GREEN), + (470, 200, 'decoys / OT', GREEN), + (682, 190, 'HTML replay', TEAL), + ], + ), + ( + 264, + 'Agents', + [ + (36, 200, 'heuristics', GREEN), + (248, 220, 'kill-chain Red', GREEN), + (480, 210, 'scripted Blue', TEAL), + ], + ), + ( + 356, + 'Your code', + [ + (36, 160, 'MAPPO', CREAM), + (208, 150, 'QMIX', CREAM), + (370, 190, 'GNN trainer', CREAM), + (572, 200, 'LLM agent', CREAM), + ], + ), ] for y, lab, items in rows_e: - els.append(e_text(36, y - 28, 200, 24, lab, TITLE, 18, "left", y)) + els.append(e_text(36, y - 28, 200, 24, lab, TITLE, 18, 'left', y)) for x, w, name, fill in items: - els += [e_rect(x, y, w, 56, fill, 0, y + x), e_text(x, y + 12, w, 32, name, TITLE, 16, "center", y + x + 1)] - write_ex("status.excalidraw", els, 980, 520) + els += [ + e_rect(x, y, w, 56, fill, 0, y + x), + e_text(x, y + 12, w, 32, name, TITLE, 16, 'center', y + x + 1), + ] + write_ex('status.excalidraw', els, 980, 520) def arena(): - title = "eval splits" - parts = [header(title, 1180, 340, "train / dev / hidden - same metrics")] + title = 'eval splits' + parts = [header(title, 1180, 340, 'train / dev / hidden - same metrics')] cols = [ - (36, YEL, "train", "public", "procedural packs", "learn here"), - (414, TEAL, "dev", "public", "held-out offset", "pick a checkpoint"), - (792, PINK, "hidden", "organizer", "reserved + OOD", "final check"), + (36, YEL, 'train', 'public', 'procedural packs', 'learn here'), + (414, TEAL, 'dev', 'public', 'held-out offset', 'pick a checkpoint'), + (792, PINK, 'hidden', 'organizer', 'reserved + OOD', 'final check'), ] for x, fill, name, who, how, use in cols: cx = x + 175 parts.append( card( - x, 78, 350, 180, fill, 0.12 if fill == YEL else (-0.1 if fill == TEAL else 0.06), - t(cx, 130, 28, "800", TITLE, name) - + t(cx, 168, 16, "600", INK, who) - + t(cx, 192, 16, "600", INK, how) - + t(cx, 228, 14, "600", TITLE, use), + x, + 78, + 350, + 180, + fill, + 0.12 if fill == YEL else (-0.1 if fill == TEAL else 0.06), + t(cx, 130, 28, '800', TITLE, name) + + t(cx, 168, 16, '600', INK, who) + + t(cx, 192, 16, '600', INK, how) + + t(cx, 228, 14, '600', TITLE, use), ) ) - parts.append(chip(200, 274, 780, 36, BLUE, -0.7, "mission, SLA, false positives, kinetic - not return alone")) + parts.append( + chip( + 200, + 274, + 780, + 36, + BLUE, + -0.7, + 'mission, SLA, false positives, kinetic - not return alone', + ) + ) parts.append(close()) - write("arena.svg", "".join(parts)) + write('arena.svg', ''.join(parts)) els = [ - e_text(28, 12, 280, 32, "eval splits", TITLE, 28, "left", 400), - e_text(280, 44, 620, 24, "train / dev / hidden - same metrics", TITLE, 18, "center", 401), + e_text(28, 12, 280, 32, 'eval splits', TITLE, 28, 'left', 400), + e_text( + 280, + 44, + 620, + 24, + 'train / dev / hidden - same metrics', + TITLE, + 18, + 'center', + 401, + ), ] for x, fill, name, who, how, use in cols: els += [ e_rect(x, 78, 350, 180, fill, 0, 410 + x), - e_text(x, 110, 350, 40, name, TITLE, 28, "center", 411 + x), - e_text(x, 160, 350, 70, f"{who}\n{how}\n{use}", INK, 18, "center", 412 + x), + e_text(x, 110, 350, 40, name, TITLE, 28, 'center', 411 + x), + e_text(x, 160, 350, 70, f'{who}\n{how}\n{use}', INK, 18, 'center', 412 + x), ] - write_ex("arena.excalidraw", els, 1180, 340) + write_ex('arena.excalidraw', els, 1180, 340) def belief(): - title = "SIEM vs full state" - parts = [header(title, 980, 300, "Blue trains on logs - full map is diagnostics")] + title = 'SIEM vs full state' + parts = [ + header(title, 980, 300, 'Blue trains on logs - full map is diagnostics') + ] parts.append( card( - 36, 78, 420, 160, YEL, -0.12, - t(246, 130, 22, "800", TITLE, "SIEM belief") - + t(246, 162, 16, "600", INK, "what a SOC would see") - + t(246, 186, 16, "600", INK, "partial, delayed, noisy") - + t(246, 214, 14, "600", TITLE, "default for Blue"), + 36, + 78, + 420, + 160, + YEL, + -0.12, + t(246, 130, 22, '800', TITLE, 'SIEM belief') + + t(246, 162, 16, '600', INK, 'what a SOC would see') + + t(246, 186, 16, '600', INK, 'partial, delayed, noisy') + + t(246, 214, 14, '600', TITLE, 'default for Blue'), ) ) parts.append(arrow(468, 158, 524, 158, title, 150)) parts.append( card( - 534, 78, 410, 160, PINK, 0.14, - t(739, 130, 22, "800", TITLE, "oracle graph") - + t(739, 162, 16, "600", INK, "true compromise") - + t(739, 186, 16, "600", INK, "debug / replay") - + t(739, 214, 14, "600", TITLE, "not a Blue obs"), + 534, + 78, + 410, + 160, + PINK, + 0.14, + t(739, 130, 22, '800', TITLE, 'oracle graph') + + t(739, 162, 16, '600', INK, 'true compromise') + + t(739, 186, 16, '600', INK, 'debug / replay') + + t(739, 214, 14, '600', TITLE, 'not a Blue obs'), ) ) - parts.append(chip(220, 250, 540, 36, TEAL, -1.1, "netforge questions belief-vs-oracle")) + parts.append( + chip(220, 250, 540, 36, TEAL, -1.1, 'netforge questions belief-vs-oracle') + ) parts.append(close()) - write("belief.svg", "".join(parts)) + write('belief.svg', ''.join(parts)) write_ex( - "belief.excalidraw", + 'belief.excalidraw', [ - e_text(28, 12, 400, 32, "SIEM vs full state", TITLE, 26, "left", 500), + e_text(28, 12, 400, 32, 'SIEM vs full state', TITLE, 26, 'left', 500), e_rect(36, 78, 420, 160, YEL, -0.002, 501), - e_text(36, 120, 420, 80, "SIEM belief\nwhat a SOC would see", INK, 20, "center", 502), + e_text( + 36, + 120, + 420, + 80, + 'SIEM belief\nwhat a SOC would see', + INK, + 20, + 'center', + 502, + ), e_arrow(456, 158, 78, 0, 503), e_rect(534, 78, 410, 160, PINK, 0.002, 504), - e_text(534, 120, 410, 80, "oracle graph\ntrue compromise, debug only", INK, 20, "center", 505), + e_text( + 534, + 120, + 410, + 80, + 'oracle graph\ntrue compromise, debug only', + INK, + 20, + 'center', + 505, + ), ], 980, 300, ) + def main() -> None: (OUT / 'mark.svg').write_text(mark_svg(), encoding='utf-8') print('wrote mark.svg') diff --git a/docs/getting-started.md b/docs/getting-started.md index d895fa8..117437e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -21,7 +21,7 @@ import netforge_rl from pettingzoo import make from netforge_rl.environment.graph_wrapper import GraphObservationWrapper -env = GraphObservationWrapper(make("parallel", "netforge/ransomware-v4", max_ticks=80)) +env = GraphObservationWrapper(make('parallel', 'netforge/ransomware-v4', max_ticks=80)) obs, infos = env.reset(seed=0) ``` diff --git a/docs/index.md b/docs/index.md index e316fea..dd69a2c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -112,9 +112,9 @@ import netforge_rl import gymnasium as gym from pettingzoo import make -env = make("parallel", "netforge/ransomware-v4", max_ticks=80) +env = make('parallel', 'netforge/ransomware-v4', max_ticks=80) obs, infos = env.reset(seed=0) -blue = gym.make("NetForge/Blue-v4", max_ticks=80) +blue = gym.make('NetForge/Blue-v4', max_ticks=80) ``` Not on PyPI. Python 3.12+. Trainers stay in your repo. diff --git a/docs/interop/pettingzoo.md b/docs/interop/pettingzoo.md index 4110985..fb80d58 100644 --- a/docs/interop/pettingzoo.md +++ b/docs/interop/pettingzoo.md @@ -5,8 +5,8 @@ import netforge_rl import gymnasium as gym from pettingzoo import make -env = make("parallel", "netforge/ransomware-v4", max_ticks=200) -single = gym.make("NetForge/Blue-v4", max_ticks=200) +env = make('parallel', 'netforge/ransomware-v4', max_ticks=200) +single = gym.make('NetForge/Blue-v4', max_ticks=200) ``` | API | Id | diff --git a/docs/interop/soc_export.md b/docs/interop/soc_export.md index a9d6492..72329cf 100644 --- a/docs/interop/soc_export.md +++ b/docs/interop/soc_export.md @@ -6,7 +6,9 @@ from netforge_rl.environment.parallel_env import NetForgeRLEnv from netforge_rl.siem.export import export_ocsf, siem_to_ocsf -env = NetForgeRLEnv({'scenario_type': 'ransomware', 'max_ticks': 100, 'record_siem': True}) +env = NetForgeRLEnv( + {'scenario_type': 'ransomware', 'max_ticks': 100, 'record_siem': True} +) env.reset(seed=0) export_ocsf(env, 'runs/episode.ocsf.jsonl') siem_to_ocsf(log_line, subnet='10.0.0.0/24', tick=42) diff --git a/docs/python-quickstart.md b/docs/python-quickstart.md index 0105215..8164f36 100644 --- a/docs/python-quickstart.md +++ b/docs/python-quickstart.md @@ -13,12 +13,12 @@ import netforge_rl import gymnasium as gym from pettingzoo import make -env = make("parallel", "netforge/ransomware-v4", max_ticks=200) +env = make('parallel', 'netforge/ransomware-v4', max_ticks=200) obs, infos = env.reset(seed=0) actions = {agent: env.action_space(agent).sample() for agent in env.agents} obs, rewards, term, trunc, infos = env.step(actions) -blue = gym.make("NetForge/Blue-v4", max_ticks=200) +blue = gym.make('NetForge/Blue-v4', max_ticks=200) ``` | API | Id | | diff --git a/docs/submissions.md b/docs/submissions.md index 731615e..f1d7308 100644 --- a/docs/submissions.md +++ b/docs/submissions.md @@ -8,11 +8,13 @@ netforge evaluate examples/submissions/heuristic_blue.py --seeds 0 1 --max-ticks ``` ```python -NAME, TEAM, TECHNIQUE = "my-blue", "lab", "ppo-belief" +NAME, TEAM, TECHNIQUE = 'my-blue', 'lab', 'ppo-belief' + def wrap(env): return env # belief graphs already on; do not attach oracle_graph + def make_blue(seed=0): return MyPolicy(seed=seed) ``` diff --git a/docs/training/rllib.md b/docs/training/rllib.md index 974edc1..33cd69f 100644 --- a/docs/training/rllib.md +++ b/docs/training/rllib.md @@ -3,7 +3,7 @@ ```python from netforge_rl.bridges.rllib_bridge import NetForgeRLlibEnv -env = NetForgeRLlibEnv({"scenario_type": "ransomware", "max_ticks": 100}) +env = NetForgeRLlibEnv({'scenario_type': 'ransomware', 'max_ticks': 100}) ``` Reference: `python benchmarks/rllib_rmappo.py` — shared LSTM PPO, red + blue, @@ -16,21 +16,21 @@ from ray.rllib.algorithms.ppo import PPOConfig from netforge_rl.bridges.rllib_bridge import NetForgeRLlibEnv ray.init() -register_env("netforge-v0", lambda cfg: NetForgeRLlibEnv(cfg)) +register_env('netforge-v0', lambda cfg: NetForgeRLlibEnv(cfg)) config = ( PPOConfig() - .environment("netforge-v0", env_config={"scenario_type": "ransomware"}) + .environment('netforge-v0', env_config={'scenario_type': 'ransomware'}) .api_stack( enable_rl_module_and_learner=False, enable_env_runner_and_connector_v2=False, ) .multi_agent( policies={ - "red_rmappo": (None, None, None, {"model": {"use_lstm": True}}), - "blue_rmappo": (None, None, None, {"model": {"use_lstm": True}}), + 'red_rmappo': (None, None, None, {'model': {'use_lstm': True}}), + 'blue_rmappo': (None, None, None, {'model': {'use_lstm': True}}), }, policy_mapping_fn=lambda agent_id, *a, **kw: ( - "red_rmappo" if "red" in agent_id else "blue_rmappo" + 'red_rmappo' if 'red' in agent_id else 'blue_rmappo' ), ) .training(train_batch_size=1024) diff --git a/docs/training/scenarios.md b/docs/training/scenarios.md index 650b4d2..9047f55 100644 --- a/docs/training/scenarios.md +++ b/docs/training/scenarios.md @@ -6,6 +6,7 @@ Subclass `BaseScenario`: `calculate_reward(agent_id, global_state, effect)` and ```python from netforge_rl.scenarios.base_scenario import BaseScenario + class CustomRansomware(BaseScenario): def calculate_reward(self, agent_id, global_state, effect=None): reward = 0.0 @@ -29,8 +30,7 @@ class CustomRansomware(BaseScenario): def _blue_reward(self, global_state, effect): hosts = global_state.all_hosts.values() healthy = sum( - 1 for h in hosts - if h.compromised_by == 'None' and h.status != 'isolated' + 1 for h in hosts if h.compromised_by == 'None' and h.status != 'isolated' ) return healthy / len(global_state.all_hosts)