diff --git a/.github/workflows/publish-to-pypi-and-test-pypi.yml b/.github/workflows/publish-to-pypi-and-test-pypi.yml index 285aef0f..91784b91 100644 --- a/.github/workflows/publish-to-pypi-and-test-pypi.yml +++ b/.github/workflows/publish-to-pypi-and-test-pypi.yml @@ -3,72 +3,204 @@ name: Publish Python distributions to PyPI and TestPyPI on: push jobs: - build: - name: Build Python distribution - runs-on: ubuntu-latest + linux: + name: wheels (linux ${{ matrix.platform.target }}) + runs-on: ${{ matrix.platform.runner }} + strategy: + fail-fast: false + matrix: + platform: + - runner: ubuntu-22.04 + target: x86_64 + - runner: ubuntu-22.04 + target: aarch64 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.10" + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + manylinux: auto + - name: Test wheel (native target only) + if: matrix.platform.target == 'x86_64' + run: | + pip install mal-toolbox --find-links dist --force-reinstall + python -c "import maltoolbox" + - name: Upload wheels + uses: actions/upload-artifact@v6 + with: + name: wheels-linux-${{ matrix.platform.target }} + path: dist + + musllinux: + name: wheels (musllinux ${{ matrix.platform.target }}) + runs-on: ${{ matrix.platform.runner }} + strategy: + fail-fast: false + matrix: + platform: + - runner: ubuntu-22.04 + target: x86_64 + - runner: ubuntu-22.04 + target: aarch64 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.10" + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + manylinux: musllinux_1_2 + - name: Upload wheels + uses: actions/upload-artifact@v6 + with: + name: wheels-musllinux-${{ matrix.platform.target }} + path: dist - steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.x" + windows: + name: wheels (windows ${{ matrix.platform.target }}) + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + platform: + - target: x64 + python_arch: x64 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.10" + architecture: ${{ matrix.platform.python_arch }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + - name: Test wheel + run: | + pip install mal-toolbox --find-links dist --force-reinstall + python -c "import maltoolbox" + - name: Upload wheels + uses: actions/upload-artifact@v6 + with: + name: wheels-windows-${{ matrix.platform.target }} + path: dist - - name: Install pypa/build - run: >- - python3 -m - pip install - build - --user + macos: + name: wheels (macos ${{ matrix.platform.target }}) + runs-on: ${{ matrix.platform.runner }} + strategy: + fail-fast: false + matrix: + platform: + - runner: macos-15-intel + target: x86_64 + - runner: macos-latest + target: aarch64 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.10" + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + - name: Test wheel + run: | + pip install mal-toolbox --find-links dist --force-reinstall + python -c "import maltoolbox" + - name: Upload wheels + uses: actions/upload-artifact@v6 + with: + name: wheels-macos-${{ matrix.platform.target }} + path: dist - - name: Build a binary wheel and a source tarball - run: python3 -m build - - name: Store the distribution packages - uses: actions/upload-artifact@v4 - with: - name: python-package-distributions - path: dist/ + sdist: + name: sdist + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + - name: Upload sdist + uses: actions/upload-artifact@v6 + with: + name: wheels-sdist + path: dist - publish-to-pypi: - name: Publish Python distribution to PyPI - if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes - needs: - - build - runs-on: ubuntu-latest - environment: - name: pypi - url: https://pypi.org/p/mal-toolbox - permissions: - id-token: write # IMPORTANT: mandatory for trusted publishing + publish-to-testpypi: + name: Publish Python distribution to TestPyPI + if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes + needs: + - linux + - musllinux + - windows + - macos + - sdist + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/mal-toolbox + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing - steps: + steps: - name: Download all the dists - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: - name: python-package-distributions + pattern: wheels-* path: dist/ - - name: Publish distribution to PyPI + merge-multiple: true + - name: Publish distribution to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ - publish-to-testpypi: - name: Publish Python distribution to TestPyPI - if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes - needs: - - build - runs-on: ubuntu-latest - environment: - name: testpypi - url: https://test.pypi.org/p/mal-toolbox - permissions: - id-token: write # IMPORTANT: mandatory for trusted publishing + publish-to-pypi: + name: Publish Python distribution to PyPI + if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes + needs: + - linux + - musllinux + - windows + - macos + - sdist + - publish-to-testpypi # TestPyPI is a gate: must succeed before the real publish + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/mal-toolbox + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + attestations: write # for the GitHub-native build provenance attestation below - steps: + steps: - name: Download all the dists - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: - name: python-package-distributions + pattern: wheels-* path: dist/ - - name: Publish distribution to TestPyPI - uses: pypa/gh-action-pypi-publish@release/v1 + merge-multiple: true + - name: Generate artifact attestation + uses: actions/attest@v4 with: - repository-url: https://test.pypi.org/legacy/ + subject-path: "dist/*" + - name: Publish distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/run-mypy.yml b/.github/workflows/run-mypy.yml index 99b4ea71..63153266 100644 --- a/.github/workflows/run-mypy.yml +++ b/.github/workflows/run-mypy.yml @@ -20,4 +20,4 @@ jobs: - name: Type checking with MyPy run: | pip install mypy types-PyYAML - mypy maltoolbox tests --ignore-missing-imports + mypy python/maltoolbox tests --ignore-missing-imports diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 00000000..75d54527 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,51 @@ +name: Rust + +on: + push: + branches: + - main + pull_request: ~ + +permissions: + contents: read + +jobs: + fmt: + name: fmt (${{ matrix.workspace }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + workspace: + - . + - py-bindings + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: cargo fmt --check + working-directory: ${{ matrix.workspace }} + run: cargo fmt --all --check + + build: + name: build (${{ matrix.workspace }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + workspace: + - . + - py-bindings + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.x' + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: ${{ matrix.workspace }} + - name: cargo build + working-directory: ${{ matrix.workspace }} + run: cargo build --workspace --all-targets --locked diff --git a/.github/workflows/test-pytest.yml b/.github/workflows/test-pytest.yml index 270b6a11..d3573c8d 100644 --- a/.github/workflows/test-pytest.yml +++ b/.github/workflows/test-pytest.yml @@ -18,7 +18,7 @@ jobs: sudo apt-get update sudo apt-get install -y graphviz python -m pip install --upgrade pip - pip install . + pip install -e . - name: Test with pytest run: | pip install pytest diff --git a/.gitignore b/.gitignore index 098f91f8..63330859 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,20 @@ nosetests.xml !.github/workflows/*.yml !examples/resources/* +# Rust crate test fixtures/golden files (.json/.yml), not covered by the +# blanket Python-oriented *.json/*.yaml/*.yml ignores above +!core/**/*.json +!core/**/*.yml +!core/**/*.yaml + +# Rust +/target +# py-bindings/ is its own standalone Cargo workspace, so /target above +# doesn't reach it +/py-bindings/target +**/*.rs.bk +*.pdb + # uv lock file uv.lock @@ -73,3 +87,4 @@ uv.lock # Profiling Data prof/ +.langs/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..5a51a01e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,641 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "maltoolbox-attackgraph" +version = "0.1.0" +dependencies = [ + "indexmap", + "maltoolbox-io", + "maltoolbox-language", + "maltoolbox-model", + "rustc-hash", + "serde", + "serde_json", + "serde_yaml", + "slotmap", + "thiserror", +] + +[[package]] +name = "maltoolbox-cli" +version = "0.1.0" +dependencies = [ + "clap", + "maltoolbox-attackgraph", + "maltoolbox-io", + "maltoolbox-language", + "maltoolbox-model", + "maltoolbox-patternfinder", + "serde_json", + "serde_yaml", +] + +[[package]] +name = "maltoolbox-io" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "serde_yaml", + "thiserror", +] + +[[package]] +name = "maltoolbox-language" +version = "0.1.0" +dependencies = [ + "indexmap", + "maltoolbox-io", + "rustc-hash", + "serde", + "serde_json", + "serde_yaml", + "slotmap", + "thiserror", + "tree-sitter", + "tree-sitter-mal", + "zip", +] + +[[package]] +name = "maltoolbox-model" +version = "0.1.0" +dependencies = [ + "maltoolbox-io", + "maltoolbox-language", + "rustc-hash", + "serde", + "serde_json", + "serde_yaml", + "thiserror", +] + +[[package]] +name = "maltoolbox-patternfinder" +version = "0.1.0" +dependencies = [ + "maltoolbox-attackgraph", + "maltoolbox-language", + "maltoolbox-model", + "thiserror", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tree-sitter" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" + +[[package]] +name = "tree-sitter-mal" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ca67bb562b91b7224a339c0e67312fd873ad1f96a024a77d81c14aed4aa1aba" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..0ae904a9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,37 @@ +[workspace] +resolver = "2" +members = [ + "core/maltoolbox-io", + "core/maltoolbox-language", + "core/maltoolbox-model", + "core/maltoolbox-attackgraph", + "core/maltoolbox-patternfinder", + "core/maltoolbox-cli", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/mal-lang/mal-toolbox" +publish = false + +[workspace.dependencies] +maltoolbox-io = { path = "core/maltoolbox-io" } +maltoolbox-language = { path = "core/maltoolbox-language" } +maltoolbox-model = { path = "core/maltoolbox-model" } +maltoolbox-attackgraph = { path = "core/maltoolbox-attackgraph" } +maltoolbox-patternfinder = { path = "core/maltoolbox-patternfinder" } +maltoolbox-cli = { path = "core/maltoolbox-cli" } + +tree-sitter = "0.25" +tree-sitter-mal = "1.3.1" +indexmap = "2" +rustc-hash = "2" +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +serde_yaml = "0.9" +slotmap = "1" +thiserror = "2" +clap = { version = "4", features = ["derive"] } +zip = { version = "2", default-features = false, features = ["deflate"] } diff --git a/PORTING_NOTES.md b/PORTING_NOTES.md new file mode 100644 index 00000000..d8191ff1 --- /dev/null +++ b/PORTING_NOTES.md @@ -0,0 +1,192 @@ +# Porting notes: differences from mal-toolbox (Python) and remaining work + +This document tracks how this Rust rewrite differs from the upstream +Python [mal-toolbox](https://github.com/mal-lang/mal-toolbox), both by +deliberate scope decision and by behavior, and what's still left to do. +It complements the module-level doc comments in each crate (`//!` at the +top of each file), which have the precise rationale for any one piece — +this file is the cross-cutting summary. + +## 1. Scope: what's intentionally excluded + +Decided up front, not a gap: + +- **`visualization`** and **`translators`** packages — excluded entirely. +- **CLI subcommands** that depend on them: `upgrade-model` (needs + `translators.updater`), `visualize-model`, and `generate-attack-graph`'s + `--graphviz`/`--neo4j` flags. +- **`language_graph_from_git_url`** — needs a git clone at runtime; not + used by anything else in scope. +- Python-specific test categories with no Rust equivalent: pickling + (`test_attackgraph_pickle`, `test_model_pickle`), `copy.deepcopy` + semantics (`test_attackgraph_deepcopy`), and tests that mock Python + internals (`test_attackgraph_init`). + +## 2. Architecture: deliberate design differences + +These were chosen for Rust idioms, not accidents of translation — see +each crate's module docs for the full rationale. + +| Area | Python | Rust | +|---|---|---| +| Graph storage | Live object graph, `ModelAsset`/`LanguageGraphAsset` hold direct references to each other | Arena + generational keys (`slotmap`): `AssetId`/`AttackStepId`/`AttackGraphNodeId` are `Copy` keys into a `SlotMap`, not references | +| `AttackGraph` ↔ `Model` | `AttackGraph.model` is a persistent, mutable reference | `AttackGraph` holds no `Model` reference at all; every method that needs one (`to_dict`, regeneration, name resolution) takes `&Model` explicitly | +| MAL parsing traversal | Raw tree-sitter cursor walk, explicitly skipping `comment` extras (`go_to_sibling`) | tree-sitter's *field* API (`child_by_field_name`/`children_by_field_name`), which never includes `comment` nodes in the first place | +| JSON key order | Python `dict` preserves insertion order by default | `serde_json`'s `preserve_order` feature enabled workspace-wide, to match Python's dict-order-dependent wire format | +| Semantic analysis | Stateful visitor hooked into the same incremental tree-sitter walk as the compiler (`mal_analyzer.py`), accumulating state dicts node-by-node | A single, separate post-pass (`compiler::semantic::analyze`) over the already-compiled langspec, re-deriving the same inheritance-aware step/field/variable resolution Python's incremental hooks got for free | +| Expression chains | One dataclass (`ExpressionsChain`) with many `Optional` fields, validated post-hoc | A Rust `enum` with one variant per shape (`Binary`/`Field`/`Transitive`/`SubType`/`AssocOp`/`Multiplicity`), so each variant only carries the fields it needs | +| Own + inherited accessors | Python `@property`/`@cached_property` on the object itself (e.g. `attack_step.children`) | Rust methods take `&LanguageGraph` explicitly (e.g. `step.children(&graph)`), since the step doesn't hold a reference to the graph it lives in | + +- **Cosmetic compiler warnings, surfaced without a logging framework** + (`maltoolbox-language/src/compiler/{mod.rs,semantic.rs}`): + `mal_analyzer.py`'s two advisory-only `logger.warning` calls - "abstract + asset never extended" (`semantic.rs::warn_abstract_never_extended`) and + "duplicate CIA classification" (`mod.rs::visit_cias`) - are ported, but + printed with plain `eprintln!` rather than routed through a logging + crate, since this project doesn't port Python's `logging`/ + `maltoolbox.yml` config subsystem (see §7). Neither warning affects + compilation success or serialized output in either implementation; the + duplicate-CIA one in particular is detected during the raw tree-sitter + walk in `mod.rs`, not in `semantic.rs`'s later pass over the compiled + langspec, since by that point the `risk` flags are already deduplicated + booleans and the repeated letter is no longer visible. +- **`AssetSnapshot` avoids a dangling-id trap in partial + regeneration** (`maltoolbox-model/src/model.rs`, + `maltoolbox-attackgraph/src/{graph.rs,partially_generate.rs}`): + Python's object-reference model (see the "Graph storage" row above) + lets `Model.remove_asset` run before or after + `AttackGraph.partially_regenerate_graph` interchangeably, because a + removed `ModelAsset` object is still fully readable even after being + dropped from `Model.assets`. This port's `AttackGraphNode.model_asset` + stores only an `i64` id, so naively porting this would make call order + load-bearing: `partially_regenerate_graph` would need to look a removed + asset up by id in `model` to resolve its language type/name, which only + works *before* `Model.remove_asset` deletes it. Rather than document + that as a contract, `Model.remove_asset` now returns a + `AssetSnapshot { name, lg_asset, final_state }` - captured at the one + moment this port can still cheaply answer "what was this asset" - and + `partially_regenerate_graph`'s `removed_assets` parameter carries one + per id instead of a bare `HashSet`. Every lookup that used to need + `model.get_asset_by_id` for a possibly-removed id now reads the + snapshot instead, so the two methods can be called in either order with + no efficiency cost (no scanning; same O(1) lookups as before, just + sourced from the snapshot instead of a live `model` query). +- **`AttackGraph` deserialization doesn't stash `model` on the graph** + (`maltoolbox-attackgraph/src/graph.rs`, `AttackGraph::from_dict`/ + `load_from_file`): `attack_graph_from_dict` sets `attack_graph.model = + model` as a side effect, consistent with `AttackGraph.model` being a + persistent field in the first place (see the `AttackGraph` ↔ `Model` + row above). This port never had that field, so a caller that loads with + a model keeps it and passes `Some(&model)` to every method that needs + one, same as everywhere else. Two things `attack_graph_from_dict` + effectively discards are dropped here too, not ported: `Detector`s + (`node_dict['detectors']` is written by `to_dict` but never read back + by the Python original either) and `ModelAsset.attack_step_nodes` (a + `# TODO: deprecate this` Python-only back-reference list populated as a + side effect of loading; this port's `ModelAsset` never had the field). + +## 3. Confirmed upstream bugs fixed, not reproduced + +Unlike the deliberate design differences in §2, these are cases where an +upstream dependency or the Python oracle's behavior was confirmed buggy +and this project fixed it rather than working around or reproducing it, +since silently under-resolving or dropping data (or staying pinned to a +broken parser) seemed worse than wire-compatibility here. Documented at +the site in both implementations: + +- **`tree-sitter-mal` 1.3.0 can't parse unlabeled detector context — + fixed upstream in 1.3.1.** `! logExploit (step) [tpr: 0.1]` (no label + before the rate clause) parsed under the PyPI `tree_sitter_mal` 1.3.0 + wheel but failed — a missing/error node — under both the crates.io + `tree-sitter-mal` 1.3.0 crate and a from-source build of the `v1.3.0` + git tag itself. Four tests in `maltoolbox-language/tests/ + test_detectors.rs` were `#[ignore]`d with this reason rather than + deleted; the workspace now pins `tree-sitter-mal = "1.3.1"`, the four + tests pass unmodified, and the `#[ignore]` attributes have been + removed. +- **`*` transitive-closure operator didn't actually iterate** + (`maltoolbox-language/src/graph/assoc_traversal.rs`, + `glob_assoc_traversal`): `assoc_traversal_processor.py`'s + `_glob_assoc_traversal` recomputes both its seed and every loop + iteration from the same unchanging `instigating_assets` instead of the + growing result, so despite the while-loop shape it always resolves to + exactly one application of the pattern rather than a real closure. + Confirmed against the running Python oracle. The Rust port implements + an actual fixed-point closure, feeding the growing result back into the + pattern traversal each iteration. +- **Model metadata key mismatch** (`maltoolbox-model/src/file.rs`): + `to_dict` (`model.rs`) writes `"MAL-Toolbox Version"` (hyphenated), but + `_from_dict` in the Python original reads `"MAL Toolbox Version"` + (spaced), so the field never actually round-trips there and silently + falls back to the running tool's own version. The Rust port's + `from_dict` reads the same hyphenated key `to_dict` writes, so the + field round-trips correctly. +- **Mutual two-file `include` doesn't raise** (`maltoolbox-language/src/ + compiler/mod.rs`): `mal_compiler.py`'s own `visited_files` dedup + intercepts a repeated include *before* `mal_analyzer.py`'s + `_include_stack` cycle-detection logic can see it, and that same + dedup also means a cycle looping back to the *root* file is never + caught even when `_include_stack` itself does run, since the root is + never pushed onto it as an include target in the first place - only + files reached via an `include_declaration` are. A genuine two-file + mutual include (A includes B includes A) therefore silently compiles + in the real implementation. Confirmed by running the Python oracle, + not assumed from reading the source. The Rust port tracks the active + include chain (`compile_inner`'s `active` parameter) and raises a + `CompileError::Semantic("Include sequence contains cycle: ...")` for + any real cycle, while still allowing non-cyclic repeated includes + (a "diamond": both B and C include A) to compile once and short-circuit + on the repeat, same as before. + +## 4. Two real porting bugs caught before landing (for context) + +Not divergences — these were mistakes in the Rust translation itself, +caught by diffing against the running Python oracle before being +committed: + +- `quantity_filter` (a multiplicity qualifier like `field:4..10`) is a + field on *all three* traversal-chain element types in Python + (`AssocTraversal`/`GlobAssocTraversal`/`AssocSet` are all NamedTuples + with it as their last field) — an early draft only applied it to plain + field traversals, which broke on `multiplicity_lang.mal`'s `createFile` + step (the multiplicity there lands on a set-difference, not a field). +- A step-expression multiplicity's `min`/`max` are raw strings (the + compiler only normalizes *association* multiplicities, not + step-expression ones) — Python's `int(x)`-based parser tolerates this + silently; an early draft assumed already-numeric JSON and silently + dropped the filter. + +## 5. Testing methodology + +Every ported component was verified against a real, running Python +mal-toolbox (installed in a scratch venv), not hand-asserted from reading +the source: either by diffing serialized output directly +(`serde_json::Value` equality against Python-generated golden JSON — see +`*_golden.rs` in each crate), or by running small hand-written `.mal` +snippets through both implementations and checking they agree on +success/failure. This caught every divergence and bug listed in §2–§4. +Note that §3's fixes (`glob_assoc_traversal`, model-metadata round- +tripping, and include-cycle detection) make this port genuinely +*intentionally* diverge from the Python oracle's output for those three +cases — they'll disagree with Python by design. + +Current status: **117 passing tests, 0 `#[ignore]`d**. + +## 6. What's left to do + +- **Python interop (PyO3 bindings)** — explicitly left undecided/deferred + by the user. The core's public API shape is kept FFI-friendly in case + bindings get added later, but none exist yet. +- Everything else from the original scope is done: MAL compiler + + semantic analyzer, `LanguageGraph` builder (including model effects), + `Model`, `AttackGraph` (full build + partial regeneration + + deserialization), pattern finder, file I/O (`.mar`/JSON/YAML), and the + `maltoolbox` CLI (`compile`, `generate-attack-graph`). + +## 7. Minor CLI differences worth knowing about + +- `generate-attack-graph` takes an explicit `` argument. + The Python original instead wrote to a `maltoolbox.yml`-configured + debug path (defaulting to `logs/attackgraph.yml`) as a side effect; + that config/logging subsystem isn't ported, so an explicit argument + replaces an otherwise-silent write target. diff --git a/README.md b/README.md index fb70161c..e5736ba6 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ To run all tests, use the `pytest` command. To run just a specific file or test ## Making a release -1. Make a PR with one commit that updates the version number in `pyproject.toml` and `maltoolbox/__init__.py`. +1. Make a PR with one commit that updates the version number in `pyproject.toml` and `python/maltoolbox/__init__.py`. Follow [Semantic versioning](https://semver.org/). 2. Get the PR reviewed and merged to `main`. @@ -83,7 +83,7 @@ export MALTOOLBOX_CONFIG=path/to/yml/config/file The default configuration can be found here: -https://github.com/mal-lang/mal-toolbox/blob/main/maltoolbox/__init__.py#L39-L53 +https://github.com/mal-lang/mal-toolbox/blob/main/python/maltoolbox/__init__.py#L39-L53 ## Command Line Client diff --git a/core/maltoolbox-attackgraph/Cargo.toml b/core/maltoolbox-attackgraph/Cargo.toml new file mode 100644 index 00000000..eacfcc6f --- /dev/null +++ b/core/maltoolbox-attackgraph/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "maltoolbox-attackgraph" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[dependencies] +maltoolbox-io = { workspace = true } +maltoolbox-language = { workspace = true } +maltoolbox-model = { workspace = true } +indexmap = { workspace = true } +slotmap = { workspace = true } +rustc-hash = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } diff --git a/core/maltoolbox-attackgraph/src/detector.rs b/core/maltoolbox-attackgraph/src/detector.rs new file mode 100644 index 00000000..e4b8684f --- /dev/null +++ b/core/maltoolbox-attackgraph/src/detector.rs @@ -0,0 +1,14 @@ +//! Port of `maltoolbox/attackgraph/detector.py`. + +use std::collections::{HashMap, HashSet}; + +use crate::ids::AttackGraphNodeId; + +#[derive(Debug, Clone, PartialEq)] +pub struct Detector { + pub name: Option, + pub node: AttackGraphNodeId, + pub potential_context: HashMap>, + pub tprate: Option, + pub fprate: Option, +} diff --git a/core/maltoolbox-attackgraph/src/expr_follow.rs b/core/maltoolbox-attackgraph/src/expr_follow.rs new file mode 100644 index 00000000..d3fef0f3 --- /dev/null +++ b/core/maltoolbox-attackgraph/src/expr_follow.rs @@ -0,0 +1,138 @@ +//! Port of the `follow_*_expr_chain` family in +//! `maltoolbox/attackgraph/generate.py`: walks an +//! [`ExpressionsChain`] (produced by the language graph) over a +//! [`Model`]'s actual asset-association links, resolving a starting set +//! of model assets to the set of model assets reached by that chain. + +use rustc_hash::FxHashSet; + +use maltoolbox_language::graph::{ExprType, ExpressionsChain}; +use maltoolbox_model::Model; + +use crate::GraphError; + +pub fn follow_field_expr_chain( + model: &Model, + target_assets: &FxHashSet, + fieldname: &str, +) -> FxHashSet { + let mut result = FxHashSet::default(); + for &asset_id in target_assets { + if let Some(asset) = model.get_asset_by_id(asset_id) { + if let Some(linked) = asset.associated_assets.get(fieldname) { + result.extend(linked); + } + } + } + result +} + +pub fn follow_transitive_expr_chain( + model: &Model, + target_assets: &FxHashSet, + sub_link: &ExpressionsChain, +) -> Result, GraphError> { + let mut accumulated = target_assets.clone(); + loop { + let stepped = follow_expr_chain(model, &accumulated, Some(sub_link))?; + let new_assets: FxHashSet = stepped.difference(&accumulated).copied().collect(); + if new_assets.is_empty() { + break; + } + accumulated.extend(new_assets); + } + Ok(accumulated) +} + +pub fn follow_subtype_expr_chain( + model: &Model, + target_assets: &FxHashSet, + sub_link: &ExpressionsChain, + subtype: maltoolbox_language::graph::AssetId, +) -> Result, GraphError> { + let candidates = follow_expr_chain(model, target_assets, Some(sub_link))?; + let mut selected = FxHashSet::default(); + for asset_id in candidates { + let Some(asset) = model.get_asset_by_id(asset_id) else { + continue; + }; + if model.lang_graph.is_subasset_of(asset.lg_asset, subtype) { + selected.insert(asset_id); + } + } + Ok(selected) +} + +pub fn follow_set_op_expr_chain( + model: &Model, + target_assets: &FxHashSet, + op: ExprType, + left: Option<&ExpressionsChain>, + right: Option<&ExpressionsChain>, +) -> Result, GraphError> { + let lh_targets = follow_expr_chain(model, target_assets, left)?; + let rh_targets = follow_expr_chain(model, target_assets, right)?; + + Ok(match op { + ExprType::Union => lh_targets.union(&rh_targets).copied().collect(), + ExprType::Intersection => lh_targets.intersection(&rh_targets).copied().collect(), + ExprType::Difference => lh_targets.difference(&rh_targets).copied().collect(), + other => { + return Err(GraphError::Malformed(format!( + "Expr chain must be of type union, intersection or difference, got {other:?}" + ))) + } + }) +} + +pub fn follow_collect_expr_chain( + model: &Model, + target_assets: &FxHashSet, + left: Option<&ExpressionsChain>, + right: Option<&ExpressionsChain>, +) -> Result, GraphError> { + let lh_targets = follow_expr_chain(model, target_assets, left)?; + let mut rh_targets = FxHashSet::default(); + for lh_target in lh_targets { + let singleton = FxHashSet::from_iter([lh_target]); + rh_targets.extend(follow_expr_chain(model, &singleton, right)?); + } + Ok(rh_targets) +} + +pub fn follow_expr_chain( + model: &Model, + target_assets: &FxHashSet, + expr_chain: Option<&ExpressionsChain>, +) -> Result, GraphError> { + let Some(chain) = expr_chain else { + return Ok(target_assets.clone()); + }; + + match chain { + ExpressionsChain::Binary { op, left, right } + if op.is_binary() && *op != ExprType::Collect => + { + follow_set_op_expr_chain(model, target_assets, *op, left.as_deref(), right.as_deref()) + } + ExpressionsChain::Binary { op, left, right } if *op == ExprType::Collect => { + follow_collect_expr_chain(model, target_assets, left.as_deref(), right.as_deref()) + } + ExpressionsChain::Binary { op, .. } => Err(GraphError::Malformed(format!( + "Unknown attack expressions chain type: {op:?}" + ))), + ExpressionsChain::Field { fieldname, .. } => { + Ok(follow_field_expr_chain(model, target_assets, fieldname)) + } + ExpressionsChain::Transitive { sub } => { + follow_transitive_expr_chain(model, target_assets, sub) + } + ExpressionsChain::SubType { sub, subtype } => { + follow_subtype_expr_chain(model, target_assets, sub, *subtype) + } + other => Err(GraphError::Malformed(format!( + "Unknown attack expressions chain type: {:?}", + other.expr_type() + ))), + } +} diff --git a/core/maltoolbox-attackgraph/src/factories.rs b/core/maltoolbox-attackgraph/src/factories.rs new file mode 100644 index 00000000..84011167 --- /dev/null +++ b/core/maltoolbox-attackgraph/src/factories.rs @@ -0,0 +1,46 @@ +//! Port of `maltoolbox/attackgraph/factories.py`. +//! +//! The Python original's `maltoolbox.yml`-driven debug dumps of the +//! loaded langspec/langgraph/model are not ported; that config/logging +//! subsystem has no equivalent here. + +use std::path::Path; +use std::rc::Rc; + +use maltoolbox_model::Model; + +use crate::{AttackGraph, GraphError}; + +#[derive(Debug, thiserror::Error)] +pub enum FactoryError { + #[error("{0}")] + LanguageLoad(#[from] maltoolbox_language::LanguageLoadError), + #[error("{0}")] + ModelLoad(#[from] maltoolbox_model::LoadError), + #[error("{0}")] + Graph(#[from] GraphError), +} + +/// Build an [`AttackGraph`] from a language file path (`.mar` or `.mal`, +/// trying `.mar` first and falling back to `.mal` on failure) and a model +/// file path. +/// +/// Returns the loaded [`Model`] alongside the graph, since callers need +/// it for anything that resolves node names from model assets, e.g. +/// `AttackGraph::to_dict`/`save_to_file`. +pub fn create_attack_graph( + lang_path: impl AsRef, + model_path: impl AsRef, +) -> Result<(AttackGraph, Model), FactoryError> { + let lang_path = lang_path.as_ref(); + let lang_graph = match maltoolbox_language::from_mar_archive(lang_path) { + Ok(graph) => graph, + Err(_) => maltoolbox_language::from_mal_spec(lang_path)?, + }; + let lang_graph = Rc::new(lang_graph); + + let model = maltoolbox_model::load_from_file(model_path, lang_graph)?; + let attack_graph = AttackGraph::from_model(&model)?; + + Ok((attack_graph, model)) +} diff --git a/core/maltoolbox-attackgraph/src/generate.rs b/core/maltoolbox-attackgraph/src/generate.rs new file mode 100644 index 00000000..8d73c2ab --- /dev/null +++ b/core/maltoolbox-attackgraph/src/generate.rs @@ -0,0 +1,352 @@ +//! Port of `maltoolbox/attackgraph/generate.py`. + +use std::collections::{HashMap, HashSet}; + +use maltoolbox_language::graph::attack_step::AttackStepType; +use maltoolbox_language::graph::AttackStepId; +use maltoolbox_model::{AssetSnapshot, Model, ModelAsset}; +use rustc_hash::FxHashSet; +use serde_json::Map; +use slotmap::SlotMap; + +use crate::detector::Detector; +use crate::expr_follow::follow_expr_chain; +use crate::ids::AttackGraphNodeId; +use crate::node::AttackGraphNode; +use crate::ttcs::get_ttc_dist; +use crate::{FxIndexMap, GraphError}; + +pub struct GeneratedGraph { + pub nodes: SlotMap, + /// `IndexMap`, matching `AttackGraph::id_to_node`/`full_name_to_node`: + /// insertion order here is node creation order, which must be deterministic. + pub id_to_node: FxIndexMap, + pub attack_steps: Vec, + pub defense_steps: Vec, + pub full_name_to_node: FxIndexMap, +} + +/// Just-created nodes, produced by [`create_nodes_for`] directly into a +/// caller-supplied `SlotMap` (as opposed to [`GeneratedGraph`], which owns a +/// fresh one). Needed for partial regeneration, since slotmap keys are only +/// valid within the `SlotMap` instance that created them. +pub struct CreatedNodes { + pub id_to_node: FxIndexMap, + pub full_name_to_node: FxIndexMap, + pub attack_steps: Vec, + pub defense_steps: Vec, +} + +/// Existence status for `exist`/`notExist` steps: `true` if any +/// requirement expression resolves to at least one target asset, `false` +/// if all are empty, `None` for other step types. +pub fn get_existence_status( + model: &Model, + asset_id: i64, + lg_step_id: AttackStepId, +) -> Result, GraphError> { + let lg_step = model.lang_graph.step(lg_step_id); + if !matches!( + lg_step.step_type, + AttackStepType::Exist | AttackStepType::NotExist + ) { + return Ok(None); + } + + for requirement in lg_step.requires(&model.lang_graph) { + let targets = + follow_expr_chain(model, &FxHashSet::from_iter([asset_id]), Some(&requirement))?; + if !targets.is_empty() { + return Ok(Some(true)); + } + } + Ok(Some(false)) +} + +pub fn create_nodes_from_model( + nodes: &mut SlotMap, + model: &Model, +) -> Result { + create_nodes_for( + nodes, + model.asset_order.iter().copied(), + 0, + model, + &HashMap::new(), + ) +} + +/// Port of `partially_generate.py`'s `create_nodes_from_assets`: build nodes +/// only for `asset_ids`, continuing node-id assignment from `starting_id`. +/// +/// `removed_assets` is consulted as a fallback when an id in `asset_ids` is +/// no longer in `model.assets` - a DynaMAL model effect can add and remove +/// the same asset within one `partially_regenerate_graph` batch. +pub fn create_nodes_from_assets( + nodes: &mut SlotMap, + asset_ids: &std::collections::HashSet, + starting_id: i64, + model: &Model, + removed_assets: &HashMap, +) -> Result { + create_nodes_for( + nodes, + asset_ids.iter().copied(), + starting_id, + model, + removed_assets, + ) +} + +fn create_nodes_for( + nodes: &mut SlotMap, + asset_ids: impl Iterator, + starting_id: i64, + model: &Model, + removed_assets: &HashMap, +) -> Result { + let mut id_to_node = FxIndexMap::default(); + let mut full_name_to_node = FxIndexMap::default(); + let mut attack_steps = Vec::new(); + let mut defense_steps = Vec::new(); + let mut node_id: i64 = starting_id; + + for asset_id in asset_ids { + // `asset_id` may already be gone from `model.assets` - a same-batch + // add-then-remove (e.g. a DynaMAL step whose `A>`/`R>` model effects + // both touch the same asset) leaves it in both `new_assets` and + // `removed_assets`. Any node built here is transient in that case: + // `nodes_to_be_removed` removes it again right after. + let asset: &ModelAsset = match model.assets.get(&asset_id) { + Some(asset) => asset, + None => removed_assets + .get(&asset_id) + .map(|snapshot| &snapshot.final_state) + .ok_or_else(|| GraphError::Malformed(format!("Unknown asset id {asset_id}")))?, + }; + let lg_step_ids: Vec = model + .lang_graph + .asset(asset.lg_asset) + .attack_steps + .values() + .copied() + .collect(); + + for lg_step_id in lg_step_ids { + let lg_step = model.lang_graph.step(lg_step_id); + let full_name = format!("{}:{}", asset.name, lg_step.name); + let existence_status = get_existence_status(model, asset_id, lg_step_id)?; + + let node = AttackGraphNode { + id: node_id, + lg_attack_step: lg_step_id, + name: lg_step.name.clone(), + step_type: lg_step.step_type, + causal_mode: lg_step.causal_mode, + ttc: get_ttc_dist(asset, lg_step), + tags: lg_step.tags.clone(), + additive_model_effects: crate::node::non_empty( + lg_step.additive_model_effects(&model.lang_graph), + ), + subtractive_model_effects: crate::node::non_empty( + lg_step.subtractive_model_effects(&model.lang_graph), + ), + model_asset: Some(asset_id), + existence_status, + children: FxHashSet::default(), + parents: FxHashSet::default(), + extras: Map::new(), + detectors: HashMap::new(), + full_name_override: None, + }; + + let step_type = node.step_type; + let key = nodes.insert(node); + id_to_node.insert(node_id, key); + full_name_to_node.insert(full_name, key); + match step_type { + AttackStepType::Or | AttackStepType::And => attack_steps.push(key), + AttackStepType::Defense => defense_steps.push(key), + _ => {} + } + node_id += 1; + } + } + + Ok(CreatedNodes { + id_to_node, + full_name_to_node, + attack_steps, + defense_steps, + }) +} + +/// Port of `link_node_children`. Calls `LanguageGraphAttackStep::children` +/// (already own+inherited, unless overridden) once; the Python original +/// also walks the `inherits` chain, but that only recomputes children +/// already included recursively, which are no-ops against the +/// `children`/`parents` sets. Behaviorally identical, less redundant work. +pub fn link_node_children( + model: &Model, + nodes: &mut SlotMap, + ag_node_key: AttackGraphNodeId, + full_name_to_node: &FxIndexMap, +) -> Result<(), GraphError> { + let (model_asset_id, lg_attack_step_id) = { + let node = &nodes[ag_node_key]; + let asset_id = node.model_asset.ok_or_else(|| { + GraphError::Malformed("Attack graph node is missing asset link".into()) + })?; + (asset_id, node.lg_attack_step) + }; + + let children = model + .lang_graph + .step(lg_attack_step_id) + .children(&model.lang_graph); + + for (child_lg_step, chains) in children { + let child_step_name = model.lang_graph.step(child_lg_step).name.clone(); + for chain in chains { + link_from_expr_chain( + model, + nodes, + ag_node_key, + model_asset_id, + &child_step_name, + chain.as_ref(), + full_name_to_node, + )?; + } + } + Ok(()) +} + +fn link_from_expr_chain( + model: &Model, + nodes: &mut SlotMap, + ag_node_key: AttackGraphNodeId, + model_asset_id: i64, + child_step_name: &str, + expr_chain: Option<&maltoolbox_language::graph::ExpressionsChain>, + full_name_to_node: &FxIndexMap, +) -> Result<(), GraphError> { + let target_assets = + follow_expr_chain(model, &FxHashSet::from_iter([model_asset_id]), expr_chain)?; + + for target_asset_id in target_assets { + let target_asset = model + .get_asset_by_id(target_asset_id) + .expect("follow_expr_chain only returns existing asset ids"); + let full_name = format!("{}:{child_step_name}", target_asset.name); + + let target_key = *full_name_to_node.get(&full_name).ok_or_else(|| { + let (ag_full_name, ag_id) = { + let node = &nodes[ag_node_key]; + let owner_name = model + .get_asset_by_id(model_asset_id) + .map(|a| a.name.as_str()) + .unwrap_or("?"); + (format!("{owner_name}:{}", node.name), node.id) + }; + GraphError::StepExpression(format!( + "Failed to find target node \"{full_name}\" for \"{ag_full_name}\"({ag_id})" + )) + })?; + + nodes[ag_node_key].children.insert(target_key); + nodes[target_key].parents.insert(ag_node_key); + } + Ok(()) +} + +pub fn link_nodes_by_language( + model: &Model, + nodes: &mut SlotMap, + full_name_to_node: &FxIndexMap, +) -> Result<(), GraphError> { + let keys: Vec = full_name_to_node.values().copied().collect(); + for key in keys { + link_node_children(model, nodes, key, full_name_to_node)?; + } + Ok(()) +} + +fn get_potential_context( + model: &Model, + asset_id: i64, + full_name_to_node: &FxIndexMap, + lg_detector: &maltoolbox_language::graph::LanguageGraphDetector, +) -> Result>, GraphError> { + let mut context: HashMap> = HashMap::new(); + for (label, item) in &lg_detector.context { + let targets = + follow_expr_chain(model, &FxHashSet::from_iter([asset_id]), item.expr.as_ref())?; + for target_asset_id in targets { + let target_asset = model + .get_asset_by_id(target_asset_id) + .expect("follow_expr_chain only returns existing asset ids"); + let step_name = item.attack_step_name.clone().unwrap_or_default(); + let full_name = format!("{}:{step_name}", target_asset.name); + let target_key = *full_name_to_node.get(&full_name).ok_or_else(|| { + GraphError::Malformed(format!( + "Failed to find target node for context item \"{label}\" with asset \"{}\" and attack step \"{step_name}\"", + target_asset.name + )) + })?; + context.entry(label.clone()).or_default().insert(target_key); + } + } + Ok(context) +} + +pub fn create_detectors( + nodes: &mut SlotMap, + full_name_to_node: &FxIndexMap, + model: &Model, +) -> Result<(), GraphError> { + let node_keys: Vec = full_name_to_node.values().copied().collect(); + + for node_key in node_keys { + let (model_asset_id, lg_step_id) = { + let node = &nodes[node_key]; + let asset_id = node.model_asset.ok_or_else(|| { + GraphError::Malformed("Attack graph node is missing asset link".into()) + })?; + (asset_id, node.lg_attack_step) + }; + + let lg_detectors = model.lang_graph.step(lg_step_id).detectors.clone(); + let mut node_detectors = HashMap::new(); + for (label, lg_detector) in &lg_detectors { + let potential_context = + get_potential_context(model, model_asset_id, full_name_to_node, lg_detector)?; + node_detectors.insert( + label.clone(), + Detector { + name: lg_detector.name.clone(), + node: node_key, + potential_context, + tprate: lg_detector.tprate, + fprate: lg_detector.fprate, + }, + ); + } + nodes[node_key].detectors = node_detectors; + } + Ok(()) +} + +pub fn generate_graph(model: &Model) -> Result { + let mut nodes = SlotMap::with_key(); + let created = create_nodes_from_model(&mut nodes, model)?; + link_nodes_by_language(model, &mut nodes, &created.full_name_to_node)?; + create_detectors(&mut nodes, &created.full_name_to_node, model)?; + Ok(GeneratedGraph { + nodes, + id_to_node: created.id_to_node, + attack_steps: created.attack_steps, + defense_steps: created.defense_steps, + full_name_to_node: created.full_name_to_node, + }) +} diff --git a/core/maltoolbox-attackgraph/src/graph.rs b/core/maltoolbox-attackgraph/src/graph.rs new file mode 100644 index 00000000..81ba6b0a --- /dev/null +++ b/core/maltoolbox-attackgraph/src/graph.rs @@ -0,0 +1,592 @@ +//! Port of `maltoolbox/attackgraph/attackgraph.py`'s `AttackGraph` class, +//! including `attack_graph_from_dict`/`attack_graph_from_file` +//! (`AttackGraph::from_dict`/`AttackGraph::load_from_file` here). +//! +//! Holds `lang_graph` persistently (compiled once, never mutated, so an +//! `Rc` is safe to share) but *not* `model` - methods that need the model +//! take `&Model` explicitly instead, unlike the Python original which +//! stashes `model` on the returned `AttackGraph`. +//! +//! Two behaviors are intentionally not reconstructed on load, matching what +//! the Python original effectively discards too: `Detector`s (a loaded +//! graph's `detectors` are always empty), and `ModelAsset.attack_step_nodes` +//! (a Python-only back-reference list this port's `ModelAsset` never had). + +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; + +use maltoolbox_language::graph::LanguageGraph; +use maltoolbox_model::Model; +use rustc_hash::FxHashSet; +use serde_json::{json, Map, Value}; +use slotmap::SlotMap; + +use crate::generate::{self, GeneratedGraph}; +use crate::ids::AttackGraphNodeId; +use crate::node::AttackGraphNode; +use crate::node_getters::get_node_by_full_name; +use crate::partially_generate::{self, AssocAffectedDict}; +use crate::{FxIndexMap, GraphError}; + +/// `Clone` deep-copies node storage (the `SlotMap`/`IndexMap`s, each holding +/// `Clone`-able `AttackGraphNode`s) while cheaply sharing `lang_graph` (an +/// `Rc`, never mutated after construction). The cloned graph's +/// `AttackGraphNodeId` keys, and hence every node's `children`/`parents` +/// cross-references, stay valid and mutually consistent with no manual +/// re-linking required. +#[derive(Clone)] +pub struct AttackGraph { + pub lang_graph: Rc, + pub nodes: SlotMap, + /// `IndexMap`, not `HashMap`: iteration order over these two maps feeds + /// node-by-id/by-name lookups whose failure paths and downstream + /// consumers rely on deterministic, insertion-matching order. A plain + /// `HashMap`'s randomized per-process order would make attack-graph + /// generation output nondeterministic. + pub id_to_node: FxIndexMap, + pub full_name_to_node: FxIndexMap, + pub attack_steps: Vec, + pub defense_steps: Vec, + pub next_node_id: i64, +} + +impl AttackGraph { + /// An empty graph with no nodes, mirroring `AttackGraph(lang_graph)` + /// (no `model`). + pub fn empty(lang_graph: Rc) -> Self { + AttackGraph { + lang_graph, + nodes: SlotMap::with_key(), + id_to_node: FxIndexMap::default(), + full_name_to_node: FxIndexMap::default(), + attack_steps: Vec::new(), + defense_steps: Vec::new(), + next_node_id: 0, + } + } + + pub fn from_model(model: &Model) -> Result { + let generated = generate::generate_graph(model)?; + Ok(Self::from_generated(model.lang_graph.clone(), generated)) + } + + fn from_generated(lang_graph: Rc, generated: GeneratedGraph) -> Self { + let next_node_id = generated + .id_to_node + .keys() + .max() + .map(|&m| m + 1) + .unwrap_or(0); + AttackGraph { + lang_graph, + nodes: generated.nodes, + id_to_node: generated.id_to_node, + full_name_to_node: generated.full_name_to_node, + attack_steps: generated.attack_steps, + defense_steps: generated.defense_steps, + next_node_id, + } + } + + /// Rebuild the whole graph from `model`, discarding the current + /// contents. See `crate::generate::GeneratedGraph` for the full + /// rebuild. + pub fn regenerate_graph(&mut self, model: &Model) -> Result<(), GraphError> { + let generated = generate::generate_graph(model)?; + let rebuilt = Self::from_generated(model.lang_graph.clone(), generated); + *self = rebuilt; + Ok(()) + } + + /// Port of `AttackGraph.partially_regenerate_graph`. Updates the graph + /// incrementally for the given model changes instead of a full + /// `regenerate_graph` rebuild. `new_associations`/`removed_associations` + /// are `(left_asset_id, fieldname, right_asset_id)` triples, mirroring + /// the Python original's `(ModelAsset, str, ModelAsset)` tuples. + /// Returns the newly created attack/defense step nodes. + /// + /// Unlike the Python original, `removed_assets` carries a + /// [`maltoolbox_model::AssetSnapshot`] per id (returned by + /// `Model::remove_asset`) rather than a bare id, since nodes only store + /// `model_asset: i64` and can't recover a removed asset's type/name from + /// `model` once it's gone. This also means `model.remove_asset` and this + /// method can be called in either order. + pub fn partially_regenerate_graph( + &mut self, + model: &Model, + new_assets: &HashSet, + new_associations: &HashSet<(i64, String, i64)>, + removed_assets: &HashMap, + removed_associations: &HashSet<(i64, String, i64)>, + ) -> Result, GraphError> { + let created = generate::create_nodes_from_assets( + &mut self.nodes, + new_assets, + self.next_node_id, + model, + removed_assets, + )?; + + self.next_node_id += created.id_to_node.len() as i64; + self.id_to_node.extend(created.id_to_node); + self.attack_steps + .extend(created.attack_steps.iter().copied()); + self.defense_steps + .extend(created.defense_steps.iter().copied()); + self.full_name_to_node + .extend(created.full_name_to_node.clone()); + + for &key in created.full_name_to_node.values() { + generate::link_node_children(model, &mut self.nodes, key, &self.full_name_to_node)?; + } + + let mut removed_assoc_dict: AssocAffectedDict = AssocAffectedDict::default(); + for (left_id, fieldname, right_id) in removed_associations { + removed_assoc_dict + .entry(*left_id) + .or_default() + .entry(fieldname.clone()) + .or_default() + .insert(*right_id); + // `left_id` may already be gone from `model`, hence the + // snapshot-aware lookup rather than a plain `switch_fieldname`. + let opposite = partially_generate::switch_fieldname_possibly_removed( + model, + *left_id, + fieldname, + removed_assets, + )?; + removed_assoc_dict + .entry(*right_id) + .or_default() + .entry(opposite) + .or_default() + .insert(*left_id); + } + + let mut new_assoc_dict: AssocAffectedDict = AssocAffectedDict::default(); + for (left_id, fieldname, right_id) in new_associations { + new_assoc_dict + .entry(*left_id) + .or_default() + .entry(fieldname.clone()) + .or_default() + .insert(*right_id); + // `left_id` may already be gone from `model`, hence the + // snapshot-aware lookup rather than a plain `switch_fieldname`. + let opposite = partially_generate::switch_fieldname_possibly_removed( + model, + *left_id, + fieldname, + removed_assets, + )?; + new_assoc_dict + .entry(*right_id) + .or_default() + .entry(opposite) + .or_default() + .insert(*left_id); + } + + let mut affected_assoc_dict: AssocAffectedDict = AssocAffectedDict::default(); + for assoc_dict in [&new_assoc_dict, &removed_assoc_dict] { + for (asset_id, fields) in assoc_dict { + let dest_fields = affected_assoc_dict.entry(*asset_id).or_default(); + for (fieldname, assets) in fields { + dest_fields + .entry(fieldname.clone()) + .or_default() + .extend(assets); + } + } + } + + let nodes_of_modified_assoc = partially_generate::assoc_affected_nodes( + model, + &affected_assoc_dict, + &self.full_name_to_node, + )?; + for key in nodes_of_modified_assoc { + partially_generate::correct_node_children_on_modified_assoc( + model, + &mut self.nodes, + key, + &self.full_name_to_node, + )?; + } + + generate::create_detectors(&mut self.nodes, &created.full_name_to_node, model)?; + + let removal_candidates = partially_generate::nodes_to_be_removed( + removed_assets, + model, + &self.full_name_to_node, + )?; + for candidate in removal_candidates { + self.remove_node(candidate)?; + } + + let mut result: HashSet = created.attack_steps.into_iter().collect(); + result.extend(created.defense_steps); + Ok(result) + } + + pub fn get_node_by_full_name(&self, full_name: &str) -> Result { + get_node_by_full_name(&self.full_name_to_node, full_name) + } + + /// `AttackGraphNode::full_name`: explicit override, else + /// `"{model asset name}:{step name}"` when `model` resolves the + /// node's asset, else the `"{id}:{name}"` fallback. + pub fn full_name_of(&self, key: AttackGraphNodeId, model: Option<&Model>) -> String { + let node = &self.nodes[key]; + if let Some(explicit) = &node.full_name_override { + return explicit.clone(); + } + if let (Some(model), Some(asset_id)) = (model, node.model_asset) { + if let Some(asset) = model.get_asset_by_id(asset_id) { + return format!("{}:{}", asset.name, node.name); + } + } + node.fallback_full_name() + } + + #[allow(clippy::too_many_arguments)] + pub fn add_node( + &mut self, + lg_attack_step: maltoolbox_language::graph::AttackStepId, + model: Option<&Model>, + node_id: Option, + model_asset: Option, + ttc_dist: Option, + existence_status: Option, + full_name: Option, + ) -> Result { + let node_id = node_id.unwrap_or(self.next_node_id); + if self.id_to_node.contains_key(&node_id) { + return Err(GraphError::DuplicateNodeId(node_id)); + } + self.next_node_id = node_id + 1; + + let lg_step = self.lang_graph.step(lg_attack_step); + let node = AttackGraphNode { + id: node_id, + lg_attack_step, + name: lg_step.name.clone(), + step_type: lg_step.step_type, + causal_mode: lg_step.causal_mode, + ttc: ttc_dist.or_else(|| lg_step.ttc.clone()), + tags: lg_step.tags.clone(), + additive_model_effects: crate::node::non_empty( + lg_step.additive_model_effects(&self.lang_graph), + ), + subtractive_model_effects: crate::node::non_empty( + lg_step.subtractive_model_effects(&self.lang_graph), + ), + model_asset, + existence_status, + children: Default::default(), + parents: Default::default(), + extras: Map::new(), + detectors: HashMap::new(), + full_name_override: full_name, + }; + + let step_type = node.step_type; + let computed_full_name = { + if let Some(explicit) = &node.full_name_override { + explicit.clone() + } else if let (Some(model), Some(asset_id)) = (model, model_asset) { + match model.get_asset_by_id(asset_id) { + Some(asset) => format!("{}:{}", asset.name, node.name), + None => node.fallback_full_name(), + } + } else { + node.fallback_full_name() + } + }; + + let key = self.nodes.insert(node); + self.id_to_node.insert(node_id, key); + self.full_name_to_node.insert(computed_full_name, key); + match step_type { + maltoolbox_language::graph::attack_step::AttackStepType::Or + | maltoolbox_language::graph::attack_step::AttackStepType::And => { + self.attack_steps.push(key) + } + maltoolbox_language::graph::attack_step::AttackStepType::Defense => { + self.defense_steps.push(key) + } + _ => {} + } + + Ok(key) + } + + /// Removes a node, returning its final state - captured after the + /// parent/child unlinking loops below (so `children`/`parents` on the + /// returned value are empty) but before the node is dropped from + /// `self.nodes`. Mirrors `Model::remove_asset`'s `AssetSnapshot` + /// capture-point placement, for the same reason: it's the only way to + /// keep a removed node's handle readable afterward. + pub fn remove_node(&mut self, key: AttackGraphNodeId) -> Result { + let (id, children, parents) = { + let node = &self.nodes[key]; + (node.id, node.children.clone(), node.parents.clone()) + }; + for child in children { + self.nodes[child].parents.remove(&key); + } + for parent in parents { + self.nodes[parent].children.remove(&key); + } + + let full_name = self + .full_name_to_node + .iter() + .find_map(|(name, &k)| (k == key).then(|| name.clone())); + + let final_state = self.nodes[key].clone(); + self.nodes.remove(key); + // `shift_remove` (not `swap_remove`): preserves relative order of + // the remaining entries, matching Python's `del dict[k]` semantics. + self.id_to_node.shift_remove(&id); + if let Some(name) = full_name { + self.full_name_to_node.shift_remove(&name); + } + self.attack_steps.retain(|&k| k != key); + self.defense_steps.retain(|&k| k != key); + Ok(final_state) + } + + pub fn to_dict(&self, model: Option<&Model>) -> Value { + let mut steps = Map::new(); + for key in self.nodes.keys() { + let full_name = self.full_name_of(key, model); + steps.insert(full_name, self.node_to_dict(key, model)); + } + json!({ "attack_steps": steps }) + } + + pub fn save_to_file( + &self, + model: Option<&Model>, + path: impl AsRef, + ) -> Result<(), maltoolbox_io::FileUtilError> { + maltoolbox_io::save_dict_to_file(path, &self.to_dict(model)) + } + + /// Port of `attack_graph_from_dict`: rebuild an [`AttackGraph`] from + /// the shape produced by [`AttackGraph::to_dict`]. `model` is + /// optional, same as the Python original - when absent, nodes keep + /// the full name recorded in `serialized` verbatim + /// (`full_name_override`) rather than recomputing one from a model + /// asset that isn't available. + pub fn from_dict( + serialized: &Value, + lang_graph: Rc, + model: Option<&Model>, + ) -> Result { + let mut attack_graph = AttackGraph::empty(lang_graph.clone()); + let serialized_steps = serialized["attack_steps"].as_object().ok_or_else(|| { + GraphError::Malformed("attack graph dict missing \"attack_steps\"".to_string()) + })?; + + for (node_full_name, node_dict) in serialized_steps { + let node_asset_id = match (model, node_dict.get("asset").and_then(Value::as_str)) { + (Some(model), Some(asset_name)) => { + let asset = model.get_asset_by_name(asset_name).ok_or_else(|| { + GraphError::Malformed(format!( + "Failed to find asset with name \"{asset_name}\" when loading from attack graph dict" + )) + })?; + Some(asset.id) + } + _ => None, + }; + + let lg_full_name = node_dict["lang_graph_attack_step"] + .as_str() + .ok_or_else(|| { + GraphError::Malformed(format!( + "attack graph node \"{node_full_name}\" missing \"lang_graph_attack_step\"" + )) + })?; + let (lg_asset_name, lg_step_name) = lg_full_name.split_once(':').ok_or_else(|| { + GraphError::Malformed(format!( + "malformed lang_graph_attack_step \"{lg_full_name}\"" + )) + })?; + let lg_asset_id = lang_graph.asset_id(lg_asset_name).ok_or_else(|| { + GraphError::Malformed(format!( + "Failed to find asset type \"{lg_asset_name}\" in language graph" + )) + })?; + let lg_step_id = *lang_graph + .asset(lg_asset_id) + .attack_steps + .get(lg_step_name) + .ok_or_else(|| { + GraphError::Malformed(format!( + "Failed to find attack step \"{lg_step_name}\" on asset type \"{lg_asset_name}\"" + )) + })?; + + let node_id = node_dict["id"].as_i64().ok_or_else(|| { + GraphError::Malformed(format!( + "attack graph node \"{node_full_name}\" missing \"id\"" + )) + })?; + let ttc_dist = match node_dict.get("ttc") { + Some(Value::Null) | None => None, + Some(v) => Some(v.clone()), + }; + let existence_status = node_dict.get("existence_status").and_then(Value::as_bool); + + let key = attack_graph.add_node( + lg_step_id, + model, + Some(node_id), + node_asset_id, + ttc_dist, + existence_status, + if model.is_none() { + Some(node_full_name.clone()) + } else { + None + }, + )?; + + attack_graph.nodes[key].tags = node_dict + .get("tags") + .and_then(Value::as_array) + .map(|tags| { + tags.iter() + .filter_map(|t| t.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + attack_graph.nodes[key].extras = node_dict + .get("extras") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + } + + // Re-establish links between nodes, now that every node exists. + let resolve_relation = |id_to_node: &FxIndexMap, + node_dict: &Value, + relation: &'static str| + -> Result, GraphError> { + let mut resolved = FxHashSet::default(); + if let Some(entries) = node_dict.get(relation).and_then(Value::as_object) { + for id_str in entries.keys() { + let id: i64 = id_str.parse().map_err(|_| { + GraphError::Malformed(format!("malformed {relation} id \"{id_str}\"")) + })?; + let related_key = *id_to_node.get(&id).ok_or_else(|| { + GraphError::Malformed(format!( + "Failed to find {relation} node with id {id} when loading from attack graph from dict" + )) + })?; + resolved.insert(related_key); + } + } + Ok(resolved) + }; + + for node_dict in serialized_steps.values() { + let node_id = node_dict["id"].as_i64().expect("validated above"); + let key = *attack_graph.id_to_node.get(&node_id).ok_or_else(|| { + GraphError::Malformed(format!( + "Failed to find node with id {node_id} when loading attack graph from dict" + )) + })?; + + let children = resolve_relation(&attack_graph.id_to_node, node_dict, "children")?; + let parents = resolve_relation(&attack_graph.id_to_node, node_dict, "parents")?; + attack_graph.nodes[key].children = children; + attack_graph.nodes[key].parents = parents; + } + + Ok(attack_graph) + } + + /// Port of `AttackGraph.load_from_file`: `from_dict` over a + /// JSON/YAML file, dispatching on the file extension. + pub fn load_from_file( + path: impl AsRef, + lang_graph: Rc, + model: Option<&Model>, + ) -> Result { + let serialized = maltoolbox_io::load_dict_from_file(path)?; + Self::from_dict(&serialized, lang_graph, model) + } + + /// Serializes a single node the same way `to_dict` does, mirroring the + /// Python original's `AttackGraphNode.to_dict`. + pub fn node_to_dict(&self, key: AttackGraphNodeId, model: Option<&Model>) -> Value { + let node = &self.nodes[key]; + + let mut children = Map::new(); + for &child_key in &node.children { + children.insert( + self.nodes[child_key].id.to_string(), + Value::String(self.full_name_of(child_key, model)), + ); + } + let mut parents = Map::new(); + for &parent_key in &node.parents { + parents.insert( + self.nodes[parent_key].id.to_string(), + Value::String(self.full_name_of(parent_key, model)), + ); + } + + let mut dict = json!({ + "id": node.id, + "type": node.step_type.as_str(), + "lang_graph_attack_step": self.lang_graph.step(node.lg_attack_step).full_name(&self.lang_graph), + "name": node.name, + "ttc": node.ttc, + "children": children, + "parents": parents, + }); + + // Python's `Detector.to_dict()` embeds live object references that + // aren't actually JSON-serializable; this port serializes just + // `name` and `tprate`. + if !node.detectors.is_empty() { + let mut detectors = Map::new(); + for detector in node.detectors.values() { + let key_name = detector.name.clone().unwrap_or_default(); + detectors.insert( + key_name, + json!({ + "name": detector.name, + "tprate": detector.tprate, + }), + ); + } + dict["detectors"] = Value::Object(detectors); + } + if let Some(asset_id) = node.model_asset { + if let Some(model) = model { + if let Some(asset) = model.get_asset_by_id(asset_id) { + dict["asset"] = Value::String(asset.name.clone()); + } + } + } + if let Some(status) = node.existence_status { + dict["existence_status"] = Value::Bool(status); + } + if !node.tags.is_empty() { + dict["tags"] = Value::Array(node.tags.iter().cloned().map(Value::String).collect()); + } + if !node.extras.is_empty() { + dict["extras"] = Value::Object(node.extras.clone()); + } + + dict + } +} diff --git a/core/maltoolbox-attackgraph/src/ids.rs b/core/maltoolbox-attackgraph/src/ids.rs new file mode 100644 index 00000000..4947bf53 --- /dev/null +++ b/core/maltoolbox-attackgraph/src/ids.rs @@ -0,0 +1,11 @@ +use slotmap::new_key_type; + +new_key_type! { + /// Generational key for an [`crate::node::AttackGraphNode`]. + /// + /// Distinct from `AttackGraphNode::id` (the stable, user/file-facing + /// `i64`): `partially_regenerate_graph` adds and removes nodes at + /// runtime, and a generational key lets a handle held across such a + /// mutation detect "gone" instead of silently aliasing a reused slot. + pub struct AttackGraphNodeId; +} diff --git a/core/maltoolbox-attackgraph/src/lib.rs b/core/maltoolbox-attackgraph/src/lib.rs new file mode 100644 index 00000000..3e7ed5e6 --- /dev/null +++ b/core/maltoolbox-attackgraph/src/lib.rs @@ -0,0 +1,50 @@ +//! Port of mal-toolbox's `maltoolbox/attackgraph/` package: builds an +//! [`AttackGraph`] of concrete attack/defense steps from a [`Model`] and +//! its [`maltoolbox_language::graph::LanguageGraph`]. +//! +//! Unlike the Python original, [`AttackGraph`] does not hold a +//! persistent reference to the [`Model`] it was built from - every +//! method that needs one (`to_dict`, regeneration, full-name resolution) +//! takes `&Model` as an explicit parameter instead. Python's +//! `self.model` works because the same mutable object is shared between +//! caller and graph; doing that in Rust would require wrapping `Model` +//! in `Rc>` just so external code can mutate it out from +//! under a struct that otherwise only reads it. + +pub mod detector; +pub mod expr_follow; +pub mod factories; +pub mod generate; +pub mod graph; +pub mod ids; +pub mod node; +pub mod node_getters; +pub mod partially_generate; +pub mod ttcs; + +pub use detector::Detector; +pub use factories::{create_attack_graph, FactoryError}; +pub use graph::AttackGraph; +pub use ids::AttackGraphNodeId; +pub use node::AttackGraphNode; + +/// Insertion-ordered map hashed with FxHash, for the graph's node lookup +/// tables: order stays deterministic (see `AttackGraph::id_to_node`), and +/// their keys are trusted, so SipHash's DoS resistance buys nothing. +pub type FxIndexMap = indexmap::IndexMap; + +#[derive(Debug, thiserror::Error)] +pub enum GraphError { + #[error("{0}")] + Malformed(String), + #[error("{0}")] + StepExpression(String), + #[error("Node index {0} already in use.")] + DuplicateNodeId(i64), + #[error("{0}")] + Language(#[from] maltoolbox_language::graph::GraphError), + #[error("{0}")] + Model(#[from] maltoolbox_model::ModelError), + #[error("{0}")] + FileUtil(#[from] maltoolbox_io::FileUtilError), +} diff --git a/core/maltoolbox-attackgraph/src/node.rs b/core/maltoolbox-attackgraph/src/node.rs new file mode 100644 index 00000000..88255082 --- /dev/null +++ b/core/maltoolbox-attackgraph/src/node.rs @@ -0,0 +1,63 @@ +//! Port of `maltoolbox/attackgraph/node.py`'s `AttackGraphNode`. +//! +//! `AttackGraph` does not keep a persistent `Model` reference, so +//! resolving a node's full name requires a `&Model` passed in; see +//! `AttackGraph::full_name_of` and `AttackGraph::to_dict`. +//! +//! `additive_model_effects`/`subtractive_model_effects` are plain data +//! copied through from the language graph; mal-toolbox itself never +//! applies them to a `Model` - a downstream consumer (e.g. mal-simulator) +//! interprets them. + +use std::collections::HashMap; + +use rustc_hash::FxHashSet; + +use serde_json::{Map, Value}; + +use maltoolbox_language::graph::attack_step::{AttackStepType, CausalMode}; +use maltoolbox_language::graph::model_effect::LanguageGraphModelEffect; + +use crate::detector::Detector; +use crate::ids::AttackGraphNodeId; + +#[derive(Debug, Clone)] +pub struct AttackGraphNode { + pub id: i64, + pub lg_attack_step: maltoolbox_language::graph::AttackStepId, + pub name: String, + pub step_type: AttackStepType, + pub causal_mode: Option, + pub ttc: Option, + pub tags: Vec, + pub additive_model_effects: Option>, + pub subtractive_model_effects: Option>, + pub model_asset: Option, + pub existence_status: Option, + pub children: FxHashSet, + pub parents: FxHashSet, + pub extras: Map, + pub detectors: HashMap, + /// Explicitly-set full name, used when a node is loaded from a file + /// without an accompanying model (see `attack_graph_from_dict`). + pub full_name_override: Option, +} + +/// Returns `Some(effects)` when non-empty, else `None`. +pub fn non_empty(effects: Vec) -> Option> { + if effects.is_empty() { + None + } else { + Some(effects) + } +} + +impl AttackGraphNode { + /// Fallback full name (`"{id}:{name}"`) used when no model asset is + /// available to resolve a name from. Prefer `AttackGraph::full_name_of` + /// when a model is available, since it also honors + /// `full_name_override` and the model-asset-derived name. + pub fn fallback_full_name(&self) -> String { + format!("{}:{}", self.id, self.name) + } +} diff --git a/core/maltoolbox-attackgraph/src/node_getters.rs b/core/maltoolbox-attackgraph/src/node_getters.rs new file mode 100644 index 00000000..58c89b82 --- /dev/null +++ b/core/maltoolbox-attackgraph/src/node_getters.rs @@ -0,0 +1,67 @@ +//! Port of `maltoolbox/attackgraph/node_getters.py`, plus the +//! `levenshtein_distance` helper from `maltoolbox/str_utils.py` (inlined +//! here since it has no other caller in this crate). + +use crate::ids::AttackGraphNodeId; +use crate::{FxIndexMap, GraphError}; + +fn levenshtein_distance(a: &str, b: &str) -> usize { + if a == b { + return 0; + } + if a.is_empty() { + return b.chars().count(); + } + if b.is_empty() { + return a.chars().count(); + } + + let b_chars: Vec = b.chars().collect(); + let mut prev_row: Vec = (0..=b_chars.len()).collect(); + + for ca in a.chars() { + let mut curr_row = vec![prev_row[0] + 1]; + for (j, &cb) in b_chars.iter().enumerate() { + let insertions = prev_row[j + 1] + 1; + let deletions = curr_row[j] + 1; + let substitutions = prev_row[j] + usize::from(ca != cb); + curr_row.push(insertions.min(deletions).min(substitutions)); + } + prev_row = curr_row; + } + + *prev_row.last().unwrap() +} + +pub fn get_similar_full_names( + full_name_to_node: &FxIndexMap, + query: &str, +) -> Vec { + let mut shortest_dist = 100usize; + let mut similar_names = Vec::new(); + + for full_name in full_name_to_node.keys() { + let dist = levenshtein_distance(query, full_name); + if dist == shortest_dist { + similar_names.push(full_name.clone()); + } else if dist < shortest_dist { + similar_names = vec![full_name.clone()]; + shortest_dist = dist; + } + } + + similar_names +} + +pub fn get_node_by_full_name( + full_name_to_node: &FxIndexMap, + full_name: &str, +) -> Result { + full_name_to_node.get(full_name).copied().ok_or_else(|| { + let similar_names = get_similar_full_names(full_name_to_node, full_name); + GraphError::Malformed(format!( + "Could not find node with name \"{full_name}\". Did you mean: {}?", + similar_names.join(", ") + )) + }) +} diff --git a/core/maltoolbox-attackgraph/src/partially_generate.rs b/core/maltoolbox-attackgraph/src/partially_generate.rs new file mode 100644 index 00000000..e3a94eef --- /dev/null +++ b/core/maltoolbox-attackgraph/src/partially_generate.rs @@ -0,0 +1,579 @@ +//! Port of `maltoolbox/attackgraph/partially_generate.py`: incrementally +//! update an [`crate::AttackGraph`] in response to model changes, instead +//! of a full [`crate::generate::generate_graph`] rebuild. + +use std::collections::{HashMap, HashSet}; + +use rustc_hash::{FxHashMap, FxHashSet}; + +use maltoolbox_language::graph::{AssetId, ExprType, ExpressionsChain}; +use maltoolbox_model::{AssetSnapshot, Model}; +use slotmap::SlotMap; + +use crate::expr_follow::{follow_expr_chain, follow_field_expr_chain}; +use crate::ids::AttackGraphNodeId; +use crate::node::AttackGraphNode; +use crate::{FxIndexMap, GraphError}; + +/// `affected_assoc_dict`: per model-asset-id, per-fieldname, the set of +/// other asset ids an association add/remove touched. +pub type AssocAffectedDict = FxHashMap>>; + +pub fn switch_fieldname( + model: &Model, + asset_id: i64, + fieldname: &str, +) -> Result { + let asset = model + .get_asset_by_id(asset_id) + .ok_or_else(|| GraphError::Malformed(format!("Unknown asset id {asset_id}")))?; + switch_fieldname_for_lg_asset(model, asset.lg_asset, &asset.name, fieldname) +} + +/// Same lookup as `switch_fieldname`, but keyed off an already-known +/// language-graph asset type + name rather than an instance id, so it +/// works even if the asset has been removed from `model` (the caller +/// supplies the type/name from an [`AssetSnapshot`]). +fn switch_fieldname_for_lg_asset( + model: &Model, + lg_asset: AssetId, + asset_name: &str, + fieldname: &str, +) -> Result { + let assoc = model + .lang_graph + .associations(lg_asset) + .get(fieldname) + .cloned() + .ok_or_else(|| { + GraphError::Malformed(format!( + "Fieldname {fieldname} not found in associations of asset {asset_name}" + )) + })?; + if fieldname == assoc.left_field.fieldname { + Ok(assoc.right_field.fieldname.clone()) + } else if fieldname == assoc.right_field.fieldname { + Ok(assoc.left_field.fieldname.clone()) + } else { + Err(GraphError::Malformed(format!( + "Fieldname {fieldname} not found in association {}", + assoc.name + ))) + } +} + +/// Resolves the opposite fieldname for `(asset_id, fieldname)`, where +/// `asset_id` may already have been removed from `model` - in which case +/// `removed` must contain its snapshot. +pub fn switch_fieldname_possibly_removed( + model: &Model, + asset_id: i64, + fieldname: &str, + removed: &HashMap, +) -> Result { + if let Some(snapshot) = removed.get(&asset_id) { + switch_fieldname_for_lg_asset(model, snapshot.lg_asset, &snapshot.name, fieldname) + } else { + switch_fieldname(model, asset_id, fieldname) + } +} + +/// Recompute `affected_key`'s children from the model's current +/// associations (the expression chain's actual target may be several +/// hops past the association that changed), and reconcile against what +/// is currently linked. +pub fn correct_node_children_on_modified_assoc( + model: &Model, + nodes: &mut SlotMap, + affected_key: AttackGraphNodeId, + full_name_to_node: &FxIndexMap, +) -> Result<(), GraphError> { + let (model_asset_id, lg_attack_step_id) = { + let node = &nodes[affected_key]; + let asset_id = node.model_asset.ok_or_else(|| { + GraphError::Malformed("Attack graph node is missing asset link".into()) + })?; + (asset_id, node.lg_attack_step) + }; + + let children = model + .lang_graph + .step(lg_attack_step_id) + .children(&model.lang_graph); + + let mut correct_children: FxHashSet = FxHashSet::default(); + for (child_lg_step, chains) in children { + let child_step_name = model.lang_graph.step(child_lg_step).name.clone(); + for chain in chains { + let target_assets = follow_expr_chain( + model, + &FxHashSet::from_iter([model_asset_id]), + chain.as_ref(), + )?; + for target_asset_id in target_assets { + let target_asset = model + .get_asset_by_id(target_asset_id) + .expect("follow_expr_chain only returns existing asset ids"); + let full_name = format!("{}:{child_step_name}", target_asset.name); + let target_key = *full_name_to_node.get(&full_name).ok_or_else(|| { + let node = &nodes[affected_key]; + let owner_name = model + .get_asset_by_id(model_asset_id) + .map(|a| a.name.as_str()) + .unwrap_or("?"); + let affected_full_name = format!("{owner_name}:{}", node.name); + GraphError::Malformed(format!( + "Failed to find target node \"{full_name}\" for \"{affected_full_name}\"({})", + node.id + )) + })?; + correct_children.insert(target_key); + } + } + } + + // Reconcile in place rather than cloning `children` to diff against: + // this runs once per affected node, and hub nodes have large child sets. + let mut removed = Vec::new(); + let mut added = Vec::new(); + let children = &mut nodes[affected_key].children; + children.retain(|target_key| { + let keep = correct_children.contains(target_key); + if !keep { + removed.push(*target_key); + } + keep + }); + for target_key in correct_children { + if children.insert(target_key) { + added.push(target_key); + } + } + for target_key in added { + nodes[target_key].parents.insert(affected_key); + } + for target_key in removed { + nodes[target_key].parents.remove(&affected_key); + } + + Ok(()) +} + +/// `removed_assets` carries a [`AssetSnapshot`] per id rather than a bare +/// `FxHashSet` so this works for ids already gone from `model`; only +/// `model.lang_graph` (unaffected by instance-model mutation) is needed. +pub fn nodes_to_be_removed( + removed_assets: &HashMap, + model: &Model, + full_name_to_node: &FxIndexMap, +) -> Result, GraphError> { + let mut removal_candidates = FxHashSet::default(); + for snapshot in removed_assets.values() { + let lg_step_ids: Vec<_> = model + .lang_graph + .asset(snapshot.lg_asset) + .attack_steps + .values() + .copied() + .collect(); + for lg_step_id in lg_step_ids { + let lg_step = model.lang_graph.step(lg_step_id); + let full_name = format!("{}:{}", snapshot.name, lg_step.name); + let node_key = full_name_to_node.get(&full_name).copied().ok_or_else(|| { + GraphError::Malformed(format!( + "Failed to find {} for removed asset {}.", + lg_step.full_name(&model.lang_graph), + snapshot.name + )) + })?; + removal_candidates.insert(node_key); + } + } + Ok(removal_candidates) +} + +pub fn assoc_affected_expr_chain( + model: &Model, + instigating_assets: &FxHashSet, + affected_assoc_dict: &AssocAffectedDict, + expr_chain: Option<&ExpressionsChain>, +) -> Result { + let modified_fieldnames: HashSet = affected_assoc_dict + .values() + .flat_map(|fields| fields.keys().cloned()) + .collect(); + assoc_affected_expr_chain_inner( + model, + instigating_assets, + affected_assoc_dict, + expr_chain, + &modified_fieldnames, + ) +} + +fn assoc_affected_expr_chain_inner( + model: &Model, + instigating_assets: &FxHashSet, + affected_assoc_dict: &AssocAffectedDict, + expr_chain: Option<&ExpressionsChain>, + modified_fieldnames: &HashSet, +) -> Result { + let Some(chain) = expr_chain else { + return Ok(false); + }; + if instigating_assets.is_empty() { + return Ok(false); + } + if chain.fieldnames().is_disjoint(modified_fieldnames) { + return Ok(false); + } + + match chain { + ExpressionsChain::Field { fieldname, .. } => { + Ok(instigating_assets.iter().any(|asset_id| { + affected_assoc_dict + .get(asset_id) + .map(|fields| fields.contains_key(fieldname)) + .unwrap_or(false) + })) + } + ExpressionsChain::Binary { op, left, right } if *op != ExprType::Collect => { + Ok(assoc_affected_expr_chain_inner( + model, + instigating_assets, + affected_assoc_dict, + left.as_deref(), + modified_fieldnames, + )? || assoc_affected_expr_chain_inner( + model, + instigating_assets, + affected_assoc_dict, + right.as_deref(), + modified_fieldnames, + )?) + } + ExpressionsChain::Binary { op, left, right } if *op == ExprType::Collect => { + use maltoolbox_language::graph::expr_chain::chain_fieldnames; + if !chain_fieldnames(left.as_deref()).is_disjoint(modified_fieldnames) + && assoc_affected_expr_chain_inner( + model, + instigating_assets, + affected_assoc_dict, + left.as_deref(), + modified_fieldnames, + )? + { + return Ok(true); + } + if chain_fieldnames(right.as_deref()).is_disjoint(modified_fieldnames) { + return Ok(false); + } + let next_assets = follow_expr_chain(model, instigating_assets, left.as_deref())?; + assoc_affected_expr_chain_inner( + model, + &next_assets, + affected_assoc_dict, + right.as_deref(), + modified_fieldnames, + ) + } + ExpressionsChain::SubType { sub, .. } => assoc_affected_expr_chain_inner( + model, + instigating_assets, + affected_assoc_dict, + Some(sub), + modified_fieldnames, + ), + ExpressionsChain::Transitive { sub } => { + let mut frontier = instigating_assets.clone(); + let mut visited: FxHashSet = FxHashSet::default(); + while !frontier.is_empty() { + if assoc_affected_expr_chain_inner( + model, + &frontier, + affected_assoc_dict, + Some(sub), + modified_fieldnames, + )? { + return Ok(true); + } + visited.extend(&frontier); + let stepped = follow_expr_chain(model, &frontier, Some(sub))?; + frontier = stepped.difference(&visited).copied().collect(); + } + Ok(false) + } + other => Err(GraphError::Malformed(format!( + "Unknown expression chain type: {:?}", + other.expr_type() + ))), + } +} + +pub fn assoc_left_assets( + model: &Model, + affected_assoc_dict: &AssocAffectedDict, + expr_chain: Option<&ExpressionsChain>, + modified_fieldnames: &HashSet, +) -> Result, GraphError> { + let Some(chain) = expr_chain else { + return Ok(FxHashSet::default()); + }; + if chain.fieldnames().is_disjoint(modified_fieldnames) { + return Ok(FxHashSet::default()); + } + + use maltoolbox_language::graph::expr_chain::chain_fieldnames; + + match chain { + ExpressionsChain::Field { + association, + fieldname, + } => Ok(affected_assoc_dict + .iter() + .filter(|(asset_id, fields)| { + fields.contains_key(fieldname) + && model + .get_asset_by_id(**asset_id) + .and_then(|a| { + model + .lang_graph + .associations(a.lg_asset) + .get(fieldname) + .cloned() + }) + .map(|a| *a == **association) + .unwrap_or(false) + }) + .map(|(asset_id, _)| *asset_id) + .collect()), + ExpressionsChain::Binary { op, left, right } if *op != ExprType::Collect => { + let mut result = assoc_left_assets( + model, + affected_assoc_dict, + left.as_deref(), + modified_fieldnames, + )?; + result.extend(assoc_left_assets( + model, + affected_assoc_dict, + right.as_deref(), + modified_fieldnames, + )?); + Ok(result) + } + ExpressionsChain::Binary { op, left, right } if *op == ExprType::Collect => { + let mut roots = FxHashSet::default(); + if !chain_fieldnames(left.as_deref()).is_disjoint(modified_fieldnames) { + roots.extend(assoc_left_assets( + model, + affected_assoc_dict, + left.as_deref(), + modified_fieldnames, + )?); + } + if !chain_fieldnames(right.as_deref()).is_disjoint(modified_fieldnames) { + let right_roots = assoc_left_assets( + model, + affected_assoc_dict, + right.as_deref(), + modified_fieldnames, + )?; + if !right_roots.is_empty() { + roots.extend(follow_reverse_expr_chain( + model, + &right_roots, + left.as_deref(), + )?); + } + } + Ok(roots) + } + ExpressionsChain::SubType { sub, .. } => { + assoc_left_assets(model, affected_assoc_dict, Some(sub), modified_fieldnames) + } + ExpressionsChain::Transitive { sub } => { + let seed_roots = + assoc_left_assets(model, affected_assoc_dict, Some(sub), modified_fieldnames)?; + let mut roots = seed_roots.clone(); + let mut frontier = seed_roots; + while !frontier.is_empty() { + let stepped = follow_reverse_expr_chain(model, &frontier, Some(sub))?; + frontier = stepped.difference(&roots).copied().collect(); + roots.extend(&frontier); + } + Ok(roots) + } + other => Err(GraphError::Malformed(format!( + "Unknown expression chain type: {:?}", + other.expr_type() + ))), + } +} + +/// Walks `expr_chain` backwards from `target_assets`: returns the assets +/// that following `expr_chain` forwards could reach `target_assets` from. +/// Unlike following [`reverse_expr_chain`]'s output, this applies a +/// `[Subtype]` filter to the assets the reversed hop starts from (where the +/// forward chain applies it), not to the assets it ends at. +/// +/// [`reverse_expr_chain`]: maltoolbox_language::graph::step_expr::reverse_expr_chain +fn follow_reverse_expr_chain( + model: &Model, + target_assets: &FxHashSet, + expr_chain: Option<&ExpressionsChain>, +) -> Result, GraphError> { + let Some(chain) = expr_chain else { + return Ok(target_assets.clone()); + }; + + match chain { + ExpressionsChain::Binary { op, left, right } if *op == ExprType::Collect => { + let rh_sources = follow_reverse_expr_chain(model, target_assets, right.as_deref())?; + follow_reverse_expr_chain(model, &rh_sources, left.as_deref()) + } + ExpressionsChain::Binary { op, left, right } => { + let lh_sources = follow_reverse_expr_chain(model, target_assets, left.as_deref())?; + let rh_sources = follow_reverse_expr_chain(model, target_assets, right.as_deref())?; + Ok(match op { + ExprType::Union => lh_sources.union(&rh_sources).copied().collect(), + ExprType::Intersection => lh_sources.intersection(&rh_sources).copied().collect(), + ExprType::Difference => lh_sources.difference(&rh_sources).copied().collect(), + other => { + return Err(GraphError::Malformed(format!( + "Expr chain must be of type union, intersection or difference, got {other:?}" + ))) + } + }) + } + ExpressionsChain::Field { + association, + fieldname, + } => { + let opposite = association.get_opposite_fieldname(fieldname)?; + Ok(follow_field_expr_chain(model, target_assets, &opposite)) + } + ExpressionsChain::Transitive { sub } => { + let mut accumulated = target_assets.clone(); + loop { + let stepped = follow_reverse_expr_chain(model, &accumulated, Some(sub))?; + let new_assets: FxHashSet = + stepped.difference(&accumulated).copied().collect(); + if new_assets.is_empty() { + break; + } + accumulated.extend(new_assets); + } + Ok(accumulated) + } + ExpressionsChain::SubType { sub, subtype } => { + let selected: FxHashSet = target_assets + .iter() + .copied() + .filter(|&asset_id| { + model.get_asset_by_id(asset_id).is_some_and(|asset| { + model.lang_graph.is_subasset_of(asset.lg_asset, *subtype) + }) + }) + .collect(); + follow_reverse_expr_chain(model, &selected, Some(sub)) + } + other => Err(GraphError::Malformed(format!( + "Unknown attack expressions chain type: {:?}", + other.expr_type() + ))), + } +} + +pub fn assoc_affected_nodes( + model: &Model, + affected_assoc_dict: &AssocAffectedDict, + full_name_to_node: &FxIndexMap, +) -> Result, GraphError> { + let modified_fieldnames: HashSet = affected_assoc_dict + .values() + .flat_map(|fields| fields.keys().cloned()) + .collect(); + + let candidate_map = model.lang_graph.fieldname_to_candidate_steps(); + let mut candidate_steps: FxHashSet<(String, String)> = FxHashSet::default(); + for fieldname in &modified_fieldnames { + if let Some(steps) = candidate_map.get(fieldname) { + candidate_steps.extend(steps.iter().cloned()); + } + } + + let mut assets_by_type: Option>> = None; + + let mut ret_nodes = FxHashSet::default(); + for (asset_type, step_name) in candidate_steps { + let Some(asset_type_id) = model.lang_graph.asset_id(&asset_type) else { + continue; + }; + let Some(&lg_step_id) = model + .lang_graph + .asset(asset_type_id) + .attack_steps + .get(&step_name) + else { + continue; + }; + let lg_step = model.lang_graph.step(lg_step_id); + + let mut affected_assets: FxHashSet = FxHashSet::default(); + for chains in lg_step.children(&model.lang_graph).values() { + for chain in chains { + let Some(chain) = chain else { continue }; + if chain.is_additive() { + affected_assets.extend(assoc_left_assets( + model, + affected_assoc_dict, + Some(chain), + &modified_fieldnames, + )?); + continue; + } + if assets_by_type.is_none() { + let mut by_type: FxHashMap> = FxHashMap::default(); + for &asset_id in &model.asset_order { + let asset = &model.assets[&asset_id]; + by_type + .entry(asset.asset_type.clone()) + .or_default() + .push(asset_id); + } + assets_by_type = Some(by_type); + } + if let Some(candidates) = assets_by_type.as_ref().and_then(|m| m.get(&asset_type)) { + for &asset_id in candidates { + if assoc_affected_expr_chain( + model, + &FxHashSet::from_iter([asset_id]), + affected_assoc_dict, + Some(chain), + )? { + affected_assets.insert(asset_id); + } + } + } + } + } + + for asset_id in affected_assets { + let asset = &model.assets[&asset_id]; + if !model + .lang_graph + .asset(asset.lg_asset) + .attack_steps + .contains_key(&step_name) + { + continue; + } + let full_name = format!("{}:{step_name}", asset.name); + if let Some(&node_key) = full_name_to_node.get(&full_name) { + ret_nodes.insert(node_key); + } + } + } + Ok(ret_nodes) +} diff --git a/core/maltoolbox-attackgraph/src/ttcs.rs b/core/maltoolbox-attackgraph/src/ttcs.rs new file mode 100644 index 00000000..db8cf2c5 --- /dev/null +++ b/core/maltoolbox-attackgraph/src/ttcs.rs @@ -0,0 +1,23 @@ +//! Port of `maltoolbox/attackgraph/ttcs.py`. + +use serde_json::{json, Value}; + +use maltoolbox_language::graph::attack_step::AttackStepType; +use maltoolbox_language::graph::LanguageGraphAttackStep; +use maltoolbox_model::ModelAsset; + +/// Step TTC distribution based on the language, overridden by the +/// model's defense value when `attack_step` is a defense the model sets +/// explicitly. +pub fn get_ttc_dist(asset: &ModelAsset, attack_step: &LanguageGraphAttackStep) -> Option { + if attack_step.step_type == AttackStepType::Defense { + if let Some(&defense_value) = asset.defenses.get(&attack_step.name) { + return Some(json!({ + "arguments": [defense_value], + "name": "Bernoulli", + "type": "function", + })); + } + } + attack_step.ttc.clone() +} diff --git a/core/maltoolbox-attackgraph/tests/fixtures/detector_lang.mal b/core/maltoolbox-attackgraph/tests/fixtures/detector_lang.mal new file mode 100644 index 00000000..35ddd4e7 --- /dev/null +++ b/core/maltoolbox-attackgraph/tests/fixtures/detector_lang.mal @@ -0,0 +1,41 @@ +// vim:ft=mal +#id: "test-actions-effects" +#version: "0.0.0" + +category System{ + + asset Computer { + & physicalAccess + -> attemptAuthenticate + + | attemptAuthenticate [HardAndCertain] + -> authenticate + + & effect authenticate + -> computerApps.attemptExploit + } + + asset Application { + # shutDown + -> exploit + + | attemptExploit [HardAndCertain] + -> exploit + + & effect exploit + ! logExploit (computerOfApp.authenticate comp) [tpr: 0.9, fpr: 0.1] + -> toApplications.attemptExploit, + dataOnApp.read + } + + asset Data { + | read + } + +} + +associations { + Computer [computerOfApp] * <-- appExecution --> * [computerApps] Application + Application [fromApplications] * <-- AppToAppCommunication --> * [toApplications] Application + Data [dataOnApp] * <-- AppData --> * [appWithData] Application +} \ No newline at end of file diff --git a/core/maltoolbox-attackgraph/tests/fixtures/org.mal-lang.trainingLang-1.0.0.mar b/core/maltoolbox-attackgraph/tests/fixtures/org.mal-lang.trainingLang-1.0.0.mar new file mode 100644 index 00000000..a20c98a3 Binary files /dev/null and b/core/maltoolbox-attackgraph/tests/fixtures/org.mal-lang.trainingLang-1.0.0.mar differ diff --git a/core/maltoolbox-attackgraph/tests/fixtures/simple_example_model.yml b/core/maltoolbox-attackgraph/tests/fixtures/simple_example_model.yml new file mode 100644 index 00000000..edbe73e7 --- /dev/null +++ b/core/maltoolbox-attackgraph/tests/fixtures/simple_example_model.yml @@ -0,0 +1,153 @@ +assets: + 0: + associated_assets: + appExecutedApps: + 1: Program 1 + 2: Program 2 + 3: IDPS 1 + name: OS App + type: Application + 1: + associated_assets: + hostApp: + 0: OS App + defenses: + notPresent: 1.0 + name: Program 1 + type: Application + 2: + associated_assets: + containedData: + 5: Data:5 + hostApp: + 0: OS App + vulnerabilities: + 4: SoftwareVulnerability:4 + name: Program 2 + type: Application + 3: + associated_assets: + hostApp: + 0: OS App + infoDependedUpon: + 13: Group:13 + name: IDPS 1 + type: IDPS + 4: + associated_assets: + application: + 2: Program 2 + defenses: + availabilityImpactLimitations: 1.0 + lowPrivilegesRequired: 1.0 + name: SoftwareVulnerability:4 + type: SoftwareVulnerability + 5: + associated_assets: + containingApp: + 2: Program 2 + encryptCreds: + 6: Credentials:6 + name: Data:5 + type: Data + 6: + associated_assets: + encryptedData: + 5: Data:5 + identities: + 8: Identity:8 + requiredFactors: + 7: Credentials:7 + defenses: + unique: 0.0 + name: Credentials:6 + type: Credentials + 7: + associated_assets: + credentials: + 6: Credentials:6 + defenses: + unique: 0.0 + name: Credentials:7 + type: Credentials + 8: + associated_assets: + credentials: + 6: Credentials:6 + users: + 12: User:12 + name: Identity:8 + type: Identity + 9: + associated_assets: + identities: + 11: Identity:11 + defenses: + unique: 0.0 + name: Credentials:9 + type: Credentials + 10: + associated_assets: + identities: + 11: Identity:11 + name: Credentials:10 + type: Credentials + 11: + associated_assets: + credentials: + 9: Credentials:9 + 10: Credentials:10 + users: + 12: User:12 + name: Identity:11 + type: Identity + 12: + associated_assets: + userIds: + 8: Identity:8 + 11: Identity:11 + defenses: + noPasswordReuse: 0.0 + name: User:12 + type: User + 13: + associated_assets: + dependentApps: + 3: IDPS 1 + name: Group:13 + type: Group +attackers: + 14: + entry_points: + Credentials:6: + asset_id: 6 + attack_steps: + - attemptCredentialsReuse + Credentials:7: + asset_id: 7 + attack_steps: + - attemptCredentialsReuse + - guessCredentials + Credentials:9: + asset_id: 9 + attack_steps: + - attemptCredentialsReuse + OS App: + asset_id: 0 + attack_steps: + - softwareProductAbuse + name: Attacker:14 + 15: + entry_points: + OS App: + asset_id: 0 + attack_steps: + - fullAccess + name: Attacker:15 +metadata: + MAL-Toolbox Version: 0.2.0 + info: Created by the mal-toolbox model python module. + langID: org.mal-lang.coreLang + langVersion: 1.0.0 + malVersion: 0.1.0-SNAPSHOT + name: Simple Example Model diff --git a/core/maltoolbox-attackgraph/tests/fixtures/simple_traininglang_model.yml b/core/maltoolbox-attackgraph/tests/fixtures/simple_traininglang_model.yml new file mode 100644 index 00000000..72168393 --- /dev/null +++ b/core/maltoolbox-attackgraph/tests/fixtures/simple_traininglang_model.yml @@ -0,0 +1,55 @@ +assets: + 0: + associated_assets: + data: + 2: Data:2 + networks: + 4: Network:3 + users: + 3: User:3 + name: Host:0 + type: Host + 1: + associated_assets: + networks: + 4: Network:3 + name: Host:1 + type: Host + 2: + associated_assets: + hosts: + 0: Host:0 + name: Data:2 + type: Data + 3: + associated_assets: + hosts: + 0: Host:0 + name: User:3 + type: User + 4: + associated_assets: + hosts: + 0: Host:0 + 1: Host:1 + name: Network:3 + type: Network +attackers: + 5: + entry_points: + Host:0: + asset_id: 0 + attack_steps: + - connect + User:3: + asset_id: 3 + attack_steps: + - phishing + name: Attacker1 +metadata: + MAL-Toolbox Version: 0.2.0 + info: Created by the mal-toolbox model python module. + langID: org.mal-lang.trainingLang + langVersion: 1.0.0 + malVersion: 0.1.0-SNAPSHOT + name: Simple Example Model for trainingLang diff --git a/core/maltoolbox-attackgraph/tests/golden/wiper_attackgraph.json b/core/maltoolbox-attackgraph/tests/golden/wiper_attackgraph.json new file mode 100644 index 00000000..2c68775b --- /dev/null +++ b/core/maltoolbox-attackgraph/tests/golden/wiper_attackgraph.json @@ -0,0 +1,78 @@ +{ + "attack_steps": { + "device1:infect": { + "asset": "device1", + "children": { + "1": "wiper1:activate" + }, + "id": 0, + "lang_graph_attack_step": "Device:infect", + "name": "infect", + "parents": {}, + "ttc": null, + "type": "or" + }, + "wiper1:activate": { + "asset": "wiper1", + "children": { + "2": "wiper1:exfiltrate", + "3": "wiper1:propagate", + "4": "wiper1:trigger" + }, + "id": 1, + "lang_graph_attack_step": "Wiper:activate", + "name": "activate", + "parents": { + "0": "device1:infect" + }, + "ttc": null, + "type": "or" + }, + "wiper1:exfiltrate": { + "asset": "wiper1", + "children": {}, + "id": 2, + "lang_graph_attack_step": "Wiper:exfiltrate", + "name": "exfiltrate", + "parents": { + "1": "wiper1:activate" + }, + "ttc": null, + "type": "or" + }, + "wiper1:propagate": { + "asset": "wiper1", + "children": {}, + "id": 3, + "lang_graph_attack_step": "Wiper:propagate", + "name": "propagate", + "parents": { + "1": "wiper1:activate" + }, + "ttc": null, + "type": "or" + }, + "wiper1:test": { + "asset": "wiper1", + "children": {}, + "id": 5, + "lang_graph_attack_step": "Wiper:test", + "name": "test", + "parents": {}, + "ttc": null, + "type": "or" + }, + "wiper1:trigger": { + "asset": "wiper1", + "children": {}, + "id": 4, + "lang_graph_attack_step": "Wiper:trigger", + "name": "trigger", + "parents": { + "1": "wiper1:activate" + }, + "ttc": null, + "type": "or" + } + } +} \ No newline at end of file diff --git a/core/maltoolbox-attackgraph/tests/golden/wiper_partial_regen.json b/core/maltoolbox-attackgraph/tests/golden/wiper_partial_regen.json new file mode 100644 index 00000000..bf277b2a --- /dev/null +++ b/core/maltoolbox-attackgraph/tests/golden/wiper_partial_regen.json @@ -0,0 +1,141 @@ +{ + "attack_steps": { + "device1:infect": { + "asset": "device1", + "children": { + "1": "wiper1:activate", + "6": "wiper2:activate" + }, + "id": 0, + "lang_graph_attack_step": "Device:infect", + "name": "infect", + "parents": {}, + "ttc": null, + "type": "or" + }, + "wiper1:activate": { + "asset": "wiper1", + "children": { + "2": "wiper1:exfiltrate", + "3": "wiper1:propagate", + "4": "wiper1:trigger" + }, + "id": 1, + "lang_graph_attack_step": "Wiper:activate", + "name": "activate", + "parents": { + "0": "device1:infect" + }, + "ttc": null, + "type": "or" + }, + "wiper1:exfiltrate": { + "asset": "wiper1", + "children": {}, + "id": 2, + "lang_graph_attack_step": "Wiper:exfiltrate", + "name": "exfiltrate", + "parents": { + "1": "wiper1:activate" + }, + "ttc": null, + "type": "or" + }, + "wiper1:propagate": { + "asset": "wiper1", + "children": {}, + "id": 3, + "lang_graph_attack_step": "Wiper:propagate", + "name": "propagate", + "parents": { + "1": "wiper1:activate" + }, + "ttc": null, + "type": "or" + }, + "wiper1:test": { + "asset": "wiper1", + "children": {}, + "id": 5, + "lang_graph_attack_step": "Wiper:test", + "name": "test", + "parents": {}, + "ttc": null, + "type": "or" + }, + "wiper1:trigger": { + "asset": "wiper1", + "children": {}, + "id": 4, + "lang_graph_attack_step": "Wiper:trigger", + "name": "trigger", + "parents": { + "1": "wiper1:activate" + }, + "ttc": null, + "type": "or" + }, + "wiper2:activate": { + "asset": "wiper2", + "children": { + "7": "wiper2:exfiltrate", + "8": "wiper2:propagate", + "9": "wiper2:trigger" + }, + "id": 6, + "lang_graph_attack_step": "Wiper:activate", + "name": "activate", + "parents": { + "0": "device1:infect" + }, + "ttc": null, + "type": "or" + }, + "wiper2:exfiltrate": { + "asset": "wiper2", + "children": {}, + "id": 7, + "lang_graph_attack_step": "Wiper:exfiltrate", + "name": "exfiltrate", + "parents": { + "6": "wiper2:activate" + }, + "ttc": null, + "type": "or" + }, + "wiper2:propagate": { + "asset": "wiper2", + "children": {}, + "id": 8, + "lang_graph_attack_step": "Wiper:propagate", + "name": "propagate", + "parents": { + "6": "wiper2:activate" + }, + "ttc": null, + "type": "or" + }, + "wiper2:test": { + "asset": "wiper2", + "children": {}, + "id": 10, + "lang_graph_attack_step": "Wiper:test", + "name": "test", + "parents": {}, + "ttc": null, + "type": "or" + }, + "wiper2:trigger": { + "asset": "wiper2", + "children": {}, + "id": 9, + "lang_graph_attack_step": "Wiper:trigger", + "name": "trigger", + "parents": { + "6": "wiper2:activate" + }, + "ttc": null, + "type": "or" + } + } +} \ No newline at end of file diff --git a/core/maltoolbox-attackgraph/tests/golden/wiper_partial_regen_removal.json b/core/maltoolbox-attackgraph/tests/golden/wiper_partial_regen_removal.json new file mode 100644 index 00000000..2c68775b --- /dev/null +++ b/core/maltoolbox-attackgraph/tests/golden/wiper_partial_regen_removal.json @@ -0,0 +1,78 @@ +{ + "attack_steps": { + "device1:infect": { + "asset": "device1", + "children": { + "1": "wiper1:activate" + }, + "id": 0, + "lang_graph_attack_step": "Device:infect", + "name": "infect", + "parents": {}, + "ttc": null, + "type": "or" + }, + "wiper1:activate": { + "asset": "wiper1", + "children": { + "2": "wiper1:exfiltrate", + "3": "wiper1:propagate", + "4": "wiper1:trigger" + }, + "id": 1, + "lang_graph_attack_step": "Wiper:activate", + "name": "activate", + "parents": { + "0": "device1:infect" + }, + "ttc": null, + "type": "or" + }, + "wiper1:exfiltrate": { + "asset": "wiper1", + "children": {}, + "id": 2, + "lang_graph_attack_step": "Wiper:exfiltrate", + "name": "exfiltrate", + "parents": { + "1": "wiper1:activate" + }, + "ttc": null, + "type": "or" + }, + "wiper1:propagate": { + "asset": "wiper1", + "children": {}, + "id": 3, + "lang_graph_attack_step": "Wiper:propagate", + "name": "propagate", + "parents": { + "1": "wiper1:activate" + }, + "ttc": null, + "type": "or" + }, + "wiper1:test": { + "asset": "wiper1", + "children": {}, + "id": 5, + "lang_graph_attack_step": "Wiper:test", + "name": "test", + "parents": {}, + "ttc": null, + "type": "or" + }, + "wiper1:trigger": { + "asset": "wiper1", + "children": {}, + "id": 4, + "lang_graph_attack_step": "Wiper:trigger", + "name": "trigger", + "parents": { + "1": "wiper1:activate" + }, + "ttc": null, + "type": "or" + } + } +} \ No newline at end of file diff --git a/core/maltoolbox-attackgraph/tests/graph_golden.rs b/core/maltoolbox-attackgraph/tests/graph_golden.rs new file mode 100644 index 00000000..aae1478a --- /dev/null +++ b/core/maltoolbox-attackgraph/tests/graph_golden.rs @@ -0,0 +1,112 @@ +//! Diffs the Rust AttackGraph builder's output against golden JSON +//! produced by the real Python mal-toolbox, for a small hand-built model +//! over the wiperLang.mal fixture. +//! +//! The comparison is topology-based rather than raw JSON equality: +//! `AttackGraphNode.id` is an arbitrary integer assigned by iteration +//! order, and Rust `HashMap` iteration order isn't guaranteed to match +//! Python dict insertion order - so node ids legitimately differ between +//! the two even when the graphs are identical. Both sides are +//! canonicalized to replace id-keyed children/parents with sorted lists +//! of target full names, and the raw `id` field is dropped, before +//! comparing. + +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; + +use maltoolbox_attackgraph::AttackGraph; +use maltoolbox_language::{compile_file, generate_graph}; +use maltoolbox_model::Model; +use serde_json::Value; + +fn fixture_path(name: &str) -> std::path::PathBuf { + std::path::Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../maltoolbox-language/tests/fixtures" + )) + .join(name) +} + +fn canonicalize(attack_steps: &Value) -> HashMap { + let obj = attack_steps + .as_object() + .expect("attack_steps must be an object"); + let mut out = HashMap::new(); + for (full_name, node) in obj { + let mut node = node.clone(); + node.as_object_mut().unwrap().remove("id"); + for key in ["children", "parents"] { + let names: HashSet = node[key] + .as_object() + .unwrap() + .values() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + let mut sorted: Vec = names.into_iter().collect(); + sorted.sort(); + node[key] = Value::Array(sorted.into_iter().map(Value::String).collect()); + } + out.insert(full_name.clone(), node); + } + out +} + +#[test] +fn matches_python_oracle_for_wiper_attackgraph() { + let spec = compile_file(fixture_path("wiperLang.mal")).expect("compile"); + let lang_graph = Rc::new(generate_graph(spec).expect("build language graph")); + + let mut model = Model::new("Test Model", lang_graph); + let internet = model + .add_asset("Internet", Some("internet1".into()), None, None, None, true) + .unwrap(); + let device = model + .add_asset( + "Device", + Some("device1".into()), + None, + Some(HashMap::from([("someDefense".to_string(), 0.5)])), + Some(serde_json::Map::from_iter([( + "note".to_string(), + Value::String("test".into()), + )])), + true, + ) + .unwrap(); + let data = model + .add_asset("Data", Some("data1".into()), None, None, None, true) + .unwrap(); + let wiper = model + .add_asset("Wiper", Some("wiper1".into()), None, None, None, true) + .unwrap(); + + model + .add_associated_assets(internet, "hosts", HashSet::from([device])) + .unwrap(); + model + .add_associated_assets(device, "data", HashSet::from([data])) + .unwrap(); + model + .add_associated_assets(device, "malware", HashSet::from([wiper])) + .unwrap(); + + let attack_graph = AttackGraph::from_model(&model).expect("build attack graph"); + let actual_full = attack_graph.to_dict(Some(&model)); + let actual = canonicalize(&actual_full["attack_steps"]); + + let golden_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/golden/wiper_attackgraph.json" + ); + let expected_full: Value = + serde_json::from_str(&std::fs::read_to_string(golden_path).unwrap()).unwrap(); + let expected = canonicalize(&expected_full["attack_steps"]); + + assert_eq!( + actual, + expected, + "\n--- expected ---\n{}\n--- actual ---\n{}", + serde_json::to_string_pretty(&expected).unwrap(), + serde_json::to_string_pretty(&actual).unwrap(), + ); +} diff --git a/core/maltoolbox-attackgraph/tests/partial_regen_golden.rs b/core/maltoolbox-attackgraph/tests/partial_regen_golden.rs new file mode 100644 index 00000000..31df2d9c --- /dev/null +++ b/core/maltoolbox-attackgraph/tests/partial_regen_golden.rs @@ -0,0 +1,119 @@ +//! Diffs partial-regeneration output against golden JSON produced by the +//! real Python mal-toolbox's `AttackGraph.partially_regenerate_graph`. +//! Topology-based comparison, same rationale as `graph_golden.rs`. + +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; + +use maltoolbox_attackgraph::AttackGraph; +use maltoolbox_language::{compile_file, generate_graph}; +use maltoolbox_model::Model; +use serde_json::Value; + +fn fixture_path(name: &str) -> std::path::PathBuf { + std::path::Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../maltoolbox-language/tests/fixtures" + )) + .join(name) +} + +fn canonicalize(attack_steps: &Value) -> HashMap { + let obj = attack_steps + .as_object() + .expect("attack_steps must be an object"); + let mut out = HashMap::new(); + for (full_name, node) in obj { + let mut node = node.clone(); + node.as_object_mut().unwrap().remove("id"); + for key in ["children", "parents"] { + let names: HashSet = node[key] + .as_object() + .unwrap() + .values() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + let mut sorted: Vec = names.into_iter().collect(); + sorted.sort(); + node[key] = Value::Array(sorted.into_iter().map(Value::String).collect()); + } + out.insert(full_name.clone(), node); + } + out +} + +#[test] +fn matches_python_oracle_for_partial_regeneration() { + let spec = compile_file(fixture_path("wiperLang.mal")).expect("compile"); + let lang_graph = Rc::new(generate_graph(spec).expect("build language graph")); + + let mut model = Model::new("Test Model", lang_graph); + let internet = model + .add_asset("Internet", Some("internet1".into()), None, None, None, true) + .unwrap(); + let device = model + .add_asset("Device", Some("device1".into()), None, None, None, true) + .unwrap(); + let data = model + .add_asset("Data", Some("data1".into()), None, None, None, true) + .unwrap(); + let wiper1 = model + .add_asset("Wiper", Some("wiper1".into()), None, None, None, true) + .unwrap(); + + model + .add_associated_assets(internet, "hosts", HashSet::from([device])) + .unwrap(); + model + .add_associated_assets(device, "data", HashSet::from([data])) + .unwrap(); + model + .add_associated_assets(device, "malware", HashSet::from([wiper1])) + .unwrap(); + + let mut attack_graph = AttackGraph::from_model(&model).expect("build attack graph"); + + // Now add a second Wiper, associated to device1 via "malware", and + // partially regenerate instead of rebuilding from scratch. + let wiper2 = model + .add_asset("Wiper", Some("wiper2".into()), None, None, None, true) + .unwrap(); + model + .add_associated_assets(device, "malware", HashSet::from([wiper2])) + .unwrap(); + + let created = attack_graph + .partially_regenerate_graph( + &model, + &HashSet::from([wiper2]), + &HashSet::from([(device, "malware".to_string(), wiper2)]), + &HashMap::new(), + &HashSet::new(), + ) + .expect("partial regeneration"); + + assert_eq!( + created.len(), + 5, + "expected the 5 new Wiper attack steps to be created" + ); + + let actual_full = attack_graph.to_dict(Some(&model)); + let actual = canonicalize(&actual_full["attack_steps"]); + + let golden_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/golden/wiper_partial_regen.json" + ); + let expected_full: Value = + serde_json::from_str(&std::fs::read_to_string(golden_path).unwrap()).unwrap(); + let expected = canonicalize(&expected_full["attack_steps"]); + + assert_eq!( + actual, + expected, + "\n--- expected ---\n{}\n--- actual ---\n{}", + serde_json::to_string_pretty(&expected).unwrap(), + serde_json::to_string_pretty(&actual).unwrap(), + ); +} diff --git a/core/maltoolbox-attackgraph/tests/partial_regen_removal_golden.rs b/core/maltoolbox-attackgraph/tests/partial_regen_removal_golden.rs new file mode 100644 index 00000000..e3bceef4 --- /dev/null +++ b/core/maltoolbox-attackgraph/tests/partial_regen_removal_golden.rs @@ -0,0 +1,114 @@ +//! Diffs partial-regeneration *removal* against golden JSON produced by +//! the real Python mal-toolbox. Companion to `partial_regen_golden.rs`, +//! which only covers addition. + +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; + +use maltoolbox_attackgraph::AttackGraph; +use maltoolbox_language::{compile_file, generate_graph}; +use maltoolbox_model::Model; +use serde_json::Value; + +fn fixture_path(name: &str) -> std::path::PathBuf { + std::path::Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../maltoolbox-language/tests/fixtures" + )) + .join(name) +} + +fn canonicalize(attack_steps: &Value) -> HashMap { + let obj = attack_steps + .as_object() + .expect("attack_steps must be an object"); + let mut out = HashMap::new(); + for (full_name, node) in obj { + let mut node = node.clone(); + node.as_object_mut().unwrap().remove("id"); + for key in ["children", "parents"] { + let names: HashSet = node[key] + .as_object() + .unwrap() + .values() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + let mut sorted: Vec = names.into_iter().collect(); + sorted.sort(); + node[key] = Value::Array(sorted.into_iter().map(Value::String).collect()); + } + out.insert(full_name.clone(), node); + } + out +} + +#[test] +fn matches_python_oracle_for_partial_regeneration_removal() { + let spec = compile_file(fixture_path("wiperLang.mal")).expect("compile"); + let lang_graph = Rc::new(generate_graph(spec).expect("build language graph")); + + let mut model = Model::new("Test Model", lang_graph); + let internet = model + .add_asset("Internet", Some("internet1".into()), None, None, None, true) + .unwrap(); + let device = model + .add_asset("Device", Some("device1".into()), None, None, None, true) + .unwrap(); + let data = model + .add_asset("Data", Some("data1".into()), None, None, None, true) + .unwrap(); + let wiper1 = model + .add_asset("Wiper", Some("wiper1".into()), None, None, None, true) + .unwrap(); + let wiper2 = model + .add_asset("Wiper", Some("wiper2".into()), None, None, None, true) + .unwrap(); + + model + .add_associated_assets(internet, "hosts", HashSet::from([device])) + .unwrap(); + model + .add_associated_assets(device, "data", HashSet::from([data])) + .unwrap(); + model + .add_associated_assets(device, "malware", HashSet::from([wiper1, wiper2])) + .unwrap(); + + let mut attack_graph = AttackGraph::from_model(&model).expect("build attack graph"); + + // No ordering contract any more (see `AttackGraph::partially_regenerate_graph` + // docs): `model.remove_asset` - which already disconnects associations + // itself - can now run *before* partial regeneration. Its returned + // `AssetSnapshot` carries everything partial regeneration needs + // to resolve the now-gone asset. + let snapshot = model.remove_asset(wiper2).unwrap(); + + attack_graph + .partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &HashMap::from([(wiper2, snapshot)]), + &HashSet::from([(device, "malware".to_string(), wiper2)]), + ) + .expect("partial regeneration"); + + let actual_full = attack_graph.to_dict(Some(&model)); + let actual = canonicalize(&actual_full["attack_steps"]); + + let golden_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/golden/wiper_partial_regen_removal.json" + ); + let expected_full: Value = + serde_json::from_str(&std::fs::read_to_string(golden_path).unwrap()).unwrap(); + let expected = canonicalize(&expected_full["attack_steps"]); + + assert_eq!( + actual, + expected, + "\n--- expected ---\n{}\n--- actual ---\n{}", + serde_json::to_string_pretty(&expected).unwrap(), + serde_json::to_string_pretty(&actual).unwrap(), + ); +} diff --git a/core/maltoolbox-attackgraph/tests/test_attackgraph.rs b/core/maltoolbox-attackgraph/tests/test_attackgraph.rs new file mode 100644 index 00000000..76603dd9 --- /dev/null +++ b/core/maltoolbox-attackgraph/tests/test_attackgraph.rs @@ -0,0 +1,939 @@ +//! Port of `tests/attackgraph/test_attackgraph.py`. +//! +//! Not ported: +//! - `test_attackgraph_init` (mocks Python internals, not a meaningful +//! check in Rust) +//! - `test_attackgraph_generate_graph` (redundant with the oracle-verified +//! `graph_golden.rs`/`partial_regen_golden.rs`, which already check +//! full regeneration produces the right node set) +//! - `test_attackgraph_deepcopy`/`test_deepcopy_memo_test` (Python +//! `copy.deepcopy` semantics have no meaningful Rust equivalent here) +//! - `test_attackgraph_pickle`/`test_model_pickle` (no Rust equivalent) +//! +//! `test_create_dynamic_ag` is ported as `create_dynamic_ag` below, but +//! only its first half (asserting the `Wiper:test` node's +//! `additive_model_effects` structure) - the Python original's second +//! half manually walks the traversal chain and calls `model.add_asset`/ +//! `add_associated_assets` as a *demonstration* of how a downstream +//! consumer (e.g. mal-simulator) would apply the effect; it doesn't +//! exercise any new behavior of this crate, since those `Model` methods +//! are already covered by `maltoolbox-model`'s own test suite. Also uses +//! a minimal hand-built model (one `Device`, one `Wiper`) rather than +//! the oracle's full `wiper_model.yml` topology, since the model-effect +//! structure being checked is a language-graph-level property, the same +//! for every `Wiper:test` node regardless of which model it's attached +//! to. + +use std::collections::HashSet; +use std::rc::Rc; + +use maltoolbox_attackgraph::AttackGraph; +use maltoolbox_language::from_mar_archive; +use maltoolbox_model::Model; + +fn fixtures_dir() -> &'static std::path::Path { + std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")) +} + +fn lang_fixtures_dir() -> &'static std::path::Path { + std::path::Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../maltoolbox-language/tests/fixtures" + )) +} + +/// The repo-root `tests/testdata` dir, shared with the Python test suite - +/// `attackgraph.json`/`.yml` there are real Python-oracle output for +/// corelang, and `org.mal-lang.coreLang-1.0.0.mar` is byte-identical to +/// the one in `lang_fixtures_dir()`. +fn repo_testdata_dir() -> &'static std::path::Path { + std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../tests/testdata")) +} + +fn corelang() -> Rc { + Rc::new( + from_mar_archive(lang_fixtures_dir().join("org.mal-lang.coreLang-1.0.0.mar")) + .expect("load corelang"), + ) +} + +#[test] +fn get_node_by_full_name_suggests_similar() { + let lang_graph = corelang(); + let mut model = Model::new("Test Model", lang_graph); + let app1 = model + .add_asset( + "Application", + Some("Application 1".into()), + None, + None, + None, + true, + ) + .unwrap(); + let app2 = model + .add_asset( + "Application", + Some("Application 2".into()), + None, + None, + None, + true, + ) + .unwrap(); + model + .add_associated_assets(app1, "appExecutedApps", HashSet::from([app2])) + .unwrap(); + + let attack_graph = AttackGraph::from_model(&model).expect("build attack graph"); + + let err = attack_graph + .get_node_by_full_name("Application 2") + .unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("Could not find node with name \"Application 2\"")); + assert!(msg.contains("Did you mean")); + // Both of "Application 2:read" and "Application 2:deny" are + // equidistant (by Levenshtein) from the query; which one Rust's + // HashMap iterates first isn't guaranteed to match Python's dict + // order, so just check both appear as candidates somewhere. + assert!(msg.contains("Application 2:")); +} + +#[test] +fn create_dynamic_ag() { + use maltoolbox_language::graph::model_effect::AssocTraversalElem; + + let lang = Rc::new( + maltoolbox_language::from_mal_spec(lang_fixtures_dir().join("wiperLang.mal")) + .expect("compile wiperLang"), + ); + let mut model = Model::new("Wiper Model", lang.clone()); + + let device = model + .add_asset( + "Device", + Some("InfectedDevice".into()), + None, + None, + None, + true, + ) + .unwrap(); + let wiper = model + .add_asset("Wiper", Some("Wiper".into()), None, None, None, true) + .unwrap(); + model + .add_associated_assets(device, "malware", HashSet::from([wiper])) + .unwrap(); + + let attack_graph = AttackGraph::from_model(&model).expect("build attack graph"); + let wiper_test = attack_graph + .get_node_by_full_name("Wiper:test") + .expect("Wiper:test node"); + let node = &attack_graph.nodes[wiper_test]; + + let effects = node + .additive_model_effects + .as_ref() + .expect("Wiper:test should have additive model effects (own + inherited from Malware)"); + + // Two effects are expected: one inherited from `Malware`'s own + // `test` (`A> self / victim[Device]`), one from `Wiper`'s own `test` + // declaration (`+A> self / victim[C2Server]`) - an *append*, so it + // adds to the inherited effect rather than replacing it. + assert_eq!(effects.len(), 2); + let mut seen_filters = HashSet::new(); + + for model_effect in effects { + assert_eq!( + model_effect.base.len(), + 1, + "Too many assoc traversals for base in dynamic statement" + ); + match &model_effect.base[0] { + AssocTraversalElem::Traversal(t) => { + assert_eq!( + t.field_name, "self", + "Base field name is not correct for dynamic statement" + ) + } + other => panic!("expected a plain traversal for base, got {other:?}"), + } + + for dyn_target in &model_effect.targets { + assert!( + !dyn_target.assoc_op, + "Dynamic target should not operate on associations" + ); + assert_eq!( + dyn_target.assoc_traversal.len(), + 1, + "Dynamic target should have exactly one assoc traversal" + ); + match &dyn_target.assoc_traversal[0] { + AssocTraversalElem::Traversal(t) => { + assert_eq!( + t.field_name, "victim", + "Dynamic target assoc traversal field name is not correct" + ); + let filter_id = t.asset_filter.expect("expected an asset type filter"); + let filter_name = lang.asset(filter_id).name.clone(); + assert!( + matches!(filter_name.as_str(), "C2Server" | "Device"), + "unexpected asset filter {filter_name}" + ); + seen_filters.insert(filter_name); + } + other => panic!("expected a plain traversal for the dynamic target, got {other:?}"), + } + } + } + + assert_eq!( + seen_filters, + HashSet::from(["C2Server".to_string(), "Device".to_string()]) + ); +} + +#[test] +fn according_to_corelang() { + let lang_graph = corelang(); + let mut model = Model::new("Test Model", lang_graph); + let app1 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + let app2 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + model + .add_associated_assets(app1, "appExecutedApps", HashSet::from([app2])) + .unwrap(); + + let attack_graph = AttackGraph::from_model(&model).expect("build attack graph"); + + let expected_names: HashSet<&str> = [ + "notPresent", + "attemptUseVulnerability", + "successfulUseVulnerability", + "useVulnerability", + "attemptReverseReach", + "successfulReverseReach", + "reverseReach", + "localConnect", + "networkConnectUninspected", + "networkConnectInspected", + "networkConnect", + "specificAccessNetworkConnect", + "accessNetworkAndConnections", + "attemptNetworkConnectFromResponse", + "networkConnectFromResponse", + "specificAccessFromLocalConnection", + "specificAccessFromNetworkConnection", + "specificAccess", + "bypassContainerization", + "authenticate", + "specificAccessAuthenticate", + "localAccess", + "networkAccess", + "fullAccess", + "physicalAccessAchieved", + "attemptUnsafeUserActivity", + "successfulUnsafeUserActivity", + "unsafeUserActivity", + "attackerUnsafeUserActivityCapability", + "attackerUnsafeUserActivityCapabilityWithReverseReach", + "attackerUnsafeUserActivityCapabilityWithoutReverseReach", + "supplyChainAuditing", + "bypassSupplyChainAuditing", + "supplyChainAuditingBypassed", + "attemptFullAccessFromSupplyChainCompromise", + "fullAccessFromSupplyChainCompromise", + "attemptReadFromSoftProdVulnerability", + "attemptModifyFromSoftProdVulnerability", + "attemptDenyFromSoftProdVulnerability", + "softwareCheck", + "softwareProductVulnerabilityLocalAccessAchieved", + "softwareProductVulnerabilityNetworkAccessAchieved", + "softwareProductVulnerabilityPhysicalAccessAchieved", + "softwareProductVulnerabilityLowPrivilegesAchieved", + "softwareProductVulnerabilityHighPrivilegesAchieved", + "softwareProductVulnerabilityUserInteractionAchieved", + "attemptSoftwareProductAbuse", + "softwareProductAbuse", + "readFromSoftProdVulnerability", + "modifyFromSoftProdVulnerability", + "denyFromSoftProdVulnerability", + "attemptApplicationRespondConnectThroughData", + "successfulApplicationRespondConnectThroughData", + "applicationRespondConnectThroughData", + "attemptAuthorizedApplicationRespondConnectThroughData", + "successfulAuthorizedApplicationRespondConnectThroughData", + "authorizedApplicationRespondConnectThroughData", + "attemptRead", + "successfulRead", + "read", + "specificAccessRead", + "attemptModify", + "successfulModify", + "modify", + "specificAccessModify", + "attemptDeny", + "successfulDeny", + "deny", + "specificAccessDelete", + "denyFromNetworkingAsset", + "denyFromLockout", + ] + .into_iter() + .collect(); + + let actual_names: HashSet<&str> = attack_graph + .nodes + .values() + .map(|n| n.name.as_str()) + .collect(); + assert_eq!(actual_names, expected_names); + + let expected_notpresent_children: HashSet<&str> = [ + "successfulUseVulnerability", + "successfulReverseReach", + "networkConnectFromResponse", + "specificAccessFromLocalConnection", + "specificAccessFromNetworkConnection", + "localAccess", + "networkAccess", + "successfulUnsafeUserActivity", + "fullAccessFromSupplyChainCompromise", + "readFromSoftProdVulnerability", + "modifyFromSoftProdVulnerability", + "denyFromSoftProdVulnerability", + "successfulApplicationRespondConnectThroughData", + "successfulAuthorizedApplicationRespondConnectThroughData", + "successfulRead", + "successfulModify", + "successfulDeny", + ] + .into_iter() + .collect(); + + let notpresent = attack_graph + .nodes + .values() + .find(|n| n.name == "notPresent") + .expect("notPresent node"); + let notpresent_children: HashSet<&str> = notpresent + .children + .iter() + .map(|&id| attack_graph.nodes[id].name.as_str()) + .collect(); + assert_eq!(notpresent_children, expected_notpresent_children); +} + +#[test] +fn remove_node() { + let lang_graph = corelang(); + let mut model = Model::new("Test Model", lang_graph); + let app1 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + let app2 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + model + .add_associated_assets(app1, "appExecutedApps", HashSet::from([app2])) + .unwrap(); + + let mut attack_graph = AttackGraph::from_model(&model).expect("build attack graph"); + + let node_to_remove = attack_graph.nodes.keys().next().unwrap(); + let parents: Vec<_> = attack_graph.nodes[node_to_remove] + .parents + .iter() + .copied() + .collect(); + let children: Vec<_> = attack_graph.nodes[node_to_remove] + .children + .iter() + .copied() + .collect(); + + attack_graph.remove_node(node_to_remove).unwrap(); + + assert!(!attack_graph.nodes.contains_key(node_to_remove)); + for parent in parents { + assert!(!attack_graph.nodes[parent] + .children + .contains(&node_to_remove)); + } + for child in children { + assert!(!attack_graph.nodes[child].parents.contains(&node_to_remove)); + } +} + +#[test] +fn subtype() { + let spec = + maltoolbox_language::compile_file(lang_fixtures_dir().join("subtype_attack_step.mal")) + .unwrap(); + let lang_graph = Rc::new(maltoolbox_language::generate_graph(spec).unwrap()); + let mut model = Model::new("Test Model", lang_graph); + + let base1 = model + .add_asset( + "BaseAsset", + Some("BaseAsset 1".into()), + None, + None, + None, + true, + ) + .unwrap(); + let sub1 = model + .add_asset( + "SubAsset", + Some("SubAsset 1".into()), + None, + None, + None, + true, + ) + .unwrap(); + let other1 = model + .add_asset( + "OtherAsset", + Some("OtherAsset 1".into()), + None, + None, + None, + true, + ) + .unwrap(); + + model + .add_associated_assets(sub1, "field2", HashSet::from([other1])) + .unwrap(); + model + .add_associated_assets(base1, "field2", HashSet::from([other1])) + .unwrap(); + + let attack_graph = AttackGraph::from_model(&model).unwrap(); + + let ba1_base1 = attack_graph + .get_node_by_full_name("BaseAsset 1:base_step1") + .unwrap(); + let ba1_base2 = attack_graph + .get_node_by_full_name("BaseAsset 1:base_step2") + .unwrap(); + let sa1_base1 = attack_graph + .get_node_by_full_name("SubAsset 1:base_step1") + .unwrap(); + let sa1_base2 = attack_graph + .get_node_by_full_name("SubAsset 1:base_step2") + .unwrap(); + let sa1_sub1 = attack_graph + .get_node_by_full_name("SubAsset 1:subasset_step1") + .unwrap(); + let oa1_other1 = attack_graph + .get_node_by_full_name("OtherAsset 1:other_step1") + .unwrap(); + + let children = &attack_graph.nodes[oa1_other1].children; + assert!(children.contains(&ba1_base1)); + assert!(!children.contains(&ba1_base2)); + assert!(children.contains(&sa1_base1)); + assert!(children.contains(&sa1_base2)); + assert!(children.contains(&sa1_sub1)); +} + +#[test] +fn setops() { + let spec = maltoolbox_language::compile_file(lang_fixtures_dir().join("set_ops.mal")).unwrap(); + let lang_graph = Rc::new(maltoolbox_language::generate_graph(spec).unwrap()); + let mut model = Model::new("Test Model", lang_graph); + + let origin = model + .add_asset("Origin", Some("Origin".into()), None, None, None, true) + .unwrap(); + let t1 = model + .add_asset("Target", Some("Target 1".into()), None, None, None, true) + .unwrap(); + let t2 = model + .add_asset("Target", Some("Target 2".into()), None, None, None, true) + .unwrap(); + let t3 = model + .add_asset("Target", Some("Target 3".into()), None, None, None, true) + .unwrap(); + + model + .add_associated_assets(origin, "setA", HashSet::from([t1, t2])) + .unwrap(); + model + .add_associated_assets(origin, "setB", HashSet::from([t2, t3])) + .unwrap(); + + let attack_graph = AttackGraph::from_model(&model).unwrap(); + let check = attack_graph.get_node_by_full_name("Origin:check").unwrap(); + let children = &attack_graph.nodes[check].children; + + let get = |name: &str| attack_graph.get_node_by_full_name(name).unwrap(); + assert!(children.contains(&get("Target 1:unionResult"))); + assert!(!children.contains(&get("Target 1:intersectionResult"))); + assert!(children.contains(&get("Target 1:differenceResult"))); + assert!(children.contains(&get("Target 2:unionResult"))); + assert!(children.contains(&get("Target 2:intersectionResult"))); + assert!(!children.contains(&get("Target 2:differenceResult"))); + assert!(children.contains(&get("Target 3:unionResult"))); + assert!(!children.contains(&get("Target 3:intersectionResult"))); + assert!(!children.contains(&get("Target 3:differenceResult"))); +} + +#[test] +fn setops_adv() { + let spec = + maltoolbox_language::compile_file(lang_fixtures_dir().join("set_ops_adv.mal")).unwrap(); + let lang_graph = Rc::new(maltoolbox_language::generate_graph(spec).unwrap()); + let mut model = Model::new("Test Model", lang_graph); + + let hub1 = model + .add_asset("Hub", Some("Hub 1".into()), None, None, None, true) + .unwrap(); + let hub2 = model + .add_asset("Hub", Some("Hub 2".into()), None, None, None, true) + .unwrap(); + let hub3 = model + .add_asset("Hub", Some("Hub 3".into()), None, None, None, true) + .unwrap(); + let t1 = model + .add_asset("Target", Some("Target 1".into()), None, None, None, true) + .unwrap(); + let t2 = model + .add_asset("Target", Some("Target 2".into()), None, None, None, true) + .unwrap(); + let t3 = model + .add_asset("Target", Some("Target 3".into()), None, None, None, true) + .unwrap(); + + model + .add_associated_assets(hub2, "setA", HashSet::from([t1, t2])) + .unwrap(); + model + .add_associated_assets(hub3, "setB", HashSet::from([t2, t3])) + .unwrap(); + model + .add_associated_assets(hub1, "siblings", HashSet::from([hub2, hub3])) + .unwrap(); + + let attack_graph = AttackGraph::from_model(&model).unwrap(); + + let get = |name: &str| attack_graph.get_node_by_full_name(name).unwrap(); + let hub1_inner = get("Hub 1:innerIntersection"); + let hub1_outer = get("Hub 1:outerIntersection"); + + let inner_children = &attack_graph.nodes[hub1_inner].children; + assert!(!inner_children.contains(&get("Target 1:intersectionResult"))); + assert!(!inner_children.contains(&get("Target 2:intersectionResult"))); + assert!(!inner_children.contains(&get("Target 3:intersectionResult"))); + + let outer_children = &attack_graph.nodes[hub1_outer].children; + assert!(!outer_children.contains(&get("Target 1:intersectionResult"))); + assert!(outer_children.contains(&get("Target 2:intersectionResult"))); + assert!(!outer_children.contains(&get("Target 3:intersectionResult"))); +} + +#[test] +fn transitive() { + let spec = + maltoolbox_language::compile_file(lang_fixtures_dir().join("transitive.mal")).unwrap(); + let lang_graph = Rc::new(maltoolbox_language::generate_graph(spec).unwrap()); + let mut model = Model::new("Test Model", lang_graph); + + let assets: Vec = (1..=6) + .map(|i| { + model + .add_asset( + "TestAsset", + Some(format!("TestAsset {i}")), + None, + None, + None, + true, + ) + .unwrap() + }) + .collect(); + let (a1, a2, a3, a4, a5, a6) = ( + assets[0], assets[1], assets[2], assets[3], assets[4], assets[5], + ); + + model + .add_associated_assets(a1, "field2", HashSet::from([a2])) + .unwrap(); + model + .add_associated_assets(a2, "field2", HashSet::from([a3])) + .unwrap(); + model + .add_associated_assets(a3, "field2", HashSet::from([a4])) + .unwrap(); + model + .add_associated_assets(a3, "field2", HashSet::from([a5])) + .unwrap(); + model + .add_associated_assets(a6, "field2", HashSet::from([a1])) + .unwrap(); + + let attack_graph = AttackGraph::from_model(&model).unwrap(); + let step = |i: usize| { + attack_graph + .get_node_by_full_name(&format!("TestAsset {i}:test_step")) + .unwrap() + }; + + let expected_reachable: [&[usize]; 6] = [ + &[1, 2, 3, 4, 5], + &[2, 3, 4, 5], + &[3, 4, 5], + &[4], + &[5], + &[1, 2, 3, 4, 5, 6], + ]; + + for (i, reachable) in expected_reachable.iter().enumerate() { + let from = step(i + 1); + let children = &attack_graph.nodes[from].children; + for j in 1..=6 { + let expects_reachable = reachable.contains(&j); + assert_eq!( + children.contains(&step(j)), + expects_reachable, + "TestAsset {}:test_step -> TestAsset {}:test_step", + i + 1, + j + ); + } + } +} + +#[test] +fn transitive_advanced() { + let spec = + maltoolbox_language::compile_file(lang_fixtures_dir().join("transitive_advanced.mal")) + .unwrap(); + let lang_graph = Rc::new(maltoolbox_language::generate_graph(spec).unwrap()); + let mut model = Model::new("Test Model", lang_graph); + + let a1 = model + .add_asset( + "TestAsset", + Some("TestAsset 1".into()), + None, + None, + None, + true, + ) + .unwrap(); + let a2 = model + .add_asset( + "TestAsset", + Some("TestAsset 2".into()), + None, + None, + None, + true, + ) + .unwrap(); + let a3 = model + .add_asset( + "TestAsset", + Some("TestAsset 3".into()), + None, + None, + None, + true, + ) + .unwrap(); + let a4 = model + .add_asset( + "TestAsset", + Some("TestAsset 4".into()), + None, + None, + None, + true, + ) + .unwrap(); + + model + .add_associated_assets(a1, "fieldA2", HashSet::from([a2, a3])) + .unwrap(); + model + .add_associated_assets(a1, "fieldB2", HashSet::from([a3, a4])) + .unwrap(); + + let attack_graph = AttackGraph::from_model(&model).unwrap(); + let step = |n: &str| { + attack_graph + .get_node_by_full_name(&format!("{n}:test_step")) + .unwrap() + }; + let children = &attack_graph.nodes[step("TestAsset 1")].children; + + assert!(children.contains(&step("TestAsset 1"))); + assert!(!children.contains(&step("TestAsset 2"))); + assert!(children.contains(&step("TestAsset 3"))); + assert!(!children.contains(&step("TestAsset 4"))); +} + +#[test] +fn create_attack_graph_wrapper() { + let mar = lang_fixtures_dir().join("org.mal-lang.coreLang-1.0.0.mar"); + let model = fixtures_dir().join("simple_example_model.yml"); + maltoolbox_attackgraph::create_attack_graph(mar, model) + .expect("create_attack_graph should not error"); +} + +#[test] +fn create_ag_from_model() { + // Predefined model in trainingLang: + // User:3 --- Host:0 --- Network:3 --- Host:1 + // | + // Data:2 + let mar = fixtures_dir().join("org.mal-lang.trainingLang-1.0.0.mar"); + let model_path = fixtures_dir().join("simple_traininglang_model.yml"); + let (attack_graph, model) = + maltoolbox_attackgraph::create_attack_graph(mar, model_path).unwrap(); + + let full_names: HashSet = attack_graph + .nodes + .keys() + .map(|k| attack_graph.full_name_of(k, Some(&model))) + .collect(); + let expected: HashSet = [ + "Host:0:notPresent", + "Host:0:authenticate", + "Host:0:connect", + "Host:0:access", + "Host:1:notPresent", + "Host:1:authenticate", + "Host:1:connect", + "Host:1:access", + "Data:2:notPresent", + "Data:2:read", + "Data:2:modify", + "User:3:notPresent", + "User:3:compromise", + "User:3:phishing", + "Network:3:access", + ] + .into_iter() + .map(String::from) + .collect(); + assert_eq!(full_names, expected); + + let check = |parent_fn: &str, children_fns: &[&str]| { + let parent = attack_graph.get_node_by_full_name(parent_fn).unwrap(); + let actual_children: HashSet = attack_graph.nodes[parent] + .children + .iter() + .map(|&c| attack_graph.full_name_of(c, Some(&model))) + .collect(); + let expected_children: HashSet = + children_fns.iter().map(|s| s.to_string()).collect(); + assert_eq!( + actual_children, expected_children, + "children of {parent_fn}" + ); + + for child_fn in children_fns { + let child = attack_graph.get_node_by_full_name(child_fn).unwrap(); + let parent_names: HashSet = attack_graph.nodes[child] + .parents + .iter() + .map(|&p| attack_graph.full_name_of(p, Some(&model))) + .collect(); + assert!( + parent_names.contains(parent_fn), + "{child_fn} should have parent {parent_fn}" + ); + } + }; + + check("Host:0:notPresent", &["Host:0:connect", "Host:0:access"]); + check("Host:0:authenticate", &["Host:0:access"]); + check("Host:0:connect", &["Host:0:access"]); + check( + "Host:0:access", + &["Data:2:modify", "Data:2:read", "Network:3:access"], + ); + check("Host:1:notPresent", &["Host:1:connect", "Host:1:access"]); + check("Host:1:authenticate", &["Host:1:access"]); + check("Host:1:connect", &["Host:1:access"]); + check("Host:1:access", &["Network:3:access"]); + check("Data:2:notPresent", &["Data:2:read", "Data:2:modify"]); + check("Data:2:read", &[]); + check("Data:2:modify", &[]); + check("User:3:notPresent", &["User:3:compromise"]); + check("User:3:compromise", &["Host:0:authenticate"]); + check("User:3:phishing", &["User:3:compromise"]); + check("Network:3:access", &["Host:0:connect", "Host:1:connect"]); +} + +#[test] +fn create_ag_step_lists() { + let mar = fixtures_dir().join("org.mal-lang.trainingLang-1.0.0.mar"); + let model_path = fixtures_dir().join("simple_traininglang_model.yml"); + let (attack_graph, _model) = + maltoolbox_attackgraph::create_attack_graph(mar, model_path).unwrap(); + + let defenses: HashSet<_> = attack_graph + .nodes + .iter() + .filter(|(_, n)| { + n.step_type == maltoolbox_language::graph::attack_step::AttackStepType::Defense + }) + .map(|(k, _)| k) + .collect(); + let attacks: HashSet<_> = attack_graph + .nodes + .iter() + .filter(|(_, n)| { + matches!( + n.step_type, + maltoolbox_language::graph::attack_step::AttackStepType::Or + | maltoolbox_language::graph::attack_step::AttackStepType::And + ) + }) + .map(|(k, _)| k) + .collect(); + + assert_eq!( + defenses, + attack_graph.defense_steps.iter().copied().collect() + ); + assert_eq!(attacks, attack_graph.attack_steps.iter().copied().collect()); +} + +#[test] +fn load_attack_graph_json_and_yaml_agree() { + let lang_graph = corelang(); + let json_ag = AttackGraph::load_from_file( + repo_testdata_dir().join("attackgraph.json"), + lang_graph.clone(), + None, + ) + .expect("load attackgraph.json"); + let yml_ag = AttackGraph::load_from_file( + repo_testdata_dir().join("attackgraph.yml"), + lang_graph, + None, + ) + .expect("load attackgraph.yml"); + + assert_eq!(json_ag.nodes.len(), 545); + assert_eq!(json_ag.to_dict(None), yml_ag.to_dict(None)); + + for node in json_ag.nodes.values() { + if node.step_type == maltoolbox_language::graph::attack_step::AttackStepType::Exist { + // Just needs to type-check as `Option` - already does in + // Rust - this only exercises that loading didn't panic on it. + let _: Option = node.existence_status; + } + } +} + +#[test] +fn attackgraph_save_load_round_trip_no_model() { + let mar = lang_fixtures_dir().join("org.mal-lang.coreLang-1.0.0.mar"); + let model_path = fixtures_dir().join("simple_example_model.yml"); + let (mut attack_graph, model) = + maltoolbox_attackgraph::create_attack_graph(mar, model_path).unwrap(); + + let node_with_reward = attack_graph.nodes.keys().next().unwrap(); + attack_graph.nodes[node_with_reward] + .extras + .insert("reward".to_string(), serde_json::json!(1)); + let node_with_reward_id = attack_graph.nodes[node_with_reward].id; + + let dir = std::env::temp_dir().join(format!( + "maltoolbox-attackgraph-roundtrip-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("example_graph.yml"); + attack_graph + .save_to_file(Some(&model), &path) + .expect("save"); + + let loaded = + AttackGraph::load_from_file(&path, attack_graph.lang_graph.clone(), None).expect("load"); + std::fs::remove_dir_all(&dir).ok(); + + assert_eq!(attack_graph.nodes.len(), loaded.nodes.len()); + + let loaded_key = *loaded + .id_to_node + .get(&node_with_reward_id) + .expect("reloaded node by id"); + assert_eq!( + loaded.nodes[loaded_key].extras.get("reward"), + Some(&serde_json::json!(1)) + ); + + // Loaded-without-model nodes have no "asset" key, so compare every + // other field: the saved dict (with model) minus "asset" should equal + // the reloaded dict (without model). + let mut original = attack_graph.to_dict(Some(&model)); + for node in original["attack_steps"] + .as_object_mut() + .unwrap() + .values_mut() + { + node.as_object_mut().unwrap().remove("asset"); + } + assert_eq!(original, loaded.to_dict(None)); +} + +#[test] +fn attackgraph_save_and_load_round_trip_with_model() { + let mar = lang_fixtures_dir().join("org.mal-lang.coreLang-1.0.0.mar"); + let model_path = fixtures_dir().join("simple_example_model.yml"); + let (attack_graph, model) = + maltoolbox_attackgraph::create_attack_graph(mar, model_path).unwrap(); + + let dir = std::env::temp_dir().join(format!( + "maltoolbox-attackgraph-roundtrip-model-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + + for ext in ["yml", "json"] { + let path = dir.join(format!("attackgraph.{ext}")); + attack_graph + .save_to_file(Some(&model), &path) + .expect("save"); + let loaded = + AttackGraph::load_from_file(&path, attack_graph.lang_graph.clone(), Some(&model)) + .expect("load"); + + assert_eq!( + attack_graph.to_dict(Some(&model)), + loaded.to_dict(Some(&model)) + ); + + for (loaded_key, loaded_node) in loaded.nodes.iter() { + let asset_id = loaded_node + .model_asset + .expect("node should get an asset when loaded with model"); + let asset = model.get_asset_by_id(asset_id).unwrap(); + let full_name = loaded.full_name_of(loaded_key, Some(&model)); + assert_eq!(full_name, format!("{}:{}", asset.name, loaded_node.name)); + + let found_by_id = *loaded.id_to_node.get(&loaded_node.id).unwrap(); + assert_eq!(found_by_id, loaded_key); + let found_by_name = loaded.get_node_by_full_name(&full_name).unwrap(); + assert_eq!(found_by_name, loaded_key); + } + } + + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/core/maltoolbox-attackgraph/tests/test_detectors.rs b/core/maltoolbox-attackgraph/tests/test_detectors.rs new file mode 100644 index 00000000..c546e50f --- /dev/null +++ b/core/maltoolbox-attackgraph/tests/test_detectors.rs @@ -0,0 +1,288 @@ +//! Whole-stack detector tests (language -> model -> attack graph). +//! +//! These moved out of `maltoolbox-language`'s test suite because they +//! exercise `Model` and `AttackGraph`, not just the language graph; keeping +//! them there made `maltoolbox-language` depend on its own downstream +//! crates via dev-dependencies. Language-only detector parsing tests +//! (`only_tpr`, `only_fpr`, `wrong_labels_rejected`) remain in +//! `maltoolbox-language`. + +use std::collections::HashSet; +use std::rc::Rc; + +use maltoolbox_attackgraph::AttackGraph; +use maltoolbox_language::from_mal_spec; +use maltoolbox_model::Model; + +fn fixtures_dir() -> &'static std::path::Path { + std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")) +} + +fn tmp_lang_file(tag: &str, src: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "maltoolbox-detector-test-{tag}-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("lang.mal"); + std::fs::write(&path, src).unwrap(); + path +} + +#[test] +fn detector_presence() { + let lang_graph = + Rc::new(from_mal_spec(fixtures_dir().join("detector_lang.mal")).expect("compile")); + let mut model = Model::new("Example Model", lang_graph); + let comp1 = model + .add_asset( + "Computer", + Some("Computer 1".into()), + None, + None, + None, + true, + ) + .unwrap(); + let app1 = model + .add_asset( + "Application", + Some("Application 1".into()), + None, + None, + None, + true, + ) + .unwrap(); + let app2 = model + .add_asset( + "Application", + Some("Application 2".into()), + None, + None, + None, + true, + ) + .unwrap(); + model + .add_associated_assets(comp1, "computerApps", HashSet::from([app1])) + .unwrap(); + model + .add_associated_assets(app1, "toApplications", HashSet::from([app2])) + .unwrap(); + + let attack_graph = AttackGraph::from_model(&model).expect("build attack graph"); + + let app1_exploit = attack_graph + .get_node_by_full_name("Application 1:exploit") + .expect("node"); + let node = &attack_graph.nodes[app1_exploit]; + assert!(!node.detectors.is_empty(), "expected detectors on exploit"); + + let log_exploit = node + .detectors + .get("logExploit") + .expect("logExploit detector"); + assert_eq!(log_exploit.tprate, Some(0.9)); + assert_eq!(log_exploit.fprate, Some(0.1)); + assert!(!log_exploit.potential_context.is_empty()); + + let comp_nodes = log_exploit + .potential_context + .get("comp") + .expect("comp context"); + assert_eq!(comp_nodes.len(), 1); + let comp_node_key = *comp_nodes.iter().next().unwrap(); + assert_eq!( + attack_graph.full_name_of(comp_node_key, Some(&model)), + "Computer 1:authenticate" + ); +} + +#[test] +fn detector_unlabeled_context() { + let lang_str = r#" + #id: "test-actions-effects" + #version: "0.0.0" + + category System{ + asset Computer { + & physicalAccess + -> attemptAuthenticate + + | attemptAuthenticate [HardAndCertain] + -> authenticate + + & effect authenticate + -> computerApps.attemptExploit + } + + asset Application { + # shutDown + -> exploit + + | attemptExploit [HardAndCertain] + -> exploit + + & effect exploit + ! logExploit (computerOfApp.authenticate) [tpr: 0.9, fpr: 0.1] + -> toApplications.attemptExploit, + dataOnApp.read + } + + asset Data { + | read + } + } + + associations { + Computer [computerOfApp] * <-- appExecution --> * [computerApps] Application + Application [fromApplications] * <-- AppToAppCommunication --> * [toApplications] Application + Data [dataOnApp] * <-- AppData --> * [appWithData] Application + } + "#; + let path = tmp_lang_file("unlabeled-context", lang_str); + let lang_graph = Rc::new(from_mal_spec(&path).expect("compile")); + let mut model = Model::new("Test Model", lang_graph); + let comp1 = model + .add_asset( + "Computer", + Some("Computer 1".into()), + None, + None, + None, + true, + ) + .unwrap(); + let app1 = model + .add_asset( + "Application", + Some("Application 1".into()), + None, + None, + None, + true, + ) + .unwrap(); + model + .add_associated_assets(comp1, "computerApps", HashSet::from([app1])) + .unwrap(); + + let attack_graph = AttackGraph::from_model(&model).expect("build attack graph"); + let app1_exploit = attack_graph + .get_node_by_full_name("Application 1:exploit") + .expect("node"); + let detectors = &attack_graph.nodes[app1_exploit].detectors; + assert!(!detectors.is_empty()); + let log_exploit = &detectors["logExploit"]; + assert_eq!(log_exploit.fprate, Some(0.1)); + assert_eq!(log_exploit.tprate, Some(0.9)); + + let computer1_authenticate = attack_graph + .get_node_by_full_name("Computer 1:authenticate") + .expect("node"); + assert_eq!(log_exploit.potential_context.len(), 1); + assert_eq!( + log_exploit + .potential_context + .get("computerOfApp.authenticate"), + Some(&HashSet::from([computer1_authenticate])) + ); +} + +#[test] +fn multiple_detectors() { + let lang_str = r#" + #id: "test-actions-effects" + #version: "0.0.0" + + category System{ + asset Computer { + & physicalAccess + ! physicalAccessDetector [tpr: 0.8, fpr: 0.2] + -> attemptAuthenticate + + | attemptAuthenticate [HardAndCertain] + -> authenticate + + & effect authenticate + -> computerApps.attemptExploit + } + + asset Application { + # shutDown + -> exploit + + | attemptExploit [HardAndCertain] + -> exploit + + & effect exploit + ! logExploit (computerOfApp.authenticate) [tpr: 0.9, fpr: 0.1] + ! logExploit2 + -> toApplications.attemptExploit, + dataOnApp.read + } + + asset Data { + | read + } + } + + associations { + Computer [computerOfApp] * <-- appExecution --> * [computerApps] Application + Application [fromApplications] * <-- AppToAppCommunication --> * [toApplications] Application + Data [dataOnApp] * <-- AppData --> * [appWithData] Application + } + "#; + let path = tmp_lang_file("multiple-detectors", lang_str); + let lang_graph = Rc::new(from_mal_spec(&path).expect("compile")); + let mut model = Model::new("Test Model", lang_graph); + let comp1 = model + .add_asset( + "Computer", + Some("Computer 1".into()), + None, + None, + None, + true, + ) + .unwrap(); + let app1 = model + .add_asset( + "Application", + Some("Application 1".into()), + None, + None, + None, + true, + ) + .unwrap(); + model + .add_associated_assets(comp1, "computerApps", HashSet::from([app1])) + .unwrap(); + + let attack_graph = AttackGraph::from_model(&model).expect("build attack graph"); + + let comp1_physical_access = attack_graph + .get_node_by_full_name("Computer 1:physicalAccess") + .expect("node"); + assert!(attack_graph.nodes[comp1_physical_access] + .detectors + .contains_key("physicalAccessDetector")); + + let app1_exploit = attack_graph + .get_node_by_full_name("Application 1:exploit") + .expect("node"); + let detectors = &attack_graph.nodes[app1_exploit].detectors; + assert!(detectors.contains_key("logExploit")); + assert!(detectors.contains_key("logExploit2")); + assert_eq!(detectors["logExploit"].tprate, Some(0.9)); + assert_eq!(detectors["logExploit"].fprate, Some(0.1)); + assert_eq!(detectors["logExploit2"].tprate, None); + assert_eq!(detectors["logExploit2"].fprate, None); + + let physical_access_detector = + &attack_graph.nodes[comp1_physical_access].detectors["physicalAccessDetector"]; + assert_eq!(physical_access_detector.tprate, Some(0.8)); + assert_eq!(physical_access_detector.fprate, Some(0.2)); +} diff --git a/core/maltoolbox-attackgraph/tests/test_partial_regeneration.rs b/core/maltoolbox-attackgraph/tests/test_partial_regeneration.rs new file mode 100644 index 00000000..aa5dcb6d --- /dev/null +++ b/core/maltoolbox-attackgraph/tests/test_partial_regeneration.rs @@ -0,0 +1,1594 @@ +//! Port of `tests/attackgraph/test_partial_regeneration.py`'s +//! integration-style tests: for each, mutate a `Model`, call +//! `partially_regenerate_graph`, then independently build a fresh +//! `AttackGraph` from scratch and assert the two are equivalent. Several +//! of these are regression tests for real historical bugs in the Python +//! original's partial regeneration (see the per-test comments there) - +//! kept in full since they're exactly the kind of case oracle-golden +//! tests over a single snapshot wouldn't catch. +//! +//! Not ported: the low-level unit tests calling private +//! `partially_generate` helpers directly with hand-built +//! `ExpressionsChain`s (`test_assoc_affected_expr_chain_*`, +//! `test_affected_root_assets_subtype`) - the same logic is already +//! exercised end-to-end by the integration tests below, and +//! constructing raw `ExpressionsChain` values to test in isolation adds +//! little over that. Two cheap error-path unit tests are kept +//! (`switch_fieldname`/`nodes_to_be_removed` raising on bad input). + +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; + +use maltoolbox_attackgraph::{AttackGraph, AttackGraphNodeId}; +use maltoolbox_language::graph::LanguageGraph; +use maltoolbox_model::Model; + +fn lang_fixtures_dir() -> &'static std::path::Path { + std::path::Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../maltoolbox-language/tests/fixtures" + )) +} + +fn ag_fixtures_dir() -> &'static std::path::Path { + std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")) +} + +fn training_lang() -> Rc { + Rc::new( + maltoolbox_language::from_mar_archive( + ag_fixtures_dir().join("org.mal-lang.trainingLang-1.0.0.mar"), + ) + .unwrap(), + ) +} + +fn corelang() -> Rc { + Rc::new( + maltoolbox_language::from_mar_archive( + lang_fixtures_dir().join("org.mal-lang.coreLang-1.0.0.mar"), + ) + .unwrap(), + ) +} + +fn compile_lang(name: &str) -> Rc { + let spec = maltoolbox_language::compile_file(lang_fixtures_dir().join(name)).unwrap(); + Rc::new(maltoolbox_language::generate_graph(spec).unwrap()) +} + +/// Captures a [`maltoolbox_model::AssetSnapshot`] per id from the +/// still-live `model`, for tests that call `partially_regenerate_graph` +/// before `model.remove_asset` (both orders are valid; `snapshot` just +/// captures from wherever the asset currently lives instead of from +/// `remove_asset`'s return value). +fn snapshot(model: &Model, ids: &HashSet) -> HashMap { + ids.iter() + .map(|&id| { + let asset = model.get_asset_by_id(id).unwrap(); + ( + id, + maltoolbox_model::AssetSnapshot { + name: asset.name.clone(), + lg_asset: asset.lg_asset, + final_state: asset.clone(), + }, + ) + }) + .collect() +} + +/// Port of `check_graph_equivalence`: asserts two attack graphs have the +/// same node set and the same children/parents relationships, by full +/// name (ids are allowed to differ between an incrementally-updated +/// graph and a freshly-generated one). +fn check_graph_equivalence(model: &Model, expected: &AttackGraph, actual: &AttackGraph) { + assert_eq!( + expected.nodes.len(), + actual.nodes.len(), + "Number of nodes differ: expected {} != actual {}", + expected.nodes.len(), + actual.nodes.len() + ); + + let expected_names: HashSet<&String> = expected.full_name_to_node.keys().collect(); + let actual_names: HashSet<&String> = actual.full_name_to_node.keys().collect(); + assert_eq!( + expected_names, + actual_names, + "Node full names differ: only in expected: {:?}; only in actual: {:?}", + expected_names.difference(&actual_names), + actual_names.difference(&expected_names) + ); + + let names_of = + |graph: &AttackGraph, ids: &rustc_hash::FxHashSet| -> HashSet { + ids.iter() + .map(|&id| graph.full_name_of(id, Some(model))) + .collect() + }; + + for (full_name, &expected_key) in &expected.full_name_to_node { + let actual_key = *actual + .full_name_to_node + .get(full_name) + .unwrap_or_else(|| panic!("{full_name} not in partially regenerated graph")); + + let expected_children = names_of(expected, &expected.nodes[expected_key].children); + let actual_children = names_of(actual, &actual.nodes[actual_key].children); + assert_eq!( + expected_children, actual_children, + "different children for {full_name}" + ); + + let expected_parents = names_of(expected, &expected.nodes[expected_key].parents); + let actual_parents = names_of(actual, &actual.nodes[actual_key].parents); + assert_eq!( + expected_parents, actual_parents, + "different parents for {full_name}" + ); + } +} + +#[test] +fn partial_regeneration_training_lang() { + let lang_graph = training_lang(); + let mut model = Model::new("Test Model", lang_graph.clone()); + let network = model + .add_asset("Network", Some("LAN".into()), None, None, None, true) + .unwrap(); + let mut ag = AttackGraph::from_model(&model).unwrap(); + + macro_rules! check { + () => { + let regenerated = AttackGraph::from_model(&model).unwrap(); + check_graph_equivalence(&model, ®enerated, &ag); + }; + } + + let host0 = model + .add_asset("Host", Some("Host0".into()), None, None, None, true) + .unwrap(); + model + .add_associated_assets(network, "hosts", HashSet::from([host0])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::from([host0]), + &HashSet::from([(network, "hosts".to_string(), host0)]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + check!(); + + let user0 = model + .add_asset("User", Some("User0".into()), None, None, None, true) + .unwrap(); + model + .add_associated_assets(host0, "users", HashSet::from([user0])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::from([user0]), + &HashSet::from([(host0, "users".to_string(), user0)]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + check!(); + + let data0 = model + .add_asset("Data", Some("Data0".into()), None, None, None, true) + .unwrap(); + model + .add_associated_assets(host0, "data", HashSet::from([data0])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::from([data0]), + &HashSet::from([(host0, "data".to_string(), data0)]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + check!(); + + // Add a second host to the already existing network: requires + // relinking of the pre-existing Network:LAN:access node's children. + let host1 = model + .add_asset("Host", Some("Host1".into()), None, None, None, true) + .unwrap(); + model + .add_associated_assets(network, "hosts", HashSet::from([host1])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::from([host1]), + &HashSet::from([(network, "hosts".to_string(), host1)]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + check!(); + + // Associate the same user with the second host: relinking of an + // existing node (User0:compromise) to a new child. + model + .add_associated_assets(host1, "users", HashSet::from([user0])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::from([(host1, "users".to_string(), user0)]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + check!(); + + // A second Data asset on Host1, and another on Host0 (already has + // Data0): fresh host/data pairing plus a host gaining another child. + let data1 = model + .add_asset("Data", Some("Data1".into()), None, None, None, true) + .unwrap(); + let data2 = model + .add_asset("Data", Some("Data2".into()), None, None, None, true) + .unwrap(); + model + .add_associated_assets(host1, "data", HashSet::from([data1])) + .unwrap(); + model + .add_associated_assets(host0, "data", HashSet::from([data2])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::from([data1, data2]), + &HashSet::from([ + (host1, "data".to_string(), data1), + (host0, "data".to_string(), data2), + ]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + check!(); + + let network2 = model + .add_asset("Network", Some("WAN".into()), None, None, None, true) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::from([network2]), + &HashSet::new(), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + model + .add_associated_assets(network, "toNetworks", HashSet::from([network2])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::from([(network, "toNetworks".to_string(), network2)]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + check!(); + + // Now tear everything back down. + model + .remove_associated_assets(network, "toNetworks", &HashSet::from([network2])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &HashMap::new(), + &HashSet::from([(network, "toNetworks".to_string(), network2)]), + ) + .unwrap(); + check!(); + + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &snapshot(&model, &HashSet::from([network2])), + &HashSet::new(), + ) + .unwrap(); + model.remove_asset(network2).unwrap(); + check!(); + + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &snapshot(&model, &HashSet::from([data1])), + &HashSet::from([(host1, "data".to_string(), data1)]), + ) + .unwrap(); + model.remove_asset(data1).unwrap(); + check!(); + + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &snapshot(&model, &HashSet::from([data2])), + &HashSet::from([(host0, "data".to_string(), data2)]), + ) + .unwrap(); + model.remove_asset(data2).unwrap(); + check!(); + + model + .remove_associated_assets(host1, "users", &HashSet::from([user0])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &HashMap::new(), + &HashSet::from([(host1, "users".to_string(), user0)]), + ) + .unwrap(); + check!(); + + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &snapshot(&model, &HashSet::from([host1])), + &HashSet::from([(network, "hosts".to_string(), host1)]), + ) + .unwrap(); + model.remove_asset(host1).unwrap(); + check!(); + + model + .remove_associated_assets(host0, "data", &HashSet::from([data0])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &HashMap::new(), + &HashSet::from([(host0, "data".to_string(), data0)]), + ) + .unwrap(); + check!(); + + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &snapshot(&model, &HashSet::from([data0])), + &HashSet::new(), + ) + .unwrap(); + model.remove_asset(data0).unwrap(); + check!(); + + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &snapshot(&model, &HashSet::from([user0])), + &HashSet::from([(host0, "users".to_string(), user0)]), + ) + .unwrap(); + model.remove_asset(user0).unwrap(); + check!(); + + model + .remove_associated_assets(network, "hosts", &HashSet::from([host0])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &HashMap::new(), + &HashSet::from([(network, "hosts".to_string(), host0)]), + ) + .unwrap(); + check!(); + + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &snapshot(&model, &HashSet::from([host0])), + &HashSet::new(), + ) + .unwrap(); + model.remove_asset(host0).unwrap(); + check!(); + + assert_eq!(model.assets.len(), 1); + assert!(model.assets.contains_key(&network)); +} + +#[test] +fn partial_regeneration_with_assoc_chain_lang() { + let lang_graph = compile_lang("assocChainLang.mal"); + let mut model = Model::new("Test Model", lang_graph.clone()); + + let mut parent = model + .add_asset("A", Some("A:0".into()), None, None, None, true) + .unwrap(); + // (child type to create, fieldname on `parent` pointing to it) - Python + // derives the fieldname directly as `asset_type.lower()`. + let children = ["B", "C", "D", "E", "F", "G", "H", "I"]; + for asset_type in children { + let fieldname = asset_type.to_lowercase(); + let child = model + .add_asset( + asset_type, + Some(format!("{asset_type}:0")), + None, + None, + None, + true, + ) + .unwrap(); + model + .add_associated_assets(parent, &fieldname, HashSet::from([child])) + .unwrap(); + parent = child; + } + let mut ag = AttackGraph::from_model(&model).unwrap(); + + // (parent type, fieldname on parent pointing to the next child in the chain) + let chain = [ + ("A", "b"), + ("B", "c"), + ("C", "d"), + ("D", "e"), + ("E", "f"), + ("F", "g"), + ("G", "h"), + ("H", "i"), + ]; + for &(asset_type, fieldname) in &chain { + let parent_id = model + .get_asset_by_name(&format!("{asset_type}:0")) + .unwrap() + .id; + let child_id = model + .get_asset_by_name(&format!("{}:0", fieldname.to_uppercase())) + .unwrap() + .id; + + model + .remove_associated_assets(parent_id, fieldname, &HashSet::from([child_id])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &HashMap::new(), + &HashSet::from([(parent_id, fieldname.to_string(), child_id)]), + ) + .unwrap(); + let regenerated = AttackGraph::from_model(&model).unwrap(); + check_graph_equivalence(&model, ®enerated, &ag); + + model + .add_associated_assets(parent_id, fieldname, HashSet::from([child_id])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::from([(parent_id, fieldname.to_string(), child_id)]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + let regenerated = AttackGraph::from_model(&model).unwrap(); + check_graph_equivalence(&model, ®enerated, &ag); + } +} + +#[test] +fn partial_regeneration_transitive() { + let lang_graph = compile_lang("transitive.mal"); + let mut model = Model::new("Test Model", lang_graph.clone()); + + let mut root = model + .add_asset( + "TestAsset", + Some("RootTestAsset".into()), + None, + None, + None, + true, + ) + .unwrap(); + let mut ag = AttackGraph::from_model(&model).unwrap(); + + let mut chain = vec![root]; + for i in 0..20 { + let next = model + .add_asset( + "TestAsset", + Some(format!("TestAsset:{i}")), + None, + None, + None, + true, + ) + .unwrap(); + model + .add_associated_assets(root, "field2", HashSet::from([next])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::from([next]), + &HashSet::from([(root, "field2".to_string(), next)]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + let regenerated = AttackGraph::from_model(&model).unwrap(); + check_graph_equivalence(&model, ®enerated, &ag); + root = next; + chain.push(next); + } + + for i in (0..20).step_by(2).rev() { + let child_id = chain[i + 1]; + let parent_id = *model.get_asset_by_id(child_id).unwrap().associated_assets["field1"] + .iter() + .next() + .unwrap(); + model + .remove_associated_assets(parent_id, "field2", &HashSet::from([child_id])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &HashMap::new(), + &HashSet::from([(parent_id, "field2".to_string(), child_id)]), + ) + .unwrap(); + let regenerated = AttackGraph::from_model(&model).unwrap(); + check_graph_equivalence(&model, ®enerated, &ag); + } +} + +#[test] +fn partial_regeneration_set_ops_adv() { + let lang_graph = compile_lang("set_ops_adv.mal"); + let mut model = Model::new("Test Model", lang_graph.clone()); + + let hub1 = model + .add_asset("Hub", Some("Hub 1".into()), None, None, None, true) + .unwrap(); + let hub2 = model + .add_asset("Hub", Some("Hub 2".into()), None, None, None, true) + .unwrap(); + let hub3 = model + .add_asset("Hub", Some("Hub 3".into()), None, None, None, true) + .unwrap(); + let t1 = model + .add_asset("Target", Some("Target 1".into()), None, None, None, true) + .unwrap(); + let t2 = model + .add_asset("Target", Some("Target 2".into()), None, None, None, true) + .unwrap(); + let t3 = model + .add_asset("Target", Some("Target 3".into()), None, None, None, true) + .unwrap(); + + model + .add_associated_assets(hub2, "setA", HashSet::from([t1, t2])) + .unwrap(); + model + .add_associated_assets(hub3, "setB", HashSet::from([t2, t3])) + .unwrap(); + model + .add_associated_assets(hub1, "siblings", HashSet::from([hub2, hub3])) + .unwrap(); + + let mut ag = AttackGraph::from_model(&model).unwrap(); + + model + .remove_associated_assets(hub3, "setB", &HashSet::from([t2])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &HashMap::new(), + &HashSet::from([(hub3, "setB".to_string(), t2)]), + ) + .unwrap(); + let regenerated = AttackGraph::from_model(&model).unwrap(); + check_graph_equivalence(&model, ®enerated, &ag); + + model + .add_associated_assets(hub3, "setB", HashSet::from([t2])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::from([(hub3, "setB".to_string(), t2)]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + let regenerated = AttackGraph::from_model(&model).unwrap(); + check_graph_equivalence(&model, ®enerated, &ag); + + model + .remove_associated_assets(hub1, "siblings", &HashSet::from([hub3])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &HashMap::new(), + &HashSet::from([(hub1, "siblings".to_string(), hub3)]), + ) + .unwrap(); + let regenerated = AttackGraph::from_model(&model).unwrap(); + check_graph_equivalence(&model, ®enerated, &ag); + + model + .add_associated_assets(hub1, "siblings", HashSet::from([hub3])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::from([(hub1, "siblings".to_string(), hub3)]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + let regenerated = AttackGraph::from_model(&model).unwrap(); + check_graph_equivalence(&model, ®enerated, &ag); +} + +#[test] +fn partial_regeneration_set_ops_collect_left() { + // Chain has a difference nested on the left side of a collect + // ((setA - setB).next.reach) - a position where the reverse walk in + // assoc_left_assets is unsafe if more than one association changes + // in the same call. + let lang_graph = compile_lang("set_ops_collect_left.mal"); + let mut model = Model::new("Test Model", lang_graph.clone()); + + let origin = model + .add_asset("Origin", Some("Origin".into()), None, None, None, true) + .unwrap(); + let node1 = model + .add_asset("Node", Some("Node 1".into()), None, None, None, true) + .unwrap(); + let node2 = model + .add_asset("Node", Some("Node 2".into()), None, None, None, true) + .unwrap(); + let target1 = model + .add_asset("Target", Some("Target 1".into()), None, None, None, true) + .unwrap(); + let target2 = model + .add_asset("Target", Some("Target 2".into()), None, None, None, true) + .unwrap(); + + model + .add_associated_assets(origin, "setA", HashSet::from([node1, node2])) + .unwrap(); + model + .add_associated_assets(origin, "setB", HashSet::from([node2])) + .unwrap(); + model + .add_associated_assets(node1, "next", HashSet::from([target1])) + .unwrap(); + model + .add_associated_assets(node2, "next", HashSet::from([target2])) + .unwrap(); + + let mut ag = AttackGraph::from_model(&model).unwrap(); + let check_node = ag.get_node_by_full_name("Origin:check").unwrap(); + assert!(ag.nodes[check_node] + .children + .contains(&ag.get_node_by_full_name("Target 1:reach").unwrap())); + assert!(!ag.nodes[check_node] + .children + .contains(&ag.get_node_by_full_name("Target 2:reach").unwrap())); + + model + .remove_associated_assets(node1, "next", &HashSet::from([target1])) + .unwrap(); + model + .remove_associated_assets(node2, "next", &HashSet::from([target2])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &HashMap::new(), + &HashSet::from([ + (node1, "next".to_string(), target1), + (node2, "next".to_string(), target2), + ]), + ) + .unwrap(); + let regenerated = AttackGraph::from_model(&model).unwrap(); + check_graph_equivalence(&model, ®enerated, &ag); + + model + .add_associated_assets(node1, "next", HashSet::from([target1])) + .unwrap(); + model + .add_associated_assets(node2, "next", HashSet::from([target2])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::from([ + (node1, "next".to_string(), target1), + (node2, "next".to_string(), target2), + ]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + let regenerated = AttackGraph::from_model(&model).unwrap(); + check_graph_equivalence(&model, ®enerated, &ag); +} + +#[test] +fn partial_regeneration_shared_assoc_sibling() { + // Two sibling subtypes share an inherited association but only one + // of them defines the attack step that uses it. + let lang_graph = compile_lang("shared_assoc_sibling.mal"); + let mut model = Model::new("Test Model", lang_graph.clone()); + + let a = model + .add_asset("SiblingA", Some("A1".into()), None, None, None, true) + .unwrap(); + let b = model + .add_asset("SiblingB", Some("B1".into()), None, None, None, true) + .unwrap(); + let t1 = model + .add_asset("Target", Some("T1".into()), None, None, None, true) + .unwrap(); + + model + .add_associated_assets(b, "target", HashSet::from([t1])) + .unwrap(); + let mut ag = AttackGraph::from_model(&model).unwrap(); + + model + .remove_associated_assets(b, "target", &HashSet::from([t1])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &HashMap::new(), + &HashSet::from([(b, "target".to_string(), t1)]), + ) + .unwrap(); + let regenerated = AttackGraph::from_model(&model).unwrap(); + check_graph_equivalence(&model, ®enerated, &ag); + + model + .add_associated_assets(b, "target", HashSet::from([t1])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::from([(b, "target".to_string(), t1)]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + let regenerated = AttackGraph::from_model(&model).unwrap(); + check_graph_equivalence(&model, ®enerated, &ag); + + model + .add_associated_assets(a, "target", HashSet::from([t1])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::from([(a, "target".to_string(), t1)]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + let regenerated = AttackGraph::from_model(&model).unwrap(); + check_graph_equivalence(&model, ®enerated, &ag); +} + +#[test] +fn partial_regeneration_subtype_hop_new_assoc() { + // A step chain passes through a `[Subtype]` hop and then an + // association that gets a new asset. The parent asset must be found by + // walking back from the changed asset, with the subtype filter applied + // to the changed end, not to the parent end. + let lang_graph = compile_lang("subtype_hop_new_assoc.mal"); + let mut model = Model::new("Test Model", lang_graph.clone()); + + let f = model + .add_asset("File", Some("F".into()), None, None, None, true) + .unwrap(); + let p = model + .add_asset("Pipe", Some("P".into()), None, None, None, true) + .unwrap(); + let fd = model + .add_asset("FileDescriptor", Some("FD".into()), None, None, None, true) + .unwrap(); + let pd = model + .add_asset("FileDescriptor", Some("PD".into()), None, None, None, true) + .unwrap(); + model + .add_associated_assets(fd, "io", HashSet::from([f])) + .unwrap(); + model + .add_associated_assets(pd, "io", HashSet::from([p])) + .unwrap(); + let mut ag = AttackGraph::from_model(&model).unwrap(); + + let x = model + .add_asset("Xattr", Some("X".into()), None, None, None, true) + .unwrap(); + model + .add_associated_assets(f, "xattrs", HashSet::from([x])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::from([x]), + &HashSet::from([(f, "xattrs".to_string(), x)]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + let regenerated = AttackGraph::from_model(&model).unwrap(); + check_graph_equivalence(&model, ®enerated, &ag); + + let fd_setxattr = ag.full_name_to_node["FD:setxattr"]; + let children: HashSet = ag.nodes[fd_setxattr] + .children + .iter() + .map(|&c| ag.full_name_of(c, Some(&model))) + .collect(); + assert_eq!(children, HashSet::from(["X:setxattr".to_string()])); + + model + .remove_associated_assets(f, "xattrs", &HashSet::from([x])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &HashMap::new(), + &HashSet::from([(f, "xattrs".to_string(), x)]), + ) + .unwrap(); + let regenerated = AttackGraph::from_model(&model).unwrap(); + check_graph_equivalence(&model, ®enerated, &ag); +} + +#[test] +fn partial_regeneration_corelang() { + let lang_graph = corelang(); + let mut model = Model::new("Test Model", lang_graph.clone()); + let corpnet = model + .add_asset("Network", Some("CorpNet".into()), None, None, None, true) + .unwrap(); + let mut ag = AttackGraph::from_model(&model).unwrap(); + + macro_rules! check { + () => { + let regenerated = AttackGraph::from_model(&model).unwrap(); + check_graph_equivalence(&model, ®enerated, &ag); + }; + } + + let webapp = model + .add_asset("Application", Some("WebApp".into()), None, None, None, true) + .unwrap(); + model + .add_associated_assets(corpnet, "applications", HashSet::from([webapp])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::from([webapp]), + &HashSet::from([(corpnet, "applications".to_string(), webapp)]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + check!(); + + let webhw = model + .add_asset("Hardware", Some("WebServer".into()), None, None, None, true) + .unwrap(); + model + .add_associated_assets(webhw, "sysExecutedApps", HashSet::from([webapp])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::from([webhw]), + &HashSet::from([(webhw, "sysExecutedApps".to_string(), webapp)]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + check!(); + + let webdata = model + .add_asset("Data", Some("WebAppData".into()), None, None, None, true) + .unwrap(); + model + .add_associated_assets(webapp, "containedData", HashSet::from([webdata])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::from([webdata]), + &HashSet::from([(webapp, "containedData".to_string(), webdata)]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + check!(); + + let dbapp = model + .add_asset("Application", Some("DBApp".into()), None, None, None, true) + .unwrap(); + model + .add_associated_assets(corpnet, "applications", HashSet::from([dbapp])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::from([dbapp]), + &HashSet::from([(corpnet, "applications".to_string(), dbapp)]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + check!(); + + model + .add_associated_assets(webapp, "appExecutedApps", HashSet::from([dbapp])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::from([(webapp, "appExecutedApps".to_string(), dbapp)]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + check!(); + + let dbhw = model + .add_asset("Hardware", Some("DBServer".into()), None, None, None, true) + .unwrap(); + let dbdata = model + .add_asset("Data", Some("DBData".into()), None, None, None, true) + .unwrap(); + model + .add_associated_assets(dbhw, "sysExecutedApps", HashSet::from([dbapp])) + .unwrap(); + model + .add_associated_assets(dbhw, "hostedData", HashSet::from([dbdata])) + .unwrap(); + model + .add_associated_assets(dbapp, "containedData", HashSet::from([dbdata])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::from([dbhw, dbdata]), + &HashSet::from([ + (dbhw, "sysExecutedApps".to_string(), dbapp), + (dbhw, "hostedData".to_string(), dbdata), + (dbapp, "containedData".to_string(), dbdata), + ]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + check!(); + + let netidps = model + .add_asset("IDPS", Some("NetIDPS".into()), None, None, None, true) + .unwrap(); + model + .add_associated_assets(netidps, "protectedApps", HashSet::from([webapp, dbapp])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::from([netidps]), + &HashSet::from([ + (netidps, "protectedApps".to_string(), webapp), + (netidps, "protectedApps".to_string(), dbapp), + ]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + check!(); + + let svcidentity = model + .add_asset( + "Identity", + Some("SvcIdentity".into()), + None, + None, + None, + true, + ) + .unwrap(); + let svccreds = model + .add_asset( + "Credentials", + Some("SvcCreds".into()), + None, + None, + None, + true, + ) + .unwrap(); + let alice = model + .add_asset("User", Some("Alice".into()), None, None, None, true) + .unwrap(); + model + .add_associated_assets(svcidentity, "credentials", HashSet::from([svccreds])) + .unwrap(); + model + .add_associated_assets(alice, "userIds", HashSet::from([svcidentity])) + .unwrap(); + model + .add_associated_assets(svcidentity, "readPrivData", HashSet::from([webdata])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::from([svcidentity, svccreds, alice]), + &HashSet::from([ + (svcidentity, "credentials".to_string(), svccreds), + (alice, "userIds".to_string(), svcidentity), + (svcidentity, "readPrivData".to_string(), webdata), + ]), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + check!(); + + // --- Phase 2: change model (almost) completely --- + let mut removed_assets = HashSet::new(); + let mut removed_associations = HashSet::new(); + for (&asset_id, asset) in &model.assets { + for (fieldname, assoc_assets) in &asset.associated_assets { + for &other_id in assoc_assets { + removed_associations.insert((asset_id, fieldname.clone(), other_id)); + } + } + } + for &asset_id in model.asset_order.clone().iter() { + if asset_id != corpnet { + removed_assets.insert(asset_id); + } + } + // Strip the associations now (so partially_regenerate_graph sees the + // post-removal truth when relinking), but defer actually deleting the + // assets themselves until after that call runs - no ordering + // contract requires this any more (`snapshot` below captures what's + // needed regardless of order), it just keeps this test's structure + // close to the Python original. `removed_associations` contains both + // directions of each link; the second attempt at either direction is + // expected to report "not associated" since the first already + // cleared both sides - ignored here. + for (asset_id, fieldname, other_id) in &removed_associations { + let _ = model.remove_associated_assets(*asset_id, fieldname, &HashSet::from([*other_id])); + } + + let mut new_assets = HashSet::new(); + let mut new_associations = HashSet::new(); + + let dmz = model + .add_asset("Network", Some("DMZ".into()), None, None, None, true) + .unwrap(); + let backup = model + .add_asset("Network", Some("BackupNet".into()), None, None, None, true) + .unwrap(); + new_assets.extend([dmz, backup]); + + let cr1 = model + .add_asset( + "ConnectionRule", + Some("CR-CorpNet-DMZ".into()), + None, + None, + None, + true, + ) + .unwrap(); + let cr2 = model + .add_asset( + "ConnectionRule", + Some("CR-DMZ-Backup".into()), + None, + None, + None, + true, + ) + .unwrap(); + new_assets.extend([cr1, cr2]); + model + .add_associated_assets(cr1, "networks", HashSet::from([corpnet, dmz])) + .unwrap(); + model + .add_associated_assets(cr2, "networks", HashSet::from([dmz, backup])) + .unwrap(); + new_associations.extend([ + (cr1, "networks".to_string(), corpnet), + (cr1, "networks".to_string(), dmz), + (cr2, "networks".to_string(), dmz), + (cr2, "networks".to_string(), backup), + ]); + + let proxyapp = model + .add_asset( + "Application", + Some("ProxyApp".into()), + None, + None, + None, + true, + ) + .unwrap(); + let dmzapp = model + .add_asset("Application", Some("DmzApp".into()), None, None, None, true) + .unwrap(); + let backupapp = model + .add_asset( + "Application", + Some("BackupApp".into()), + None, + None, + None, + true, + ) + .unwrap(); + new_assets.extend([proxyapp, dmzapp, backupapp]); + model + .add_associated_assets(corpnet, "applications", HashSet::from([proxyapp])) + .unwrap(); + model + .add_associated_assets(dmz, "applications", HashSet::from([dmzapp])) + .unwrap(); + model + .add_associated_assets(backup, "applications", HashSet::from([backupapp])) + .unwrap(); + new_associations.extend([ + (corpnet, "applications".to_string(), proxyapp), + (dmz, "applications".to_string(), dmzapp), + (backup, "applications".to_string(), backupapp), + ]); + + let proxyhw = model + .add_asset( + "Hardware", + Some("ProxyServer".into()), + None, + None, + None, + true, + ) + .unwrap(); + new_assets.insert(proxyhw); + model + .add_associated_assets(proxyhw, "sysExecutedApps", HashSet::from([proxyapp])) + .unwrap(); + new_associations.insert((proxyhw, "sysExecutedApps".to_string(), proxyapp)); + + let shareddata = model + .add_asset("Data", Some("SharedData".into()), None, None, None, true) + .unwrap(); + new_assets.insert(shareddata); + model + .add_associated_assets(proxyapp, "containedData", HashSet::from([shareddata])) + .unwrap(); + model + .add_associated_assets(shareddata, "hardware", HashSet::from([proxyhw])) + .unwrap(); + new_associations.extend([ + (proxyapp, "containedData".to_string(), shareddata), + (shareddata, "hardware".to_string(), proxyhw), + ]); + + let svcidentity2 = model + .add_asset( + "Identity", + Some("SvcIdentity2".into()), + None, + None, + None, + true, + ) + .unwrap(); + let svccreds2 = model + .add_asset( + "Credentials", + Some("SvcCreds2".into()), + None, + None, + None, + true, + ) + .unwrap(); + let bob = model + .add_asset("User", Some("Bob".into()), None, None, None, true) + .unwrap(); + new_assets.extend([svcidentity2, svccreds2, bob]); + model + .add_associated_assets(svcidentity2, "credentials", HashSet::from([svccreds2])) + .unwrap(); + model + .add_associated_assets(bob, "userIds", HashSet::from([svcidentity2])) + .unwrap(); + model + .add_associated_assets(svcidentity2, "readPrivData", HashSet::from([shareddata])) + .unwrap(); + new_associations.extend([ + (svcidentity2, "credentials".to_string(), svccreds2), + (bob, "userIds".to_string(), svcidentity2), + (svcidentity2, "readPrivData".to_string(), shareddata), + ]); + + let edgeidps = model + .add_asset("IDPS", Some("EdgeIDPS".into()), None, None, None, true) + .unwrap(); + new_assets.insert(edgeidps); + model + .add_associated_assets(edgeidps, "protectedApps", HashSet::from([proxyapp, dmzapp])) + .unwrap(); + new_associations.extend([ + (edgeidps, "protectedApps".to_string(), proxyapp), + (edgeidps, "protectedApps".to_string(), dmzapp), + ]); + + ag.partially_regenerate_graph( + &model, + &new_assets, + &new_associations, + &snapshot(&model, &removed_assets), + &removed_associations, + ) + .unwrap(); + for &asset_id in &removed_assets { + model.remove_asset(asset_id).unwrap(); + } + check!(); + + // --- Phase 3: teardown --- + model + .remove_associated_assets(edgeidps, "protectedApps", &HashSet::from([dmzapp])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &HashMap::new(), + &HashSet::from([(edgeidps, "protectedApps".to_string(), dmzapp)]), + ) + .unwrap(); + check!(); + + model + .remove_associated_assets(svcidentity2, "readPrivData", &HashSet::from([shareddata])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &HashMap::new(), + &HashSet::from([(svcidentity2, "readPrivData".to_string(), shareddata)]), + ) + .unwrap(); + check!(); + + model + .remove_associated_assets(shareddata, "hardware", &HashSet::from([proxyhw])) + .unwrap(); + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &HashMap::new(), + &HashSet::from([(shareddata, "hardware".to_string(), proxyhw)]), + ) + .unwrap(); + check!(); + + let mut removed_assets = HashSet::new(); + let mut removed_associations = HashSet::new(); + for &asset_id in &[backup, backupapp, cr2] { + for (fieldname, assoc_assets) in &model.assets[&asset_id].associated_assets { + for &other_id in assoc_assets { + removed_associations.insert((asset_id, fieldname.clone(), other_id)); + } + } + } + for (asset_id, fieldname, other_id) in &removed_associations { + let _ = model.remove_associated_assets(*asset_id, fieldname, &HashSet::from([*other_id])); + } + for &asset_id in &[backup, backupapp, cr2] { + removed_assets.insert(asset_id); + } + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &snapshot(&model, &removed_assets), + &removed_associations, + ) + .unwrap(); + for &asset_id in &[backup, backupapp, cr2] { + model.remove_asset(asset_id).unwrap(); + } + check!(); + + // Final bulk teardown back to only CorpNet. + let mut removed_assets = HashSet::new(); + let mut removed_associations = HashSet::new(); + for (&asset_id, asset) in &model.assets { + for (fieldname, assoc_assets) in &asset.associated_assets { + for &other_id in assoc_assets { + removed_associations.insert((asset_id, fieldname.clone(), other_id)); + } + } + } + for &asset_id in model.asset_order.clone().iter() { + if asset_id != corpnet && model.assets.contains_key(&asset_id) { + removed_assets.insert(asset_id); + } + } + for (asset_id, fieldname, other_id) in &removed_associations { + let _ = model.remove_associated_assets(*asset_id, fieldname, &HashSet::from([*other_id])); + } + ag.partially_regenerate_graph( + &model, + &HashSet::new(), + &HashSet::new(), + &snapshot(&model, &removed_assets), + &removed_associations, + ) + .unwrap(); + for &asset_id in &removed_assets { + model.remove_asset(asset_id).unwrap(); + } + check!(); + + assert_eq!(model.assets.len(), 1); + assert!(model.assets.contains_key(&corpnet)); +} + +#[test] +fn partial_regeneration_new_association_on_asset_removed_same_batch() { + // Regression test: DynaMAL-style model effect that both adds a new + // association *and* removes an asset in the same batch, where the + // newly-added association's left-hand asset is the one being removed + // (e.g. a rule like `R> victim / malware ^ ~data` firing a `trigger` + // that both severs/removes assets and creates a fresh association in + // one go). Before the fix, the `new_associations` loop in + // `partially_regenerate_graph` resolved the opposite fieldname via + // the plain `switch_fieldname(model, *left_id, fieldname)`, which + // calls `model.get_asset_by_id(*left_id)` - this panicked with + // `GraphError::Malformed("Unknown asset id ...")` whenever `left_id` + // was simultaneously present in `removed_assets` for the same call, + // since by the time `partially_regenerate_graph` runs it may already + // be gone from `model.assets` (no ordering contract between + // `model.remove_asset` and this call - see the doc comment on + // `partially_regenerate_graph`). The fix mirrors the + // `removed_associations` loop by using + // `switch_fieldname_possibly_removed` instead. + let lang_graph = compile_lang("wiperLang.mal"); + let mut model = Model::new("Test Model", lang_graph.clone()); + + let internet = model + .add_asset("Internet", Some("internet1".into()), None, None, None, true) + .unwrap(); + let device = model + .add_asset("Device", Some("device1".into()), None, None, None, true) + .unwrap(); + let data = model + .add_asset("Data", Some("data1".into()), None, None, None, true) + .unwrap(); + let wiper1 = model + .add_asset("Wiper", Some("wiper1".into()), None, None, None, true) + .unwrap(); + + model + .add_associated_assets(internet, "hosts", HashSet::from([device])) + .unwrap(); + model + .add_associated_assets(device, "data", HashSet::from([data])) + .unwrap(); + model + .add_associated_assets(device, "malware", HashSet::from([wiper1])) + .unwrap(); + + let mut ag = AttackGraph::from_model(&model).unwrap(); + + // Simultaneously: remove `device1` (which also severs its `hosts`, + // `data` and `malware` associations), and add a brand new `Wiper` + // asset associated to `device1` via `malware` - i.e. a new + // association whose left_id (`device`) is also a key in + // `removed_assets` for this exact same call. + let removed_assets_set = HashSet::from([device]); + let removed_assets_snapshot = snapshot(&model, &removed_assets_set); + let removed_associations = HashSet::from([ + (internet, "hosts".to_string(), device), + (device, "data".to_string(), data), + (device, "malware".to_string(), wiper1), + ]); + + let wiper2 = model + .add_asset("Wiper", Some("wiper2".into()), None, None, None, true) + .unwrap(); + model + .add_associated_assets(device, "malware", HashSet::from([wiper2])) + .unwrap(); + let new_assets = HashSet::from([wiper2]); + let new_associations = HashSet::from([(device, "malware".to_string(), wiper2)]); + + model.remove_asset(device).unwrap(); + + // Must not raise `GraphError::Malformed("Unknown asset id ...")`. + ag.partially_regenerate_graph( + &model, + &new_assets, + &new_associations, + &removed_assets_snapshot, + &removed_associations, + ) + .expect("partial regeneration must handle a new association whose left_id was removed in the same batch"); + + // `device1` is gone from the graph entirely (only `device1`, the + // asset actually in `removed_assets`, is expected to vanish - + // `data1`/`wiper1`/`wiper2` remain as assets in the model, just + // stripped of their association to the now-gone `device1`). + assert!( + !ag.full_name_to_node + .keys() + .any(|k| k.starts_with("device1:")), + "expected no nodes left for removed asset device1, found: {:?}", + ag.full_name_to_node + .keys() + .filter(|k| k.starts_with("device1:")) + .collect::>() + ); + + // `wiper2`'s `test` step (`A> self / victim[C2Server]`) depends on + // `malware`/`victim`, so it must be present and - since the new + // association's opposite fieldname was resolved correctly despite + // `device` being simultaneously removed - must show no children + // (device is gone, so there's no live `victim` to reach through the + // severed association). + let wiper2_test = ag + .get_node_by_full_name("wiper2:test") + .expect("wiper2:test node must exist"); + assert!( + ag.nodes[wiper2_test].children.is_empty(), + "wiper2:test should have no children once device1 (its only would-be victim) is removed" + ); + + // Independently rebuilding from the current model (device1 gone, + // wiper2 now an orphaned, unassociated Wiper asset) must match. + let regenerated = AttackGraph::from_model(&model).unwrap(); + check_graph_equivalence(&model, ®enerated, &ag); +} + +#[test] +fn partial_regeneration_new_asset_removed_same_batch() { + // Regression test: DynaMAL-style model effect that adds a brand new + // asset *and* removes that same asset in the same + // `partially_regenerate_graph` batch (e.g. `baseDynamicTestLang4.mal`'s + // `Bowl.tamper`: `A> self / apples` immediately followed by + // `R> self / apples` on the same step - the new `Apple` ends up in + // both `new_assets` and `removed_assets` for this one call). Before + // the fix, `generate::create_nodes_for` indexed `model.assets[&id]` + // directly for every id in `new_assets`, which panics + // ("no entry found for key") once `model.remove_asset` has already + // run for that id - unlike the association-resolution path + // (`switch_fieldname_possibly_removed`), node creation had no + // removed-assets fallback at all. + let lang_graph = compile_lang("dynamal_test_langs/basic/baseDynamicTestLang4.mal"); + let mut model = Model::new("Test Model", lang_graph.clone()); + + let bowl = model + .add_asset("Bowl", Some("bowl1".into()), None, None, None, true) + .unwrap(); + + let mut ag = AttackGraph::from_model(&model).unwrap(); + + // Simulate the same-step A>/R> model effect: add a new Apple, + // associate it to the Bowl via "apples", then remove it again - + // all before `partially_regenerate_graph` is ever called. + let apple = model + .add_asset("Apple", Some("apple1".into()), None, None, None, true) + .unwrap(); + model + .add_associated_assets(bowl, "apples", HashSet::from([apple])) + .unwrap(); + + let new_assets = HashSet::from([apple]); + let new_associations = HashSet::from([(bowl, "apples".to_string(), apple)]); + let removed_assets_snapshot = snapshot(&model, &HashSet::from([apple])); + let removed_associations = HashSet::from([(bowl, "apples".to_string(), apple)]); + + model.remove_asset(apple).unwrap(); + + // Must not panic. + ag.partially_regenerate_graph( + &model, + &new_assets, + &new_associations, + &removed_assets_snapshot, + &removed_associations, + ) + .expect("partial regeneration must handle a new asset removed in the same batch"); + + // `Apple` has no attack steps (`asset Apple {}`), so no nodes are + // expected for it either way - the real assertion is that + // regeneration didn't panic. Confirm nothing stray survives. + assert!( + !ag.full_name_to_node + .keys() + .any(|k| k.starts_with("apple1:")), + "expected no nodes left for the transient, same-batch-removed apple1, found: {:?}", + ag.full_name_to_node + .keys() + .filter(|k| k.starts_with("apple1:")) + .collect::>() + ); + + // Independently rebuilding from the current model (apple1 gone, + // bowl1 with no apples left) must match. + let regenerated = AttackGraph::from_model(&model).unwrap(); + check_graph_equivalence(&model, ®enerated, &ag); +} + +#[test] +fn switch_fieldname_unknown_fieldname_raises() { + let lang_graph = training_lang(); + let mut model = Model::new("Test Model", lang_graph); + let network = model + .add_asset("Network", Some("LAN".into()), None, None, None, true) + .unwrap(); + + let err = maltoolbox_attackgraph::partially_generate::switch_fieldname( + &model, + network, + "doesNotExist", + ) + .unwrap_err(); + assert!(err.to_string().contains("not found in associations")); +} + +#[test] +fn nodes_to_be_removed_missing_node_raises() { + let lang_graph = training_lang(); + let mut model = Model::new("Test Model", lang_graph); + let network = model + .add_asset("Network", Some("LAN".into()), None, None, None, true) + .unwrap(); + + let err = maltoolbox_attackgraph::partially_generate::nodes_to_be_removed( + &snapshot(&model, &HashSet::from([network])), + &model, + &maltoolbox_attackgraph::FxIndexMap::default(), + ) + .unwrap_err(); + assert!(err.to_string().contains("Failed to find")); +} diff --git a/core/maltoolbox-cli/Cargo.toml b/core/maltoolbox-cli/Cargo.toml new file mode 100644 index 00000000..b9ecb962 --- /dev/null +++ b/core/maltoolbox-cli/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "maltoolbox-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[[bin]] +name = "maltoolbox" +path = "src/main.rs" + +[dependencies] +maltoolbox-io = { workspace = true } +maltoolbox-language = { workspace = true } +maltoolbox-model = { workspace = true } +maltoolbox-attackgraph = { workspace = true } +maltoolbox-patternfinder = { workspace = true } +clap = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } diff --git a/core/maltoolbox-cli/src/main.rs b/core/maltoolbox-cli/src/main.rs new file mode 100644 index 00000000..33c02b7b --- /dev/null +++ b/core/maltoolbox-cli/src/main.rs @@ -0,0 +1,81 @@ +//! Port of `maltoolbox/__main__.py`. The `upgrade-model`/`visualize-model` +//! subcommands and the `--graphviz`/`--neo4j` flags are out of scope (no +//! visualization/translator subsystem ported). +//! +//! `generate-attack-graph` takes an explicit `` argument here, +//! replacing the Python original's implicit write to a +//! `maltoolbox.yml`-configured debug path. + +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command( + name = "maltoolbox", + version, + about = "Command-line interface for MAL toolbox operations" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Compile a MAL language spec (.mal or .mar) and write the result to `output_file` (.json/.yml/.yaml). + Compile { + lang_file: PathBuf, + output_file: PathBuf, + }, + /// Generate an attack graph from `model_file` and `lang_file`, and write it to `output_file` (.json/.yml/.yaml). + GenerateAttackGraph { + model_file: PathBuf, + lang_file: PathBuf, + output_file: PathBuf, + }, +} + +fn compile(lang_file: &std::path::Path, output_file: &std::path::Path) -> Result<(), String> { + let lang_graph = + maltoolbox_language::load_language_graph_from_file(lang_file).map_err(|e| e.to_string())?; + maltoolbox_language::save_language_graph_to_file(&lang_graph, output_file) + .map_err(|e| e.to_string()) +} + +fn generate_attack_graph( + model_file: &std::path::Path, + lang_file: &std::path::Path, + output_file: &std::path::Path, +) -> Result<(), String> { + let (attack_graph, model) = maltoolbox_attackgraph::create_attack_graph(lang_file, model_file) + .map_err(|e| e.to_string())?; + attack_graph + .save_to_file(Some(&model), output_file) + .map_err(|e| e.to_string()) +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + + let result = match &cli.command { + Command::Compile { + lang_file, + output_file, + } => compile(lang_file, output_file), + Command::GenerateAttackGraph { + model_file, + lang_file, + output_file, + } => generate_attack_graph(model_file, lang_file, output_file), + }; + + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("Error: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/core/maltoolbox-io/Cargo.toml b/core/maltoolbox-io/Cargo.toml new file mode 100644 index 00000000..862d8e3b --- /dev/null +++ b/core/maltoolbox-io/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "maltoolbox-io" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } diff --git a/core/maltoolbox-io/src/lib.rs b/core/maltoolbox-io/src/lib.rs new file mode 100644 index 00000000..aa2cfc54 --- /dev/null +++ b/core/maltoolbox-io/src/lib.rs @@ -0,0 +1,102 @@ +//! Port of `python/maltoolbox/file_utils.py`'s JSON/YAML (de)serialization +//! helpers, used by every crate that saves/loads a dict-shaped value +//! (`LanguageGraph`, `Model`, `AttackGraph`) to/from a file, dispatching +//! on the file extension. + +use std::fs; +use std::path::Path; + +use serde_json::Value; + +#[derive(Debug, thiserror::Error)] +pub enum FileUtilError { + #[error("failed to read '{path}': {source}")] + Io { + path: String, + #[source] + source: std::io::Error, + }, + #[error("failed to parse JSON in '{path}': {source}")] + Json { + path: String, + #[source] + source: serde_json::Error, + }, + #[error("failed to parse YAML in '{path}': {source}")] + Yaml { + path: String, + #[source] + source: serde_yaml::Error, + }, + #[error("Unknown file extension, expected json/yml/yaml")] + UnknownExtension, +} + +pub fn save_dict_to_json_file( + filename: impl AsRef, + value: &Value, +) -> Result<(), FileUtilError> { + let path = filename.as_ref(); + let json = serde_json::to_string_pretty(value).expect("Value serialization cannot fail"); + fs::write(path, json).map_err(|e| FileUtilError::Io { + path: path.display().to_string(), + source: e, + }) +} + +pub fn save_dict_to_yaml_file( + filename: impl AsRef, + value: &Value, +) -> Result<(), FileUtilError> { + let path = filename.as_ref(); + let yaml = serde_yaml::to_string(value).expect("Value serialization cannot fail"); + fs::write(path, yaml).map_err(|e| FileUtilError::Io { + path: path.display().to_string(), + source: e, + }) +} + +/// Save `value` to JSON or YAML depending on `filename`'s extension. +pub fn save_dict_to_file(filename: impl AsRef, value: &Value) -> Result<(), FileUtilError> { + let path = filename.as_ref(); + match path.extension().and_then(|e| e.to_str()) { + Some("yml") | Some("yaml") => save_dict_to_yaml_file(path, value), + Some("json") => save_dict_to_json_file(path, value), + _ => Err(FileUtilError::UnknownExtension), + } +} + +pub fn load_dict_from_json_file(filename: impl AsRef) -> Result { + let path = filename.as_ref(); + let text = fs::read_to_string(path).map_err(|e| FileUtilError::Io { + path: path.display().to_string(), + source: e, + })?; + serde_json::from_str(&text).map_err(|e| FileUtilError::Json { + path: path.display().to_string(), + source: e, + }) +} + +pub fn load_dict_from_yaml_file(filename: impl AsRef) -> Result { + let path = filename.as_ref(); + let text = fs::read_to_string(path).map_err(|e| FileUtilError::Io { + path: path.display().to_string(), + source: e, + })?; + serde_yaml::from_str(&text).map_err(|e| FileUtilError::Yaml { + path: path.display().to_string(), + source: e, + }) +} + +/// Load a dict-shaped value from JSON or YAML depending on `filename`'s +/// extension. +pub fn load_dict_from_file(filename: impl AsRef) -> Result { + let path = filename.as_ref(); + match path.extension().and_then(|e| e.to_str()) { + Some("yml") | Some("yaml") => load_dict_from_yaml_file(path), + Some("json") => load_dict_from_json_file(path), + _ => Err(FileUtilError::UnknownExtension), + } +} diff --git a/core/maltoolbox-language/Cargo.toml b/core/maltoolbox-language/Cargo.toml new file mode 100644 index 00000000..853fc4f4 --- /dev/null +++ b/core/maltoolbox-language/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "maltoolbox-language" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[dependencies] +maltoolbox-io = { workspace = true } +tree-sitter = { workspace = true } +tree-sitter-mal = { workspace = true } +indexmap = { workspace = true } +rustc-hash = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +slotmap = { workspace = true } +zip = { workspace = true } diff --git a/core/maltoolbox-language/src/compiler/distributions.rs b/core/maltoolbox-language/src/compiler/distributions.rs new file mode 100644 index 00000000..8e457911 --- /dev/null +++ b/core/maltoolbox-language/src/compiler/distributions.rs @@ -0,0 +1,178 @@ +//! Port of `maltoolbox/language/compiler/distributions.py`. +//! +//! Per-TTC-distribution parameter arity/range validation, used by the +//! semantic analyzer ([`super::semantic`]) when checking attack step TTC +//! expressions. + +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +pub struct DistributionsError(pub String); + +fn err(msg: impl Into) -> DistributionsError { + DistributionsError(msg.into()) +} + +pub fn validate(distribution_name: &str, params: &[f64]) -> Result<(), DistributionsError> { + match distribution_name { + "Bernoulli" => validate_bernoulli(params), + "Binomial" => validate_binomial(params), + "Exponential" => validate_exponential(params), + "Gamma" => validate_gamma(params), + "LogNormal" => validate_log_normal(params), + "Pareto" => validate_pareto(params), + "TruncatedNormal" => validate_truncated_normal(params), + "Uniform" => validate_uniform(params), + "Enabled" + | "Disabled" + | "Zero" + | "Infinity" + | "EasyAndCertain" + | "EasyAndUncertain" + | "HardAndCertain" + | "HardAndUncertain" + | "VeryHardAndCertain" + | "VeryHardAndUncertain" => validate_combination(params), + other => Err(err(format!("Distribution {other} is not supported"))), + } +} + +fn validate_bernoulli(params: &[f64]) -> Result<(), DistributionsError> { + if params.len() != 1 { + return Err(err( + "Expected exactly one parameter (probability), for Bernoulli distribution", + )); + } + if !(0.0..=1.0).contains(¶ms[0]) { + return Err(err(format!( + "{} is not in valid range '0 <= probability <= 1', for Bernoulli distribution", + params[0] + ))); + } + Ok(()) +} + +fn validate_binomial(params: &[f64]) -> Result<(), DistributionsError> { + if params.len() != 2 { + return Err(err( + "Expected exactly two parameters (trials, probability), for Binomial distribution", + )); + } + if !(0.0..=1.0).contains(¶ms[1]) { + return Err(err(format!( + "{} is not in valid range '0 <= probability <= 1', for Binomial distribution", + params[1] + ))); + } + Ok(()) +} + +fn validate_exponential(params: &[f64]) -> Result<(), DistributionsError> { + if params.len() != 1 { + return Err(err( + "Expected exactly one parameter (lambda), for Exponential distribution", + )); + } + if params[0] <= 0.0 { + return Err(err(format!( + "{} is not in valid range 'lambda > 0', for Exponential distribution", + params[0] + ))); + } + Ok(()) +} + +fn validate_gamma(params: &[f64]) -> Result<(), DistributionsError> { + if params.len() != 2 { + return Err(err( + "Expected exactly two parameters (shape, scale), for Gamma distribution", + )); + } + if params[0] <= 0.0 { + return Err(err(format!( + "{} is not in valid range 'shape > 0', for Gamma distribution", + params[0] + ))); + } + if params[1] <= 0.0 { + return Err(err(format!( + "{} is not in valid range 'scale > 0', for Gamma distribution", + params[1] + ))); + } + Ok(()) +} + +fn validate_log_normal(params: &[f64]) -> Result<(), DistributionsError> { + if params.len() != 2 { + return Err(err( + "Expected exactly two parameters (mean, standardDeviation), for LogNormal distribution", + )); + } + if params[1] <= 0.0 { + return Err(err(format!( + "{} is not in valid range 'standardDeviation > 0', for LogNormal distribution", + params[1] + ))); + } + Ok(()) +} + +fn validate_pareto(params: &[f64]) -> Result<(), DistributionsError> { + if params.len() != 2 { + return Err(err( + "Expected exactly two parameters (min, shape), for Pareto distribution", + )); + } + if params[0] <= 0.0 { + return Err(err(format!( + "{} is not in valid range 'min > 0', for Pareto distribution", + params[0] + ))); + } + if params[1] <= 0.0 { + return Err(err(format!( + "{} is not in valid range 'shape > 0', for Pareto distribution", + params[1] + ))); + } + Ok(()) +} + +fn validate_truncated_normal(params: &[f64]) -> Result<(), DistributionsError> { + if params.len() != 2 { + return Err(err( + "Expected exactly two parameters (mean, standardDeviation), for TruncatedNormal distribution", + )); + } + if params[1] <= 0.0 { + return Err(err(format!( + "{} is not in valid range 'standardDeviation > 0', for TruncatedNormal distribution", + params[1] + ))); + } + Ok(()) +} + +fn validate_uniform(params: &[f64]) -> Result<(), DistributionsError> { + if params.len() != 2 { + return Err(err( + "Expected exactly two parameters (min, max), for Uniform distribution", + )); + } + if params[0] > params[1] { + return Err(err(format!( + "({}, {}) does not meet requirement 'min <= max', for Uniform distribution", + params[0], params[1] + ))); + } + Ok(()) +} + +fn validate_combination(params: &[f64]) -> Result<(), DistributionsError> { + if !params.is_empty() { + return Err(err( + "Expected exactly zero parameters, for combination distributions", + )); + } + Ok(()) +} diff --git a/core/maltoolbox-language/src/compiler/mod.rs b/core/maltoolbox-language/src/compiler/mod.rs new file mode 100644 index 00000000..2a3c8c71 --- /dev/null +++ b/core/maltoolbox-language/src/compiler/mod.rs @@ -0,0 +1,1027 @@ +//! Port of mal-toolbox's `maltoolbox/language/compiler/mal_compiler.py`. +//! +//! Walks a tree-sitter-mal parse tree into the same loosely-typed +//! `langspec` JSON shape the Python compiler produces (formatVersion / +//! defines / categories / assets / associations). Structural visiting +//! only; semantic validation lives in the sibling [`semantic`] module and +//! runs once, at the end of `compile_file`, over the fully-merged +//! langspec. +//! +//! Walks the tree via tree-sitter's field API +//! (`child_by_field_name`/`children_by_field_name`), which never yields +//! `comment` nodes. Positional (non-field) child iteration still has to +//! filter `comment` out explicitly - see `named_children_no_comments`. + +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde_json::{json, Map, Value}; +use tree_sitter::Node; + +use crate::new_parser; + +pub mod distributions; +pub mod semantic; + +#[derive(Debug, thiserror::Error)] +pub enum CompileError { + #[error("failed to read '{path}': {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("syntax error in '{path}' at line {line}, column {column}")] + Syntax { + path: PathBuf, + line: usize, + column: usize, + }, + #[error("malformed MAL source: {0}")] + Malformed(String), + #[error("{0}")] + Semantic(String), +} + +/// Compile a `.mal` file (and any `include`d files) into the langspec +/// JSON shape, then run the semantic analyzer over the fully-merged +/// result. +pub fn compile_file(path: impl AsRef) -> Result { + let mut visited = HashSet::new(); + let mut active: Vec = Vec::new(); + let mut path_stack: Vec = Vec::new(); + let mut seen_custom_defines = HashSet::new(); + let langspec = compile_inner( + path.as_ref(), + &mut visited, + &mut active, + &mut path_stack, + &mut seen_custom_defines, + )?; + semantic::analyze(&langspec)?; + Ok(langspec) +} + +/// `active` holds the chain of files currently being compiled (the +/// include "call stack"), used for cycle detection: finding +/// `current_file` in it means an include chain has looped back on +/// itself, anywhere in the chain - including back to the root file. +/// +/// This intentionally diverges from the Python original, whose cycle +/// detection misses a mutual include that loops back to the root file +/// specifically (see `PORTING_NOTES.md`); this port always detects such +/// cycles rather than matching that bug. +fn compile_inner( + malfile: &Path, + visited: &mut HashSet, + active: &mut Vec, + path_stack: &mut Vec, + seen_custom_defines: &mut HashSet, +) -> Result { + let mut current_file = malfile.to_path_buf(); + if !current_file.is_absolute() { + if let Some(parent_dir) = path_stack.last() { + current_file = parent_dir.join(¤t_file); + } + } + + if let Some(cycle_start) = active.iter().position(|f| f == ¤t_file) { + let mut chain: Vec = active[cycle_start..] + .iter() + .map(|f| f.display().to_string()) + .collect(); + chain.push(current_file.display().to_string()); + return Err(CompileError::Semantic(format!( + "Include sequence contains cycle: {}", + chain.join(" -> ") + ))); + } + + if visited.contains(¤t_file) { + // Already fully compiled via another, non-cyclic include path + // (e.g. a "diamond": both B and C include A) - safe to skip + // rather than recompile. + return Ok(json!({})); + } + visited.insert(current_file.clone()); + active.push(current_file.clone()); + path_stack.push( + current_file + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")), + ); + + let source = fs::read(¤t_file).map_err(|e| CompileError::Io { + path: current_file.clone(), + source: e, + })?; + + let mut parser = new_parser(); + let tree = parser + .parse(&source, None) + .ok_or_else(|| CompileError::Malformed("tree-sitter parser did not run".into()))?; + let root = tree.root_node(); + + if let Some(err_node) = first_error_node(root) { + path_stack.pop(); + active.pop(); + return Err(CompileError::Syntax { + path: current_file, + line: err_node.start_position().row + 1, + column: err_node.start_position().column + 1, + }); + } + + let result = visit_source_file( + root, + &source, + visited, + active, + path_stack, + seen_custom_defines, + ); + path_stack.pop(); + active.pop(); + result +} + +fn first_error_node(node: Node) -> Option { + if node.is_error() || node.is_missing() { + return Some(node); + } + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + if let Some(found) = first_error_node(child) { + return Some(found); + } + } + } + None +} + +fn text<'a>(node: Node, source: &'a [u8]) -> &'a str { + node.utf8_text(source).unwrap_or("") +} + +/// Positional children, skipping `comment` extras - unlike field lookups, +/// `named_child` iteration does not exclude them. +fn named_children_no_comments<'a>(node: Node<'a>) -> impl Iterator> { + (0..node.named_child_count()) + .filter_map(move |i| node.named_child(i)) + .filter(|n| n.kind() != "comment") +} + +/// For fields that bundle optional surrounding punctuation under the same +/// field name (e.g. ttc_binop's parenthesized `left`/`right`), pick the +/// first *named* node tagged with `field`, skipping anonymous tokens like +/// `(`/`)`. +fn field_operand<'a>(node: Node<'a>, field: &'static str) -> Result, CompileError> { + let mut cursor = node.walk(); + let found = node + .children_by_field_name(field, &mut cursor) + .find(|n| n.is_named()); + found.ok_or_else(|| CompileError::Malformed(format!("expected operand for field '{field}'"))) +} + +fn required_field<'a>(node: Node<'a>, field: &'static str) -> Result, CompileError> { + node.child_by_field_name(field) + .ok_or_else(|| CompileError::Malformed(format!("missing required field '{field}'"))) +} + +fn strip_quotes(s: &str) -> &str { + let bytes = s.as_bytes(); + if bytes.len() >= 2 && bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"' { + &s[1..s.len() - 1] + } else { + s + } +} + +fn insert_object(value: &mut Value, key: &str, entry_key: String, entry_value: Value) { + value[key] + .as_object_mut() + .expect("langspec field should be an object") + .insert(entry_key, entry_value); +} + +fn extend_array(value: &mut Value, key: &str, items: Vec) { + value[key] + .as_array_mut() + .expect("langspec field should be an array") + .extend(items); +} + +fn dedup_array_in_place(arr: &mut Vec) { + let mut unique: Vec = Vec::with_capacity(arr.len()); + for item in arr.drain(..) { + if !unique.contains(&item) { + unique.push(item); + } + } + *arr = unique; +} + +// --------------------------------------------------------------------- +// source_file / declaration +// --------------------------------------------------------------------- + +fn visit_source_file( + root: Node, + source: &[u8], + visited: &mut HashSet, + active: &mut Vec, + path_stack: &mut Vec, + seen_custom_defines: &mut HashSet, +) -> Result { + let mut langspec = json!({ + "formatVersion": "1.0.0", + "defines": {}, + "categories": [], + "assets": [], + "associations": [], + }); + + for declaration in named_children_no_comments(root) { + // `declaration` is a transparent wrapper around exactly one of + // the four top-level alternatives. + let variant = declaration + .named_child(0) + .ok_or_else(|| CompileError::Malformed("empty top-level declaration".into()))?; + + match variant.kind() { + "category_declaration" => { + let (category, assets) = visit_category_declaration(variant, source)?; + extend_array(&mut langspec, "categories", vec![category]); + extend_array(&mut langspec, "assets", assets); + } + "define_declaration" => { + let (key, value) = visit_define_declaration(variant, source)?; + if key != "id" && key != "version" && !seen_custom_defines.insert(key.clone()) { + return Err(CompileError::Semantic(format!( + "Define '{key}' previously defined" + ))); + } + insert_object(&mut langspec, "defines", key, Value::String(value)); + } + "associations_declaration" => { + let associations = visit_associations_declaration(variant, source)?; + extend_array(&mut langspec, "associations", associations); + } + "include_declaration" => { + let included_path = visit_include_declaration(variant, source)?; + let included = compile_inner( + Path::new(&included_path), + visited, + active, + path_stack, + seen_custom_defines, + )?; + if let Some(defines) = included.get("defines").and_then(Value::as_object) { + for (k, v) in defines { + insert_object(&mut langspec, "defines", k.clone(), v.clone()); + } + } + for key in ["categories", "assets", "associations"] { + if let Some(items) = included.get(key).and_then(Value::as_array) { + extend_array(&mut langspec, key, items.clone()); + } + } + } + other => { + return Err(CompileError::Malformed(format!( + "unexpected top-level declaration: {other}" + ))) + } + } + } + + for key in ["categories", "assets", "associations"] { + if let Some(arr) = langspec[key].as_array_mut() { + let mut owned = std::mem::take(arr); + dedup_array_in_place(&mut owned); + langspec[key] = Value::Array(owned); + } + } + + Ok(langspec) +} + +fn visit_define_declaration(node: Node, source: &[u8]) -> Result<(String, String), CompileError> { + let key = text(required_field(node, "id")?, source).to_string(); + let raw_value = text(required_field(node, "value")?, source); + Ok((key, strip_quotes(raw_value).to_string())) +} + +fn visit_include_declaration(node: Node, source: &[u8]) -> Result { + let raw = text(required_field(node, "file")?, source); + // mirrors Python's `[1:-1]` slice: strip exactly one leading/trailing byte. + Ok(raw[1..raw.len() - 1].to_string()) +} + +fn visit_meta(node: Node, source: &[u8]) -> Result<(String, String), CompileError> { + let key = text(required_field(node, "id")?, source).to_string(); + let raw_info = text(required_field(node, "info")?, source); + Ok((key, raw_info[1..raw_info.len() - 1].to_string())) +} + +fn visit_meta_fields( + node: Node, + source: &[u8], + field: &'static str, +) -> Result, CompileError> { + let mut meta = Map::new(); + let mut cursor = node.walk(); + for meta_node in node.children_by_field_name(field, &mut cursor) { + let (k, v) = visit_meta(meta_node, source)?; + if meta.contains_key(&k) { + return Err(CompileError::Semantic(format!( + "Metadata '{k}' previously defined" + ))); + } + meta.insert(k, Value::String(v)); + } + Ok(meta) +} + +// --------------------------------------------------------------------- +// categories / assets +// --------------------------------------------------------------------- + +fn visit_category_declaration( + node: Node, + source: &[u8], +) -> Result<(Value, Vec), CompileError> { + let name = text(required_field(node, "id")?, source).to_string(); + let meta = visit_meta_fields(node, source, "meta")?; + + let mut assets = Vec::new(); + let mut cursor = node.walk(); + for asset_node in node.children_by_field_name("assets", &mut cursor) { + assets.push(visit_asset_declaration(asset_node, source, &name)?); + } + + let category = json!({ "name": name, "meta": meta }); + Ok((category, assets)) +} + +fn has_abstract_keyword(node: Node) -> bool { + (0..node.child_count()).any(|i| { + node.child(i) + .map(|c| c.kind() == "abstract") + .unwrap_or(false) + }) +} + +fn visit_asset_declaration( + node: Node, + source: &[u8], + category: &str, +) -> Result { + let name = text(required_field(node, "id")?, source).to_string(); + let is_abstract = has_abstract_keyword(node); + + let mut super_asset: Option = None; + let mut cursor = node.walk(); + for extends_node in node.children_by_field_name("extends", &mut cursor) { + if extends_node.is_named() && extends_node.kind() == "identifier" { + super_asset = Some(text(extends_node, source).to_string()); + } + } + + let meta = visit_meta_fields(node, source, "meta")?; + + let (variables, attack_steps) = match node.child_by_field_name("body") { + Some(body) => visit_asset_definition(body, source, &name)?, + None => (Vec::new(), Vec::new()), + }; + + Ok(json!({ + "name": name, + "meta": meta, + "category": category, + "isAbstract": is_abstract, + "superAsset": super_asset, + "variables": variables, + "attackSteps": attack_steps, + })) +} + +fn visit_asset_definition( + node: Node, + source: &[u8], + asset_name: &str, +) -> Result<(Vec, Vec), CompileError> { + let mut variables = Vec::new(); + let mut steps = Vec::new(); + for child in named_children_no_comments(node) { + match child.kind() { + "asset_variable" => variables.push(visit_asset_variable(child, source)?), + "attack_step" => steps.push(visit_attack_step(child, source, asset_name)?), + other => { + return Err(CompileError::Malformed(format!( + "unexpected child of asset_definition: {other}" + ))) + } + } + } + Ok((variables, steps)) +} + +fn visit_asset_variable(node: Node, source: &[u8]) -> Result { + let name = text(required_field(node, "id")?, source).to_string(); + let step_expression = visit_asset_expr(required_field(node, "value")?, source)?; + Ok(json!({ "name": name, "stepExpression": step_expression })) +} + +// --------------------------------------------------------------------- +// attack steps +// --------------------------------------------------------------------- + +fn step_type_name(raw: &str) -> String { + match raw { + "&" => "and".to_string(), + "|" => "or".to_string(), + "#" => "defense".to_string(), + "E" => "exist".to_string(), + "!E" => "notExist".to_string(), + other => other.to_string(), + } +} + +fn visit_attack_step(node: Node, source: &[u8], asset_name: &str) -> Result { + let step_type = step_type_name(text(required_field(node, "step_type")?, source)); + + let causal_mode = node + .child_by_field_name("causal_mode") + .map(|n| text(n, source).to_string()); + + let name = text(required_field(node, "id")?, source).to_string(); + + let mut tags = Vec::new(); + let mut tag_cursor = node.walk(); + for tag_node in node.children_by_field_name("tag", &mut tag_cursor) { + if tag_node.is_named() { + tags.push(Value::String(text(tag_node, source).to_string())); + } + } + + let mut risk = Value::Null; + let mut cias_cursor = node.walk(); + for cias_field_node in node.children_by_field_name("cias", &mut cias_cursor) { + if cias_field_node.is_named() && cias_field_node.kind() == "cias" { + risk = visit_cias(cias_field_node, source, asset_name, &name); + } + } + + let ttc = match node.child_by_field_name("ttc") { + Some(ttc_node) => visit_ttc(ttc_node, source)?, + None => Value::Null, + }; + + let meta = visit_meta_fields(node, source, "meta")?; + + let mut detectors = Map::new(); + let mut det_cursor = node.walk(); + for det_node in node.children_by_field_name("detector", &mut det_cursor) { + let detector = visit_detector(det_node, source)?; + let det_name = detector + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + detectors.insert(det_name, detector); + } + + let requires = match node.child_by_field_name("preconditions") { + Some(pre_node) => visit_preconditions(pre_node, source)?, + None => Value::Null, + }; + + let mut reaches = Value::Null; + let mut append_reaches = Value::Null; + let mut remove_reaches = Value::Null; + let mut reach_cursor = node.walk(); + for reach_node in node.children_by_field_name("reaches", &mut reach_cursor) { + match reach_node.kind() { + "reaching" => reaches = visit_reaching(reach_node, source)?, + "append_reaching" => append_reaches = visit_append_reaching(reach_node, source)?, + "remove_reaching" => remove_reaches = visit_remove_reaching(reach_node, source)?, + _ => {} + } + } + + Ok(json!({ + "name": name, + "meta": meta, + "detectors": detectors, + "type": step_type, + "causal_mode": causal_mode, + "tags": tags, + "risk": risk, + "ttc": ttc, + "requires": requires, + "reaches": reaches, + "append_reaches": append_reaches, + "remove_reaches": remove_reaches, + })) +} + +/// Builds the CIA risk flags and warns (once) on a duplicate +/// classification (e.g. `C, C, I`), but still sets `risk` for every +/// letter even after warning. +fn visit_cias(node: Node, source: &[u8], asset_name: &str, step_name: &str) -> Value { + let mut risk = json!({ + "isConfidentiality": false, + "isIntegrity": false, + "isAvailability": false, + }); + let mut seen = HashSet::new(); + let mut warned = false; + for cia_node in named_children_no_comments(node) { + if cia_node.kind() != "cia" { + continue; + } + let letter = text(cia_node, source); + let key = match letter { + "C" => "isConfidentiality", + "I" => "isIntegrity", + "A" => "isAvailability", + _ => continue, + }; + if !warned && !seen.insert(letter) { + eprintln!( + "warning: attack step {asset_name}.{step_name} contains duplicate classification {letter}" + ); + warned = true; + } + risk[key] = Value::Bool(true); + } + risk +} + +// --------------------------------------------------------------------- +// detectors +// --------------------------------------------------------------------- + +fn visit_detector(node: Node, source: &[u8]) -> Result { + let name = node + .child_by_field_name("name") + .map(|n| text(n, source).to_string()); + + let context = match node.child_by_field_name("context") { + Some(ctx_node) => visit_detector_context(ctx_node, source)?, + None => Value::Object(Map::new()), + }; + + let detector_type = node + .child_by_field_name("type") + .map(|n| text(n, source).to_string()); + + let (tprate, fprate) = match node.child_by_field_name("tp_fp_rate") { + Some(rate_node) => visit_tp_fp_rate(rate_node, source)?, + None => (None, None), + }; + + Ok(json!({ + "name": name, + "context": context, + "type": detector_type, + "tprate": tprate, + "fprate": fprate, + })) +} + +fn visit_detector_context(node: Node, source: &[u8]) -> Result { + let mut context = Map::new(); + for reference_node in named_children_no_comments(node) { + if reference_node.kind() != "detector_context_reference" { + continue; + } + let (label, value) = visit_detector_context_reference(reference_node, source)?; + context.insert(label, value); + } + Ok(Value::Object(context)) +} + +fn visit_detector_context_reference( + node: Node, + source: &[u8], +) -> Result<(String, Value), CompileError> { + let ctx_step = required_field(node, "ctx_step")?; + let context_ref_text = text(ctx_step, source).to_string(); + let context = visit_asset_expr(ctx_step, source)?; + + let label = match node.child_by_field_name("id") { + Some(id_node) => text(id_node, source).to_string(), + None => context_ref_text, + }; + + Ok((label, context)) +} + +fn parse_number(node: Node, source: &[u8]) -> Result { + text(node, source) + .parse::() + .map_err(|_| CompileError::Malformed(format!("invalid number: {}", text(node, source)))) +} + +fn visit_tp_fp_rate(node: Node, source: &[u8]) -> Result<(Option, Option), CompileError> { + let inner = named_children_no_comments(node) + .next() + .ok_or_else(|| CompileError::Malformed("empty tp_fp_rate".into()))?; + + match inner.kind() { + "tpr_only" => { + let rate = parse_number(required_field(inner, "tp_rate")?, source)?; + Ok((Some(rate), None)) + } + "fpr_only" => { + let rate = parse_number(required_field(inner, "fp_rate")?, source)?; + Ok((None, Some(rate))) + } + "tp_fp_pair" => { + let tp = parse_number(required_field(inner, "tp_rate")?, source)?; + let fp = parse_number(required_field(inner, "fp_rate")?, source)?; + Ok((Some(tp), Some(fp))) + } + other => Err(CompileError::Malformed(format!( + "unknown tp/fp rate node: {other}" + ))), + } +} + +// --------------------------------------------------------------------- +// TTC +// --------------------------------------------------------------------- + +fn visit_ttc(node: Node, source: &[u8]) -> Result { + let inner = named_children_no_comments(node) + .next() + .ok_or_else(|| CompileError::Malformed("empty ttc".into()))?; + visit_ttc_operand(inner, source) +} + +fn visit_ttc_operand(node: Node, source: &[u8]) -> Result { + match node.kind() { + "float" | "integer" => { + Ok(json!({ "type": "number", "value": parse_number(node, source)? })) + } + "identifier" => Ok(json!({ + "type": "function", + "name": text(node, source), + "arguments": [], + })), + "ttc_distribution" => visit_ttc_distribution(node, source), + "ttc_binop" => visit_ttc_binop(node, source), + other => Err(CompileError::Malformed(format!( + "unexpected ttc expression node: {other}" + ))), + } +} + +fn ttc_binop_name(op: &str) -> Option<&'static str> { + match op { + "+" => Some("addition"), + "-" => Some("subtraction"), + "*" => Some("multiplication"), + "/" => Some("division"), + "^" => Some("exponentiation"), + _ => None, + } +} + +fn visit_ttc_binop(node: Node, source: &[u8]) -> Result { + let left = field_operand(node, "left")?; + let right = field_operand(node, "right")?; + let operator = text(required_field(node, "operator")?, source); + let op_name = ttc_binop_name(operator) + .ok_or_else(|| CompileError::Malformed(format!("unknown ttc operator: {operator}")))?; + + Ok(json!({ + "type": op_name, + "lhs": visit_ttc_operand(left, source)?, + "rhs": visit_ttc_operand(right, source)?, + })) +} + +fn visit_ttc_distribution(node: Node, source: &[u8]) -> Result { + let name = text(required_field(node, "id")?, source).to_string(); + + let mut arguments = Vec::new(); + let mut cursor = node.walk(); + for value_node in node.children_by_field_name("values", &mut cursor) { + if value_node.is_named() { + arguments.push(Value::from(parse_number(value_node, source)?)); + } + } + + Ok(json!({ "type": "function", "name": name, "arguments": arguments })) +} + +// --------------------------------------------------------------------- +// preconditions / reaches +// --------------------------------------------------------------------- + +fn visit_preconditions(node: Node, source: &[u8]) -> Result { + let mut step_expressions = Vec::new(); + let mut cursor = node.walk(); + for cond_node in node.children_by_field_name("condition", &mut cursor) { + if cond_node.is_named() { + step_expressions.push(visit_asset_expr(cond_node, source)?); + } + } + Ok(json!({ "overrides": true, "stepExpressions": step_expressions })) +} + +fn visit_reaching(node: Node, source: &[u8]) -> Result { + let operator = text(required_field(node, "operator")?, source); + let overrides = operator == "->"; + + let mut step_expressions = Vec::new(); + let mut cursor = node.walk(); + for reach_node in node.children_by_field_name("reaches", &mut cursor) { + if reach_node.is_named() { + step_expressions.push(visit_asset_expr(reach_node, source)?); + } + } + Ok(json!({ "overrides": overrides, "stepExpressions": step_expressions })) +} + +fn visit_append_reaching(node: Node, source: &[u8]) -> Result { + let operator = text(required_field(node, "operator")?, source); + let overrides = operator == "A>"; + + let mut step_expressions = Vec::new(); + let mut cursor = node.walk(); + for reach_node in node.children_by_field_name("reaches", &mut cursor) { + if reach_node.is_named() { + step_expressions.push(visit_dyn_sentence(reach_node, source)?); + } + } + Ok(json!({ "overrides": overrides, "stepExpressions": step_expressions })) +} + +fn visit_remove_reaching(node: Node, source: &[u8]) -> Result { + let operator = text(required_field(node, "operator")?, source); + let overrides = operator == "R>"; + + let mut step_expressions = Vec::new(); + let mut cursor = node.walk(); + for reach_node in node.children_by_field_name("reaches", &mut cursor) { + if reach_node.is_named() { + step_expressions.push(visit_dyn_sentence(reach_node, source)?); + } + } + Ok(json!({ "overrides": overrides, "stepExpressions": step_expressions })) +} + +fn visit_dyn_sentence(node: Node, source: &[u8]) -> Result { + let base = visit_asset_expr(required_field(node, "base")?, source)?; + + let mut targets = Vec::new(); + for child in named_children_no_comments(node) { + if child.kind() == "assoc_and_asset_expr" { + targets.push(visit_assoc_and_asset_expr(child, source)?); + } + } + + Ok(json!({ "type": "dyn_sentence", "base": base, "targets": targets })) +} + +fn visit_assoc_and_asset_expr(node: Node, source: &[u8]) -> Result { + let children: Vec = named_children_no_comments(node).collect(); + if let Some(assoc_op_node) = children.iter().find(|n| n.kind() == "assoc_op") { + let operand_node = children + .iter() + .find(|n| n.kind() != "assoc_op") + .ok_or_else(|| CompileError::Malformed("assoc_op without operand".into()))?; + let _ = assoc_op_node; // operator text currently unused downstream, mirrors Python's dict + Ok(json!({ + "type": "assoc_op", + "operand": visit_asset_expr(*operand_node, source)?, + })) + } else { + let operand_node = children + .first() + .ok_or_else(|| CompileError::Malformed("empty assoc_and_asset_expr".into()))?; + visit_asset_expr(*operand_node, source) + } +} + +// --------------------------------------------------------------------- +// asset expressions +// --------------------------------------------------------------------- + +fn visit_asset_expr(node: Node, source: &[u8]) -> Result { + // `asset_expr` is a transparent single-child wrapper node. + if node.kind() == "asset_expr" { + let inner = named_children_no_comments(node) + .next() + .ok_or_else(|| CompileError::Malformed("empty asset_expr".into()))?; + return visit_asset_expr_operand(inner, source); + } + visit_asset_expr_operand(node, source) +} + +fn visit_asset_expr_operand(node: Node, source: &[u8]) -> Result { + match node.kind() { + "asset_expr" => visit_asset_expr(node, source), + "identifier" => { + let role = resolve_identifier_role(node, source); + Ok(json!({ "type": role, "name": text(node, source) })) + } + "asset_variable_substitution" => Ok(json!({ + "type": "variable", + "name": text(required_field(node, "id")?, source), + })), + "asset_expr_binop" => visit_asset_expr_binop(node, source), + "asset_expr_multiplicity" => visit_asset_expr_multiplicity(node, source), + "asset_expr_type" => visit_asset_expr_type(node, source), + "asset_expr_unop" => visit_asset_expr_unop(node, source), + other => Err(CompileError::Malformed(format!( + "unexpected asset expression node: {other}" + ))), + } +} + +fn binop_name(op: &str) -> Option<&'static str> { + match op { + "." => Some("collect"), + "\\/" => Some("union"), + "/\\" => Some("intersection"), + "-" => Some("difference"), + _ => None, + } +} + +fn visit_asset_expr_binop(node: Node, source: &[u8]) -> Result { + let left = field_operand(node, "left")?; + let right = field_operand(node, "right")?; + let operator = text(required_field(node, "operator")?, source); + let op_name = binop_name(operator).ok_or_else(|| { + CompileError::Malformed(format!("unknown asset expr operator: {operator}")) + })?; + + Ok(json!({ + "type": op_name, + "lhs": visit_asset_expr_operand(left, source)?, + "rhs": visit_asset_expr_operand(right, source)?, + })) +} + +fn visit_asset_expr_multiplicity(node: Node, source: &[u8]) -> Result { + let expression = field_operand(node, "expression")?; + let step_expression = visit_asset_expr_operand(expression, source)?; + let multiplicity = visit_multiplicity(required_field(node, "multiplicity")?, source)?; + Ok(json!({ + "type": "multiplicity", + "multiplicity": multiplicity, + "stepExpression": step_expression, + })) +} + +fn visit_asset_expr_type(node: Node, source: &[u8]) -> Result { + let expression = field_operand(node, "expression")?; + let step_expression = visit_asset_expr_operand(expression, source)?; + let sub_type = text(required_field(node, "type_id")?, source).to_string(); + Ok(json!({ + "type": "subType", + "subType": sub_type, + "stepExpression": step_expression, + })) +} + +fn visit_asset_expr_unop(node: Node, source: &[u8]) -> Result { + let expression = field_operand(node, "expression")?; + let step_expression = visit_asset_expr_operand(expression, source)?; + Ok(json!({ "type": "transitive", "stepExpression": step_expression })) +} + +/// For a bare identifier used as an asset expression, decides whether it +/// denotes a `field` or an `attackStep`. +/// +/// Only walks up to a `reaching` or `detector_context_reference` ancestor +/// (not `append_reaching`/`remove_reaching`/`dyn_sentence`); if none is +/// found - including every identifier reached only through +/// `append_reaching`/`remove_reaching` - it's always a `field`. Otherwise, +/// scan the ancestor's source text right after the identifier: the first +/// `.` means `field` (chain continues), the first `,` or end-of-text means +/// `attackStep`. +fn resolve_identifier_role(identifier: Node, source: &[u8]) -> &'static str { + let mut parent = identifier.parent(); + while let Some(p) = parent { + if p.kind() == "reaching" || p.kind() == "detector_context_reference" { + break; + } + parent = p.parent(); + } + + let Some(ancestor) = parent else { + return "field"; + }; + + let ancestor_start = ancestor.start_byte(); + let scan_start = identifier.end_byte() - ancestor_start; + let ancestor_text = &source[ancestor.start_byte()..ancestor.end_byte()]; + + for &byte in &ancestor_text[scan_start.min(ancestor_text.len())..] { + match byte { + b'.' => return "field", + b',' => return "attackStep", + _ => {} + } + } + "attackStep" +} + +// --------------------------------------------------------------------- +// multiplicity / associations +// --------------------------------------------------------------------- + +fn visit_multiplicity(node: Node, source: &[u8]) -> Result { + let inner = named_children_no_comments(node) + .next() + .ok_or_else(|| CompileError::Malformed("empty multiplicity".into()))?; + + if inner.kind() == "multiplicity_range" { + return visit_multiplicity_range(inner, source); + } + + Ok(json!({ "min": text(inner, source), "max": Value::Null })) +} + +fn visit_multiplicity_range(node: Node, source: &[u8]) -> Result { + let start = required_field(node, "start")?; + let end = required_field(node, "end")?; + Ok(json!({ "min": text(start, source), "max": text(end, source) })) +} + +/// Normalizes a raw multiplicity atom (`"*"`, `"3"`, or already-null) into +/// its final numeric form, mirroring `_process_multitudes`: +/// - `max` defaults to `min` when absent. +/// - `*` means 0 for `min`, unbounded (`null`) for `max`. +/// - numeric strings become integers. +fn normalize_multiplicity(mult: &mut Value) { + let min_raw = mult.get("min").cloned().unwrap_or(Value::Null); + if mult.get("max").map(Value::is_null).unwrap_or(true) { + mult["max"] = min_raw.clone(); + } + for key in ["min", "max"] { + let resolved = match mult.get(key) { + Some(Value::String(s)) if s == "*" => { + if key == "min" { + Value::from(0) + } else { + Value::Null + } + } + Some(Value::String(s)) => match s.parse::() { + Ok(n) => Value::from(n), + Err(_) => Value::String(s.clone()), + }, + Some(other) => other.clone(), + None => Value::Null, + }; + mult[key] = resolved; + } +} + +fn visit_association(node: Node, source: &[u8]) -> Result { + let left_asset = text(required_field(node, "left_id")?, source).to_string(); + let left_field = text(required_field(node, "left_field_id")?, source).to_string(); + let mut left_multiplicity = visit_multiplicity(required_field(node, "left_mult")?, source)?; + + let name = text(required_field(node, "id")?, source).to_string(); + + let mut right_multiplicity = visit_multiplicity(required_field(node, "right_mult")?, source)?; + let right_field = text(required_field(node, "right_field_id")?, source).to_string(); + let right_asset = text(required_field(node, "right_id")?, source).to_string(); + + let meta = visit_meta_fields(node, source, "meta")?; + + normalize_multiplicity(&mut left_multiplicity); + normalize_multiplicity(&mut right_multiplicity); + + Ok(json!({ + "name": name, + "meta": meta, + "leftAsset": left_asset, + "leftField": left_field, + "leftMultiplicity": left_multiplicity, + "rightAsset": right_asset, + "rightField": right_field, + "rightMultiplicity": right_multiplicity, + })) +} + +fn visit_associations_declaration(node: Node, source: &[u8]) -> Result, CompileError> { + let mut associations = Vec::new(); + for child in named_children_no_comments(node) { + if child.kind() == "association" { + associations.push(visit_association(child, source)?); + } + } + Ok(associations) +} diff --git a/core/maltoolbox-language/src/compiler/semantic.rs b/core/maltoolbox-language/src/compiler/semantic.rs new file mode 100644 index 00000000..590c2fdd --- /dev/null +++ b/core/maltoolbox-language/src/compiler/semantic.rs @@ -0,0 +1,753 @@ +//! Port of `maltoolbox/language/compiler/mal_analyzer.py`'s semantic +//! validation, run as a single pass over the already-built `langspec` +//! [`Value`], re-deriving the inheritance-aware step/field/variable +//! resolution that the Python original's incrementally-built dicts get +//! for free. Diagnostic text drops tree-sitter line numbers and otherwise +//! mirrors the original's checks and their relative error precedence. +//! +//! Includes two purely-cosmetic warnings (abstract-asset-never-extended, +//! duplicate-CIA-classification) with no effect on compilation success or +//! serialized output, printed with `eprintln!` (see `PORTING_NOTES.md`). +//! The duplicate-CIA check actually lives in `visit_cias` in the sibling +//! `compiler` module, since by the time a langspec reaches this pass its +//! `risk` flags are already deduplicated booleans. + +use std::collections::{HashMap, HashSet}; + +use serde_json::Value; + +use super::distributions; +use super::CompileError; + +fn err(msg: impl Into) -> CompileError { + CompileError::Semantic(msg.into()) +} + +/// `asset -> field_name -> target asset name`, the flattened (own + +/// inherited) association fields available on an asset. +type FieldMap = HashMap>; +/// `asset -> variable_name -> raw stepExpression`, flattened (own + +/// inherited, pre-resolution). +type RawVarMap = HashMap>; +/// `asset -> attack step names`, flattened (own + inherited); only +/// membership is needed, not the step values. +type StepMap = HashMap>; + +struct Ctx<'a> { + assets_by_name: HashMap<&'a str, &'a Value>, + fields: FieldMap, + raw_vars: RawVarMap, + steps: StepMap, +} + +pub fn analyze(langspec: &Value) -> Result<(), CompileError> { + let assets: &[Value] = langspec["assets"] + .as_array() + .map(Vec::as_slice) + .unwrap_or(&[]); + let associations: &[Value] = langspec["associations"] + .as_array() + .map(Vec::as_slice) + .unwrap_or(&[]); + + check_duplicate_assets(assets)?; + check_duplicate_steps_and_vars(assets)?; + check_cia_and_ttc(assets)?; + + analyse_defines(langspec)?; + analyse_extends(assets)?; + analyse_parents(assets)?; + warn_abstract_never_extended(assets); + analyse_association(associations, assets)?; + + let assets_by_name: HashMap<&str, &Value> = assets + .iter() + .filter_map(|a| a["name"].as_str().map(|n| (n, a))) + .collect(); + + let steps = analyse_steps(assets, &assets_by_name)?; + let fields = analyse_fields(assets, &assets_by_name, associations, &steps)?; + let raw_vars = analyse_variables(assets, &assets_by_name)?; + + let ctx = Ctx { + assets_by_name, + fields, + raw_vars, + steps, + }; + + validate_variables_resolve(&ctx)?; + analyse_reaches(&ctx, assets)?; + + Ok(()) +} + +// --------------------------------------------------------------------- +// duplicate-name checks +// --------------------------------------------------------------------- + +fn check_duplicate_assets(assets: &[Value]) -> Result<(), CompileError> { + let mut seen = HashSet::new(); + for asset in assets { + let name = asset["name"].as_str().unwrap_or_default(); + if !seen.insert(name) { + return Err(err(format!("Asset '{name}' previously defined"))); + } + } + Ok(()) +} + +fn check_duplicate_steps_and_vars(assets: &[Value]) -> Result<(), CompileError> { + for asset in assets { + let asset_name = asset["name"].as_str().unwrap_or_default(); + + let mut seen_steps = HashSet::new(); + for step in asset["attackSteps"].as_array().into_iter().flatten() { + let step_name = step["name"].as_str().unwrap_or_default(); + if !seen_steps.insert(step_name) { + return Err(err(format!( + "Attack step '{step_name}' previously defined in asset '{asset_name}'" + ))); + } + } + + let mut seen_vars = HashSet::new(); + for var in asset["variables"].as_array().into_iter().flatten() { + let var_name = var["name"].as_str().unwrap_or_default(); + if !seen_vars.insert(var_name) { + return Err(err(format!( + "Variable '{var_name}' previously defined in asset '{asset_name}'" + ))); + } + } + } + Ok(()) +} + +// --------------------------------------------------------------------- +// defines +// --------------------------------------------------------------------- + +fn analyse_defines(langspec: &Value) -> Result<(), CompileError> { + let defines = &langspec["defines"]; + + match defines.get("id").and_then(Value::as_str) { + Some("") => return Err(err("Define 'id' cannot be empty")), + Some(_) => {} + None => return Err(err("Missing required define '#id: \"\"'")), + } + + match defines.get("version").and_then(Value::as_str) { + Some(version) if !is_semver_prefix(version) => { + return Err(err( + "Define 'version' must be valid semantic versioning without pre-release identifier and build metadata", + )) + } + Some(_) => {} + None => return Err(err("Missing required define '#version: \"\"'")), + } + + Ok(()) +} + +/// Port of Python's `re.match(r'\d+\.\d+\.\d+', version)` - a *prefix* +/// match, not a full-string one. +fn is_semver_prefix(version: &str) -> bool { + let mut parts = version.split('.'); + let is_digits = |s: Option<&str>| { + s.map(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())) + .unwrap_or(false) + }; + is_digits(parts.next()) + && is_digits(parts.next()) + && parts + .next() + .map(|p| { + let digits: String = p.chars().take_while(|c| c.is_ascii_digit()).collect(); + !digits.is_empty() + }) + .unwrap_or(false) +} + +// --------------------------------------------------------------------- +// extends / parents +// --------------------------------------------------------------------- + +fn analyse_extends(assets: &[Value]) -> Result<(), CompileError> { + let names: HashSet<&str> = assets.iter().filter_map(|a| a["name"].as_str()).collect(); + for asset in assets { + if let Some(super_asset) = asset["superAsset"].as_str() { + if !names.contains(super_asset) { + return Err(err(format!("Asset '{super_asset}' not defined"))); + } + } + } + Ok(()) +} + +/// Warns if an abstract asset is never extended - not an error, just a +/// diagnostic for likely dead weight in the language. +fn warn_abstract_never_extended(assets: &[Value]) { + for asset in assets { + if asset["isAbstract"].as_bool() != Some(true) { + continue; + } + let Some(name) = asset["name"].as_str() else { + continue; + }; + let extended = assets + .iter() + .any(|a| a["superAsset"].as_str() == Some(name)); + if !extended { + eprintln!("warning: asset '{name}' is abstract but never extended to"); + } + } +} + +fn analyse_parents(assets: &[Value]) -> Result<(), CompileError> { + let by_name: HashMap<&str, &Value> = assets + .iter() + .filter_map(|a| a["name"].as_str().map(|n| (n, a))) + .collect(); + + for asset in assets { + let name = asset["name"].as_str().unwrap_or_default(); + let mut parents: Vec = Vec::new(); + let mut current = Some(name.to_string()); + while let Some(cur) = current { + if parents.contains(&cur) { + let chain = format!("{} -> {cur}", parents.join(" -> ")); + return Err(err(format!("Asset '{cur}' extends in loop '{chain}'"))); + } + parents.push(cur.clone()); + current = by_name + .get(cur.as_str()) + .and_then(|a| a["superAsset"].as_str()) + .map(String::from); + } + } + Ok(()) +} + +/// An asset's ancestor chain, root-first, ending with the asset itself. +/// Assumes `analyse_parents` already ran to rule out cycles; the break +/// below is just a defensive fallback. +fn get_parents(assets_by_name: &HashMap<&str, &Value>, asset_name: &str) -> Vec { + let mut parents = vec![asset_name.to_string()]; + let mut current = asset_name.to_string(); + loop { + let Some(super_asset) = assets_by_name + .get(current.as_str()) + .and_then(|a| a["superAsset"].as_str()) + else { + break; + }; + if parents.iter().any(|p| p == super_asset) { + break; + } + parents.insert(0, super_asset.to_string()); + current = super_asset.to_string(); + } + parents +} + +fn is_child(assets_by_name: &HashMap<&str, &Value>, parent_name: &str, child_name: &str) -> bool { + if parent_name == child_name { + return true; + } + let mut current = child_name.to_string(); + loop { + let Some(super_asset) = assets_by_name + .get(current.as_str()) + .and_then(|a| a["superAsset"].as_str()) + else { + return false; + }; + if super_asset == parent_name { + return true; + } + current = super_asset.to_string(); + } +} + +fn get_lca(assets_by_name: &HashMap<&str, &Value>, a: &str, b: &str) -> Option { + if is_child(assets_by_name, a, b) { + return Some(a.to_string()); + } + if is_child(assets_by_name, b, a) { + return Some(b.to_string()); + } + let a_parent = assets_by_name.get(a)?["superAsset"].as_str()?; + let b_parent = assets_by_name.get(b)?["superAsset"].as_str()?; + get_lca(assets_by_name, a_parent, b_parent) +} + +// --------------------------------------------------------------------- +// associations +// --------------------------------------------------------------------- + +fn analyse_association(associations: &[Value], assets: &[Value]) -> Result<(), CompileError> { + let names: HashSet<&str> = assets.iter().filter_map(|a| a["name"].as_str()).collect(); + for assoc in associations { + let left = assoc["leftAsset"].as_str().unwrap_or_default(); + let right = assoc["rightAsset"].as_str().unwrap_or_default(); + if !names.contains(left) { + return Err(err(format!("Left asset '{left}' is not defined"))); + } + if !names.contains(right) { + return Err(err(format!("Right asset '{left}' is not defined"))); + } + } + Ok(()) +} + +// --------------------------------------------------------------------- +// steps (inheritance-aware override/type-consistency validation) +// --------------------------------------------------------------------- + +/// Whether an attack step's `reaches` operator is an override form +/// (`->`/`A>`/`R>`) rather than an append form (`+>`/`+A>`/`+R>`), or the +/// step has no reaches clause at all (vacuously fine to be a first +/// definition). Only the first present reaches-like clause is checked. +fn step_overrides_or_absent(step: &Value) -> bool { + for key in ["reaches", "append_reaches", "remove_reaches"] { + let clause = &step[key]; + if !clause.is_null() { + return clause["overrides"].as_bool().unwrap_or(false); + } + } + true +} + +fn analyse_steps( + assets: &[Value], + assets_by_name: &HashMap<&str, &Value>, +) -> Result { + let mut resolved: StepMap = HashMap::new(); + + for asset in assets { + let asset_name = asset["name"].as_str().unwrap_or_default(); + let chain = get_parents(assets_by_name, asset_name); + + let mut first_type: HashMap = HashMap::new(); + let mut names: HashSet = HashSet::new(); + + for level in &chain { + let Some(level_asset) = assets_by_name.get(level.as_str()) else { + continue; + }; + for step in level_asset["attackSteps"].as_array().into_iter().flatten() { + let step_name = step["name"].as_str().unwrap_or_default().to_string(); + let step_type = step["type"].as_str().unwrap_or_default().to_string(); + + match first_type.get(&step_name) { + None => { + if !step_overrides_or_absent(step) { + return Err(err(format!( + "Cannot inherit attack step '{step_name}' without previous definition" + ))); + } + first_type.insert(step_name.clone(), step_type); + names.insert(step_name); + } + Some(prev_type) => { + if *prev_type != step_type { + return Err(err(format!( + "Cannot override attack step '{step_name}' previously defined with different type '{step_type}' =/= '{prev_type}'" + ))); + } + names.insert(step_name); + } + } + } + } + + resolved.insert(asset_name.to_string(), names); + } + + Ok(resolved) +} + +// --------------------------------------------------------------------- +// fields (association field flattening down the hierarchy) +// --------------------------------------------------------------------- + +fn analyse_fields( + assets: &[Value], + assets_by_name: &HashMap<&str, &Value>, + associations: &[Value], + steps: &StepMap, +) -> Result { + let mut fields: FieldMap = HashMap::new(); + + for asset in assets { + let asset_name = asset["name"].as_str().unwrap_or_default(); + let parents = get_parents(assets_by_name, asset_name); + + for parent in &parents { + for assoc in associations { + let left_asset = assoc["leftAsset"].as_str().unwrap_or_default(); + let right_asset = assoc["rightAsset"].as_str().unwrap_or_default(); + + if left_asset == parent { + let right_field = assoc["rightField"].as_str().unwrap_or_default(); + add_field( + &mut fields, + steps, + parent, + asset_name, + right_field, + right_asset, + )?; + } + if right_asset == parent { + let left_field = assoc["leftField"].as_str().unwrap_or_default(); + add_field( + &mut fields, + steps, + parent, + asset_name, + left_field, + left_asset, + )?; + } + } + } + } + + Ok(fields) +} + +fn add_field( + fields: &mut FieldMap, + steps: &StepMap, + parent: &str, + asset: &str, + field: &str, + target_asset: &str, +) -> Result<(), CompileError> { + if fields + .get(asset) + .map(|m| m.contains_key(field)) + .unwrap_or(false) + { + return Err(err(format!("Field {parent}.{field} previously defined"))); + } + if steps.get(asset).map(|s| s.contains(field)).unwrap_or(false) { + return Err(err(format!( + "Field {field} previously defined as an attack step" + ))); + } + fields + .entry(asset.to_string()) + .or_default() + .insert(field.to_string(), target_asset.to_string()); + Ok(()) +} + +// --------------------------------------------------------------------- +// variables +// --------------------------------------------------------------------- + +fn own_vars_of(asset: &Value) -> HashMap { + asset["variables"] + .as_array() + .into_iter() + .flatten() + .filter_map(|v| Some((v["name"].as_str()?.to_string(), v["stepExpression"].clone()))) + .collect() +} + +/// For every asset, merges its own variables with every ancestor's own +/// variables, raising if the same name is independently declared (not +/// merely inherited) at more than one level in the chain - even if the +/// two declarations are identical. +fn analyse_variables( + assets: &[Value], + assets_by_name: &HashMap<&str, &Value>, +) -> Result { + let mut resolved: RawVarMap = HashMap::new(); + + for asset in assets { + let asset_name = asset["name"].as_str().unwrap_or_default(); + let mut chain = get_parents(assets_by_name, asset_name); + chain.pop(); // exclude self + + let own_vars = own_vars_of(asset); + let mut merged = own_vars.clone(); + + for parent in &chain { + let Some(parent_asset) = assets_by_name.get(parent.as_str()) else { + continue; + }; + let parent_own_vars = own_vars_of(parent_asset); + if parent_own_vars.is_empty() { + continue; + } + for name in own_vars.keys() { + if parent_own_vars.contains_key(name) { + return Err(err(format!("Variable '{name}' previously defined"))); + } + } + for (name, expr) in &parent_own_vars { + merged.entry(name.clone()).or_insert_with(|| expr.clone()); + } + } + + resolved.insert(asset_name.to_string(), merged); + } + + Ok(resolved) +} + +/// Every variable (own or inherited) must resolve to an asset, with +/// cycle detection. +fn validate_variables_resolve(ctx: &Ctx) -> Result<(), CompileError> { + for (asset, vars) in &ctx.raw_vars { + for (var_name, expr) in vars { + let mut stack = Vec::new(); + match resolve_to_asset(ctx, asset, expr, &mut stack)? { + Some(_) => {} + None => { + return Err(err(format!( + "Variable '{var_name}' does not point to an asset" + ))) + } + } + } + } + Ok(()) +} + +// --------------------------------------------------------------------- +// step-expression resolution +// --------------------------------------------------------------------- + +fn resolve_to_asset( + ctx: &Ctx, + asset: &str, + expr: &Value, + var_stack: &mut Vec, +) -> Result, CompileError> { + match expr["type"].as_str().unwrap_or_default() { + "field" => { + let name = expr["name"].as_str().unwrap_or_default(); + Ok(ctx.fields.get(asset).and_then(|m| m.get(name)).cloned()) + } + "variable" => { + let name = expr["name"].as_str().unwrap_or_default(); + if var_stack.iter().any(|v| v == name) { + let cycle = format!("{}->{name}", var_stack.join("->")); + return Err(err(format!("Variable '{name}' contains cycle {cycle}"))); + } + let Some(var_expr) = ctx.raw_vars.get(asset).and_then(|m| m.get(name)) else { + return Ok(None); + }; + var_stack.push(name.to_string()); + let result = resolve_to_asset(ctx, asset, var_expr, var_stack); + var_stack.pop(); + result + } + "collect" => match resolve_to_asset(ctx, asset, &expr["lhs"], var_stack)? { + Some(left) => resolve_to_asset(ctx, &left, &expr["rhs"], var_stack), + None => Ok(None), + }, + "union" | "intersection" | "difference" => { + let lhs = resolve_to_asset(ctx, asset, &expr["lhs"], var_stack)?; + let rhs = resolve_to_asset(ctx, asset, &expr["rhs"], var_stack)?; + match (lhs, rhs) { + (Some(l), Some(r)) => Ok(get_lca(&ctx.assets_by_name, &l, &r)), + _ => Ok(None), + } + } + "transitive" => match resolve_to_asset(ctx, asset, &expr["stepExpression"], var_stack)? { + Some(res) if is_child(&ctx.assets_by_name, asset, &res) => Ok(Some(res)), + _ => Ok(None), + }, + "subType" => match resolve_to_asset(ctx, asset, &expr["stepExpression"], var_stack)? { + Some(target) => { + let sub_type = expr["subType"].as_str().unwrap_or_default(); + if !ctx.assets_by_name.contains_key(sub_type) { + return Err(err(format!("Asset '{sub_type}' not defined"))); + } + if is_child(&ctx.assets_by_name, &target, sub_type) { + Ok(Some(sub_type.to_string())) + } else { + Ok(None) + } + } + None => Ok(None), + }, + other => Err(err(format!("Unexpected expression '{other}'"))), + } +} + +fn resolve_to_step( + ctx: &Ctx, + asset: &str, + expr: &Value, + var_stack: &mut Vec, +) -> Result { + match expr["type"].as_str().unwrap_or_default() { + "attackStep" => { + let name = expr["name"].as_str().unwrap_or_default(); + Ok(ctx.steps.get(asset).map(|s| s.contains(name)).unwrap_or(false)) + } + "multiplicity" => Err(err( + "`->` or `+>` with a statement that ends in a multiplicity qualifier (`:x`, `:x..y`, `:*`) is not supported", + )), + "collect" => match resolve_to_asset(ctx, asset, &expr["lhs"], var_stack)? { + Some(left) => resolve_to_step(ctx, &left, &expr["rhs"], var_stack), + None => Ok(false), + }, + _ => Err(err("Last step is not attack step")), + } +} + +// --------------------------------------------------------------------- +// reaches / requires +// --------------------------------------------------------------------- + +fn analyse_reaches(ctx: &Ctx, assets: &[Value]) -> Result<(), CompileError> { + for asset in assets { + let asset_name = asset["name"].as_str().unwrap_or_default(); + for step in asset["attackSteps"].as_array().into_iter().flatten() { + let step_type = step["type"].as_str().unwrap_or_default(); + let requires = &step["requires"]; + let ttc = &step["ttc"]; + + if step_type == "exist" || step_type == "notExist" { + if !ttc.is_null() { + return Err(err(format!( + "Attack step of type '{step_type}' must not have TTC" + ))); + } + if !requires.is_null() { + for expr in requires["stepExpressions"].as_array().into_iter().flatten() { + let mut stack = Vec::new(); + if resolve_to_asset(ctx, asset_name, expr, &mut stack)?.is_none() { + return Err(err( + "All expressions in requires ('<-') must point to a valid asset", + )); + } + } + } else { + return Err(err(format!( + "Attack step of type '{step_type}' must have require '<-'" + ))); + } + } else if !requires.is_null() { + return Err(err( + "Require '<-' may only be defined for attack step type exist 'E' or not-exist '!E'", + )); + } + + let reaches = &step["reaches"]; + if !reaches.is_null() { + for expr in reaches["stepExpressions"].as_array().into_iter().flatten() { + let mut stack = Vec::new(); + if !resolve_to_step(ctx, asset_name, expr, &mut stack)? { + return Err(err( + "All expressions in reaches ('->') must point to a valid attack step", + )); + } + } + } + } + } + Ok(()) +} + +// --------------------------------------------------------------------- +// CIA / TTC +// --------------------------------------------------------------------- + +fn check_cia_and_ttc(assets: &[Value]) -> Result<(), CompileError> { + for asset in assets { + let asset_name = asset["name"].as_str().unwrap_or_default(); + for step in asset["attackSteps"].as_array().into_iter().flatten() { + validate_cia(asset_name, step)?; + validate_ttc(asset_name, step)?; + } + } + Ok(()) +} + +fn validate_cia(asset_name: &str, step: &Value) -> Result<(), CompileError> { + if step["risk"].is_null() { + return Ok(()); + } + let step_type = step["type"].as_str().unwrap_or_default(); + if matches!(step_type, "defense" | "exist" | "notExist") { + let step_name = step["name"].as_str().unwrap_or_default(); + return Err(err(format!( + "{asset_name}.{step_name}: {step_type}: Defenses cannot have CIA classifications" + ))); + } + Ok(()) +} + +fn validate_ttc(asset_name: &str, step: &Value) -> Result<(), CompileError> { + let ttc = &step["ttc"]; + if ttc.is_null() { + return Ok(()); + } + let step_name = step["name"].as_str().unwrap_or_default(); + match step["type"].as_str().unwrap_or_default() { + "defense" => { + if ttc["type"].as_str() != Some("function") { + return Err(err(format!( + "Defense {asset_name}.{step_name} may not have advanced TTC expressions" + ))); + } + match ttc["name"].as_str().unwrap_or_default() { + "Enabled" | "Disabled" | "Bernoulli" => validate_distribution(ttc), + _ => Err(err(format!( + "Defense {asset_name}.{step_name} may only have 'Enabled', 'Disabled', or 'Bernoulli(p)' as TTC" + ))), + } + } + "exist" | "notExist" => Ok(()), + _ => check_ttc_expr(ttc, false), + } +} + +fn check_ttc_expr(expr: &Value, is_sub_div_exp: bool) -> Result<(), CompileError> { + match expr["type"].as_str().unwrap_or_default() { + "subtraction" | "exponentiation" | "division" => { + check_ttc_expr(&expr["lhs"], true)?; + check_ttc_expr(&expr["rhs"], true) + } + "multiplication" | "addition" => { + check_ttc_expr(&expr["lhs"], false)?; + check_ttc_expr(&expr["rhs"], false) + } + "function" => { + let name = expr["name"].as_str().unwrap_or_default(); + if matches!(name, "Enabled" | "Disabled") { + return Err(err( + "Distributions 'Enabled' or 'Disabled' may not be used as TTC values in '&' and '|' attack steps", + )); + } + if is_sub_div_exp && matches!(name, "Bernoulli" | "EasyAndUncertain") { + return Err(err(format!( + "TTC distribution '{name}' is not available in subtraction, division or exponential expressions." + ))); + } + validate_distribution(expr) + } + "number" => Ok(()), + other => Err(err(format!("Unexpected expression '{other}'"))), + } +} + +fn validate_distribution(function_expr: &Value) -> Result<(), CompileError> { + let name = function_expr["name"].as_str().unwrap_or_default(); + let params: Vec = function_expr["arguments"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_f64) + .collect(); + distributions::validate(name, ¶ms).map_err(|e| err(e.0)) +} diff --git a/core/maltoolbox-language/src/graph/asset.rs b/core/maltoolbox-language/src/graph/asset.rs new file mode 100644 index 00000000..cc9f61f8 --- /dev/null +++ b/core/maltoolbox-language/src/graph/asset.rs @@ -0,0 +1,78 @@ +//! Port of `maltoolbox/language/language_graph_asset.py`. +//! +//! Traversal helpers that walk `own_super_asset`/`own_sub_assets` +//! (`is_subasset_of`, `sub_assets`, `super_assets`, `associations`, +//! `variables`, `associations_to`, `get_all_common_superassets`) live as +//! `LanguageGraph` methods in `graph::mod` rather than on this struct, +//! since the arena/id design needs access to the asset arena, not just +//! `&self`. + +use std::collections::HashMap; +use std::rc::Rc; + +use indexmap::IndexMap; +use rustc_hash::FxBuildHasher; +use serde_json::json; + +use super::assoc::LanguageGraphAssociation; +use super::expr_chain::ExpressionsChain; +use super::ids::{AssetId, AttackStepId}; +use super::{GraphError, LanguageGraph}; + +#[derive(Debug, Clone)] +pub struct LanguageGraphAsset { + pub name: String, + /// Insertion order must match the MAL spec's declaration order (as + /// Python's `dict` preserves it) since it feeds attack-graph node + /// creation order; `IndexMap` keeps this deterministic where a + /// `HashMap` would not. + pub own_associations: IndexMap>, + /// Both directly-defined and inherited attack steps, by name. + /// Iteration order feeds attack-graph node id assignment + /// (`generate.rs`'s `create_nodes_for`), so this must stay an + /// `IndexMap`. + pub attack_steps: IndexMap, + pub info: HashMap, + pub own_super_asset: Option, + pub own_sub_assets: Vec, + pub own_variables: IndexMap)>, + pub is_abstract: bool, +} + +impl LanguageGraphAsset { + pub fn to_dict(&self, graph: &LanguageGraph) -> Result { + let mut associations = serde_json::Map::new(); + for (k, v) in &self.own_associations { + associations.insert(k.clone(), v.to_dict(graph)); + } + + let mut attack_steps = serde_json::Map::new(); + for step_id in self.attack_steps.values() { + let step = graph.step(*step_id); + attack_steps.insert(step.name.clone(), step.to_dict(graph)?); + } + + let mut variables = serde_json::Map::new(); + for (name, (asset_id, expr)) in &self.own_variables { + let expr_dict = match expr { + Some(e) => e.to_dict(graph)?, + None => serde_json::Value::Null, + }; + variables.insert( + name.clone(), + json!([graph.asset(*asset_id).name.clone(), expr_dict]), + ); + } + + Ok(json!({ + "name": self.name, + "associations": associations, + "attack_steps": attack_steps, + "info": self.info, + "super_asset": self.own_super_asset.map(|id| graph.asset(id).name.clone()).unwrap_or_default(), + "sub_assets": self.own_sub_assets.iter().map(|id| graph.asset(*id).name.clone()).collect::>(), + "variables": variables, + "is_abstract": self.is_abstract, + })) + } +} diff --git a/core/maltoolbox-language/src/graph/assoc.rs b/core/maltoolbox-language/src/graph/assoc.rs new file mode 100644 index 00000000..adf2bed2 --- /dev/null +++ b/core/maltoolbox-language/src/graph/assoc.rs @@ -0,0 +1,117 @@ +//! Port of `maltoolbox/language/language_graph_assoc.py`. + +use std::collections::HashMap; + +use super::ids::AssetId; +use super::{GraphError, LanguageGraph}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct LanguageGraphAssociationField { + pub asset: AssetId, + pub fieldname: String, + pub minimum: i64, + pub maximum: Option, +} + +/// `info` is excluded from `PartialEq`/`Hash` - metadata doesn't affect +/// association identity. +#[derive(Debug, Clone)] +pub struct LanguageGraphAssociation { + pub name: String, + pub left_field: LanguageGraphAssociationField, + pub right_field: LanguageGraphAssociationField, + pub info: HashMap, +} + +impl PartialEq for LanguageGraphAssociation { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + && self.left_field == other.left_field + && self.right_field == other.right_field + } +} + +impl Eq for LanguageGraphAssociation {} + +impl std::hash::Hash for LanguageGraphAssociation { + fn hash(&self, state: &mut H) { + self.name.hash(state); + self.left_field.hash(state); + self.right_field.hash(state); + } +} + +impl LanguageGraphAssociation { + pub fn full_name(&self) -> String { + format!( + "{}_{}_{}", + self.name, self.left_field.fieldname, self.right_field.fieldname + ) + } + + pub fn get_field(&self, fieldname: &str) -> &LanguageGraphAssociationField { + if self.right_field.fieldname == fieldname { + &self.right_field + } else { + &self.left_field + } + } + + pub fn contains_fieldname(&self, fieldname: &str) -> bool { + self.left_field.fieldname == fieldname || self.right_field.fieldname == fieldname + } + + pub fn contains_asset(&self, graph: &LanguageGraph, asset: AssetId) -> bool { + graph.is_subasset_of(asset, self.left_field.asset) + || graph.is_subasset_of(asset, self.right_field.asset) + } + + pub fn get_opposite_fieldname(&self, fieldname: &str) -> Result { + if self.left_field.fieldname == fieldname { + return Ok(self.right_field.fieldname.clone()); + } + if self.right_field.fieldname == fieldname { + return Ok(self.left_field.fieldname.clone()); + } + Err(GraphError::Malformed(format!( + "Requested fieldname \"{fieldname}\" from association {} which did not contain it!", + self.name + ))) + } + + pub fn resolve_field_asset_type( + &self, + graph: &LanguageGraph, + fieldname: &str, + ) -> Result { + if fieldname == self.left_field.fieldname { + return Ok(graph.asset(self.left_field.asset).name.clone()); + } + if fieldname == self.right_field.fieldname { + return Ok(graph.asset(self.right_field.asset).name.clone()); + } + Err(GraphError::Malformed(format!( + "Field \"{fieldname}\" not found in association {}", + self.name + ))) + } + + pub fn to_dict(&self, graph: &LanguageGraph) -> serde_json::Value { + serde_json::json!({ + "name": self.name, + "info": self.info, + "left": { + "asset": graph.asset(self.left_field.asset).name.clone(), + "fieldname": self.left_field.fieldname, + "min": self.left_field.minimum, + "max": self.left_field.maximum, + }, + "right": { + "asset": graph.asset(self.right_field.asset).name.clone(), + "fieldname": self.right_field.fieldname, + "min": self.right_field.minimum, + "max": self.right_field.maximum, + }, + }) + } +} diff --git a/core/maltoolbox-language/src/graph/assoc_traversal.rs b/core/maltoolbox-language/src/graph/assoc_traversal.rs new file mode 100644 index 00000000..ed1b8847 --- /dev/null +++ b/core/maltoolbox-language/src/graph/assoc_traversal.rs @@ -0,0 +1,222 @@ +//! Port of `maltoolbox/language/assoc_traversal_processor.py`. +//! +//! Resolves [`super::model_effect::AssocTraversalChain`]s against an +//! actual language graph - walking associations from a starting set of +//! assets - and validates every `LanguageGraphModelEffect` built by +//! [`super::model_effect::build_model_effect`]. + +use std::collections::HashSet; + +use super::ids::{AssetId, AttackStepId}; +use super::model_effect::{ + AssocSet, AssocTraversal, AssocTraversalElem, GlobAssocTraversal, ModelEffectType, SetOperation, +}; +use super::{GraphError, LanguageGraph}; + +fn assoc_traversal( + graph: &LanguageGraph, + step: AttackStepId, + instigating_assets: &HashSet, + traversal: &AssocTraversal, +) -> Result, GraphError> { + let mut next_assets = HashSet::new(); + for &asset in instigating_assets { + if traversal.field_name == "self" { + next_assets.insert(graph.step(step).asset); + continue; + } + let associations = graph.associations(asset); + let assoc = associations.get(&traversal.field_name).ok_or_else(|| { + GraphError::Malformed(format!( + "Asset {} does not have an association for field {}.", + graph.asset(asset).name, + traversal.field_name + )) + })?; + next_assets.insert(assoc.get_field(&traversal.field_name).asset); + } + Ok(next_assets) +} + +/// Resolves MAL's `*` operator as a transitive closure of `pattern`: +/// repeatedly re-applies it to the assets reached so far until a fixed +/// point is reached. +/// +/// NOTE: the Python original (`_glob_assoc_traversal`) recomputes each +/// iteration from the original `instigating_assets` rather than the +/// growing result, so it only ever applies `pattern` once instead of +/// computing a real closure. This port intentionally diverges and +/// implements the closure correctly. +fn glob_assoc_traversal( + graph: &LanguageGraph, + step: AttackStepId, + instigating_assets: &HashSet, + glob: &GlobAssocTraversal, +) -> Result, GraphError> { + let mut next_assets = + traverse_association_chain(graph, step, instigating_assets, &glob.pattern)?; + loop { + let applied = traverse_association_chain(graph, step, &next_assets, &glob.pattern)?; + let mut union = next_assets.clone(); + union.extend(applied); + if union.len() == next_assets.len() { + break; + } + next_assets = union; + } + Ok(next_assets) +} + +fn assoc_set_traversal( + graph: &LanguageGraph, + step: AttackStepId, + starting_assets: &HashSet, + assoc_set: &AssocSet, +) -> Result, GraphError> { + let left = traverse_association_chain(graph, step, starting_assets, &assoc_set.left)?; + let right = traverse_association_chain(graph, step, starting_assets, &assoc_set.right)?; + Ok(match assoc_set.set_op { + SetOperation::Union => left.union(&right).copied().collect(), + SetOperation::Difference => left.difference(&right).copied().collect(), + SetOperation::Intersection => left.intersection(&right).copied().collect(), + }) +} + +fn traverse_association_chain( + graph: &LanguageGraph, + step: AttackStepId, + instigating_assets: &HashSet, + chain: &[AssocTraversalElem], +) -> Result, GraphError> { + let mut current = instigating_assets.clone(); + for elem in chain { + current = match elem { + AssocTraversalElem::Traversal(t) => assoc_traversal(graph, step, ¤t, t)?, + AssocTraversalElem::Glob(g) => glob_assoc_traversal(graph, step, ¤t, g)?, + AssocTraversalElem::Set(s) => assoc_set_traversal(graph, step, ¤t, s)?, + }; + } + Ok(current) +} + +/// (anchor asset, field name used to reach the terminal, terminal asset). +pub type TerminalResolve = (AssetId, String, AssetId); + +fn resolve_terminal_traversal( + graph: &LanguageGraph, + step: AttackStepId, + instigating_assets: &HashSet, + chain: &[AssocTraversalElem], +) -> Result, GraphError> { + let split = chain.len().saturating_sub(1); + let (init, last_slice) = chain.split_at(split); + let current_assets = traverse_association_chain(graph, step, instigating_assets, init)?; + let last = last_slice + .first() + .ok_or_else(|| GraphError::Malformed("empty association traversal chain".into()))?; + + match last { + AssocTraversalElem::Traversal(t) => { + let mut result = HashSet::new(); + for &asset in ¤t_assets { + if t.field_name == "self" { + result.insert((asset, t.field_name.clone(), graph.step(step).asset)); + continue; + } + let candidate = if let Some(filter) = t.asset_filter { + filter + } else { + let associations = graph.associations(asset); + let assoc = associations.get(&t.field_name).ok_or_else(|| { + GraphError::Malformed(format!( + "Asset {} does not have an association for field {}.", + graph.asset(asset).name, + t.field_name + )) + })?; + assoc.get_field(&t.field_name).asset + }; + result.insert((asset, t.field_name.clone(), candidate)); + } + Ok(result) + } + // `*` terminates in whatever its inner pattern terminates in. + AssocTraversalElem::Glob(g) => { + resolve_terminal_traversal(graph, step, ¤t_assets, &g.pattern) + } + AssocTraversalElem::Set(s) => { + let left = resolve_terminal_traversal(graph, step, ¤t_assets, &s.left)?; + let right = resolve_terminal_traversal(graph, step, ¤t_assets, &s.right)?; + Ok(match s.set_op { + SetOperation::Union => left.union(&right).cloned().collect(), + SetOperation::Difference => left.difference(&right).cloned().collect(), + SetOperation::Intersection => left.intersection(&right).cloned().collect(), + }) + } + } +} + +/// Port of `validate_model_effects`. +pub fn validate_model_effects(graph: &LanguageGraph) -> Result<(), GraphError> { + for &asset_id in &graph.asset_order { + let step_ids: Vec = graph + .asset(asset_id) + .attack_steps + .values() + .copied() + .collect(); + for step_id in step_ids { + let step = graph.step(step_id); + let mut effects = step.additive_model_effects(graph); + effects.extend(step.subtractive_model_effects(graph)); + + for model_effect in &effects { + let base_resolves = resolve_terminal_traversal( + graph, + step_id, + &HashSet::from([asset_id]), + &model_effect.base, + )?; + + for (target_index, dyn_target) in model_effect.targets.iter().enumerate() { + let is_edge_addition = model_effect.model_effect_type + == ModelEffectType::Additive + && dyn_target.assoc_op; + + if is_edge_addition { + let dyn_target_resolves = resolve_terminal_traversal( + graph, + step_id, + &HashSet::from([asset_id]), + &dyn_target.assoc_traversal, + )?; + for &(_, _, terminating_asset) in &dyn_target_resolves { + for &(_, ref base_field_name, base_terminating_asset) in &base_resolves + { + if !graph.is_subasset_of(base_terminating_asset, terminating_asset) + { + return Err(GraphError::Malformed(format!( + "Invalid model effect for edge addition. Base terminates in field {base_field_name} with type {}, which is not a subasset of the target asset {} that terminates the dynamic target with index {target_index}, in {}.", + graph.asset(base_terminating_asset).name, + graph.asset(terminating_asset).name, + step.full_name(graph), + ))); + } + } + } + } else { + let base_terminals: HashSet = + base_resolves.iter().map(|r| r.2).collect(); + resolve_terminal_traversal( + graph, + step_id, + &base_terminals, + &dyn_target.assoc_traversal, + )?; + } + } + } + } + } + Ok(()) +} diff --git a/core/maltoolbox-language/src/graph/attack_step.rs b/core/maltoolbox-language/src/graph/attack_step.rs new file mode 100644 index 00000000..9fe35dd8 --- /dev/null +++ b/core/maltoolbox-language/src/graph/attack_step.rs @@ -0,0 +1,286 @@ +//! Port of `maltoolbox/language/language_graph_attack_step.py`. +//! +//! `own_additive_model_effects`/`own_subtractive_model_effects` are not +//! part of `to_dict()`'s output in the Python original, so +//! [`LanguageGraphAttackStep::to_dict`] omits them too. + +use std::collections::HashMap; +use std::rc::Rc; + +use indexmap::IndexMap; +use serde_json::json; + +use super::detector::LanguageGraphDetector; +use super::expr_chain::ExpressionsChain; +use super::ids::{AssetId, AttackStepId}; +use super::model_effect::LanguageGraphModelEffect; +use super::{GraphError, LanguageGraph}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AttackStepType { + Or, + And, + Defense, + Exist, + NotExist, +} + +impl AttackStepType { + pub fn as_str(self) -> &'static str { + match self { + AttackStepType::Or => "or", + AttackStepType::And => "and", + AttackStepType::Defense => "defense", + AttackStepType::Exist => "exist", + AttackStepType::NotExist => "notExist", + } + } + + pub fn parse(s: &str) -> Result { + match s { + "or" => Ok(AttackStepType::Or), + "and" => Ok(AttackStepType::And), + "defense" => Ok(AttackStepType::Defense), + "exist" => Ok(AttackStepType::Exist), + "notExist" => Ok(AttackStepType::NotExist), + other => Err(GraphError::Malformed(format!( + "unknown attack step type: {other}" + ))), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CausalMode { + Action, + Effect, +} + +impl CausalMode { + pub fn as_str(self) -> &'static str { + match self { + CausalMode::Action => "action", + CausalMode::Effect => "effect", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "action" => Some(CausalMode::Action), + "effect" => Some(CausalMode::Effect), + _ => None, + } + } +} + +#[derive(Debug, Clone)] +pub struct LanguageGraphAttackStep { + pub name: String, + pub step_type: AttackStepType, + pub asset: AssetId, + pub causal_mode: Option, + pub ttc: Option, + pub overrides: bool, + /// Insertion-ordered (like Python's `dict`): iteration order feeds + /// node-linking order in `generate.rs` and `to_dict`'s key order. + pub own_children: IndexMap>>, + pub own_parents: IndexMap>>, + pub own_additive_model_effects: Vec, + pub own_subtractive_model_effects: Vec, + pub info: HashMap, + pub inherits: Option, + pub own_requires: Vec, + pub tags: Vec, + pub detectors: IndexMap, +} + +impl LanguageGraphAttackStep { + pub fn full_name(&self, graph: &LanguageGraph) -> String { + format!("{}:{}", graph.asset(self.asset).name, self.name) + } + + /// Own + inherited children, mirroring the `children` property. + pub fn children( + &self, + graph: &LanguageGraph, + ) -> IndexMap>> { + let mut all_children = self.own_children.clone(); + if self.overrides { + return all_children; + } + let Some(inherits) = self.inherits else { + return all_children; + }; + let parent_step = graph.step(inherits); + for (child, chains) in parent_step.children(graph) { + all_children + .entry(child) + .and_modify(|existing| { + for c in &chains { + if !existing.iter().any(|e| expr_eq(e, c)) { + existing.push(c.clone()); + } + } + }) + .or_insert(chains); + } + all_children + } + + /// Own + inherited requirements, mirroring the `requires` cached + /// property. + pub fn requires(&self, graph: &LanguageGraph) -> Vec { + let mut reqs = self.own_requires.clone(); + if let Some(inherits) = self.inherits { + reqs.extend(graph.step(inherits).requires(graph)); + } + reqs + } + + /// Own + inherited additive model effects, mirroring the + /// `additive_model_effects` property. + // TODO (from Python original): confirm whether `overrides` should + // gate this, or only "static" (reaches) steps. + pub fn additive_model_effects(&self, graph: &LanguageGraph) -> Vec { + let mut all = self.own_additive_model_effects.clone(); + let Some(inherits) = self.inherits else { + return all; + }; + if self.overrides { + return all; + } + all.extend(graph.step(inherits).additive_model_effects(graph)); + all + } + + /// Own + inherited subtractive model effects, mirroring the + /// `subtractive_model_effects` property. + pub fn subtractive_model_effects( + &self, + graph: &LanguageGraph, + ) -> Vec { + let mut all = self.own_subtractive_model_effects.clone(); + let Some(inherits) = self.inherits else { + return all; + }; + if self.overrides { + return all; + } + all.extend(graph.step(inherits).subtractive_model_effects(graph)); + all + } + + pub fn to_dict(&self, graph: &LanguageGraph) -> Result { + let mut own_children = serde_json::Map::new(); + for (child_id, chains) in &self.own_children { + let child = graph.step(*child_id); + let mut arr = Vec::new(); + for chain in chains { + arr.push(match chain { + Some(c) => c.to_dict(graph)?, + None => serde_json::Value::Null, + }); + } + own_children.insert(child.full_name(graph), serde_json::Value::Array(arr)); + } + + let mut own_parents = serde_json::Map::new(); + for (parent_id, chains) in &self.own_parents { + let parent = graph.step(*parent_id); + let mut arr = Vec::new(); + for chain in chains { + arr.push(match chain { + Some(c) => c.to_dict(graph)?, + None => serde_json::Value::Null, + }); + } + own_parents.insert(parent.full_name(graph), serde_json::Value::Array(arr)); + } + + let mut detectors = serde_json::Map::new(); + for (label, detector) in &self.detectors { + detectors.insert(label.clone(), detector.to_dict(graph)); + } + + let mut node_dict = json!({ + "name": self.name, + "type": self.step_type.as_str(), + "asset": graph.asset(self.asset).name.clone(), + "ttc": self.ttc, + "own_children": own_children, + "own_parents": own_parents, + "info": self.info, + "overrides": self.overrides, + "inherits": self.inherits.map(|id| graph.step(id).full_name(graph)), + "tags": self.tags, + "detectors": detectors, + }); + + if !self.own_requires.is_empty() { + let mut reqs = Vec::new(); + for req in &self.own_requires { + reqs.push(req.to_dict(graph)?); + } + node_dict["requires"] = serde_json::Value::Array(reqs); + } + + Ok(node_dict) + } +} + +/// Structural (by-value) equality for `ExpressionsChain`, used by +/// `children()`'s "already present" dedup check. Not a `PartialEq` impl +/// because `Field`'s `Rc` is compared by +/// pointer identity here rather than derived value equality. +fn expr_eq(a: &Option, b: &Option) -> bool { + match (a, b) { + (None, None) => true, + (Some(a), Some(b)) => expr_eq_inner(a, b), + _ => false, + } +} + +fn expr_eq_inner(a: &ExpressionsChain, b: &ExpressionsChain) -> bool { + use ExpressionsChain::*; + match (a, b) { + ( + Binary { + op: op_a, + left: left_a, + right: right_a, + }, + Binary { + op: op_b, + left: left_b, + right: right_b, + }, + ) => { + op_a == op_b + && expr_eq(&left_a.as_deref().cloned(), &left_b.as_deref().cloned()) + && expr_eq(&right_a.as_deref().cloned(), &right_b.as_deref().cloned()) + } + ( + Field { + association: assoc_a, + fieldname: field_a, + }, + Field { + association: assoc_b, + fieldname: field_b, + }, + ) => Rc::ptr_eq(assoc_a, assoc_b) && field_a == field_b, + (Transitive { sub: sub_a }, Transitive { sub: sub_b }) => expr_eq_inner(sub_a, sub_b), + ( + SubType { + sub: sub_a, + subtype: subtype_a, + }, + SubType { + sub: sub_b, + subtype: subtype_b, + }, + ) => subtype_a == subtype_b && expr_eq_inner(sub_a, sub_b), + (AssocOp { sub: sub_a }, AssocOp { sub: sub_b }) => expr_eq_inner(sub_a, sub_b), + _ => false, + } +} diff --git a/core/maltoolbox-language/src/graph/builder.rs b/core/maltoolbox-language/src/graph/builder.rs new file mode 100644 index 00000000..afce3243 --- /dev/null +++ b/core/maltoolbox-language/src/graph/builder.rs @@ -0,0 +1,506 @@ +//! Port of `maltoolbox/language/language_graph_builder.py`. +//! +//! Asset creation and inheritance, associations, variables, attack step +//! creation and inheritance, `reaches`/`requires` connection, and model +//! effects (`append_reaches`/`remove_reaches`, via +//! [`connect_model_effects`] + [`assoc_traversal::validate_model_effects`]) +//! are all ported. + +use std::collections::{HashMap, VecDeque}; +use std::rc::Rc; + +use indexmap::IndexMap; +use serde_json::Value; + +use super::assoc::{LanguageGraphAssociation, LanguageGraphAssociationField}; +use super::attack_step::{AttackStepType, CausalMode, LanguageGraphAttackStep}; +use super::detector::{LanguageGraphContextItem, LanguageGraphDetector}; +use super::ids::{AssetId, AttackStepId}; +use super::step_expr::{process_step_expression, resolve_variable, reverse_expr_chain}; +use super::{asset::LanguageGraphAsset, assoc_traversal, model_effect, GraphError, LanguageGraph}; + +fn meta_map(v: &Value) -> HashMap { + v.as_object() + .into_iter() + .flatten() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() +} + +pub fn generate_graph(graph: &mut LanguageGraph) -> Result<(), GraphError> { + create_lg_assets(graph)?; + link_assets(graph)?; + create_associations_for_assets(graph)?; + set_variables_for_assets(graph)?; + generate_attack_steps(graph)?; + Ok(()) +} + +fn create_lg_assets(graph: &mut LanguageGraph) -> Result<(), GraphError> { + let assets_spec = graph.lang_spec["assets"] + .as_array() + .cloned() + .unwrap_or_default(); + for asset_dict in &assets_spec { + let name = asset_dict["name"] + .as_str() + .ok_or_else(|| GraphError::Malformed("asset missing name".into()))? + .to_string(); + let node = LanguageGraphAsset { + name: name.clone(), + own_associations: IndexMap::new(), + attack_steps: IndexMap::default(), + info: meta_map(&asset_dict["meta"]), + own_super_asset: None, + own_sub_assets: Vec::new(), + own_variables: IndexMap::new(), + is_abstract: asset_dict["isAbstract"].as_bool().unwrap_or(false), + }; + let id = graph.assets.insert(node); + graph.asset_id_by_name.insert(name, id); + graph.asset_order.push(id); + } + Ok(()) +} + +fn link_assets(graph: &mut LanguageGraph) -> Result<(), GraphError> { + let assets_spec = graph.lang_spec["assets"] + .as_array() + .cloned() + .unwrap_or_default(); + for asset_dict in &assets_spec { + let name = asset_dict["name"].as_str().unwrap_or_default(); + let asset_id = *graph.asset_id_by_name.get(name).unwrap(); + + let Some(super_name) = asset_dict["superAsset"].as_str() else { + continue; + }; + let super_id = graph + .asset_id_by_name + .get(super_name) + .copied() + .ok_or_else(|| GraphError::SuperAssetNotFound { + asset_name: name.to_string(), + super_name: super_name.to_string(), + })?; + + graph.assets[super_id].own_sub_assets.push(asset_id); + graph.assets[asset_id].own_super_asset = Some(super_id); + } + Ok(()) +} + +fn create_associations_for_assets(graph: &mut LanguageGraph) -> Result<(), GraphError> { + let associations_spec = graph.lang_spec["associations"] + .as_array() + .cloned() + .unwrap_or_default(); + for assoc_dict in &associations_spec { + let name = assoc_dict["name"].as_str().unwrap_or_default().to_string(); + let left_asset_name = assoc_dict["leftAsset"].as_str().unwrap_or_default(); + let right_asset_name = assoc_dict["rightAsset"].as_str().unwrap_or_default(); + + let left_asset_id = graph + .asset_id_by_name + .get(left_asset_name) + .copied() + .ok_or_else(|| GraphError::AssociationAssetNotFound { + assoc_name: name.clone(), + asset_name: left_asset_name.to_string(), + })?; + let right_asset_id = graph + .asset_id_by_name + .get(right_asset_name) + .copied() + .ok_or_else(|| GraphError::AssociationAssetNotFound { + assoc_name: name.clone(), + asset_name: right_asset_name.to_string(), + })?; + + let left_field = LanguageGraphAssociationField { + asset: left_asset_id, + fieldname: assoc_dict["leftField"] + .as_str() + .unwrap_or_default() + .to_string(), + minimum: assoc_dict["leftMultiplicity"]["min"].as_i64().unwrap_or(0), + maximum: assoc_dict["leftMultiplicity"]["max"].as_i64(), + }; + let right_field = LanguageGraphAssociationField { + asset: right_asset_id, + fieldname: assoc_dict["rightField"] + .as_str() + .unwrap_or_default() + .to_string(), + minimum: assoc_dict["rightMultiplicity"]["min"].as_i64().unwrap_or(0), + maximum: assoc_dict["rightMultiplicity"]["max"].as_i64(), + }; + + let assoc = Rc::new(LanguageGraphAssociation { + name, + left_field, + right_field, + info: meta_map(&assoc_dict["meta"]), + }); + + let left_fieldname = assoc.left_field.fieldname.clone(); + let right_fieldname = assoc.right_field.fieldname.clone(); + graph.assets[left_asset_id] + .own_associations + .insert(right_fieldname, assoc.clone()); + graph.assets[right_asset_id] + .own_associations + .insert(left_fieldname, assoc); + } + Ok(()) +} + +fn set_variables_for_assets(graph: &mut LanguageGraph) -> Result<(), GraphError> { + let order = graph.asset_order.clone(); + for asset_id in order { + let asset_name = graph.asset(asset_id).name.clone(); + let variables = super::lookup::get_variables_for_asset_type(&asset_name, &graph.lang_spec)?; + for var in &variables { + let var_name = var["name"] + .as_str() + .ok_or_else(|| GraphError::Malformed("variable missing name".into()))? + .to_string(); + let resolved = resolve_variable(graph, asset_id, &var_name)?; + graph.assets[asset_id] + .own_variables + .insert(var_name, resolved); + } + } + Ok(()) +} + +fn build_detectors( + graph: &LanguageGraph, + target_asset: AssetId, + step_dict: &Value, +) -> Result, GraphError> { + let mut detectors = IndexMap::new(); + let Some(dets) = step_dict.get("detectors").and_then(Value::as_object) else { + return Ok(detectors); + }; + for det in dets.values() { + let mut context = HashMap::new(); + if let Some(ctx_obj) = det.get("context").and_then(Value::as_object) { + for (label, ctx_expr) in ctx_obj { + let (asset_type, expr_chain, attack_step_name) = + process_step_expression(graph, target_asset, None, ctx_expr)?; + context.insert( + label.clone(), + LanguageGraphContextItem { + label: label.clone(), + asset_type, + attack_step_name, + expr: expr_chain, + }, + ); + } + } + let name = det.get("name").and_then(Value::as_str).map(str::to_string); + let key = name.clone().unwrap_or_default(); + detectors.insert( + key, + LanguageGraphDetector { + name, + context, + detector_type: det.get("type").and_then(Value::as_str).map(str::to_string), + tprate: det.get("tprate").and_then(Value::as_f64), + fprate: det.get("fprate").and_then(Value::as_f64), + }, + ); + } + Ok(detectors) +} + +fn create_lg_attack_step_nodes( + graph: &mut LanguageGraph, +) -> Result, GraphError> { + let mut attack_step_dicts = HashMap::new(); + let order = graph.asset_order.clone(); + + for asset_id in order { + let asset_name = graph.asset(asset_id).name.clone(); + let steps_spec = super::lookup::get_attacks_for_asset_type(&asset_name, &graph.lang_spec); + + for (step_name, step_dict) in steps_spec { + let step_type = AttackStepType::parse( + step_dict["type"] + .as_str() + .ok_or_else(|| GraphError::Malformed("attack step missing type".into()))?, + )?; + let causal_mode = step_dict["causal_mode"] + .as_str() + .and_then(CausalMode::parse); + let ttc = if step_dict["ttc"].is_null() { + None + } else { + Some(step_dict["ttc"].clone()) + }; + let overrides = step_dict + .get("reaches") + .filter(|r| !r.is_null()) + .and_then(|r| r.get("overrides")) + .and_then(Value::as_bool) + .unwrap_or(false); + let tags = step_dict["tags"] + .as_array() + .into_iter() + .flatten() + .filter_map(|t| t.as_str().map(str::to_string)) + .collect(); + let detectors = build_detectors(graph, asset_id, &step_dict)?; + + let node = LanguageGraphAttackStep { + name: step_name.clone(), + step_type, + asset: asset_id, + causal_mode, + ttc, + overrides, + own_children: IndexMap::new(), + own_parents: IndexMap::new(), + own_additive_model_effects: Vec::new(), + own_subtractive_model_effects: Vec::new(), + info: meta_map(&step_dict["meta"]), + inherits: None, + own_requires: Vec::new(), + tags, + detectors, + }; + + let full_name = format!("{asset_name}:{step_name}"); + let step_id = graph.steps.insert(node); + graph.assets[asset_id] + .attack_steps + .insert(step_name, step_id); + attack_step_dicts.insert(full_name, step_dict); + } + } + + Ok(attack_step_dicts) +} + +fn inherit_attack_steps(graph: &mut LanguageGraph) -> Result<(), GraphError> { + let mut pending: VecDeque = graph.asset_order.iter().copied().collect(); + + while let Some(asset_id) = pending.pop_front() { + let Some(super_id) = graph.asset(asset_id).own_super_asset else { + continue; + }; + if pending.contains(&super_id) { + pending.push_back(asset_id); + continue; + } + + let super_steps: Vec<(String, AttackStepId)> = graph + .asset(super_id) + .attack_steps + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect(); + + for (step_name, super_step_id) in super_steps { + let current_id = graph.asset(asset_id).attack_steps.get(&step_name).copied(); + + match current_id { + None => { + let super_step = graph.step(super_step_id).clone(); + let node = LanguageGraphAttackStep { + name: super_step.name, + step_type: super_step.step_type, + asset: asset_id, + causal_mode: super_step.causal_mode, + ttc: super_step.ttc, + overrides: false, + own_children: IndexMap::new(), + own_parents: IndexMap::new(), + own_additive_model_effects: Vec::new(), + own_subtractive_model_effects: Vec::new(), + info: super_step.info, + inherits: Some(super_step_id), + own_requires: Vec::new(), + tags: super_step.tags, + detectors: IndexMap::new(), + }; + let new_id = graph.steps.insert(node); + graph.assets[asset_id] + .attack_steps + .insert(step_name, new_id); + } + Some(current_id) => { + if graph.step(current_id).overrides { + continue; + } + let super_tags = graph.step(super_step_id).tags.clone(); + let super_info = graph.step(super_step_id).info.clone(); + let current = &mut graph.steps[current_id]; + current.inherits = Some(super_step_id); + current.tags.extend(super_tags); + // Python's `info |= super_step.info` (dict union-assign): + // on key conflicts, the super asset's value wins. + for (k, v) in super_info { + current.info.insert(k, v); + } + } + } + } + } + Ok(()) +} + +fn connect_attack_steps( + graph: &mut LanguageGraph, + attack_step_dicts: &HashMap, +) -> Result<(), GraphError> { + for asset_id in graph.asset_order.clone() { + let step_ids: Vec = graph + .asset(asset_id) + .attack_steps + .values() + .copied() + .collect(); + + for step_id in step_ids { + let full_name = graph.step(step_id).full_name(graph); + let Some(step_dict) = attack_step_dicts.get(&full_name) else { + continue; + }; + let step_dict = step_dict.clone(); + let step_asset = graph.step(step_id).asset; + + if let Some(reaches) = step_dict.get("reaches").filter(|r| !r.is_null()) { + let exprs = reaches["stepExpressions"] + .as_array() + .cloned() + .unwrap_or_default(); + for expr in exprs { + let (tgt_asset, chain, tgt_name) = + process_step_expression(graph, step_asset, None, &expr)?; + let Some(tgt_name) = tgt_name else { + return Err(GraphError::StepExpression(format!( + "Failed to find target attack step for:\n{}", + serde_json::to_string_pretty(&expr).unwrap_or_default() + ))); + }; + let tgt_step_id = *graph + .asset(tgt_asset) + .attack_steps + .get(&tgt_name) + .ok_or_else(|| { + GraphError::StepExpression(format!( + "Failed to find target attack step {tgt_name} on {}:\n{}", + graph.asset(tgt_asset).name, + serde_json::to_string_pretty(&expr).unwrap_or_default() + )) + })?; + + let reverse_chain = reverse_expr_chain(chain.as_ref())?; + graph.steps[step_id] + .own_children + .entry(tgt_step_id) + .or_default() + .push(chain); + graph.steps[tgt_step_id] + .own_parents + .entry(step_id) + .or_default() + .push(reverse_chain); + } + } + + let step_type = graph.step(step_id).step_type; + if matches!(step_type, AttackStepType::Exist | AttackStepType::NotExist) { + let reqs = step_dict + .get("requires") + .filter(|r| !r.is_null()) + .and_then(|r| r.get("stepExpressions")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if reqs.is_empty() { + return Err(GraphError::StepExpression(format!( + "Missing requirements for \"{}\" of type \"{}\":\n{}", + graph.step(step_id).name, + step_type.as_str(), + serde_json::to_string_pretty(&step_dict).unwrap_or_default() + ))); + } + for expr in reqs { + let (_, chain, _) = process_step_expression(graph, step_asset, None, &expr)?; + let chain = chain.ok_or_else(|| { + GraphError::StepExpression(format!( + "Failed to find existence step requirement for:\n{expr}" + )) + })?; + graph.steps[step_id].own_requires.push(chain); + } + } + } + } + Ok(()) +} + +fn connect_model_effects( + graph: &mut LanguageGraph, + attack_step_dicts: &HashMap, +) -> Result<(), GraphError> { + for asset_id in graph.asset_order.clone() { + let step_ids: Vec = graph + .asset(asset_id) + .attack_steps + .values() + .copied() + .collect(); + + for step_id in step_ids { + let full_name = graph.step(step_id).full_name(graph); + let Some(step_dict) = attack_step_dicts.get(&full_name) else { + continue; + }; + let step_dict = step_dict.clone(); + let step_asset = graph.step(step_id).asset; + + if let Some(append_reaches) = step_dict.get("append_reaches").filter(|r| !r.is_null()) { + let exprs = append_reaches["stepExpressions"] + .as_array() + .cloned() + .unwrap_or_default(); + for expr in exprs { + let model_effect = + model_effect::build_model_effect(graph, step_asset, &expr, true)?; + graph.steps[step_id] + .own_additive_model_effects + .push(model_effect); + } + } + + if let Some(remove_reaches) = step_dict.get("remove_reaches").filter(|r| !r.is_null()) { + let exprs = remove_reaches["stepExpressions"] + .as_array() + .cloned() + .unwrap_or_default(); + for expr in exprs { + let model_effect = + model_effect::build_model_effect(graph, step_asset, &expr, false)?; + graph.steps[step_id] + .own_subtractive_model_effects + .push(model_effect); + } + } + } + } + Ok(()) +} + +fn generate_attack_steps(graph: &mut LanguageGraph) -> Result<(), GraphError> { + let attack_step_dicts = create_lg_attack_step_nodes(graph)?; + inherit_attack_steps(graph)?; + connect_attack_steps(graph, &attack_step_dicts)?; + connect_model_effects(graph, &attack_step_dicts)?; + assoc_traversal::validate_model_effects(graph)?; + Ok(()) +} diff --git a/core/maltoolbox-language/src/graph/detector.rs b/core/maltoolbox-language/src/graph/detector.rs new file mode 100644 index 00000000..eb206b1b --- /dev/null +++ b/core/maltoolbox-language/src/graph/detector.rs @@ -0,0 +1,53 @@ +//! Port of `maltoolbox/language/language_graph_detector.py`. + +use serde_json::json; +use std::collections::HashMap; + +use super::expr_chain::ExpressionsChain; +use super::ids::AssetId; +use super::LanguageGraph; + +#[derive(Debug, Clone)] +pub struct LanguageGraphContextItem { + pub label: String, + pub asset_type: AssetId, + pub attack_step_name: Option, + pub expr: Option, +} + +impl LanguageGraphContextItem { + pub fn to_dict(&self, graph: &LanguageGraph) -> serde_json::Value { + json!({ + "label": self.label, + "asset_type": graph.asset(self.asset_type).name.clone(), + "attack_step_name": self.attack_step_name, + "expr": self.expr.as_ref().and_then(|e| e.to_dict(graph).ok()), + }) + } +} + +#[derive(Debug, Clone)] +pub struct LanguageGraphDetector { + pub name: Option, + pub context: HashMap, + pub detector_type: Option, + /// `None` when the MAL source omits a rate; `to_dict` only emits + /// `tprate`, never `fprate`. + pub tprate: Option, + pub fprate: Option, +} + +impl LanguageGraphDetector { + pub fn to_dict(&self, graph: &LanguageGraph) -> serde_json::Value { + let mut context = serde_json::Map::new(); + for (k, v) in &self.context { + context.insert(k.clone(), v.to_dict(graph)); + } + json!({ + "name": self.name, + "context": context, + "type": self.detector_type, + "tprate": self.tprate, + }) + } +} diff --git a/core/maltoolbox-language/src/graph/expr_chain.rs b/core/maltoolbox-language/src/graph/expr_chain.rs new file mode 100644 index 00000000..9eadb5d1 --- /dev/null +++ b/core/maltoolbox-language/src/graph/expr_chain.rs @@ -0,0 +1,183 @@ +//! Port of `maltoolbox/language/expression_chain.py`. +//! +//! `ExpressionsChain` represents a tree of operations describing how to +//! traverse associations in a language graph: set operations +//! (union/intersection/difference), field collection, transitive closure, +//! and subtype narrowing. + +use std::collections::HashSet; +use std::rc::Rc; + +use serde_json::{json, Value}; + +use super::assoc::LanguageGraphAssociation; +use super::ids::AssetId; +use super::GraphError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ExprType { + Union, + Intersection, + Difference, + Collect, + Field, + Transitive, + SubType, + AssocOp, + Multiplicity, +} + +impl ExprType { + pub fn is_binary(self) -> bool { + matches!( + self, + ExprType::Union | ExprType::Intersection | ExprType::Difference | ExprType::Collect + ) + } + + pub fn as_str(self) -> &'static str { + match self { + ExprType::Union => "union", + ExprType::Intersection => "intersection", + ExprType::Difference => "difference", + ExprType::Collect => "collect", + ExprType::Field => "field", + ExprType::Transitive => "transitive", + ExprType::SubType => "subType", + ExprType::AssocOp => "assoc_op", + ExprType::Multiplicity => "multiplicity", + } + } +} + +/// A single node in an expressions chain. Unlike the Python dataclass +/// (one struct with many `Option` fields, validated post-hoc), this is +/// modeled as an enum so each variant only carries the fields it needs. +#[derive(Debug, Clone)] +pub enum ExpressionsChain { + Binary { + op: ExprType, // one of Union/Intersection/Difference/Collect + left: Option>, + right: Option>, + }, + Field { + association: Rc, + fieldname: String, + }, + Transitive { + sub: Box, + }, + SubType { + sub: Box, + subtype: AssetId, + }, + AssocOp { + sub: Box, + }, + Multiplicity { + sub: Box, + multiplicity: Value, + }, +} + +impl ExpressionsChain { + pub fn expr_type(&self) -> ExprType { + match self { + ExpressionsChain::Binary { op, .. } => *op, + ExpressionsChain::Field { .. } => ExprType::Field, + ExpressionsChain::Transitive { .. } => ExprType::Transitive, + ExpressionsChain::SubType { .. } => ExprType::SubType, + ExpressionsChain::AssocOp { .. } => ExprType::AssocOp, + ExpressionsChain::Multiplicity { .. } => ExprType::Multiplicity, + } + } + + pub fn fieldnames(&self) -> HashSet { + match self { + ExpressionsChain::Field { fieldname, .. } => { + let mut s = HashSet::new(); + s.insert(fieldname.clone()); + s + } + ExpressionsChain::Binary { left, right, .. } => { + let mut s = chain_fieldnames(left.as_deref()); + s.extend(chain_fieldnames(right.as_deref())); + s + } + ExpressionsChain::Transitive { sub } + | ExpressionsChain::SubType { sub, .. } + | ExpressionsChain::AssocOp { sub } + | ExpressionsChain::Multiplicity { sub, .. } => chain_fieldnames(Some(sub)), + } + } + + pub fn is_additive(&self) -> bool { + match self { + ExpressionsChain::Binary { op, left, right } => match op { + ExprType::Intersection | ExprType::Difference => false, + _ => chain_is_additive(left.as_deref()) && chain_is_additive(right.as_deref()), + }, + ExpressionsChain::Field { .. } => true, + ExpressionsChain::Transitive { sub } + | ExpressionsChain::SubType { sub, .. } + | ExpressionsChain::AssocOp { sub } + | ExpressionsChain::Multiplicity { sub, .. } => chain_is_additive(Some(sub)), + } + } + + /// Mirrors `ExpressionsChain.to_dict`. Errors on `Multiplicity` chains, + /// which are only ever consumed by model-effect processing and never + /// serialized directly as a reaches/requires chain. + pub fn to_dict(&self, graph: &super::LanguageGraph) -> Result { + match self { + ExpressionsChain::Binary { op, left, right } => Ok(json!({ + op.as_str(): { + "left": match left { Some(l) => l.to_dict(graph)?, None => json!({}) }, + "right": match right { Some(r) => r.to_dict(graph)?, None => json!({}) }, + }, + "type": op.as_str(), + })), + ExpressionsChain::Field { + association, + fieldname, + } => { + let asset_type = association.resolve_field_asset_type(graph, fieldname)?; + Ok(json!({ + association.name.clone(): { + "fieldname": fieldname, + "asset type": asset_type, + }, + "type": ExprType::Field.as_str(), + })) + } + ExpressionsChain::Transitive { sub } => Ok(json!({ + "transitive": sub.to_dict(graph)?, + "type": ExprType::Transitive.as_str(), + })), + ExpressionsChain::SubType { sub, subtype } => Ok(json!({ + "subType": graph.asset(*subtype).name.clone(), + "expression": sub.to_dict(graph)?, + "type": ExprType::SubType.as_str(), + })), + ExpressionsChain::AssocOp { sub } => Ok(json!({ + "operand": sub.to_dict(graph)?, + "type": ExprType::AssocOp.as_str(), + })), + ExpressionsChain::Multiplicity { .. } => Err(GraphError::Malformed( + "Unknown expressions chain element multiplicity".into(), + )), + } + } +} + +pub fn chain_fieldnames(expr_chain: Option<&ExpressionsChain>) -> HashSet { + expr_chain + .map(ExpressionsChain::fieldnames) + .unwrap_or_default() +} + +pub fn chain_is_additive(expr_chain: Option<&ExpressionsChain>) -> bool { + expr_chain + .map(ExpressionsChain::is_additive) + .unwrap_or(true) +} diff --git a/core/maltoolbox-language/src/graph/file.rs b/core/maltoolbox-language/src/graph/file.rs new file mode 100644 index 00000000..0b3ba29d --- /dev/null +++ b/core/maltoolbox-language/src/graph/file.rs @@ -0,0 +1,477 @@ +//! Port of the file-loading/saving parts of `languagegraph.py`: +//! `from_mal_spec`, `from_mar_archive`, `language_graph_to_dict`/ +//! `language_graph_from_dict`, and `load_language_graph_from_file`. +//! `language_graph_from_git_url` is out of scope (needs a git clone, and +//! isn't used by anything in this rewrite's scope). + +use std::collections::HashMap; +use std::io::Read; +use std::path::Path; +use std::rc::Rc; + +use indexmap::IndexMap; +use serde_json::Value; +use slotmap::SlotMap; + +use super::asset::LanguageGraphAsset; +use super::assoc::{LanguageGraphAssociation, LanguageGraphAssociationField}; +use super::attack_step::{AttackStepType, CausalMode, LanguageGraphAttackStep}; +use super::expr_chain::ExpressionsChain; +use super::ids::AttackStepId; +use super::{language_graph_to_dict, GraphError, LanguageGraph, Metadata}; +use crate::compiler::{compile_file, CompileError}; + +#[derive(Debug, thiserror::Error)] +pub enum LoadError { + #[error("{0}")] + Compile(#[from] CompileError), + #[error("{0}")] + Graph(#[from] GraphError), + #[error("{0}")] + FileUtil(#[from] maltoolbox_io::FileUtilError), + #[error("failed to read mar archive '{0}': {1}")] + Archive(String, String), + #[error("Unknown file extension, expected json/mal/mar/yml/yaml")] + UnknownExtension, +} + +pub fn from_mal_spec(path: impl AsRef) -> Result { + let spec = compile_file(path.as_ref())?; + super::generate_graph(spec).map_err(LoadError::from) +} + +pub fn from_mar_archive(path: impl AsRef) -> Result { + let path = path.as_ref(); + let file = std::fs::File::open(path) + .map_err(|e| LoadError::Archive(path.display().to_string(), e.to_string()))?; + let mut archive = zip::ZipArchive::new(file) + .map_err(|e| LoadError::Archive(path.display().to_string(), e.to_string()))?; + let mut entry = archive + .by_name("langspec.json") + .map_err(|e| LoadError::Archive(path.display().to_string(), e.to_string()))?; + let mut contents = String::new(); + entry + .read_to_string(&mut contents) + .map_err(|e| LoadError::Archive(path.display().to_string(), e.to_string()))?; + let spec: Value = serde_json::from_str(&contents) + .map_err(|e| LoadError::Archive(path.display().to_string(), e.to_string()))?; + super::generate_graph(spec).map_err(LoadError::from) +} + +/// Save `graph.lang_spec` (the compiled langspec, *not* the full +/// `language_graph_to_dict` form) into a `.mar` zip archive as +/// `langspec.json`. A graph rebuilt via `language_graph_from_dict` never +/// has `lang_spec` set (matches the Python original), so round-tripping +/// such a graph through `.mar` writes `null`. +pub fn to_mar_archive(graph: &LanguageGraph, path: impl AsRef) -> Result<(), LoadError> { + let path = path.as_ref(); + let file = std::fs::File::create(path) + .map_err(|e| LoadError::Archive(path.display().to_string(), e.to_string()))?; + let mut writer = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + writer + .start_file("langspec.json", options) + .map_err(|e| LoadError::Archive(path.display().to_string(), e.to_string()))?; + let langspec_json = + serde_json::to_string_pretty(&graph.lang_spec).expect("Value serialization cannot fail"); + use std::io::Write; + writer + .write_all(langspec_json.as_bytes()) + .map_err(|e| LoadError::Archive(path.display().to_string(), e.to_string()))?; + writer + .finish() + .map_err(|e| LoadError::Archive(path.display().to_string(), e.to_string()))?; + Ok(()) +} + +/// Save to JSON/YAML (the full `language_graph_to_dict` form) or `.mar` +/// (just `lang_spec`, see `to_mar_archive`), depending on `path`'s +/// extension. +pub fn save_to_file(graph: &LanguageGraph, path: impl AsRef) -> Result<(), LoadError> { + let path_ref = path.as_ref(); + if path_ref.extension().and_then(|e| e.to_str()) == Some("mar") { + return to_mar_archive(graph, path_ref); + } + let dict = language_graph_to_dict(graph)?; + maltoolbox_io::save_dict_to_file(path, &dict)?; + Ok(()) +} + +pub fn load_from_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + match path.extension().and_then(|e| e.to_str()) { + Some("mal") => from_mal_spec(path), + Some("mar") => from_mar_archive(path), + Some("yml") | Some("yaml") => { + let dict = maltoolbox_io::load_dict_from_yaml_file(path)?; + language_graph_from_dict(&dict).map_err(LoadError::from) + } + Some("json") => { + let dict = maltoolbox_io::load_dict_from_json_file(path)?; + language_graph_from_dict(&dict).map_err(LoadError::from) + } + _ => Err(LoadError::UnknownExtension), + } +} + +/// Rebuild a [`LanguageGraph`] from its serialized `to_dict` form +/// (`language_graph_to_dict`'s output), bypassing the langspec-driven +/// builder entirely - matches the Python original's +/// `language_graph_from_dict`. +pub fn language_graph_from_dict(serialized: &Value) -> Result { + let obj = serialized.as_object().ok_or_else(|| { + GraphError::Malformed("serialized language graph must be an object".into()) + })?; + + let metadata = Metadata { + version: obj + .get("metadata") + .and_then(|m| m.get("version")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + id: obj + .get("metadata") + .and_then(|m| m.get("id")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }; + + let mut graph = LanguageGraph { + assets: SlotMap::with_key(), + steps: SlotMap::with_key(), + asset_id_by_name: HashMap::new(), + asset_order: Vec::new(), + metadata, + lang_spec: Value::Null, + }; + + let asset_dicts: Vec<(&String, &Value)> = obj + .iter() + .filter(|(k, _)| k.as_str() != "metadata") + .collect(); + + // Pass 1: create asset nodes. + for (name, asset) in &asset_dicts { + let node = LanguageGraphAsset { + name: (*name).clone(), + own_associations: IndexMap::new(), + attack_steps: IndexMap::default(), + info: str_map(&asset["info"]), + own_super_asset: None, + own_sub_assets: Vec::new(), + own_variables: IndexMap::new(), + is_abstract: asset["is_abstract"].as_bool().unwrap_or(false), + }; + let id = graph.assets.insert(node); + graph.asset_id_by_name.insert((*name).clone(), id); + graph.asset_order.push(id); + } + + // Pass 2: inheritance links. + for (name, asset) in &asset_dicts { + let asset_id = graph.asset_id_by_name[*name]; + if let Some(super_name) = asset["super_asset"].as_str().filter(|s| !s.is_empty()) { + let super_id = graph + .asset_id_by_name + .get(super_name) + .copied() + .ok_or_else(|| GraphError::SuperAssetNotFound { + asset_name: (*name).clone(), + super_name: super_name.to_string(), + })?; + graph.assets[super_id].own_sub_assets.push(asset_id); + graph.assets[asset_id].own_super_asset = Some(super_id); + } + } + + // Pass 3: associations. + for (name, asset) in &asset_dicts { + let asset_id = graph.asset_id_by_name[*name]; + let _ = asset_id; + for assoc in asset["associations"] + .as_object() + .into_iter() + .flatten() + .map(|(_, v)| v) + { + let left_name = assoc["left"]["asset"].as_str().unwrap_or_default(); + let right_name = assoc["right"]["asset"].as_str().unwrap_or_default(); + let left_id = *graph.asset_id_by_name.get(left_name).ok_or_else(|| { + GraphError::Malformed(format!( + "Left asset for association \"{}\" not found", + assoc["name"].as_str().unwrap_or_default() + )) + })?; + let right_id = *graph.asset_id_by_name.get(right_name).ok_or_else(|| { + GraphError::Malformed(format!( + "Right asset for association \"{}\" not found", + assoc["name"].as_str().unwrap_or_default() + )) + })?; + + let assoc_node = Rc::new(LanguageGraphAssociation { + name: assoc["name"].as_str().unwrap_or_default().to_string(), + left_field: LanguageGraphAssociationField { + asset: left_id, + fieldname: assoc["left"]["fieldname"] + .as_str() + .unwrap_or_default() + .to_string(), + minimum: assoc["left"]["min"].as_i64().unwrap_or(0), + maximum: assoc["left"]["max"].as_i64(), + }, + right_field: LanguageGraphAssociationField { + asset: right_id, + fieldname: assoc["right"]["fieldname"] + .as_str() + .unwrap_or_default() + .to_string(), + minimum: assoc["right"]["min"].as_i64().unwrap_or(0), + maximum: assoc["right"]["max"].as_i64(), + }, + info: str_map(&assoc["info"]), + }); + + let left_fieldname = assoc_node.left_field.fieldname.clone(); + let right_fieldname = assoc_node.right_field.fieldname.clone(); + graph.assets[left_id] + .own_associations + .insert(right_fieldname, assoc_node.clone()); + graph.assets[right_id] + .own_associations + .insert(left_fieldname, assoc_node); + } + } + + // Pass 4: variables. + for (name, asset) in &asset_dicts { + let asset_id = graph.asset_id_by_name[*name]; + for (var_name, entry) in asset["variables"].as_object().into_iter().flatten() { + let target_name = entry[0].as_str().ok_or_else(|| { + GraphError::Malformed(format!("variable \"{var_name}\" missing target asset name")) + })?; + let target_id = *graph.asset_id_by_name.get(target_name).ok_or_else(|| { + GraphError::Malformed(format!("Unknown asset type \"{target_name}\"")) + })?; + let chain = parse_expr_chain(&graph, &entry[1])?; + graph.assets[asset_id] + .own_variables + .insert(var_name.clone(), (target_id, chain)); + } + } + + // Pass 5: attack step nodes (without inheritance/children/parents/requires yet). + let mut step_ids_by_full_name: HashMap = HashMap::new(); + for (name, asset) in &asset_dicts { + let asset_id = graph.asset_id_by_name[*name]; + for (step_name, step) in asset["attack_steps"].as_object().into_iter().flatten() { + let node = LanguageGraphAttackStep { + name: step_name.clone(), + step_type: AttackStepType::parse(step["type"].as_str().unwrap_or_default())?, + asset: asset_id, + causal_mode: step["causal_mode"].as_str().and_then(CausalMode::parse), + ttc: Some(step["ttc"].clone()).filter(|v| !v.is_null()), + overrides: step["overrides"].as_bool().unwrap_or(false), + own_children: IndexMap::new(), + own_parents: IndexMap::new(), + own_additive_model_effects: Vec::new(), + own_subtractive_model_effects: Vec::new(), + info: str_map(&step["info"]), + inherits: None, + own_requires: Vec::new(), + tags: step["tags"] + .as_array() + .into_iter() + .flatten() + .filter_map(|t| t.as_str().map(str::to_string)) + .collect(), + detectors: IndexMap::new(), + }; + let full_name = format!("{name}:{step_name}"); + let id = graph.steps.insert(node); + step_ids_by_full_name.insert(full_name, id); + graph.assets[asset_id] + .attack_steps + .insert(step_name.clone(), id); + } + } + + // Pass 6: inheritance for attack steps. + for (name, asset) in &asset_dicts { + for (step_name, step) in asset["attack_steps"].as_object().into_iter().flatten() { + let Some(inh) = step["inherits"].as_str() else { + continue; + }; + let step_id = step_ids_by_full_name[&format!("{name}:{step_name}")]; + let inh_id = *step_ids_by_full_name.get(inh).ok_or_else(|| { + GraphError::Malformed(format!("Unknown inherited step \"{inh}\"")) + })?; + graph.steps[step_id].inherits = Some(inh_id); + } + } + + // Pass 7: children/parents expression chains, and requirements. + for (name, asset) in &asset_dicts { + for (step_name, step) in asset["attack_steps"].as_object().into_iter().flatten() { + let step_id = step_ids_by_full_name[&format!("{name}:{step_name}")]; + + for (tgt_name, exprs) in step["own_children"].as_object().into_iter().flatten() { + let tgt_id = *step_ids_by_full_name.get(tgt_name).ok_or_else(|| { + GraphError::Malformed(format!("Unknown attack step \"{tgt_name}\"")) + })?; + let mut chains = Vec::new(); + for expr in exprs.as_array().into_iter().flatten() { + chains.push(parse_expr_chain(&graph, expr)?); + } + graph.steps[step_id] + .own_children + .entry(tgt_id) + .or_default() + .extend(chains); + } + for (tgt_name, exprs) in step["own_parents"].as_object().into_iter().flatten() { + let tgt_id = *step_ids_by_full_name.get(tgt_name).ok_or_else(|| { + GraphError::Malformed(format!("Unknown attack step \"{tgt_name}\"")) + })?; + let mut chains = Vec::new(); + for expr in exprs.as_array().into_iter().flatten() { + chains.push(parse_expr_chain(&graph, expr)?); + } + graph.steps[step_id] + .own_parents + .entry(tgt_id) + .or_default() + .extend(chains); + } + + let step_type = AttackStepType::parse(step["type"].as_str().unwrap_or_default())?; + if matches!(step_type, AttackStepType::Exist | AttackStepType::NotExist) { + if let Some(reqs) = step.get("requires").and_then(Value::as_array) { + for expr in reqs { + if let Some(chain) = parse_expr_chain(&graph, expr)? { + graph.steps[step_id].own_requires.push(chain); + } + } + } + } + } + } + + Ok(graph) +} + +fn str_map(v: &Value) -> HashMap { + v.as_object() + .into_iter() + .flatten() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() +} + +/// Port of `ExpressionsChain._from_dict`. Resolves `FIELD` associations +/// by searching every asset's own associations for a name+fieldname +/// match (mirrors `_resolve_association`, which does the same via +/// `asset.associations`). +fn parse_expr_chain( + graph: &LanguageGraph, + data: &Value, +) -> Result, GraphError> { + if data.is_null() || (data.is_object() && data.as_object().unwrap().is_empty()) { + return Ok(None); + } + let expr_type = data["type"] + .as_str() + .ok_or_else(|| GraphError::Malformed("Missing expressions chain type".into()))?; + + use super::expr_chain::ExprType; + let parsed = match expr_type { + "union" | "intersection" | "difference" | "collect" => { + let op = match expr_type { + "union" => ExprType::Union, + "intersection" => ExprType::Intersection, + "difference" => ExprType::Difference, + _ => ExprType::Collect, + }; + let payload = &data[expr_type]; + let left = parse_expr_chain(graph, &payload["left"])?; + let right = parse_expr_chain(graph, &payload["right"])?; + ExpressionsChain::Binary { + op, + left: left.map(Box::new), + right: right.map(Box::new), + } + } + "transitive" => ExpressionsChain::Transitive { + sub: Box::new( + parse_expr_chain(graph, &data["transitive"])? + .ok_or_else(|| GraphError::Malformed("TRANSITIVE requires sub_link".into()))?, + ), + }, + "subType" => { + let sub = parse_expr_chain(graph, &data["expression"])? + .ok_or_else(|| GraphError::Malformed("SUBTYPE requires sub_link".into()))?; + let subtype_name = data["subType"] + .as_str() + .ok_or_else(|| GraphError::Malformed("SUBTYPE missing subType name".into()))?; + let subtype = graph.asset_id(subtype_name).ok_or_else(|| { + GraphError::Malformed(format!("Failed to find subtype {subtype_name}")) + })?; + ExpressionsChain::SubType { + sub: Box::new(sub), + subtype, + } + } + "assoc_op" => ExpressionsChain::AssocOp { + sub: Box::new( + parse_expr_chain(graph, &data["operand"])? + .ok_or_else(|| GraphError::Malformed("ASSOC_OP requires sub_link".into()))?, + ), + }, + "field" => { + let assoc_keys: Vec<&String> = data + .as_object() + .unwrap() + .keys() + .filter(|k| k.as_str() != "type") + .collect(); + let [assoc_name] = assoc_keys[..] else { + return Err(GraphError::Malformed( + "Invalid field expression format".into(), + )); + }; + let field_data = &data[assoc_name]; + let asset_name = field_data["asset type"].as_str().ok_or_else(|| { + GraphError::Malformed("field expression missing asset type".into()) + })?; + let fieldname = field_data["fieldname"].as_str().ok_or_else(|| { + GraphError::Malformed("field expression missing fieldname".into()) + })?; + let target_asset = graph.asset_id(asset_name).ok_or_else(|| { + GraphError::Malformed(format!("Unknown asset type \"{asset_name}\"")) + })?; + let association = graph + .associations(target_asset) + .values() + .find(|a| a.name == *assoc_name && a.contains_fieldname(fieldname)) + .cloned() + .ok_or_else(|| { + GraphError::Malformed(format!( + "Failed to find association \"{assoc_name}\" with fieldname \"{fieldname}\"" + )) + })?; + ExpressionsChain::Field { + association, + fieldname: fieldname.to_string(), + } + } + other => { + return Err(GraphError::Malformed(format!( + "Unknown expressions chain type {other}" + ))) + } + }; + Ok(Some(parsed)) +} diff --git a/core/maltoolbox-language/src/graph/ids.rs b/core/maltoolbox-language/src/graph/ids.rs new file mode 100644 index 00000000..1e8f0c3a --- /dev/null +++ b/core/maltoolbox-language/src/graph/ids.rs @@ -0,0 +1,6 @@ +use slotmap::new_key_type; + +new_key_type! { + pub struct AssetId; + pub struct AttackStepId; +} diff --git a/core/maltoolbox-language/src/graph/lookup.rs b/core/maltoolbox-language/src/graph/lookup.rs new file mode 100644 index 00000000..f3636ed3 --- /dev/null +++ b/core/maltoolbox-language/src/graph/lookup.rs @@ -0,0 +1,64 @@ +//! Port of `maltoolbox/language/language_graph_lookup.py`. + +use indexmap::IndexMap; +use serde_json::Value; + +use super::GraphError; + +fn find_asset<'a>(lang_spec: &'a Value, asset_type: &str) -> Option<&'a Value> { + lang_spec["assets"] + .as_array()? + .iter() + .find(|a| a["name"].as_str() == Some(asset_type)) +} + +/// Attack step dicts for `asset_type`, keyed by step name. +/// +/// Preserves the declaration order of `attackSteps` in the language spec; +/// that order determines attack-step/node-id assignment downstream, so a +/// `HashMap` here would make graph generation nondeterministic. +pub fn get_attacks_for_asset_type(asset_type: &str, lang_spec: &Value) -> IndexMap { + let Some(asset) = find_asset(lang_spec, asset_type) else { + return IndexMap::new(); + }; + asset["attackSteps"] + .as_array() + .into_iter() + .flatten() + .filter_map(|step| { + step["name"] + .as_str() + .map(|name| (name.to_string(), step.clone())) + }) + .collect() +} + +/// Variable entry dicts (`{name, stepExpression}`) for `asset_type`. +pub fn get_variables_for_asset_type( + asset_type: &str, + lang_spec: &Value, +) -> Result, GraphError> { + let asset = find_asset(lang_spec, asset_type).ok_or_else(|| { + GraphError::Lookup(format!( + "Failed to find asset type {asset_type} in language specification when looking for variables." + )) + })?; + Ok(asset["variables"].as_array().cloned().unwrap_or_default()) +} + +/// The `stepExpression` dict for a specific variable on `asset_type`. +pub fn get_var_expr_for_asset( + asset_type: &str, + var_name: &str, + lang_spec: &Value, +) -> Result { + let vars = get_variables_for_asset_type(asset_type, lang_spec)?; + vars.iter() + .find(|v| v["name"].as_str() == Some(var_name)) + .map(|v| v["stepExpression"].clone()) + .ok_or_else(|| { + GraphError::Lookup(format!( + "Failed to find variable name \"{var_name}\" in language specification when looking for variables for \"{asset_type}\" asset." + )) + }) +} diff --git a/core/maltoolbox-language/src/graph/mod.rs b/core/maltoolbox-language/src/graph/mod.rs new file mode 100644 index 00000000..965711f4 --- /dev/null +++ b/core/maltoolbox-language/src/graph/mod.rs @@ -0,0 +1,241 @@ +//! Port of mal-toolbox's `maltoolbox/language/languagegraph.py` and +//! supporting modules: a graph representation of a compiled MAL language, +//! built from the `langspec` JSON produced by [`crate::compiler`]. + +pub mod asset; +pub mod assoc; +pub mod assoc_traversal; +pub mod attack_step; +pub mod builder; +pub mod detector; +pub mod expr_chain; +pub mod file; +pub mod ids; +pub mod lookup; +pub mod model_effect; +pub mod step_expr; + +use std::collections::{HashMap, HashSet}; + +use indexmap::IndexMap; +use serde_json::{json, Value}; +use slotmap::SlotMap; + +pub use asset::LanguageGraphAsset; +pub use assoc::{LanguageGraphAssociation, LanguageGraphAssociationField}; +pub use attack_step::LanguageGraphAttackStep; +pub use detector::{LanguageGraphContextItem, LanguageGraphDetector}; +pub use expr_chain::{ExprType, ExpressionsChain}; +pub use ids::{AssetId, AttackStepId}; + +#[derive(Debug, thiserror::Error)] +pub enum GraphError { + #[error("{0}")] + Malformed(String), + #[error("super asset \"{super_name}\" for asset \"{asset_name}\" not found")] + SuperAssetNotFound { + asset_name: String, + super_name: String, + }, + #[error("left asset \"{asset_name}\" for association \"{assoc_name}\" not found")] + AssociationAssetNotFound { + assoc_name: String, + asset_name: String, + }, + #[error("failed to find target asset for step expression:\n{0}")] + StepExpression(String), + #[error("{0}")] + Lookup(String), +} + +#[derive(Debug, Clone, Default)] +pub struct Metadata { + pub version: String, + pub id: String, +} + +pub struct LanguageGraph { + pub assets: SlotMap, + pub steps: SlotMap, + pub asset_id_by_name: HashMap, + /// Insertion order of assets; some builder passes (e.g. the + /// `_inherit_attack_steps` deferral loop) are order-sensitive. + pub asset_order: Vec, + pub metadata: Metadata, + pub lang_spec: Value, +} + +impl LanguageGraph { + pub fn asset(&self, id: AssetId) -> &LanguageGraphAsset { + &self.assets[id] + } + + pub fn step(&self, id: AttackStepId) -> &LanguageGraphAttackStep { + &self.steps[id] + } + + pub fn asset_id(&self, name: &str) -> Option { + self.asset_id_by_name.get(name).copied() + } + + pub fn is_subasset_of(&self, asset: AssetId, target: AssetId) -> bool { + let mut current = Some(asset); + while let Some(id) = current { + if id == target { + return true; + } + current = self.asset(id).own_super_asset; + } + false + } + + /// This asset plus every asset that directly or indirectly extends it. + pub fn sub_assets(&self, asset: AssetId) -> HashSet { + let mut result = HashSet::new(); + result.insert(asset); + for &child in &self.asset(asset).own_sub_assets { + result.extend(self.sub_assets(child)); + } + result + } + + /// This asset plus every asset it directly or indirectly extends, + /// closest ancestor first. + pub fn super_assets(&self, asset: AssetId) -> Vec { + let mut result = Vec::new(); + let mut current = Some(asset); + while let Some(id) = current { + result.push(id); + current = self.asset(id).own_super_asset; + } + result + } + + /// Own + inherited associations (`LanguageGraphAsset.associations`). + /// `IndexMap` preserves insertion order from `own_associations`, which + /// feeds step-expression association resolution (`step_expr.rs`) and + /// is exposed to Python callers as ordered dict-like iteration. + pub fn associations( + &self, + asset: AssetId, + ) -> IndexMap> { + let mut result: IndexMap> = IndexMap::new(); + if let Some(super_asset) = self.asset(asset).own_super_asset { + result.extend(self.associations(super_asset)); + } + for (k, v) in &self.asset(asset).own_associations { + result.insert(k.clone(), v.clone()); + } + result + } + + /// Own + inherited variables (`LanguageGraphAsset.variables`). + pub fn variables( + &self, + asset: AssetId, + ) -> IndexMap)> { + let mut result: IndexMap)> = IndexMap::new(); + if let Some(super_asset) = self.asset(asset).own_super_asset { + result.extend(self.variables(super_asset)); + } + for (k, v) in &self.asset(asset).own_variables { + result.insert(k.clone(), v.clone()); + } + result + } + + /// Associations on `asset` that also appear on `asset_type` + /// (`LanguageGraphAsset.associations_to`). + pub fn associations_to( + &self, + asset: AssetId, + asset_type: AssetId, + ) -> IndexMap> { + let target_assocs: Vec<_> = self.associations(asset_type).into_values().collect(); + self.associations(asset) + .into_iter() + .filter(|(_, assoc)| target_assocs.iter().any(|a| std::rc::Rc::ptr_eq(a, assoc))) + .collect() + } + + pub fn get_all_common_superassets(&self, a: AssetId, b: AssetId) -> HashSet { + let self_names: HashSet = self + .super_assets(a) + .iter() + .map(|id| self.asset(*id).name.clone()) + .collect(); + let other_names: HashSet = self + .super_assets(b) + .iter() + .map(|id| self.asset(*id).name.clone()) + .collect(); + self_names.intersection(&other_names).cloned().collect() + } + + /// Maps each association fieldname to the `(asset_type, attack_step_name)` + /// pairs whose children expression chains can traverse that field. + /// Computed fresh each call rather than cached, to avoid invalidation + /// bookkeeping on graph mutation. + pub fn fieldname_to_candidate_steps(&self) -> HashMap> { + let mut mapping: HashMap> = HashMap::new(); + for &asset_id in &self.asset_order { + let asset = self.asset(asset_id); + for &step_id in asset.attack_steps.values() { + let step = self.step(step_id); + let mut fieldnames: HashSet = HashSet::new(); + for chains in step.children(self).values() { + for chain in chains { + fieldnames.extend(expr_chain::chain_fieldnames(chain.as_ref())); + } + } + for fieldname in fieldnames { + mapping + .entry(fieldname) + .or_default() + .insert((asset.name.clone(), step.name.clone())); + } + } + } + mapping + } +} + +/// Build a [`LanguageGraph`] from a compiled langspec (see +/// [`crate::compiler::compile_file`]). +pub fn generate_graph(lang_spec: Value) -> Result { + let metadata = Metadata { + version: lang_spec["defines"]["version"] + .as_str() + .unwrap_or_default() + .to_string(), + id: lang_spec["defines"]["id"] + .as_str() + .unwrap_or_default() + .to_string(), + }; + + let mut graph = LanguageGraph { + assets: SlotMap::with_key(), + steps: SlotMap::with_key(), + asset_id_by_name: HashMap::new(), + asset_order: Vec::new(), + metadata, + lang_spec, + }; + + builder::generate_graph(&mut graph)?; + Ok(graph) +} + +pub fn language_graph_to_dict(graph: &LanguageGraph) -> Result { + let mut serialized = serde_json::Map::new(); + serialized.insert( + "metadata".to_string(), + json!({ "version": graph.metadata.version, "id": graph.metadata.id }), + ); + for &asset_id in &graph.asset_order { + let asset = graph.asset(asset_id); + serialized.insert(asset.name.clone(), asset.to_dict(graph)?); + } + Ok(Value::Object(serialized)) +} diff --git a/core/maltoolbox-language/src/graph/model_effect.rs b/core/maltoolbox-language/src/graph/model_effect.rs new file mode 100644 index 00000000..5c94e0de --- /dev/null +++ b/core/maltoolbox-language/src/graph/model_effect.rs @@ -0,0 +1,298 @@ +//! Port of `maltoolbox/language/language_graph_model_effect.py`. +//! +//! Represents the model-instance mutations ("dynamic sentences", +//! `append_reaches`/`remove_reaches`) a compromised attack step can +//! trigger: adding or removing assets/associations reachable via an +//! association-traversal chain. mal-toolbox only *builds* and *validates* +//! these structures; applying them to a live [`crate::Model`] is left to +//! a downstream consumer (e.g. mal-simulator). + +use serde_json::Value; + +use super::expr_chain::{ExprType, ExpressionsChain}; +use super::ids::AssetId; +use super::step_expr::{process_assoc_op_step_expression, process_step_expression}; +use super::{GraphError, LanguageGraph}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QuantityFilter { + Exact(i64), + Range(i64, i64), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AssocTraversal { + pub field_name: String, + pub asset_filter: Option, + pub quantity_filter: Option, +} + +pub fn self_traversal() -> AssocTraversal { + AssocTraversal { + field_name: "self".to_string(), + asset_filter: None, + quantity_filter: None, + } +} + +#[derive(Debug, Clone)] +pub struct GlobAssocTraversal { + pub pattern: AssocTraversalChain, + pub quantity_filter: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SetOperation { + Union, + Difference, + Intersection, +} + +#[derive(Debug, Clone)] +pub struct AssocSet { + pub set_op: SetOperation, + pub left: AssocTraversalChain, + pub right: AssocTraversalChain, + pub quantity_filter: Option, +} + +#[derive(Debug, Clone)] +pub enum AssocTraversalElem { + Traversal(AssocTraversal), + Glob(GlobAssocTraversal), + Set(AssocSet), +} + +pub type AssocTraversalChain = Vec; + +#[derive(Debug, Clone)] +pub struct DynTarget { + pub assoc_op: bool, + pub assoc_traversal: AssocTraversalChain, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelEffectType { + Additive, + Subtractive, +} + +#[derive(Debug, Clone)] +pub struct LanguageGraphModelEffect { + pub model_effect_type: ModelEffectType, + pub base: AssocTraversalChain, + pub targets: Vec, +} + +/// Port of `_parse_quantity`. A step-expression multiplicity (e.g. +/// `field:4..10`) is compiled straight from source text without +/// normalization, so `min`/`max` may still be raw strings like `"4"` +/// rather than numbers; parse both forms to mirror Python's `int(x)`. +fn value_to_i64(v: &Value) -> Result { + v.as_i64() + .or_else(|| v.as_str().and_then(|s| s.parse::().ok())) + .ok_or_else(|| GraphError::Malformed(format!("invalid quantity value: {v}"))) +} + +fn parse_quantity(quantity: &Value) -> Result, GraphError> { + let min = quantity.get("min").filter(|v| !v.is_null()); + let max = quantity.get("max").filter(|v| !v.is_null()); + match (min, max) { + (None, None) => Ok(None), + (Some(mn), Some(mx)) => Ok(Some(QuantityFilter::Range( + value_to_i64(mn)?, + value_to_i64(mx)?, + ))), + (Some(mn), None) => Ok(Some(QuantityFilter::Exact(value_to_i64(mn)?))), + (None, Some(_)) => Err(GraphError::Malformed(format!( + "Invalid quantity: {quantity}" + ))), + } +} + +/// Port of `parse_assoc_traversal`. +fn parse_assoc_traversal( + expr_chain: Option<&ExpressionsChain>, +) -> Result { + let Some(chain) = expr_chain else { + return Ok(Vec::new()); + }; + + match chain { + ExpressionsChain::Binary { op, left, right } => match op { + ExprType::Collect => { + let mut l = parse_assoc_traversal(left.as_deref())?; + let r = parse_assoc_traversal(right.as_deref())?; + l.extend(r); + Ok(l) + } + ExprType::Union | ExprType::Difference | ExprType::Intersection => { + let set_op = match op { + ExprType::Union => SetOperation::Union, + ExprType::Difference => SetOperation::Difference, + ExprType::Intersection => SetOperation::Intersection, + _ => unreachable!(), + }; + let l = parse_assoc_traversal(left.as_deref())?; + let r = parse_assoc_traversal(right.as_deref())?; + Ok(vec![AssocTraversalElem::Set(AssocSet { + set_op, + left: l, + right: r, + quantity_filter: None, + })]) + } + other => Err(GraphError::Malformed(format!( + "Unexpected expression chain type: {:?}", + other.as_str() + ))), + }, + ExpressionsChain::Field { fieldname, .. } => { + Ok(vec![AssocTraversalElem::Traversal(AssocTraversal { + field_name: fieldname.clone(), + asset_filter: None, + quantity_filter: None, + })]) + } + ExpressionsChain::Multiplicity { sub, multiplicity } => { + let mut ret = parse_assoc_traversal(Some(sub))?; + let quantity_filter = parse_quantity(multiplicity)?; + // A multiplicity qualifier applies to whichever element type + // is last in the chain, not just a plain field traversal. + match ret + .last_mut() + .ok_or_else(|| GraphError::Malformed("empty traversal chain".into()))? + { + AssocTraversalElem::Traversal(t) => t.quantity_filter = quantity_filter, + AssocTraversalElem::Glob(g) => g.quantity_filter = quantity_filter, + AssocTraversalElem::Set(s) => s.quantity_filter = quantity_filter, + } + Ok(ret) + } + ExpressionsChain::SubType { sub, subtype } => { + let mut ret = parse_assoc_traversal(Some(sub))?; + match ret.last_mut() { + Some(AssocTraversalElem::Traversal(t)) => { + t.asset_filter = Some(*subtype); + } + Some(AssocTraversalElem::Glob(g)) => match g.pattern.last_mut() { + Some(AssocTraversalElem::Traversal(t)) => { + t.asset_filter = Some(*subtype); + } + _ => { + return Err(GraphError::Malformed( + "Unexpected traversal chain structure for SUBTYPE expression".into(), + )) + } + }, + _ => { + return Err(GraphError::Malformed( + "Unexpected traversal chain structure for SUBTYPE expression".into(), + )) + } + } + Ok(ret) + } + ExpressionsChain::Transitive { sub } => { + let pattern = parse_assoc_traversal(Some(sub))?; + Ok(vec![AssocTraversalElem::Glob(GlobAssocTraversal { + pattern, + quantity_filter: None, + })]) + } + other => Err(GraphError::Malformed(format!( + "Unexpected expression chain type: {:?}", + other.expr_type() + ))), + } +} + +/// Port of `build_assoc_traversals`. +fn build_assoc_traversals( + expr_chain: Option<&ExpressionsChain>, +) -> Result { + match expr_chain { + None => Ok(vec![AssocTraversalElem::Traversal(self_traversal())]), + Some(_) => parse_assoc_traversal(expr_chain), + } +} + +/// Port of `build_model_effect`. +pub fn build_model_effect( + graph: &LanguageGraph, + target_asset: AssetId, + step_expression: &Value, + is_additive: bool, +) -> Result { + if step_expression["type"].as_str() != Some("dyn_sentence") { + return Err(GraphError::Malformed( + "Only dynamic sentences can be used to build model effects".into(), + )); + } + let model_effect_type = if is_additive { + ModelEffectType::Additive + } else { + ModelEffectType::Subtractive + }; + + let base_expr = &step_expression["base"]; + let target_exprs = step_expression["targets"] + .as_array() + .cloned() + .unwrap_or_default(); + + let (base_target_asset, base_expr_chain, base_step) = + process_step_expression(graph, target_asset, None, base_expr)?; + if base_step.is_some() { + return Err(GraphError::Malformed( + "Base can not refer to an attack step in a dynamic sentence".into(), + )); + } + let base = build_assoc_traversals(base_expr_chain.as_ref())?; + + let mut targets = Vec::new(); + for target_expr in &target_exprs { + let is_assoc_op = target_expr["type"].as_str() == Some("assoc_op"); + // For link addition, the instigating asset is the one defining the + // step; otherwise it's the asset(s) collected from the base expression. + let starting_asset = if is_assoc_op && model_effect_type == ModelEffectType::Additive { + target_asset + } else { + base_target_asset + }; + let (_, target_expr_chain, target_step) = if is_assoc_op { + process_assoc_op_step_expression(graph, starting_asset, None, target_expr)? + } else { + process_step_expression(graph, starting_asset, None, target_expr)? + }; + if target_step.is_some() { + return Err(GraphError::Malformed( + "Targets can not refer to an attack step in a dynamic sentence".into(), + )); + } + + match target_expr_chain { + Some(chain) => { + let (assoc_op, unwrapped) = match chain { + ExpressionsChain::AssocOp { sub } => (true, Some(*sub)), + other => (false, Some(other)), + }; + let assoc_traversal = build_assoc_traversals(unwrapped.as_ref())?; + targets.push(DynTarget { + assoc_op, + assoc_traversal, + }); + } + None => targets.push(DynTarget { + assoc_op: false, + assoc_traversal: vec![AssocTraversalElem::Traversal(self_traversal())], + }), + } + } + + Ok(LanguageGraphModelEffect { + model_effect_type, + base, + targets, + }) +} diff --git a/core/maltoolbox-language/src/graph/step_expr.rs b/core/maltoolbox-language/src/graph/step_expr.rs new file mode 100644 index 00000000..c394368e --- /dev/null +++ b/core/maltoolbox-language/src/graph/step_expr.rs @@ -0,0 +1,380 @@ +//! Port of `maltoolbox/language/step_expression_processor.py`. + +use serde_json::Value; + +use super::expr_chain::{ExprType, ExpressionsChain}; +use super::ids::AssetId; +use super::lookup::get_var_expr_for_asset; +use super::{GraphError, LanguageGraph}; + +/// (target asset, resulting associations chain, attack step name) - +/// mirrors Python's `StepResult` triplet. +pub type StepResult = (AssetId, Option, Option); + +pub fn process_attack_step_expression( + target_asset: AssetId, + step_expression: &Value, +) -> StepResult { + let name = step_expression["name"].as_str().map(str::to_string); + (target_asset, None, name) +} + +pub fn process_set_operation_step_expression( + graph: &LanguageGraph, + target_asset: AssetId, + expr_chain: Option<&ExpressionsChain>, + step_expression: &Value, +) -> Result { + let (lh_target_asset, lh_expr_chain, _) = + process_step_expression(graph, target_asset, expr_chain, &step_expression["lhs"])?; + let (rh_target_asset, rh_expr_chain, _) = + process_step_expression(graph, target_asset, expr_chain, &step_expression["rhs"])?; + + if graph + .get_all_common_superassets(lh_target_asset, rh_target_asset) + .is_empty() + { + return Err(GraphError::Malformed(format!( + "Set operation attempted between targets that do not share any common superassets: {} and {}!", + graph.asset(lh_target_asset).name, + graph.asset(rh_target_asset).name, + ))); + } + + let op = match step_expression["type"].as_str() { + Some("union") => ExprType::Union, + Some("intersection") => ExprType::Intersection, + Some("difference") => ExprType::Difference, + other => { + return Err(GraphError::Malformed(format!( + "unknown set operation type: {other:?}" + ))) + } + }; + + let new_chain = ExpressionsChain::Binary { + op, + left: lh_expr_chain.map(Box::new), + right: rh_expr_chain.map(Box::new), + }; + Ok((lh_target_asset, Some(new_chain), None)) +} + +pub fn process_variable_step_expression( + graph: &LanguageGraph, + target_asset: AssetId, + step_expression: &Value, +) -> Result { + let var_name = step_expression["name"] + .as_str() + .ok_or_else(|| GraphError::Malformed("variable step expression missing name".into()))?; + + let (var_target_asset, var_expr_chain) = resolve_variable(graph, target_asset, var_name)?; + + if var_expr_chain.is_none() { + return Err(GraphError::Lookup(format!( + "Failed to find variable \"{var_name}\" for {}", + graph.asset(target_asset).name + ))); + } + + Ok((var_target_asset, var_expr_chain, None)) +} + +pub fn process_field_step_expression( + graph: &LanguageGraph, + target_asset: AssetId, + step_expression: &Value, +) -> Result { + let fieldname = step_expression["name"] + .as_str() + .ok_or_else(|| GraphError::Malformed("field step expression missing name".into()))?; + + for association in graph.associations(target_asset).values() { + if association.left_field.fieldname == fieldname + && graph.is_subasset_of(target_asset, association.right_field.asset) + { + return Ok(( + association.left_field.asset, + Some(ExpressionsChain::Field { + association: association.clone(), + fieldname: fieldname.to_string(), + }), + None, + )); + } + if association.right_field.fieldname == fieldname + && graph.is_subasset_of(target_asset, association.left_field.asset) + { + return Ok(( + association.right_field.asset, + Some(ExpressionsChain::Field { + association: association.clone(), + fieldname: fieldname.to_string(), + }), + None, + )); + } + } + + if fieldname == "self" { + return Ok((target_asset, None, None)); + } + + Err(GraphError::Lookup(format!( + "Failed to find field {fieldname} on asset {}!", + graph.asset(target_asset).name + ))) +} + +pub fn process_transitive_step_expression( + graph: &LanguageGraph, + target_asset: AssetId, + expr_chain: Option<&ExpressionsChain>, + step_expression: &Value, +) -> Result { + let (result_target_asset, result_expr_chain, _) = process_step_expression( + graph, + target_asset, + expr_chain, + &step_expression["stepExpression"], + )?; + let new_chain = ExpressionsChain::Transitive { + sub: Box::new( + result_expr_chain + .ok_or_else(|| GraphError::Malformed("TRANSITIVE requires sub_link".into()))?, + ), + }; + Ok((result_target_asset, Some(new_chain), None)) +} + +pub fn process_sub_type_step_expression( + graph: &LanguageGraph, + target_asset: AssetId, + expr_chain: Option<&ExpressionsChain>, + step_expression: &Value, +) -> Result { + let subtype_name = step_expression["subType"] + .as_str() + .ok_or_else(|| GraphError::Malformed("subType step expression missing subType".into()))?; + let (result_target_asset, result_expr_chain, _) = process_step_expression( + graph, + target_asset, + expr_chain, + &step_expression["stepExpression"], + )?; + + let subtype_asset = graph + .asset_id(subtype_name) + .ok_or_else(|| GraphError::Malformed(format!("Failed to find subtype {subtype_name}")))?; + + if !graph.is_subasset_of(subtype_asset, result_target_asset) { + return Err(GraphError::Malformed(format!( + "Found subtype {subtype_name} which does not extend {}, subtype cannot be resolved.", + graph.asset(result_target_asset).name + ))); + } + + let new_chain = ExpressionsChain::SubType { + sub: Box::new( + result_expr_chain + .ok_or_else(|| GraphError::Malformed("SUBTYPE requires sub_link".into()))?, + ), + subtype: subtype_asset, + }; + Ok((subtype_asset, Some(new_chain), None)) +} + +pub fn process_multiplicity_step_expression( + graph: &LanguageGraph, + target_asset: AssetId, + expr_chain: Option<&ExpressionsChain>, + step_expression: &Value, +) -> Result { + let multiplicity = step_expression["multiplicity"].clone(); + let (result_target_asset, result_expr_chain, step_name) = process_step_expression( + graph, + target_asset, + expr_chain, + &step_expression["stepExpression"], + )?; + + if let Some(step_name) = step_name { + return Err(GraphError::Malformed(format!( + "Step `{step_name}` cannot have a multiplicity qualifier." + ))); + } + + let new_chain = ExpressionsChain::Multiplicity { + sub: Box::new( + result_expr_chain + .ok_or_else(|| GraphError::Malformed("MULTIPLICITY requires sub_link".into()))?, + ), + multiplicity, + }; + Ok((result_target_asset, Some(new_chain), None)) +} + +pub fn process_collect_step_expression( + graph: &LanguageGraph, + target_asset: AssetId, + expr_chain: Option<&ExpressionsChain>, + step_expression: &Value, +) -> Result { + let (lh_target_asset, lh_expr_chain, _) = + process_step_expression(graph, target_asset, expr_chain, &step_expression["lhs"])?; + + let (rh_target_asset, rh_expr_chain, rh_attack_step_name) = + process_step_expression(graph, lh_target_asset, None, &step_expression["rhs"])?; + + let new_expr_chain = match rh_expr_chain { + Some(rh) => Some(ExpressionsChain::Binary { + op: ExprType::Collect, + left: lh_expr_chain.map(Box::new), + right: Some(Box::new(rh)), + }), + None => lh_expr_chain, + }; + + Ok((rh_target_asset, new_expr_chain, rh_attack_step_name)) +} + +pub fn process_assoc_op_step_expression( + graph: &LanguageGraph, + target_asset: AssetId, + expr_chain: Option<&ExpressionsChain>, + step_expression: &Value, +) -> Result { + let (result_target_asset, result_expr_chain, result_step_name) = + process_step_expression(graph, target_asset, expr_chain, &step_expression["operand"])?; + + if result_step_name.is_some() { + return Err(GraphError::Malformed( + "ASSOC_OP can not have an attack step as operand".into(), + )); + } + + let new_chain = ExpressionsChain::AssocOp { + sub: Box::new( + result_expr_chain + .ok_or_else(|| GraphError::Malformed("ASSOC_OP requires sub_link".into()))?, + ), + }; + Ok((result_target_asset, Some(new_chain), None)) +} + +pub fn process_step_expression( + graph: &LanguageGraph, + target_asset: AssetId, + expr_chain: Option<&ExpressionsChain>, + step_expression: &Value, +) -> Result { + match step_expression["type"].as_str() { + Some("attackStep") => Ok(process_attack_step_expression( + target_asset, + step_expression, + )), + Some("union") | Some("intersection") | Some("difference") => { + process_set_operation_step_expression(graph, target_asset, expr_chain, step_expression) + } + Some("variable") => process_variable_step_expression(graph, target_asset, step_expression), + Some("field") => process_field_step_expression(graph, target_asset, step_expression), + Some("transitive") => { + process_transitive_step_expression(graph, target_asset, expr_chain, step_expression) + } + Some("subType") => { + process_sub_type_step_expression(graph, target_asset, expr_chain, step_expression) + } + Some("multiplicity") => { + process_multiplicity_step_expression(graph, target_asset, expr_chain, step_expression) + } + Some("collect") => { + process_collect_step_expression(graph, target_asset, expr_chain, step_expression) + } + other => Err(GraphError::Lookup(format!( + "Unknown attack step type: {other:?}" + ))), + } +} + +/// Port of `reverse_expr_chain`. The Python original threads a +/// `reverse_chain` accumulator parameter through the recursion, but it is +/// always `None` in practice, so this port drops it. +pub fn reverse_expr_chain( + expr_chain: Option<&ExpressionsChain>, +) -> Result, GraphError> { + let Some(chain) = expr_chain else { + return Ok(None); + }; + + match chain { + ExpressionsChain::Binary { op, left, right } => { + let left_reverse = reverse_expr_chain(left.as_deref())?; + let right_reverse = reverse_expr_chain(right.as_deref())?; + let new_chain = if *op == ExprType::Collect { + ExpressionsChain::Binary { + op: *op, + left: right_reverse.map(Box::new), + right: left_reverse.map(Box::new), + } + } else { + ExpressionsChain::Binary { + op: *op, + left: left_reverse.map(Box::new), + right: right_reverse.map(Box::new), + } + }; + Ok(Some(new_chain)) + } + ExpressionsChain::Transitive { sub } => { + let result = reverse_expr_chain(Some(sub))?; + Ok(Some(ExpressionsChain::Transitive { + sub: Box::new( + result.ok_or_else(|| { + GraphError::Malformed("TRANSITIVE requires sub_link".into()) + })?, + ), + })) + } + ExpressionsChain::Field { + association, + fieldname, + } => { + let opposite = association.get_opposite_fieldname(fieldname)?; + Ok(Some(ExpressionsChain::Field { + association: association.clone(), + fieldname: opposite, + })) + } + ExpressionsChain::SubType { sub, subtype } => { + let result = reverse_expr_chain(Some(sub))?; + Ok(Some(ExpressionsChain::SubType { + sub: Box::new( + result + .ok_or_else(|| GraphError::Malformed("SUBTYPE requires sub_link".into()))?, + ), + subtype: *subtype, + })) + } + other => Err(GraphError::Malformed(format!( + "Unknown assoc chain element \"{:?}\"", + other.expr_type() + ))), + } +} + +pub fn resolve_variable( + graph: &LanguageGraph, + asset: AssetId, + var_name: &str, +) -> Result<(AssetId, Option), GraphError> { + let vars = graph.variables(asset); + if let Some((target, chain)) = vars.get(var_name) { + return Ok((*target, chain.clone())); + } + + let var_expr = + get_var_expr_for_asset(&graph.asset(asset).name.clone(), var_name, &graph.lang_spec)?; + let (target_asset, expr_chain, _) = process_step_expression(graph, asset, None, &var_expr)?; + Ok((target_asset, expr_chain)) +} diff --git a/core/maltoolbox-language/src/lib.rs b/core/maltoolbox-language/src/lib.rs new file mode 100644 index 00000000..b25a272c --- /dev/null +++ b/core/maltoolbox-language/src/lib.rs @@ -0,0 +1,14 @@ +//! Core MAL language support: parsing `.mal` specs into a `LanguageGraph`. + +pub mod compiler; +pub mod graph; +pub mod parser; + +pub use compiler::{compile_file, CompileError}; +pub use graph::file::{ + from_mal_spec, from_mar_archive, language_graph_from_dict, + load_from_file as load_language_graph_from_file, save_to_file as save_language_graph_to_file, + to_mar_archive as save_language_graph_to_mar_archive, LoadError as LanguageLoadError, +}; +pub use graph::{generate_graph, language_graph_to_dict, GraphError, LanguageGraph}; +pub use parser::new_parser; diff --git a/core/maltoolbox-language/src/parser.rs b/core/maltoolbox-language/src/parser.rs new file mode 100644 index 00000000..3e6a8e5e --- /dev/null +++ b/core/maltoolbox-language/src/parser.rs @@ -0,0 +1,11 @@ +use tree_sitter::{Language, Parser}; + +/// Build a fresh tree-sitter `Parser` configured for the MAL grammar. +pub fn new_parser() -> Parser { + let language = Language::new(tree_sitter_mal::LANGUAGE); + let mut parser = Parser::new(); + parser + .set_language(&language) + .expect("tree-sitter-mal grammar failed to load"); + parser +} diff --git a/core/maltoolbox-language/tests/compiler_golden.rs b/core/maltoolbox-language/tests/compiler_golden.rs new file mode 100644 index 00000000..2f13a14e --- /dev/null +++ b/core/maltoolbox-language/tests/compiler_golden.rs @@ -0,0 +1,64 @@ +//! Diffs the Rust compiler's output against golden JSON produced by the +//! real Python mal-toolbox compiler (see tests/golden/*.json), for every +//! .mal fixture that compiles standalone. + +use maltoolbox_language::compile_file; +use std::fs; +use std::path::Path; + +fn fixtures_dir() -> &'static Path { + Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")) +} + +fn golden_dir() -> &'static Path { + Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/golden")) +} + +#[test] +fn matches_python_oracle_for_all_fixtures() { + let mut stems: Vec = fs::read_dir(golden_dir()) + .expect("golden dir should exist") + .filter_map(|entry| entry.ok()) + .filter_map(|entry| { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("json") { + path.file_stem().map(|s| s.to_string_lossy().into_owned()) + } else { + None + } + }) + .collect(); + stems.sort(); + assert!(!stems.is_empty(), "expected at least one golden fixture"); + + let mut failures = Vec::new(); + + for stem in &stems { + let mal_path = fixtures_dir().join(format!("{stem}.mal")); + let golden_path = golden_dir().join(format!("{stem}.json")); + + let expected: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&golden_path).unwrap()).unwrap(); + + match compile_file(&mal_path) { + Ok(actual) => { + if actual != expected { + failures.push(format!( + "{stem}: mismatch\n--- expected ---\n{}\n--- actual ---\n{}", + serde_json::to_string_pretty(&expected).unwrap(), + serde_json::to_string_pretty(&actual).unwrap(), + )); + } + } + Err(e) => failures.push(format!("{stem}: compile error: {e}")), + } + } + + assert!( + failures.is_empty(), + "{} of {} fixtures failed:\n\n{}", + failures.len(), + stems.len(), + failures.join("\n\n") + ); +} diff --git a/core/maltoolbox-language/tests/file_io_golden.rs b/core/maltoolbox-language/tests/file_io_golden.rs new file mode 100644 index 00000000..ebac3238 --- /dev/null +++ b/core/maltoolbox-language/tests/file_io_golden.rs @@ -0,0 +1,68 @@ +//! Exercises the file-loading paths (`from_mar_archive`, +//! `language_graph_from_dict` round-trip) against real fixtures. + +use maltoolbox_language::{ + from_mar_archive, generate_graph, language_graph_from_dict, language_graph_to_dict, +}; +use serde_json::Value; +use std::path::Path; + +fn fixture_path(name: &str) -> std::path::PathBuf { + Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")).join(name) +} + +/// Loading a real-world language (coreLang, 19 assets) from its `.mar` +/// archive must match the Python oracle's `language_graph_to_dict` +/// output exactly - a much larger, real-world cross-check than the +/// hand-picked synthetic .mal fixtures used elsewhere. +#[test] +fn mar_archive_matches_python_oracle_for_corelang() { + let graph = + from_mar_archive(fixture_path("org.mal-lang.coreLang-1.0.0.mar")).expect("load .mar"); + let actual = language_graph_to_dict(&graph).expect("to_dict"); + + let golden_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/golden_graph_mar/coreLang.json" + ); + let expected: Value = + serde_json::from_str(&std::fs::read_to_string(golden_path).unwrap()).unwrap(); + + assert_eq!(actual, expected); +} + +/// Serializing a built graph and deserializing it back via +/// `language_graph_from_dict` must reproduce the same `to_dict` output - +/// exercises the from_dict path (used by `load_from_file` for +/// already-compiled .json/.yaml language graph files) independently of +/// any Python oracle, since it's a self-consistency property. +#[test] +fn round_trips_through_to_dict_and_from_dict() { + let spec = maltoolbox_language::compile_file(Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/wiperLang.mal" + ))) + .expect("compile"); + let graph = generate_graph(spec).expect("build graph"); + let dict = language_graph_to_dict(&graph).expect("to_dict"); + + let reloaded = language_graph_from_dict(&dict).expect("from_dict"); + let reloaded_dict = language_graph_to_dict(&reloaded).expect("to_dict again"); + + assert_eq!(dict, reloaded_dict); +} + +/// Same round-trip property, over coreLang, for extra confidence on a +/// much larger and more structurally varied graph (deep inheritance, +/// many associations, detectors). +#[test] +fn round_trips_corelang_through_to_dict_and_from_dict() { + let graph = + from_mar_archive(fixture_path("org.mal-lang.coreLang-1.0.0.mar")).expect("load .mar"); + let dict = language_graph_to_dict(&graph).expect("to_dict"); + + let reloaded = language_graph_from_dict(&dict).expect("from_dict"); + let reloaded_dict = language_graph_to_dict(&reloaded).expect("to_dict again"); + + assert_eq!(dict, reloaded_dict); +} diff --git a/core/maltoolbox-language/tests/fixtures/actions_effects_lang.mal b/core/maltoolbox-language/tests/fixtures/actions_effects_lang.mal new file mode 100644 index 00000000..2cc5381c --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/actions_effects_lang.mal @@ -0,0 +1,25 @@ +// vim:ft=mal +#id: "test-actions-effects" +#version: "0.0.0" + +category Test{ + asset AssetA { + | action attack + -> b_of_A.attack + } + + asset AssetB { + | effect hack + + | action attack + -> b_of_B.attack + + | test -> b_of_B.test + } + +} + +associations { + AssetA [a] * <-- AssocAB --> * [b_of_A] AssetB + AssetB [b] * <-- AssocBB --> * [b_of_B] AssetB +} diff --git a/core/maltoolbox-language/tests/fixtures/assocChainLang.mal b/core/maltoolbox-language/tests/fixtures/assocChainLang.mal new file mode 100644 index 00000000..4c173efc --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/assocChainLang.mal @@ -0,0 +1,94 @@ +// vim:ft=mal +#id: "org.mal-lang.assocChainLang" +#version: "1.0.0" + +category LongAssocChains { + + asset A { + | access + -> reach, + b.reach, + b.c.reach, + b.c.d.reach, + b.c.d.e.reach, + b.c.d.e.f.reach, + b.c.d.e.f.g.reach, + b.c.d.e.f.g.h.reach, + b.c.d.e.f.g.h.i.reach + + | reach + -> b.reach, + b.c.reach, + b.c.d.reach, + b.c.d.e.reach, + b.c.d.e.f.reach, + b.c.d.e.f.g.reach, + b.c.d.e.f.g.h.reach, + b.c.d.e.f.g.h.i.reach + + } + asset B { + | reach + -> c.reach, + c.d.reach, + c.d.e.reach, + c.d.e.f.reach, + c.d.e.f.g.reach, + c.d.e.f.g.h.reach, + c.d.e.f.g.h.i.reach + } + asset C { + | reach + -> d.reach, + d.e.reach, + d.e.f.reach, + d.e.f.g.reach, + d.e.f.g.h.reach, + d.e.f.g.h.i.reach + } + asset D { + | reach + -> e.reach, + e.f.reach, + e.f.g.reach, + e.f.g.h.reach, + e.f.g.h.i.reach + } + asset E { + | reach + -> f.reach, + f.g.reach, + f.g.h.reach, + f.g.h.i.reach + } + asset F { + | reach + -> g.reach, + g.h.reach, + g.h.i.reach + } + asset G { + | reach + -> h.reach, + h.i.reach + } + asset H { + | reach + -> i.reach + } + asset I { + | reach + } + +} + +associations { + A [a] * <-- A2B --> * [b] B + B [b] * <-- B2C --> * [c] C + C [c] * <-- C2D --> * [d] D + D [d] * <-- D2E --> * [e] E + E [e] * <-- E2F --> * [f] F + F [f] * <-- F2G --> * [g] G + G [g] * <-- G2H --> * [h] H + H [h] * <-- H2I --> * [i] I +} diff --git a/core/maltoolbox-language/tests/fixtures/association_lang.mal b/core/maltoolbox-language/tests/fixtures/association_lang.mal new file mode 100644 index 00000000..dafdbc51 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/association_lang.mal @@ -0,0 +1,49 @@ +// vim:ft=mal +#id: "test-associations" +#version: "0.0.0" + +category Test{ + asset AssetA { + | attackC + -> c_of_A.hack + | attackB + -> b_of_A.hack + } + + asset AssetB { + | hack + + | attackC + -> c_of_B.hack + | attackB + -> b_of_B.hack + } + + asset AssetC { + | hack + + | attackC + -> c_of_C.hack + | attackB + -> b_of_C.hack + } + + asset AssetD { + | attackC + -> c_of_D.hack + | attackB + -> b_of_D.hack + } +} + +associations { + AssetA [a] * <-- AssocAC --> * [c_of_A] AssetC + AssetB [b] * <-- AssocBC --> * [c_of_B] AssetC + AssetC [c] * <-- AssocCC --> * [c_of_C] AssetC + AssetD [d] * <-- AssocDC --> * [c_of_D] AssetC + + AssetA [a] * <-- AssocAB --> * [b_of_A] AssetB + AssetB [b] * <-- AssocBB --> * [b_of_B] AssetB + AssetC [c] * <-- AssocCB --> * [b_of_C] AssetB + AssetD [d] * <-- AssocDB --> * [b_of_D] AssetB +} diff --git a/core/maltoolbox-language/tests/fixtures/attackstep_inherit.mal b/core/maltoolbox-language/tests/fixtures/attackstep_inherit.mal new file mode 100644 index 00000000..b0e8adf9 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/attackstep_inherit.mal @@ -0,0 +1,24 @@ +// vim:ft=mal +#id: "test" +#version: "0.0.0" + +category Test{ + asset AA { + | s1 + -> c_of_A.s2 + } + + asset BB extends AA { + | s1 + +> c_of_B.s2 + } + + asset CC { + | s2 + } +} + +associations { + AA [a] * <-- Assoc1 --> * [c_of_A] CC + BB [b] * <-- Assoc2 --> * [c_of_B] CC +} diff --git a/core/maltoolbox-language/tests/fixtures/attackstep_override.mal b/core/maltoolbox-language/tests/fixtures/attackstep_override.mal new file mode 100644 index 00000000..abef39e8 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/attackstep_override.mal @@ -0,0 +1,41 @@ +// vim:ft=mal +#id: "test" +#version: "0.0.0" + +category Test{ + asset EmptyParent { + | target1 + + | target2 + + | target3 + + | target4 + + | attack_step_with_child + -> target1 + } + + asset Child1 extends EmptyParent { + | attackstep + } + + asset Child2 extends Child1 { + } + + asset Child3 extends Child2 { + | attackstep + +> target1 + } + + asset Child4 extends Child3 { + } + + asset FinalChild extends Child4 { + | attackstep + +> target1, + target2, + target3, + target4 + } +} diff --git a/core/maltoolbox-language/tests/fixtures/corelang-union-common-ancestor.mar b/core/maltoolbox-language/tests/fixtures/corelang-union-common-ancestor.mar new file mode 100644 index 00000000..01c0d26b Binary files /dev/null and b/core/maltoolbox-language/tests/fixtures/corelang-union-common-ancestor.mar differ diff --git a/core/maltoolbox-language/tests/fixtures/detector_lang.mal b/core/maltoolbox-language/tests/fixtures/detector_lang.mal new file mode 100644 index 00000000..35ddd4e7 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/detector_lang.mal @@ -0,0 +1,41 @@ +// vim:ft=mal +#id: "test-actions-effects" +#version: "0.0.0" + +category System{ + + asset Computer { + & physicalAccess + -> attemptAuthenticate + + | attemptAuthenticate [HardAndCertain] + -> authenticate + + & effect authenticate + -> computerApps.attemptExploit + } + + asset Application { + # shutDown + -> exploit + + | attemptExploit [HardAndCertain] + -> exploit + + & effect exploit + ! logExploit (computerOfApp.authenticate comp) [tpr: 0.9, fpr: 0.1] + -> toApplications.attemptExploit, + dataOnApp.read + } + + asset Data { + | read + } + +} + +associations { + Computer [computerOfApp] * <-- appExecution --> * [computerApps] Application + Application [fromApplications] * <-- AppToAppCommunication --> * [toApplications] Application + Data [dataOnApp] * <-- AppData --> * [appWithData] Application +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/advanced/diceLang.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/advanced/diceLang.mal new file mode 100644 index 00000000..a62bc008 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/advanced/diceLang.mal @@ -0,0 +1,190 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "diceLang" +#version: "0.1.0" +category diceLang { + // A contrived language for checking various dice configurations. + + abstract asset Die { + | even + | odd + | all + | prime + | betterPrime + | evenPrime + | altEvenPrime + | notPrime + | noPrime + | noEven + | noOdd + | onlyPrime + } + + asset One extends Die {} + asset Two extends Die {} + asset Three extends Die {} + asset Four extends Die {} + asset Five extends Die {} + asset Six extends Die {} + + asset Dice { + let even = ((die[Two] \/ die[Four]) \/ die[Six]) + let odd = (die[One] \/ (die[Three] \/ die[Five])) + let all = (even() \/ odd()) + let primes = (all() /\ (die[Two] \/ die[Three] \/ die[Five])) + let betterPrimes = (die[Two] \/ die[Three] \/ die[Five]) + let evenPrimes = (all() /\ die[Two]) + let altEvenPrimes = (die[Two] /\ all()) + let notPrimes = (die[Four] \/ die[Six] \/ die[One]) + let noPrimes = (all() - primes()) + let noEven = (all() - even()) + let noOdd = (all() - odd()) + let onlyPrimes = (all() - primes()) + + let noOnes = (die[Two] \/ die[Three] \/ die[Four] \/ die[Five] \/ die[Six]) + let noTwos = (die[One] \/ die[Three] \/ die[Four] \/ die[Five] \/ die[Six]) + let noThrees = (die[Two] \/ die[One] \/ die[Four] \/ die[Five] \/ die[Six]) + let noFours = (die[Two] \/ die[Three] \/ die[One] \/ die[Five] \/ die[Six]) + let noFives = (die[Two] \/ die[Three] \/ die[Four] \/ die[One] \/ die[Six]) + let noSixes = (die[Two] \/ die[Three] \/ die[Four] \/ die[Five] \/ die[One]) + + | startingPoint + modeler info: "Start your agents here." + -> allOnes, + ((die[Two] \/ die[Four]) \/ die[Six]).even, // Testing syntax + (die[One] \/ (die[Three] \/ die[Five])).odd, // Testing syntax + allTwos, + allThrees, + allFours, + allFives, + allSixes, + oneExists, + twoExists, + threeExists, + fourExists, + fiveExists, + sixExists, + even().even, + odd().odd, + all().all, + primes().prime, + betterPrimes().betterPrime, + evenPrimes().evenPrime, + altEvenPrimes().altEvenPrime, + notPrimes().notPrime, + noPrimes().noPrime, + noEven().noEven, + noOdd().noOdd, + onlyPrimes().onlyPrime + + + & allOnes + -> yahtzee + + & allTwos + -> yahtzee + + & allThrees + -> yahtzee + + & allFours + -> yahtzee + + & allFives + -> yahtzee + + & allSixes + -> yahtzee + + | yahtzee + user info: "All show the same number, usually six dice." + + & oneExists + -> simpleSequence, + oneTwoThree, + allOnes + + & twoExists + -> simpleSequence, + oneTwoThree, + allTwos + + & threeExists + -> simpleSequence, + oneTwoThree, + allThrees + + & fourExists + -> simpleSequence, + allFours + + & fiveExists + -> simpleSequence, + allFives + + & sixExists + -> simpleSequence, + allSixes + + & oneTwoThree + & simpleSequence + + E ifOneExists + <- die[One] + -> oneExists + + E ifTwoExists + <- die[Two] + -> twoExists + + E ifThreeExists + <- die[Three] + -> threeExists + + E ifFourExists + <- die[Four] + -> fourExists + + E ifFiveExists + <- die[Five] + -> fiveExists + + E ifSixExists + <- die[Six] + -> sixExists + + !E oneComplement + <- noOnes() + -> allOnes + + !E twoComplement + <- noTwos() + -> allTwos + + !E threeComplement + <- noThrees() + -> allThrees + + !E fourComplement + <- noFours() + -> allFours + + !E fiveComplement + <- noFives() + -> allFives + + !E sixComplement + <- noSixes() + -> allSixes + + } + +} + +associations{ + Die [die] * <-- collection --> 1 [collection] Dice +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang1.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang1.mal new file mode 100644 index 00000000..6853af9c --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang1.mal @@ -0,0 +1,25 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "baseDynamicTestLang1" +#version: "0.1.0" +category baseDynamicTestLang1 { + asset Bowl { + | access + -> addApple + + | addApple + A> self / apples + } + + asset Apple {} + +} + +associations { + Bowl [container] 1 <-- contains --> * [apples] Apple + +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang10.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang10.mal new file mode 100644 index 00000000..93fd1165 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang10.mal @@ -0,0 +1,29 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "baseDynamicTestLang10" +#version: "0.0.1" + + +category Business { + asset Plantation { + | access + -> pilferBananas + + | pilferBananas + developer info: "Burn bridge, eat bananas." + R> trees / ~plant ^ bananas + + } + + asset BananaTree{} + asset Banana {} +} + +associations { + Plantation [plant] 1 <-- Cultivate --> * [trees] BananaTree + BananaTree [tree] 1 <-- Contains --> * [bananas] Banana +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang2.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang2.mal new file mode 100644 index 00000000..a9936804 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang2.mal @@ -0,0 +1,24 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "baseDynamicTestLang2" +#version: "0.1.0" +category baseDynamicTestLang2 { + asset Bowl { + | access + -> eatApple + + | eatApple + R> self / apples + } + + asset Apple {} + +} + +associations { + Bowl [container] 1 <-- contains --> * [apples] Apple +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang3.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang3.mal new file mode 100644 index 00000000..fc832b40 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang3.mal @@ -0,0 +1,28 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "baseDynamicTestLang3" +#version: "0.1.0" +category baseDynamicTestLang3 { + asset Bowl { + | access + -> addApple, removeApple + + | addApple + A> self / apples + + | removeApple + R> self / apples + } + + asset Apple {} + +} + +associations { + Bowl [container] 1 <-- contains --> * [apples] Apple + +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang4.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang4.mal new file mode 100644 index 00000000..633e2d45 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang4.mal @@ -0,0 +1,26 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "baseDynamicTestLang4" +#version: "0.1.0" +category baseDynamicTestLang4 { + asset Bowl { + | access + -> tamper + + | tamper + A> self / apples + R> self / apples + } + + asset Apple {} + +} + +associations { + Bowl [container] 1 <-- contains --> * [apples] Apple + +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang5.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang5.mal new file mode 100644 index 00000000..6268b30f --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang5.mal @@ -0,0 +1,29 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "baseDynamicTestLang5" +#version: "0.0.1" + +category Business { + asset Monkey {} + asset Typewriter {} + + asset Storage { + | access + -> giveTypewriter + + | giveTypewriter + user info: "With enough monkeys and typewriters, we can create the complete works of Shakespeare." + A> monkeys / ~typewriters.writer + } +} + +associations { + Storage [storage] 1 <-- contains --> * [typewriters] Typewriter + Storage [storage] * <-- provision --> * [monkeys] Monkey + Monkey [writer] * <-- instrument --> * [instrument] Typewriter + +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang6.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang6.mal new file mode 100644 index 00000000..e2748c40 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang6.mal @@ -0,0 +1,27 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "baseDynamicTestLang6" +#version: "0.0.1" + +category Business { + asset Monkey {} + + asset Typewriter { + | access + -> hideTypewriter + + | hideTypewriter + user info: "Monkeys without typewriters cannot work." + developer info: "Only removes the association. The typewriter still exists in the model." + R> self / ~writer + } +} + +associations { + Typewriter [typewriter] 0..1 <-- operatedBy --> 1 [writer] Monkey + +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang7.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang7.mal new file mode 100644 index 00000000..03d6b068 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang7.mal @@ -0,0 +1,34 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "baseDynamicTestLang7" +#version: "0.0.1" + + +category Business { + asset Plantation { + | access + -> plantTree + + | plantTree + A> self / trees + + } + + asset MagicPlantation extends Plantation + { + | plantTree + +A> trees / bananas + } + + asset BananaTree{} + asset Banana {} +} + +associations { + Plantation [plant] 1 <-- cultivate --> * [trees] BananaTree + BananaTree [tree] 1 <-- contains --> * [bananas] Banana +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang8.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang8.mal new file mode 100644 index 00000000..43281859 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang8.mal @@ -0,0 +1,34 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "baseDynamicTestLang8" +#version: "0.0.1" + + +category Business { + asset Plantation { + | access + -> pilferBananas + + | pilferBananas + R> trees / bananas + + } + + asset PlantationWithSmallTrees extends Plantation + { + | pilferBananas + +R> trees / self + } + + asset BananaTree{} + asset Banana {} +} + +associations { + Plantation [plant] 1 <-- Cultivate --> * [trees] BananaTree + BananaTree [tree] 1 <-- Contains --> * [bananas] Banana +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang9.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang9.mal new file mode 100644 index 00000000..042c7ca9 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/baseDynamicTestLang9.mal @@ -0,0 +1,28 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "baseDynamicTestLang9" +#version: "0.0.1" + + +category Business { + asset Plantation { + | access + -> plantTree + + | plantTree + A> self / trees ^ trees.bananas + + } + + asset BananaTree{} + asset Banana {} +} + +associations { + Plantation [plant] 1 <-- cultivate --> * [trees] BananaTree + BananaTree [tree] 1 <-- contains --> * [bananas] Banana +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/doorProblemLang.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/doorProblemLang.mal new file mode 100644 index 00000000..97a691fe --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/doorProblemLang.mal @@ -0,0 +1,24 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "doorProblemLang" +#version: "0.1.0" +category doorProblemLang { + asset Room { + | leave + -> entrance.leave // Leave the room + R> self / ~entrance // Remove link between two rooms, i.e., close the door. + + # deathLasers + -> leave // Prevent attackers in rooms. + + } + +} + +associations { + Room [exit] * <-- Connection --> * [entrance] Room +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/easyRansomwareLang.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/easyRansomwareLang.mal new file mode 100644 index 00000000..cccc3601 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/easyRansomwareLang.mal @@ -0,0 +1,33 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "easyRansomwareLang" +#version: "0.1.0" +category easyRansomwareLang { + asset Data {} + + asset LockedData extends Data {} + + asset Host { + | connect + -> ransomware.attack + } + + asset Ransomware { + | attack + A> host / data.locked, + host / ~host.data.locked.victim + R> host / data + } + +} + +associations { + Host [host] * <-- containment --> * [data] Data + Host [host] 1 <-- infection --> * [ransomware] Ransomware + Data [plain] 1 <-- encryption --> 0..1 [locked] LockedData + Host [victim] 1 <-- victim --> * [infected] LockedData +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/glider.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/glider.mal new file mode 100644 index 00000000..22e3758c --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/glider.mal @@ -0,0 +1,21 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "glider" +#version: "0.1.0" +category glider { + asset Glider { + | glide + A> self / next + R> self / previous + -> next.glide + } + +} + +associations { + Glider [previous] 0..1 <-- link --> 0..1 [next] Glider +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/looper.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/looper.mal new file mode 100644 index 00000000..1b8057c4 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/looper.mal @@ -0,0 +1,20 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "looper" +#version: "0.1.0" +category looper { + asset Looper { + | loop + A> self / next + -> next.loop + } + +} + +associations { + Looper [previous] 0..1 <-- link --> 0..1 [next] Looper +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/posterLang.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/posterLang.mal new file mode 100644 index 00000000..cd80f9c3 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/basic/posterLang.mal @@ -0,0 +1,28 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "posterLang" +#version: "0.1.0" +category posterLang { + asset Host { + | infect // Start by infecting a host. + A> self / malware // Add the data wiping malware. + -> malware.execute // Prepare to execute the malware. + } + asset WiperMalware { + | execute // Awaken the malware. + -> victim.sendTo.infect // Propagate then... + R> victim / data // ... wipe system data. + } + asset Data {} + +} + +associations { + Host [receiveFrom] * <-- communicate --> * [sendTo] Host + Host [host] 1 <-- contains --> * [data] Data + Host [victim] 1 <-- infects --> * [malware] WiperMalware +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang1.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang1.mal new file mode 100644 index 00000000..b0436147 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang1.mal @@ -0,0 +1,32 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "intDynamicTestLang1" +#version: "0.0.1" + +category Business { + asset Apples { + | access + -> plantBadApple + + | plantBadApple + A> self / badApple + -> badApple.apples.spoil + + | spoil + } + + asset BadApple + user info: "One bad apple spoils the bunch!" + { + + } +} + +associations { + Apples [apples] 1 <-- spoiledBy --> 0..1 [badApple] BadApple + +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang10.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang10.mal new file mode 100644 index 00000000..cad226a6 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang10.mal @@ -0,0 +1,24 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "intDynamicTestLang10" +#version: "0.1.0" +category intDynamicTestLang10 { + asset Table { + | delegateFruit + A> bowls / apples, + bowls.apples / ~bowls.apples + R> bowls / ~apples + } + asset Bowl {} + asset Apple {} + +} + +associations { + Bowl [bowls] * <-- placement --> 1 [table] Table + Bowl [container] * <-- contains --> * [apples] Apple +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang11.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang11.mal new file mode 100644 index 00000000..5b665f49 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang11.mal @@ -0,0 +1,58 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "intDynamicTestLang11" +#version: "0.1.0" +category intDynamicTestLang11 { + asset Table { + | access + -> testLeftUnionRemove, + testRightUnionRemove, + testDoubleUnionRemove, + testLeftUnionAdd, + testRightUnionAdd, + testDoubleUnionAdd + + | testLeftUnionRemove + R> (bowls \/ baskets) / apples + + | testRightUnionRemove + R> bowls / (apples \/ oranges) + + | testDoubleUnionRemove + R> (bowls \/ baskets) / (apples \/ oranges) + + | testLeftUnionAdd + A> (bowls \/ baskets) / apples + + | testRightUnionAdd + A> bowls / (apples \/ oranges) + + | testDoubleUnionAdd + A> (bowls \/ baskets) / (apples \/ oranges) + } + + abstract asset Container {} + + abstract asset Fruit {} + + asset Bowl extends Container {} + + asset Basket extends Container {} + + asset Apple extends Fruit {} + + asset Orange extends Fruit {} + + +} + +associations{ + Table [table] 1 <-- BowlPlacement --> * [bowls] Bowl + Table [table] 1 <-- BasketPlacement --> * [baskets] Basket + Container [container] 1 <-- AppleContain --> * [apples] Apple + Container [container] 1 <-- OrangeContain --> * [oranges] Orange +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang12.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang12.mal new file mode 100644 index 00000000..55c2d37c --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang12.mal @@ -0,0 +1,425 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "intDynamicTestLang12" +#version: "0.1.0" +category intDynamicTestLang12 { + asset Table { + | access + -> testNodeAdditions, + testNodeRemovals, + testLinkAdditions, + testLinkRemovals, + testCaretOperations, + testEmptySetCases + + | testNodeAdditions + -> testAddLeftUnionTwo, + testAddLeftUnionThree, + testLeftUnionAddFour, + testLeftUnionAddThreeLeftNested, + testLeftUnionAddFourLeftNested, + testLeftUnionAddThreeRightNested, + testLeftUnionAddFourRightNested, + testRightUnionAddTwo, + testRightUnionAddThree, + testRightUnionAddFour, + testRightUnionAddThreeLeftNested, + testRightUnionAddFourLeftNested, + testDoubleUnionAddTwo, + testDoubleUnionAddThree, + testDoubleUnionAddFour + + | testLinkAdditions + -> testLinkLeftUnionTwo, + testLinkLeftUnionThree, + testLinkLeftUnionFour, + testLinkLeftUnionThreeLeftNested, + testLinkLeftUnionFourLeftNested, + testLinkRightUnionTwo, + testLinkRightUnionThree, + testLinkRightUnionFour, + testLinkRightUnionThreeLeftNested, + testLinkRightUnionFourLeftNested, + testLinkRightUnionThreeRightNested, + testLinkRightUnionFourRightNested, + testLinkDoubleUnionTwo, + testLinkDoubleUnionThree, + testLinkDoubleUnionFour + + | testNodeRemovals + -> testLeftUnionRemoveTwo, + testLeftUnionRemoveTwoSelf, + testLeftUnionRemoveThree, + testLeftUnionRemoveFour, + testLeftUnionRemoveThreeLeftNested, + testLeftUnionRemoveFourLeftNested, + testLeftUnionRemoveThreeRightNested, + testLeftUnionRemoveFourRightNested, + testRightUnionRemoveTwo, + testRightUnionRemoveThree, + testRightUnionRemoveFour, + testRightUnionRemoveThreeLeftNested, + testRightUnionRemoveFourLeftNested, + testDoubleUnionRemoveTwo, + testDoubleUnionRemoveThree, + testDoubleUnionRemoveFour + + | testLinkRemovals + -> testUnlinkLeftUnionRemoveTwo, + testUnlinkLeftUnionRemoveThree, + testUnlinkLeftUnionRemoveFour, + testUnlinkLeftUnionRemoveThreeLeftNested, + testUnlinkLeftUnionRemoveFourLeftNested, + testUnlinkLeftUnionRemoveThreeRightNested, + testUnlinkLeftUnionRemoveFourRightNested, + testUnlinkRightUnionRemoveTwo, + testUnlinkRightUnionRemoveThree, + testUnlinkRightUnionRemoveFour, + testUnlinkRightUnionRemoveThreeLeftNested, + testUnlinkRightUnionRemoveFourLeftNested, + testUnlinkDoubleUnionRemoveTwo, + testUnlinkDoubleUnionRemoveThree, + testUnlinkDoubleUnionRemoveFour + + | testCaretOperations + -> testAddLeftUnionAddWithCaret, + testAddRightUnionAddWithCaret, + testAddDoubleUnionWithCaret, + testAddDoubleUnionNestedWithCaret, + testAddWithBackAndForthInCaret, + testAddToTwoOthersFromBowlInCaret, + testRemoveLeftUnionRemoveWithCaret, + testRemoveRightUnionAddWithCaret, + testRemoveDoubleUnionWithCaret, + testRemoveDoubleUnionNestedWithCaret, + testRemoveWithBackAndForthInCaret, + testRemoveFromTwoOthersFromBowlInCaret + + | testEmptySetCases + -> testAddEmptyBaseIntersectOne, + testAddEmptyBaseIntersectTwo, + testAddEmptyBaseDifferenceOne, + testAddEmptyBaseDifferenceTwo, + testAddEmptyTarget, + testAddEmptyTargetInCaretOne, + testAddEmptyTargetInCaretTwo, + testRemoveEmptyBaseIntersectOne, + testRemoveEmptyBaseIntersectTwo, + testRemoveEmptyBaseDifferenceOne, + testRemoveEmptyBaseDifferenceTwo, + testRemoveEmptyTargetInCaretOne, + testRemoveEmptyTargetInCaretTwo + + + // Add with union combinations + | testAddLeftUnionTwo + A> (bowls \/ baskets) / apples + + | testAddLeftUnionThree + A> (bowls \/ baskets \/ plates) / apples + + | testLeftUnionAddFour + A> (bowls \/ baskets \/ plates \/ buckets) / apples + + | testLeftUnionAddThreeLeftNested + A> ((bowls \/ baskets) \/ plates) / apples + + | testLeftUnionAddFourLeftNested + A> (((bowls \/ baskets) \/ plates) \/ buckets) / apples + + | testLeftUnionAddThreeRightNested + A> (bowls \/ (baskets \/ plates)) / apples + + | testLeftUnionAddFourRightNested + A> (bowls \/ (baskets \/ (plates \/ buckets))) / apples + + | testRightUnionAddTwo + A> bowls / (apples \/ oranges) + + | testRightUnionAddThree + A> bowls / (apples \/ oranges \/ bananas) + + | testRightUnionAddFour + A> bowls / (apples \/ oranges \/ bananas \/ durians) + + | testRightUnionAddThreeLeftNested + A> bowls / ((apples \/ oranges) \/ bananas) + + | testRightUnionAddFourLeftNested + A> bowls / (((apples \/ oranges) \/ bananas) \/ durians) + + | testDoubleUnionAddTwo + A> (bowls \/ baskets) / (apples \/ oranges) + + | testDoubleUnionAddThree + A> (bowls \/ baskets \/ plates) / (apples \/ oranges \/ bananas) + + | testDoubleUnionAddFour + A> (bowls \/ baskets \/ plates \/ buckets) / (apples \/ oranges \/ bananas \/ durians) + + + + // Link add with union combinations + | testLinkLeftUnionTwo + A> (bowls \/ baskets) / ~plates.apples.container + + | testLinkLeftUnionThree + A> (bowls \/ baskets \/ plates) / ~buckets.apples.container + + | testLinkLeftUnionFour + A> (bowls \/ baskets \/ plates \/ buckets) / ~bowls.apples.container + + | testLinkLeftUnionThreeLeftNested + A> ((bowls \/ baskets) \/ plates) / ~buckets.apples.container + + | testLinkLeftUnionFourLeftNested + A> (((bowls \/ baskets) \/ plates) \/ buckets) / ~bowls.apples.container + + | testLinkRightUnionTwo + A> bowls / ~((bowls \/ baskets).apples).container + + | testLinkRightUnionThree + A> bowls / ~((bowls \/ baskets \/ plates).apples).container + + | testLinkRightUnionFour + A> bowls / ~((bowls \/ baskets \/ plates \/ buckets).apples).container + + | testLinkRightUnionThreeLeftNested + A> bowls / ~(((bowls \/ baskets) \/ plates).apples).container + + | testLinkRightUnionFourLeftNested + A> bowls / ~((((bowls \/ baskets) \/ plates) \/ buckets).apples).container + + | testLinkRightUnionThreeRightNested + A> bowls / ~(bowls \/ (baskets \/ plates)).apples.container + + | testLinkRightUnionFourRightNested + A> bowls / ~(bowls \/ (baskets \/ (plates \/ buckets))).apples.container + + | testLinkDoubleUnionTwo + A> (bowls \/ baskets) / ~(bowls \/ baskets).apples.container + + | testLinkDoubleUnionThree + A> (bowls \/ baskets \/ plates) / ~(bowls \/ baskets \/ plates).apples.container + + | testLinkDoubleUnionFour + A> (bowls \/ baskets \/ plates \/ buckets) / ~(bowls \/ baskets \/ plates \/ buckets).apples.container + + + + // Remove with union combinations + | testLeftUnionRemoveTwo + R> (bowls \/ baskets) / apples + + | testLeftUnionRemoveTwoSelf + R> (bowls \/ baskets).apples / self + + | testLeftUnionRemoveThree + R> (bowls \/ baskets \/ plates) / apples + + | testLeftUnionRemoveFour + R> (bowls \/ baskets \/ plates \/ buckets) / apples + + | testLeftUnionRemoveThreeLeftNested + R> ((bowls \/ baskets) \/ plates) / apples + + | testLeftUnionRemoveFourLeftNested + R> (((bowls \/ baskets) \/ plates) \/ buckets) / apples + + | testLeftUnionRemoveThreeRightNested + R> (bowls \/ (baskets \/ plates)) / apples + + | testLeftUnionRemoveFourRightNested + R> (bowls \/ (baskets \/ (plates \/ buckets))) / apples + + | testRightUnionRemoveTwo + R> bowls / (apples \/ oranges) + + | testRightUnionRemoveThree + R> bowls / (apples \/ oranges \/ bananas) + + | testRightUnionRemoveFour + R> bowls / (apples \/ oranges \/ bananas \/ durians) + + | testRightUnionRemoveThreeLeftNested + R> bowls / ((apples \/ oranges) \/ bananas) + + | testRightUnionRemoveFourLeftNested + R> bowls / (((apples \/ oranges) \/ bananas) \/ durians) + + | testDoubleUnionRemoveTwo + R> (bowls \/ baskets) / (apples \/ oranges) + + | testDoubleUnionRemoveThree + R> (bowls \/ baskets \/ plates) / (apples \/ oranges \/ bananas) + + | testDoubleUnionRemoveFour + R> (bowls \/ baskets \/ plates \/ buckets) / (apples \/ oranges \/ bananas \/ durians) + + + + // Link remove with union combinations + | testUnlinkLeftUnionRemoveTwo + R> (bowls \/ baskets) / ~apples + + | testUnlinkLeftUnionRemoveThree + R> (bowls \/ baskets \/ plates) / ~apples + + | testUnlinkLeftUnionRemoveFour + R> (bowls \/ baskets \/ plates \/ buckets) / ~apples + + | testUnlinkLeftUnionRemoveThreeLeftNested + R> ((bowls \/ baskets) \/ plates) / ~apples + + | testUnlinkLeftUnionRemoveFourLeftNested + R> (((bowls \/ baskets) \/ plates) \/ buckets) / ~apples + + | testUnlinkLeftUnionRemoveThreeRightNested + R> (bowls \/ (baskets \/ plates)) / ~apples + + | testUnlinkLeftUnionRemoveFourRightNested + R> (bowls \/ (baskets \/ (plates \/ buckets))) / ~apples + + | testUnlinkRightUnionRemoveTwo + R> bowls / ~(apples \/ oranges) + + | testUnlinkRightUnionRemoveThree + R> bowls / ~(apples \/ oranges \/ bananas) + + | testUnlinkRightUnionRemoveFour + R> bowls / ~(apples \/ oranges \/ bananas \/ durians) + + | testUnlinkRightUnionRemoveThreeLeftNested + R> bowls / ~((apples \/ oranges) \/ bananas) + + | testUnlinkRightUnionRemoveFourLeftNested + R> bowls / ~(((apples \/ oranges) \/ bananas) \/ durians) + + | testUnlinkDoubleUnionRemoveTwo + R> (bowls \/ baskets) / ~(apples \/ oranges) + + | testUnlinkDoubleUnionRemoveThree + R> (bowls \/ baskets \/ plates) / ~(apples \/ oranges \/ bananas) + + | testUnlinkDoubleUnionRemoveFour + R> (bowls \/ baskets \/ plates \/ buckets) / ~(apples \/ oranges \/ bananas \/ durians) + + + + // Tests with caret + | testAddLeftUnionAddWithCaret + A> bowls / (apples \/ oranges) ^ bananas + + | testAddRightUnionAddWithCaret + A> bowls / apples ^ (oranges \/ bananas) + + | testAddDoubleUnionWithCaret + A> bowls / (apples \/ oranges) ^ (bananas \/ durians) + + | testAddDoubleUnionNestedWithCaret + A> bowls / ((apples \/ oranges) \/ bananas) ^ (oranges \/ (bananas \/ durians)) + + | testAddWithBackAndForthInCaret + A> bowls / table.bowls.apples ^ table.bowls.oranges + + | testAddToTwoOthersFromBowlInCaret + A> bowls / (table.baskets \/ table.plates).apples ^ (table.baskets \/ table.plates).bananas + + | testRemoveLeftUnionRemoveWithCaret + R> bowls / (apples \/ oranges) ^ bananas + + | testRemoveRightUnionAddWithCaret + R> bowls / apples ^ (oranges \/ bananas) + + | testRemoveDoubleUnionWithCaret + R> bowls / (apples \/ oranges) ^ (bananas \/ durians) + + | testRemoveDoubleUnionNestedWithCaret + R> bowls / ((apples \/ oranges) \/ bananas) ^ (oranges \/ (bananas \/ durians)) + + | testRemoveWithBackAndForthInCaret + R> bowls / table.bowls.apples ^ table.bowls.oranges + + | testRemoveFromTwoOthersFromBowlInCaret + R> bowls / (table.baskets \/ table.plates).apples ^ (table.baskets \/ table.plates).bananas + + + // Empty set cases + | testAddEmptyBaseIntersectOne + A> (bowls /\ baskets) / apples + + | testAddEmptyBaseIntersectTwo + A> (bowls \/ baskets) /\ (plates \/ buckets) / apples + + | testAddEmptyBaseDifferenceOne + A> (bowls - bowls) / apples + + | testAddEmptyBaseDifferenceTwo + A> (bowls \/ baskets) - (bowls \/ baskets) / apples + + | testAddEmptyTarget + A> bowls / (apples /\ oranges) + + | testAddEmptyTargetInCaretOne + A> bowls / (apples /\ oranges) ^ apples + + | testAddEmptyTargetInCaretTwo + A> bowls / apples ^ (apples /\ oranges) + + + | testRemoveEmptyBaseIntersectOne + R> (bowls /\ baskets) / apples + + | testRemoveEmptyBaseIntersectTwo + R> (bowls \/ baskets) /\ (plates \/ buckets) / apples + + | testRemoveEmptyBaseDifferenceOne + R> (bowls - bowls) / apples + + | testRemoveEmptyBaseDifferenceTwo + R> (bowls \/ baskets) - (bowls \/ baskets) / apples + + | testRemoveEmptyTargetInCaretOne + R> bowls / (apples /\ oranges) ^ apples + + | testRemoveEmptyTargetInCaretTwo + R> bowls / apples ^ (apples /\ oranges) + } + + abstract asset Container {} + + abstract asset Fruit {} + + asset Bowl extends Container {} + + asset Basket extends Container {} + + asset Plate extends Container {} + + asset Bucket extends Container {} + + asset Apple extends Fruit {} + + asset Orange extends Fruit {} + + asset Banana extends Fruit {} + + asset Durian extends Fruit {} + + +} + +associations{ + Table [table] 1 <-- BowlPlacement --> * [bowls] Bowl + Table [table] 1 <-- BasketPlacement --> * [baskets] Basket + Table [table] 1 <-- PlatePlacement --> * [plates] Plate + Table [table] 1 <-- BucketPlacement --> * [buckets] Bucket + Container [container] 1 <-- AppleContain --> * [apples] Apple + Container [container] 1 <-- OrangeContain --> * [oranges] Orange + Container [container] 1 <-- BananaContain --> * [bananas] Banana + Container [container] 1 <-- DurianContain --> * [durians] Durian +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang13.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang13.mal new file mode 100644 index 00000000..c3f80b5f --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang13.mal @@ -0,0 +1,256 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "intDynamicTestLang13" +#version: "0.1.0" +category intDynamicTestLang13 { + asset Directory { + // Use at own risk + | testAll + -> moveTopToBottom, + moveTopToNothing, + moveTopToAll, + addTopToBottomLeft, + removeTopToBottomLeft, + addTopToBottomRight, + removeTopToBottomRight, + moveBackAndForth, + moveMiddleToBottom, + moveTopThenBottom, + moveMiddleToAll, + moveMiddleToAllAlternate, + addMiddleToTopLeft, + addMiddleToBottomLeft, + addMiddleToTopRight, + addMiddleToBottomRight, + removeMiddleToTopLeft, + removeMiddleToBottomLeft, + removeMiddleToTopRight, + removeMiddleToBottomRight, + addMiddleToAll, + removeMiddleToAll, + moveBottomToTop, + moveBottomToAll, + moveBottomToNothing, + addBottomToNothing, + addBottomToTopLeft, + addBottomToTopRight, + addBottomToAll, + removeBottomToNothingLeft, + removeBottomToNothingRight, + removeBottomToTopLeft, + removeBottomToTopRight, + removeBottomToAll + + + // Use on top directories + | testTop + -> moveTopToBottom, + moveTopToNothing, + moveTopToAll, + addTopToBottomLeft, + removeTopToBottomLeft, + addTopToBottomRight, + removeTopToBottomRight, + moveBackAndForth + + + // Use on middle directories + | testMiddle + -> addMiddleToTopLeft, + addMiddleToBottomLeft, + addMiddleToTopRight, + addMiddleToBottomRight, + removeMiddleToTopLeft, + removeMiddleToBottomLeft, + removeMiddleToTopRight, + removeMiddleToBottomRight, + addMiddleToAll, + removeMiddleToAll + + + // Use on bottom directories + | testBottom + -> moveBottomToTop, + moveTopThenBottom, + moveBottomToAll, + moveBottomToNothing, + addBottomToNothing, + addBottomToTopLeft, + addBottomToTopRight, + addBottomToAll, + removeBottomToNothingLeft, + removeBottomToNothingRight, + removeBottomToTopLeft, + removeBottomToTopRight, + removeBottomToAll + + + + //********************************************************** + + // Tests from the top + + // {middle, bottom} -> reached + | moveTopToBottom + -> sub*.files.reached + + // Empty set + | moveTopToNothing + -> super*.files.reached + + // Joins {Top} and {middle, bottom} + | moveTopToAll + -> (files \/ sub*.files).reached + + // Add from {middle, bottom}, LH + | addTopToBottomLeft + A> sub* / files + + // Remove from {middle, bottom}, LH + | removeTopToBottomLeft + R> sub*.files / self + + // Add to {middle, bottom}, RH + | addTopToBottomRight + A> self / sub*.files + + // Remove from {middle, bottom}, RH + | removeTopToBottomRight + R> self / sub*.files + + // {middle, bottom} -> {middle, top} -> {middle, bottom} -> reached + | moveBackAndForth + -> sub*.super*.sub*.files.reached + + + // Tests from the middle + + // {bottom} -> reached + | moveMiddleToBottom + -> sub*.files.reached + + + + // Joins {middle}, {top}, and {bottom} + | moveMiddleToAll + -> (files \/ super*.files \/ sub*.files).reached + + // Joins {top} and {middle, bottom} + | moveMiddleToAllAlternate + -> (super* \/ super*.sub*).files.reached + + // {top} + | addMiddleToTopLeft + A> super* / files + + // {bottom} + | addMiddleToBottomLeft + A> sub* / files + + // {top} + | addMiddleToTopRight + A> self / super*.files + + // {bottom} + | addMiddleToBottomRight + A> self / super*.files + + // {top} + | removeMiddleToTopLeft + R> super*.files / self + + // {bottom} + | removeMiddleToBottomLeft + R> sub*.files / self + + // {top} + | removeMiddleToTopRight + R> self / super*.files + + // {top} + | removeMiddleToBottomRight + R> self / sub*.files + + // {top} and {middle, bottom} + | addMiddleToAll + A> (super* \/ super*.sub*) / files + + // {top} and {middle, bottom} + | removeMiddleToAll + R> (super* \/ super*.sub*) / files + + + + // Test from the bottom + + // {middle, top} -> reached + | moveBottomToTop + -> super*.files.reached + + // {middle, top} -> {middle, bottom} + | moveTopThenBottom + -> super*.sub*.files.reached + + // Joins {bottom}, {middle, top}, and {middle, bottom} + | moveBottomToAll + -> (files \/ super*.files \/ super*.sub*.files).reached + + // Empty set + | moveBottomToNothing + -> sub*.files.reached + + // Empty set + | addBottomToNothing + A> sub* / files + + // {middle, top} + | addBottomToTopLeft + A> super* / files + + + + // {middle, top} + | addBottomToTopRight + A> self / super*.files + + // {middle, top} and {middle, bottom} + | addBottomToAll + A> (super* \/ super*.sub*) / files + + // Empty set + | removeBottomToNothingLeft + R> sub*.files / self + + // Empty set + | removeBottomToNothingRight + R> self / sub*.files + + // {middle, top} + | removeBottomToTopLeft + R> super*.files / self + + // {middle, top} + | removeBottomToTopRight + R> self / super*.files + + // {middle, top} and {middle, bottom} + | removeBottomToAll + R> (super* \/ super*.sub*) / files + } + + asset File { + | reached + + } + + +} + +associations { + Directory [super] 1 <-- nesting --> * [sub] Directory + Directory [dirs] * <-- contain --> * [files] File +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang2.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang2.mal new file mode 100644 index 00000000..1a5c6d0a --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang2.mal @@ -0,0 +1,36 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "intDynamicTestLang2" +#version: "0.0.1" + +category Business { + asset AccessPolicy {} + + asset Banana {} + + asset BananaStorage { + | access + -> pilfer + | pilfer + R> self / goods + } + + asset Monkey { + let allowedAccess = (storage /\ allowPolicies.facilities) + | attemptTheft + -> allowedAccess().access + | rewritePolicy + A> self / ~storage.policy.allowedEmployees + } +} + +associations { + Monkey [agent] * <-- Access --> * [storage] BananaStorage + BananaStorage [facilities] * <-- Govern --> 1 [policy] AccessPolicy + AccessPolicy [allowPolicies] * <-- Control --> * [allowedEmployees] Monkey + BananaStorage [container] 1 <-- Contain --> * [goods] Banana +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang3.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang3.mal new file mode 100644 index 00000000..49a8ce24 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang3.mal @@ -0,0 +1,36 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "intDynamicTestLang3" +#version: "0.0.1" + +category Business { + asset Alarm {} + asset Poison {} + + asset BananaTree { + | access + -> contaminateBananas + + | contaminateBananas + user info: "Deactivate alarms, then poison reachable bananas." + developer info: "Order of operations!" + R> self / ~alarm + A> bananas / poison + -> bananas.poison.banana.poisoned + } + + asset Banana { + | poisoned + user info: "Flag this banana as poisoned!" + } +} + +associations { + Alarm [alarm] 1 <-- protects --> * [trees] BananaTree + BananaTree [tree] 1 <-- contains --> * [bananas] Banana + Banana [banana] 1 <-- contamination --> 0..1 [poison] Poison +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang4.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang4.mal new file mode 100644 index 00000000..6bda4089 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang4.mal @@ -0,0 +1,51 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "intDynamicTestLang4" +#version: "0.0.1" + +category Business { + asset Apples { + | access + -> plantBadApple, plantInfestedApple + + | plantBadApple + A> self / badApple + -> badApple.spoil + + | plantInfestedApple + A> self / badApple[InfestedApple] + -> badApple.spoil + + | spoil +} + + asset BadApple + user info: "One bad apple spoils the bunch!" + { + | spoil + -> apples.spoil + + } + + asset InfestedApple extends BadApple + user info: "Ew!" + { + | spoil + +A> apples / pest + } + + asset Worm + user info: "Wiggly." + { + } +} + +associations { + Apples [apples] 1 <-- spoiledBy --> 0..1 [badApple] BadApple + Apples [apple] 1 <-- infestedBy --> 0..1 [pest] Worm + +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang5.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang5.mal new file mode 100644 index 00000000..c57b25a8 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang5.mal @@ -0,0 +1,45 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "intDynamicTestLang5" +#version: "0.1.0" +category intDynamicTestLang5 { + asset Business {} + + abstract asset BusinessResource {} + + abstract asset Perishable extends BusinessResource { + | consume + R> self / self + } + + abstract asset NonPerishable extends BusinessResource {} + + asset Typewriter extends NonPerishable {} + asset Apple extends Perishable {} + asset Banana extends Perishable {} + + asset Monkey { + | eatPerishables + -> business.goodies[Perishable].consume + + | eatApples + -> business.goodies[Apple].consume + + | eatBananas + -> business.goodies[Banana].consume + + | breakTypewriters + R> business.goodies[Typewriter] / self + + } + +} + +associations { + Business [organization] 1 <-- trades --> * [goodies] BusinessResource + Business [business] 1 <-- employs --> * [monkeys] Monkey +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang6.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang6.mal new file mode 100644 index 00000000..84335dd3 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang6.mal @@ -0,0 +1,30 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "intDynamicTestLang6" +#version: "0.1.0" +category intDynamicTestLang6 { + asset Table { + | access + -> bowls.addApple, bowls.transferApple + } + asset Bowl { + | addApple + A> self / apples + + | transferApple + A> apples / ~table.bowls.apples + R> self / ~apples + } + + asset Apple {} + +} + +associations { + Bowl [bowls] * <-- placement --> 1 [table] Table + Bowl [container] * <-- contains --> * [apples] Apple +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang7.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang7.mal new file mode 100644 index 00000000..999921bc --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang7.mal @@ -0,0 +1,32 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "intDynamicTestLang7" +#version: "0.1.0" +category intDynamicTestLang7 { + asset Table { + | access + -> bowls.appleToOrange, bowls.orangeToApple + } + asset Bowl { + | appleToOrange + R> self / fruit[Apple] + A> self / fruit[Orange] + | orangeToApple + R> self / fruit[Orange] + A> self / fruit[Apple] + } + + abstract asset Fruit {} + asset Apple extends Fruit {} + asset Orange extends Fruit {} + +} + +associations { + Bowl [bowls] * <-- placement --> 1 [table] Table + Bowl [container] * <-- acontains --> * [fruit] Fruit +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang8.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang8.mal new file mode 100644 index 00000000..24cdbd9e --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang8.mal @@ -0,0 +1,25 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "intDynamicTestLang8" +#version: "0.1.0" +category intDynamicTestLang8 { + asset Table { + | assembleFruitBowl + A> self / bowls, + bowls / fruit[Apple] ^ fruit[Orange] + } + asset Bowl {} + abstract asset Fruit {} + asset Apple extends Fruit {} + asset Orange extends Fruit {} + +} + +associations { + Bowl [bowls] * <-- placement --> 1 [table] Table + Bowl [container] * <-- contains --> * [fruit] Fruit +} diff --git a/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang9.mal b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang9.mal new file mode 100644 index 00000000..79893e33 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/dynamal_test_langs/intermediate/intDynamicTestLang9.mal @@ -0,0 +1,26 @@ +/** + * Created by Viktor Engström + * Repo: https://gitlab.com/kth-ssas/dynamal-group/dynamaltestlangs.git + * Converted by Sandor Berglund + */ + +#id: "intDynamicTestLang9" +#version: "0.1.0" +category intDynamicTestLang9 { + asset Room { + | addChairs + A> self / chairs + | installTable + A> self / tables, + tables / ~chairs.table + } + asset Table {} + asset Chair {} + +} + +associations { + Room [room] 1 <-- tables --> 0..* [tables] Table + Room [room] 1 <-- chairs --> 0..* [chairs] Chair + Table [table] 1 <-- arrange --> 0..* [chair] Chair +} diff --git a/core/maltoolbox-language/tests/fixtures/inherited_vars.mal b/core/maltoolbox-language/tests/fixtures/inherited_vars.mal new file mode 100644 index 00000000..a062c7d2 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/inherited_vars.mal @@ -0,0 +1,25 @@ +// vim:ft=mal +#id: "test" +#version: "0.0.0" + +category Test{ + asset Dummy { + | step + } + + asset Parent { + let parent_var = dummies + } + + asset Child extends Parent { + let child_var = parent_var() \/ more_dummies + + | hack + -> child_var().step + } +} + +associations { + Parent [parents] * <-- Assoc1 --> * [dummies] Dummy + Child [children] * <-- Assoc2 --> * [more_dummies] Dummy +} diff --git a/core/maltoolbox-language/tests/fixtures/interleaved_vars.mal b/core/maltoolbox-language/tests/fixtures/interleaved_vars.mal new file mode 100644 index 00000000..3cf82af7 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/interleaved_vars.mal @@ -0,0 +1,27 @@ +// vim:ft=mal +#id: "test" +#version: "0.0.0" + +category Test{ + asset AssetA { + let A1 = fieldB + let A2 = fieldB.B1() + | testStepA + -> A2().testStepA, + A1().testStepB + } + + asset AssetB { + let B1 = fieldA + let B2 = fieldA.A1() + | testStepB + -> B1().testStepA, + B2().testStepB + } + +} + +associations { + // Interleaved variables association + AssetA [fieldA] * <-- InterleaveVarsAssoc --> * [fieldB] AssetB +} diff --git a/core/maltoolbox-language/tests/fixtures/multiplicity_lang.mal b/core/maltoolbox-language/tests/fixtures/multiplicity_lang.mal new file mode 100644 index 00000000..ac0a3a0d --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/multiplicity_lang.mal @@ -0,0 +1,29 @@ +#id: "org.mal-lang.multiplicityLang" +#version: "1.0.0" + +category Main { + + asset Host { + | access + -> createRandomFiles, + files.read + + | createRandomFiles + A> self / files:4..10 + } + + asset Proc { + | createFile + A> onHost / files, + self / ~(openFiles - onHost.procs.openFiles):1 + } + asset File { + | read + } +} + +associations { + Host [onHost] 1 <-- running --> * [procs] Proc + Host [onHost] 1 <-- disk --> * [files] File + Proc [modifyingProcs] * <-- modifies --> * [openFiles] File +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/fixtures/org.mal-lang.coreLang-1.0.0.mar b/core/maltoolbox-language/tests/fixtures/org.mal-lang.coreLang-1.0.0.mar new file mode 100644 index 00000000..699503c4 Binary files /dev/null and b/core/maltoolbox-language/tests/fixtures/org.mal-lang.coreLang-1.0.0.mar differ diff --git a/core/maltoolbox-language/tests/fixtures/prob_dists.mal b/core/maltoolbox-language/tests/fixtures/prob_dists.mal new file mode 100644 index 00000000..a3de635e --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/prob_dists.mal @@ -0,0 +1,16 @@ +// vim:ft=mal +#id: "test" +#version: "0.0.0" + +category Test{ + asset Dummy { + | expo [Exponential(0.1)] + | berni [Bernoulli(0.5)] + | composite [Exponential(0.1) * Bernoulli(0.5)] + | compositeComplex [Bernoulli(0.5) * (Binomial(10, 0.1) + Exponential(0.2) - Gamma(0.1, 0.2) * LogNormal(5, 0.5) / Pareto(7, 0.25) ^ TruncatedNormal(6, 0.3) + Uniform(0.1, 0.9))] + | compositeComplex2 [Bernoulli(0.5) * (Binomial(10, 0.1) + Exponential(0.2) - Gamma(0.1, 0.2) * LogNormal(5, 0.5) + Pareto(7, 0.25) ^ TruncatedNormal(6, 0.3) + Uniform(0.1, 0.9))] + | hardAndUncertain [HardAndUncertain] + } + +} + diff --git a/core/maltoolbox-language/tests/fixtures/set_ops.mal b/core/maltoolbox-language/tests/fixtures/set_ops.mal new file mode 100644 index 00000000..2f2a9f75 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/set_ops.mal @@ -0,0 +1,23 @@ +// vim:ft=mal +#id: "test" +#version: "0.0.0" + +category Test{ + asset Origin { + | check + -> (setA \/ setB).unionResult, + (setA /\ setB).intersectionResult, + (setA - setB).differenceResult + } + + asset Target { + | unionResult + | intersectionResult + | differenceResult + } +} + +associations { + Origin [inSetA] * <-- HasSetA --> * [setA] Target + Origin [inSetB] * <-- HasSetB --> * [setB] Target +} diff --git a/core/maltoolbox-language/tests/fixtures/set_ops_adv.mal b/core/maltoolbox-language/tests/fixtures/set_ops_adv.mal new file mode 100644 index 00000000..a3855543 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/set_ops_adv.mal @@ -0,0 +1,23 @@ +// vim:ft=mal +#id: "test" +#version: "0.0.0" + +category Test{ + asset Hub { + | innerIntersection + -> siblings.(setA /\ setB).intersectionResult + + | outerIntersection + -> (siblings.setA /\ siblings.setB).intersectionResult + } + + asset Target { + | intersectionResult + } +} + +associations { + Hub [inSetA] * <-- HasSetA --> * [setA] Target + Hub [inSetB] * <-- HasSetB --> * [setB] Target + Hub [siblingOf] * <-- HubSibling --> * [siblings] Hub +} diff --git a/core/maltoolbox-language/tests/fixtures/set_ops_collect_left.mal b/core/maltoolbox-language/tests/fixtures/set_ops_collect_left.mal new file mode 100644 index 00000000..d175a504 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/set_ops_collect_left.mal @@ -0,0 +1,24 @@ +// vim:ft=mal +#id: "test" +#version: "0.0.0" + +category Test{ + asset Origin { + | check + -> (setA - setB).next.reach + } + + asset Node { + | reach + } + + asset Target { + | reach + } +} + +associations { + Origin [inSetA] * <-- HasSetA --> * [setA] Node + Origin [inSetB] * <-- HasSetB --> * [setB] Node + Node [nodeOf] * <-- HasNext --> * [next] Target +} diff --git a/core/maltoolbox-language/tests/fixtures/shared_assoc_sibling.mal b/core/maltoolbox-language/tests/fixtures/shared_assoc_sibling.mal new file mode 100644 index 00000000..a2865fe8 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/shared_assoc_sibling.mal @@ -0,0 +1,24 @@ +// vim:ft=mal +#id: "test" +#version: "0.0.0" + +category Test{ + abstract asset Base { + } + + asset SiblingA extends Base { + | onlyA + -> target.reach + } + + asset SiblingB extends Base { + } + + asset Target { + | reach + } +} + +associations { + Base [base] * <-- BaseTarget --> * [target] Target +} diff --git a/core/maltoolbox-language/tests/fixtures/subtype_attack_step.mal b/core/maltoolbox-language/tests/fixtures/subtype_attack_step.mal new file mode 100644 index 00000000..cc0e7ef9 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/subtype_attack_step.mal @@ -0,0 +1,27 @@ +// vim:ft=mal +#id: "test" +#version: "0.0.0" + +category Test{ + asset BaseAsset { + + | base_step1 + + | base_step2 + } + + asset SubAsset extends BaseAsset{ + | subasset_step1 + } + + asset OtherAsset { + | other_step1 + -> field1[SubAsset].subasset_step1, + field1[SubAsset].base_step2, + field1.base_step1 + } +} + +associations { + BaseAsset [field1] * <-- SubtypeTestAssoc --> * [field2] OtherAsset +} diff --git a/core/maltoolbox-language/tests/fixtures/subtype_hop_new_assoc.mal b/core/maltoolbox-language/tests/fixtures/subtype_hop_new_assoc.mal new file mode 100644 index 00000000..ff6531ec --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/subtype_hop_new_assoc.mal @@ -0,0 +1,28 @@ +// vim:ft=mal +#id: "test" +#version: "0.0.0" + +category Test { + asset Xattr { + | setxattr + } + + abstract asset OpenableIO { + } + + asset File extends OpenableIO { + } + + asset Pipe extends OpenableIO { + } + + asset FileDescriptor { + | setxattr + -> io[File].xattrs.setxattr + } +} + +associations { + OpenableIO [io] 1 <-- DescribedIO --> * [descriptors] FileDescriptor + File [file] 1 <-- FileXattr --> * [xattrs] Xattr +} diff --git a/core/maltoolbox-language/tests/fixtures/transitive.mal b/core/maltoolbox-language/tests/fixtures/transitive.mal new file mode 100644 index 00000000..80b7b433 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/transitive.mal @@ -0,0 +1,15 @@ +// vim:ft=mal +#id: "test" +#version: "0.0.0" + +category Test{ + asset TestAsset { + | test_step + -> field2*.test_step + } + +} + +associations { + TestAsset [field1] * <-- TransitiveTestAssoc --> * [field2] TestAsset +} diff --git a/core/maltoolbox-language/tests/fixtures/transitive_advanced.mal b/core/maltoolbox-language/tests/fixtures/transitive_advanced.mal new file mode 100644 index 00000000..2d346f28 --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/transitive_advanced.mal @@ -0,0 +1,16 @@ +// vim:ft=mal +#id: "test" +#version: "0.0.0" + +category Test{ + asset TestAsset { + | test_step + -> (fieldA2 /\ fieldB2)*.test_step + } + +} + +associations { + TestAsset [fieldA1] * <-- TransitiveTestAssocA --> * [fieldA2] TestAsset + TestAsset [fieldB1] * <-- TransitiveTestAssocB --> * [fieldB2] TestAsset +} diff --git a/core/maltoolbox-language/tests/fixtures/wiperLang.mal b/core/maltoolbox-language/tests/fixtures/wiperLang.mal new file mode 100644 index 00000000..77f954bb --- /dev/null +++ b/core/maltoolbox-language/tests/fixtures/wiperLang.mal @@ -0,0 +1,58 @@ +/** + * @file wiperLang.mal + * @brief WiperLang example model + * @author Viktor Engström + + source: https://doi.org/10.1145/3664476.3664508 +**/ + + +#id: "org.mal-lang.wiperLang" +#version: "1.0.0" + +category WiperLang { + asset Internet {} + abstract asset Host {} + asset C2Server extends Host {} + asset Device extends Host { + | infect + A> self / malware[Wiper] // Add Malware vertex + -> malware.activate + } + + abstract asset Malware { + | activate + -> trigger + + | trigger + + | test + A> self / victim[Device] + } + + asset Wiper extends Malware { + | activate + +> exfiltrate, propagate + | exfiltrate + A> victim.data / // Link data + ~victim.inet.hosts[C2Server].data // to C2 + | propagate + A> victim.sendTo / malware[Wiper] + -> victim.sendTo.malware[Wiper].activate + | trigger + R> victim / malware ^ ~data + // Retain victim, then self-destruct + wipe + + | test + +A> self / victim[C2Server] + } + + asset Data {} +} + +associations { + Internet [inet] 1 <-- contains --> * [hosts] Host + Host [receiveFrom] * <-- neighbor --> * [sendTo] Host + Host [node] 1 <-- store --> * [data] Data + Host [victim] 1 <-- infect --> * [malware] Malware +} diff --git a/core/maltoolbox-language/tests/golden/actions_effects_lang.json b/core/maltoolbox-language/tests/golden/actions_effects_lang.json new file mode 100644 index 00000000..91bfe98a --- /dev/null +++ b/core/maltoolbox-language/tests/golden/actions_effects_lang.json @@ -0,0 +1,170 @@ +{ + "assets": [ + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": "action", + "detectors": {}, + "meta": {}, + "name": "attack", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "b_of_A", + "type": "field" + }, + "rhs": { + "name": "attack", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "AssetA", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": "effect", + "detectors": {}, + "meta": {}, + "name": "hack", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": "action", + "detectors": {}, + "meta": {}, + "name": "attack", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "b_of_B", + "type": "field" + }, + "rhs": { + "name": "attack", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "test", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "b_of_B", + "type": "field" + }, + "rhs": { + "name": "test", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "AssetB", + "superAsset": null, + "variables": [] + } + ], + "associations": [ + { + "leftAsset": "AssetA", + "leftField": "a", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "AssocAB", + "rightAsset": "AssetB", + "rightField": "b_of_A", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "AssetB", + "leftField": "b", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "AssocBB", + "rightAsset": "AssetB", + "rightField": "b_of_B", + "rightMultiplicity": { + "max": null, + "min": 0 + } + } + ], + "categories": [ + { + "meta": {}, + "name": "Test" + } + ], + "defines": { + "id": "test-actions-effects", + "version": "0.0.0" + }, + "formatVersion": "1.0.0" +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden/assocChainLang.json b/core/maltoolbox-language/tests/golden/assocChainLang.json new file mode 100644 index 00000000..3872eec7 --- /dev/null +++ b/core/maltoolbox-language/tests/golden/assocChainLang.json @@ -0,0 +1,1683 @@ +{ + "assets": [ + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "access", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "name": "reach", + "type": "attackStep" + }, + { + "lhs": { + "name": "b", + "type": "field" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "name": "b", + "type": "field" + }, + "rhs": { + "name": "c", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "name": "b", + "type": "field" + }, + "rhs": { + "name": "c", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "d", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "name": "b", + "type": "field" + }, + "rhs": { + "name": "c", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "d", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "e", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "name": "b", + "type": "field" + }, + "rhs": { + "name": "c", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "d", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "e", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "f", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "name": "b", + "type": "field" + }, + "rhs": { + "name": "c", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "d", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "e", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "f", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "g", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "name": "b", + "type": "field" + }, + "rhs": { + "name": "c", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "d", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "e", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "f", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "g", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "h", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "name": "b", + "type": "field" + }, + "rhs": { + "name": "c", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "d", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "e", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "f", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "g", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "h", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "i", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "reach", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "b", + "type": "field" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "name": "b", + "type": "field" + }, + "rhs": { + "name": "c", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "name": "b", + "type": "field" + }, + "rhs": { + "name": "c", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "d", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "name": "b", + "type": "field" + }, + "rhs": { + "name": "c", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "d", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "e", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "name": "b", + "type": "field" + }, + "rhs": { + "name": "c", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "d", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "e", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "f", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "name": "b", + "type": "field" + }, + "rhs": { + "name": "c", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "d", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "e", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "f", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "g", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "name": "b", + "type": "field" + }, + "rhs": { + "name": "c", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "d", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "e", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "f", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "g", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "h", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "name": "b", + "type": "field" + }, + "rhs": { + "name": "c", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "d", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "e", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "f", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "g", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "h", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "i", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "LongAssocChains", + "isAbstract": false, + "meta": {}, + "name": "A", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "reach", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "c", + "type": "field" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "name": "c", + "type": "field" + }, + "rhs": { + "name": "d", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "name": "c", + "type": "field" + }, + "rhs": { + "name": "d", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "e", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "name": "c", + "type": "field" + }, + "rhs": { + "name": "d", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "e", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "f", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "name": "c", + "type": "field" + }, + "rhs": { + "name": "d", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "e", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "f", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "g", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "name": "c", + "type": "field" + }, + "rhs": { + "name": "d", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "e", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "f", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "g", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "h", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "name": "c", + "type": "field" + }, + "rhs": { + "name": "d", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "e", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "f", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "g", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "h", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "i", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "LongAssocChains", + "isAbstract": false, + "meta": {}, + "name": "B", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "reach", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "d", + "type": "field" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "name": "d", + "type": "field" + }, + "rhs": { + "name": "e", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "name": "d", + "type": "field" + }, + "rhs": { + "name": "e", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "f", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "name": "d", + "type": "field" + }, + "rhs": { + "name": "e", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "f", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "g", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "name": "d", + "type": "field" + }, + "rhs": { + "name": "e", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "f", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "g", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "h", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "name": "d", + "type": "field" + }, + "rhs": { + "name": "e", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "f", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "g", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "h", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "i", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "LongAssocChains", + "isAbstract": false, + "meta": {}, + "name": "C", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "reach", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "e", + "type": "field" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "name": "e", + "type": "field" + }, + "rhs": { + "name": "f", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "name": "e", + "type": "field" + }, + "rhs": { + "name": "f", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "g", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "name": "e", + "type": "field" + }, + "rhs": { + "name": "f", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "g", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "h", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "name": "e", + "type": "field" + }, + "rhs": { + "name": "f", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "g", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "h", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "i", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "LongAssocChains", + "isAbstract": false, + "meta": {}, + "name": "D", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "reach", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "f", + "type": "field" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "name": "f", + "type": "field" + }, + "rhs": { + "name": "g", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "name": "f", + "type": "field" + }, + "rhs": { + "name": "g", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "h", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "name": "f", + "type": "field" + }, + "rhs": { + "name": "g", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "h", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "i", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "LongAssocChains", + "isAbstract": false, + "meta": {}, + "name": "E", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "reach", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "g", + "type": "field" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "name": "g", + "type": "field" + }, + "rhs": { + "name": "h", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "lhs": { + "name": "g", + "type": "field" + }, + "rhs": { + "name": "h", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "i", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "LongAssocChains", + "isAbstract": false, + "meta": {}, + "name": "F", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "reach", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "h", + "type": "field" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "name": "h", + "type": "field" + }, + "rhs": { + "name": "i", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "LongAssocChains", + "isAbstract": false, + "meta": {}, + "name": "G", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "reach", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "i", + "type": "field" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "LongAssocChains", + "isAbstract": false, + "meta": {}, + "name": "H", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "reach", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "LongAssocChains", + "isAbstract": false, + "meta": {}, + "name": "I", + "superAsset": null, + "variables": [] + } + ], + "associations": [ + { + "leftAsset": "A", + "leftField": "a", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "A2B", + "rightAsset": "B", + "rightField": "b", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "B", + "leftField": "b", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "B2C", + "rightAsset": "C", + "rightField": "c", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "C", + "leftField": "c", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "C2D", + "rightAsset": "D", + "rightField": "d", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "D", + "leftField": "d", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "D2E", + "rightAsset": "E", + "rightField": "e", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "E", + "leftField": "e", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "E2F", + "rightAsset": "F", + "rightField": "f", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "F", + "leftField": "f", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "F2G", + "rightAsset": "G", + "rightField": "g", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "G", + "leftField": "g", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "G2H", + "rightAsset": "H", + "rightField": "h", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "H", + "leftField": "h", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "H2I", + "rightAsset": "I", + "rightField": "i", + "rightMultiplicity": { + "max": null, + "min": 0 + } + } + ], + "categories": [ + { + "meta": {}, + "name": "LongAssocChains" + } + ], + "defines": { + "id": "org.mal-lang.assocChainLang", + "version": "1.0.0" + }, + "formatVersion": "1.0.0" +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden/association_lang.json b/core/maltoolbox-language/tests/golden/association_lang.json new file mode 100644 index 00000000..0f0ff89e --- /dev/null +++ b/core/maltoolbox-language/tests/golden/association_lang.json @@ -0,0 +1,445 @@ +{ + "assets": [ + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "attackC", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "c_of_A", + "type": "field" + }, + "rhs": { + "name": "hack", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "attackB", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "b_of_A", + "type": "field" + }, + "rhs": { + "name": "hack", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "AssetA", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "hack", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "attackC", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "c_of_B", + "type": "field" + }, + "rhs": { + "name": "hack", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "attackB", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "b_of_B", + "type": "field" + }, + "rhs": { + "name": "hack", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "AssetB", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "hack", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "attackC", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "c_of_C", + "type": "field" + }, + "rhs": { + "name": "hack", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "attackB", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "b_of_C", + "type": "field" + }, + "rhs": { + "name": "hack", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "AssetC", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "attackC", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "c_of_D", + "type": "field" + }, + "rhs": { + "name": "hack", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "attackB", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "b_of_D", + "type": "field" + }, + "rhs": { + "name": "hack", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "AssetD", + "superAsset": null, + "variables": [] + } + ], + "associations": [ + { + "leftAsset": "AssetA", + "leftField": "a", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "AssocAC", + "rightAsset": "AssetC", + "rightField": "c_of_A", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "AssetB", + "leftField": "b", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "AssocBC", + "rightAsset": "AssetC", + "rightField": "c_of_B", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "AssetC", + "leftField": "c", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "AssocCC", + "rightAsset": "AssetC", + "rightField": "c_of_C", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "AssetD", + "leftField": "d", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "AssocDC", + "rightAsset": "AssetC", + "rightField": "c_of_D", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "AssetA", + "leftField": "a", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "AssocAB", + "rightAsset": "AssetB", + "rightField": "b_of_A", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "AssetB", + "leftField": "b", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "AssocBB", + "rightAsset": "AssetB", + "rightField": "b_of_B", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "AssetC", + "leftField": "c", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "AssocCB", + "rightAsset": "AssetB", + "rightField": "b_of_C", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "AssetD", + "leftField": "d", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "AssocDB", + "rightAsset": "AssetB", + "rightField": "b_of_D", + "rightMultiplicity": { + "max": null, + "min": 0 + } + } + ], + "categories": [ + { + "meta": {}, + "name": "Test" + } + ], + "defines": { + "id": "test-associations", + "version": "0.0.0" + }, + "formatVersion": "1.0.0" +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden/attackstep_inherit.json b/core/maltoolbox-language/tests/golden/attackstep_inherit.json new file mode 100644 index 00000000..39e07cea --- /dev/null +++ b/core/maltoolbox-language/tests/golden/attackstep_inherit.json @@ -0,0 +1,151 @@ +{ + "assets": [ + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "s1", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "c_of_A", + "type": "field" + }, + "rhs": { + "name": "s2", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "AA", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "s1", + "reaches": { + "overrides": false, + "stepExpressions": [ + { + "lhs": { + "name": "c_of_B", + "type": "field" + }, + "rhs": { + "name": "s2", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "BB", + "superAsset": "AA", + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "s2", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "CC", + "superAsset": null, + "variables": [] + } + ], + "associations": [ + { + "leftAsset": "AA", + "leftField": "a", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "Assoc1", + "rightAsset": "CC", + "rightField": "c_of_A", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "BB", + "leftField": "b", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "Assoc2", + "rightAsset": "CC", + "rightField": "c_of_B", + "rightMultiplicity": { + "max": null, + "min": 0 + } + } + ], + "categories": [ + { + "meta": {}, + "name": "Test" + } + ], + "defines": { + "id": "test", + "version": "0.0.0" + }, + "formatVersion": "1.0.0" +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden/attackstep_override.json b/core/maltoolbox-language/tests/golden/attackstep_override.json new file mode 100644 index 00000000..35faa2c0 --- /dev/null +++ b/core/maltoolbox-language/tests/golden/attackstep_override.json @@ -0,0 +1,222 @@ +{ + "assets": [ + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "target1", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "target2", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "target3", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "target4", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "attack_step_with_child", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "name": "target1", + "type": "attackStep" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "EmptyParent", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "attackstep", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "Child1", + "superAsset": "EmptyParent", + "variables": [] + }, + { + "attackSteps": [], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "Child2", + "superAsset": "Child1", + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "attackstep", + "reaches": { + "overrides": false, + "stepExpressions": [ + { + "name": "target1", + "type": "attackStep" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "Child3", + "superAsset": "Child2", + "variables": [] + }, + { + "attackSteps": [], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "Child4", + "superAsset": "Child3", + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "attackstep", + "reaches": { + "overrides": false, + "stepExpressions": [ + { + "name": "target1", + "type": "attackStep" + }, + { + "name": "target2", + "type": "attackStep" + }, + { + "name": "target3", + "type": "attackStep" + }, + { + "name": "target4", + "type": "attackStep" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "FinalChild", + "superAsset": "Child4", + "variables": [] + } + ], + "associations": [], + "categories": [ + { + "meta": {}, + "name": "Test" + } + ], + "defines": { + "id": "test", + "version": "0.0.0" + }, + "formatVersion": "1.0.0" +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden/detector_lang.json b/core/maltoolbox-language/tests/golden/detector_lang.json new file mode 100644 index 00000000..ba004faa --- /dev/null +++ b/core/maltoolbox-language/tests/golden/detector_lang.json @@ -0,0 +1,294 @@ +{ + "assets": [ + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "physicalAccess", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "name": "attemptAuthenticate", + "type": "attackStep" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "and" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "attemptAuthenticate", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "name": "authenticate", + "type": "attackStep" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": { + "arguments": [], + "name": "HardAndCertain", + "type": "function" + }, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": "effect", + "detectors": {}, + "meta": {}, + "name": "authenticate", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "computerApps", + "type": "field" + }, + "rhs": { + "name": "attemptExploit", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "and" + } + ], + "category": "System", + "isAbstract": false, + "meta": {}, + "name": "Computer", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "shutDown", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "name": "exploit", + "type": "attackStep" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "defense" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "attemptExploit", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "name": "exploit", + "type": "attackStep" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": { + "arguments": [], + "name": "HardAndCertain", + "type": "function" + }, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": "effect", + "detectors": { + "logExploit": { + "context": { + "comp": { + "lhs": { + "name": "computerOfApp", + "type": "field" + }, + "rhs": { + "name": "authenticate", + "type": "attackStep" + }, + "type": "collect" + } + }, + "fprate": 0.1, + "name": "logExploit", + "tprate": 0.9, + "type": null + } + }, + "meta": {}, + "name": "exploit", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "toApplications", + "type": "field" + }, + "rhs": { + "name": "attemptExploit", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "name": "dataOnApp", + "type": "field" + }, + "rhs": { + "name": "read", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "and" + } + ], + "category": "System", + "isAbstract": false, + "meta": {}, + "name": "Application", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "read", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "System", + "isAbstract": false, + "meta": {}, + "name": "Data", + "superAsset": null, + "variables": [] + } + ], + "associations": [ + { + "leftAsset": "Computer", + "leftField": "computerOfApp", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "appExecution", + "rightAsset": "Application", + "rightField": "computerApps", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "Application", + "leftField": "fromApplications", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "AppToAppCommunication", + "rightAsset": "Application", + "rightField": "toApplications", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "Data", + "leftField": "dataOnApp", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "AppData", + "rightAsset": "Application", + "rightField": "appWithData", + "rightMultiplicity": { + "max": null, + "min": 0 + } + } + ], + "categories": [ + { + "meta": {}, + "name": "System" + } + ], + "defines": { + "id": "test-actions-effects", + "version": "0.0.0" + }, + "formatVersion": "1.0.0" +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden/inherited_vars.json b/core/maltoolbox-language/tests/golden/inherited_vars.json new file mode 100644 index 00000000..eb1734f9 --- /dev/null +++ b/core/maltoolbox-language/tests/golden/inherited_vars.json @@ -0,0 +1,144 @@ +{ + "assets": [ + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "step", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "Dummy", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "Parent", + "superAsset": null, + "variables": [ + { + "name": "parent_var", + "stepExpression": { + "name": "dummies", + "type": "field" + } + } + ] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "hack", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "child_var", + "type": "variable" + }, + "rhs": { + "name": "step", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "Child", + "superAsset": "Parent", + "variables": [ + { + "name": "child_var", + "stepExpression": { + "lhs": { + "name": "parent_var", + "type": "variable" + }, + "rhs": { + "name": "more_dummies", + "type": "field" + }, + "type": "union" + } + } + ] + } + ], + "associations": [ + { + "leftAsset": "Parent", + "leftField": "parents", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "Assoc1", + "rightAsset": "Dummy", + "rightField": "dummies", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "Child", + "leftField": "children", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "Assoc2", + "rightAsset": "Dummy", + "rightField": "more_dummies", + "rightMultiplicity": { + "max": null, + "min": 0 + } + } + ], + "categories": [ + { + "meta": {}, + "name": "Test" + } + ], + "defines": { + "id": "test", + "version": "0.0.0" + }, + "formatVersion": "1.0.0" +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden/interleaved_vars.json b/core/maltoolbox-language/tests/golden/interleaved_vars.json new file mode 100644 index 00000000..9c3fdb47 --- /dev/null +++ b/core/maltoolbox-language/tests/golden/interleaved_vars.json @@ -0,0 +1,177 @@ +{ + "assets": [ + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "testStepA", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "A2", + "type": "variable" + }, + "rhs": { + "name": "testStepA", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "name": "A1", + "type": "variable" + }, + "rhs": { + "name": "testStepB", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "AssetA", + "superAsset": null, + "variables": [ + { + "name": "A1", + "stepExpression": { + "name": "fieldB", + "type": "field" + } + }, + { + "name": "A2", + "stepExpression": { + "lhs": { + "name": "fieldB", + "type": "field" + }, + "rhs": { + "name": "B1", + "type": "variable" + }, + "type": "collect" + } + } + ] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "testStepB", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "B1", + "type": "variable" + }, + "rhs": { + "name": "testStepA", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "name": "B2", + "type": "variable" + }, + "rhs": { + "name": "testStepB", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "AssetB", + "superAsset": null, + "variables": [ + { + "name": "B1", + "stepExpression": { + "name": "fieldA", + "type": "field" + } + }, + { + "name": "B2", + "stepExpression": { + "lhs": { + "name": "fieldA", + "type": "field" + }, + "rhs": { + "name": "A1", + "type": "variable" + }, + "type": "collect" + } + } + ] + } + ], + "associations": [ + { + "leftAsset": "AssetA", + "leftField": "fieldA", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "InterleaveVarsAssoc", + "rightAsset": "AssetB", + "rightField": "fieldB", + "rightMultiplicity": { + "max": null, + "min": 0 + } + } + ], + "categories": [ + { + "meta": {}, + "name": "Test" + } + ], + "defines": { + "id": "test", + "version": "0.0.0" + }, + "formatVersion": "1.0.0" +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden/multiplicity_lang.json b/core/maltoolbox-language/tests/golden/multiplicity_lang.json new file mode 100644 index 00000000..097ac453 --- /dev/null +++ b/core/maltoolbox-language/tests/golden/multiplicity_lang.json @@ -0,0 +1,255 @@ +{ + "assets": [ + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "access", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "name": "createRandomFiles", + "type": "attackStep" + }, + { + "lhs": { + "name": "files", + "type": "field" + }, + "rhs": { + "name": "read", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": { + "overrides": true, + "stepExpressions": [ + { + "base": { + "name": "self", + "type": "field" + }, + "targets": [ + { + "multiplicity": { + "max": "10", + "min": "4" + }, + "stepExpression": { + "name": "files", + "type": "field" + }, + "type": "multiplicity" + } + ], + "type": "dyn_sentence" + } + ] + }, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "createRandomFiles", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Main", + "isAbstract": false, + "meta": {}, + "name": "Host", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": { + "overrides": true, + "stepExpressions": [ + { + "base": { + "name": "onHost", + "type": "field" + }, + "targets": [ + { + "name": "files", + "type": "field" + } + ], + "type": "dyn_sentence" + }, + { + "base": { + "name": "self", + "type": "field" + }, + "targets": [ + { + "operand": { + "multiplicity": { + "max": null, + "min": "1" + }, + "stepExpression": { + "lhs": { + "name": "openFiles", + "type": "field" + }, + "rhs": { + "lhs": { + "lhs": { + "name": "onHost", + "type": "field" + }, + "rhs": { + "name": "procs", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "openFiles", + "type": "field" + }, + "type": "collect" + }, + "type": "difference" + }, + "type": "multiplicity" + }, + "type": "assoc_op" + } + ], + "type": "dyn_sentence" + } + ] + }, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "createFile", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Main", + "isAbstract": false, + "meta": {}, + "name": "Proc", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "read", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Main", + "isAbstract": false, + "meta": {}, + "name": "File", + "superAsset": null, + "variables": [] + } + ], + "associations": [ + { + "leftAsset": "Host", + "leftField": "onHost", + "leftMultiplicity": { + "max": 1, + "min": 1 + }, + "meta": {}, + "name": "running", + "rightAsset": "Proc", + "rightField": "procs", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "Host", + "leftField": "onHost", + "leftMultiplicity": { + "max": 1, + "min": 1 + }, + "meta": {}, + "name": "disk", + "rightAsset": "File", + "rightField": "files", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "Proc", + "leftField": "modifyingProcs", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "modifies", + "rightAsset": "File", + "rightField": "openFiles", + "rightMultiplicity": { + "max": null, + "min": 0 + } + } + ], + "categories": [ + { + "meta": {}, + "name": "Main" + } + ], + "defines": { + "id": "org.mal-lang.multiplicityLang", + "version": "1.0.0" + }, + "formatVersion": "1.0.0" +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden/prob_dists.json b/core/maltoolbox-language/tests/golden/prob_dists.json new file mode 100644 index 00000000..48de94b5 --- /dev/null +++ b/core/maltoolbox-language/tests/golden/prob_dists.json @@ -0,0 +1,306 @@ +{ + "assets": [ + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "expo", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": { + "arguments": [ + 0.1 + ], + "name": "Exponential", + "type": "function" + }, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "berni", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": { + "arguments": [ + 0.5 + ], + "name": "Bernoulli", + "type": "function" + }, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "composite", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": { + "lhs": { + "arguments": [ + 0.1 + ], + "name": "Exponential", + "type": "function" + }, + "rhs": { + "arguments": [ + 0.5 + ], + "name": "Bernoulli", + "type": "function" + }, + "type": "multiplication" + }, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "compositeComplex", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": { + "lhs": { + "arguments": [ + 0.5 + ], + "name": "Bernoulli", + "type": "function" + }, + "rhs": { + "lhs": { + "lhs": { + "lhs": { + "arguments": [ + 10.0, + 0.1 + ], + "name": "Binomial", + "type": "function" + }, + "rhs": { + "arguments": [ + 0.2 + ], + "name": "Exponential", + "type": "function" + }, + "type": "addition" + }, + "rhs": { + "lhs": { + "lhs": { + "arguments": [ + 0.1, + 0.2 + ], + "name": "Gamma", + "type": "function" + }, + "rhs": { + "arguments": [ + 5.0, + 0.5 + ], + "name": "LogNormal", + "type": "function" + }, + "type": "multiplication" + }, + "rhs": { + "lhs": { + "arguments": [ + 7.0, + 0.25 + ], + "name": "Pareto", + "type": "function" + }, + "rhs": { + "arguments": [ + 6.0, + 0.3 + ], + "name": "TruncatedNormal", + "type": "function" + }, + "type": "exponentiation" + }, + "type": "division" + }, + "type": "subtraction" + }, + "rhs": { + "arguments": [ + 0.1, + 0.9 + ], + "name": "Uniform", + "type": "function" + }, + "type": "addition" + }, + "type": "multiplication" + }, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "compositeComplex2", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": { + "lhs": { + "arguments": [ + 0.5 + ], + "name": "Bernoulli", + "type": "function" + }, + "rhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "arguments": [ + 10.0, + 0.1 + ], + "name": "Binomial", + "type": "function" + }, + "rhs": { + "arguments": [ + 0.2 + ], + "name": "Exponential", + "type": "function" + }, + "type": "addition" + }, + "rhs": { + "lhs": { + "arguments": [ + 0.1, + 0.2 + ], + "name": "Gamma", + "type": "function" + }, + "rhs": { + "arguments": [ + 5.0, + 0.5 + ], + "name": "LogNormal", + "type": "function" + }, + "type": "multiplication" + }, + "type": "subtraction" + }, + "rhs": { + "lhs": { + "arguments": [ + 7.0, + 0.25 + ], + "name": "Pareto", + "type": "function" + }, + "rhs": { + "arguments": [ + 6.0, + 0.3 + ], + "name": "TruncatedNormal", + "type": "function" + }, + "type": "exponentiation" + }, + "type": "addition" + }, + "rhs": { + "arguments": [ + 0.1, + 0.9 + ], + "name": "Uniform", + "type": "function" + }, + "type": "addition" + }, + "type": "multiplication" + }, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "hardAndUncertain", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": { + "arguments": [], + "name": "HardAndUncertain", + "type": "function" + }, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "Dummy", + "superAsset": null, + "variables": [] + } + ], + "associations": [], + "categories": [ + { + "meta": {}, + "name": "Test" + } + ], + "defines": { + "id": "test", + "version": "0.0.0" + }, + "formatVersion": "1.0.0" +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden/set_ops.json b/core/maltoolbox-language/tests/golden/set_ops.json new file mode 100644 index 00000000..dc3bf76d --- /dev/null +++ b/core/maltoolbox-language/tests/golden/set_ops.json @@ -0,0 +1,183 @@ +{ + "assets": [ + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "check", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "lhs": { + "name": "setA", + "type": "field" + }, + "rhs": { + "name": "setB", + "type": "field" + }, + "type": "union" + }, + "rhs": { + "name": "unionResult", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "name": "setA", + "type": "field" + }, + "rhs": { + "name": "setB", + "type": "field" + }, + "type": "intersection" + }, + "rhs": { + "name": "intersectionResult", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "lhs": { + "name": "setA", + "type": "field" + }, + "rhs": { + "name": "setB", + "type": "field" + }, + "type": "difference" + }, + "rhs": { + "name": "differenceResult", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "Origin", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "unionResult", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "intersectionResult", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "differenceResult", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "Target", + "superAsset": null, + "variables": [] + } + ], + "associations": [ + { + "leftAsset": "Origin", + "leftField": "inSetA", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "HasSetA", + "rightAsset": "Target", + "rightField": "setA", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "Origin", + "leftField": "inSetB", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "HasSetB", + "rightAsset": "Target", + "rightField": "setB", + "rightMultiplicity": { + "max": null, + "min": 0 + } + } + ], + "categories": [ + { + "meta": {}, + "name": "Test" + } + ], + "defines": { + "id": "test", + "version": "0.0.0" + }, + "formatVersion": "1.0.0" +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden/set_ops_adv.json b/core/maltoolbox-language/tests/golden/set_ops_adv.json new file mode 100644 index 00000000..ae64af3c --- /dev/null +++ b/core/maltoolbox-language/tests/golden/set_ops_adv.json @@ -0,0 +1,192 @@ +{ + "assets": [ + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "innerIntersection", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "lhs": { + "name": "siblings", + "type": "field" + }, + "rhs": { + "lhs": { + "name": "setA", + "type": "field" + }, + "rhs": { + "name": "setB", + "type": "field" + }, + "type": "intersection" + }, + "type": "collect" + }, + "rhs": { + "name": "intersectionResult", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "outerIntersection", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "lhs": { + "lhs": { + "name": "siblings", + "type": "field" + }, + "rhs": { + "name": "setA", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "lhs": { + "name": "siblings", + "type": "field" + }, + "rhs": { + "name": "setB", + "type": "field" + }, + "type": "collect" + }, + "type": "intersection" + }, + "rhs": { + "name": "intersectionResult", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "Hub", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "intersectionResult", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "Target", + "superAsset": null, + "variables": [] + } + ], + "associations": [ + { + "leftAsset": "Hub", + "leftField": "inSetA", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "HasSetA", + "rightAsset": "Target", + "rightField": "setA", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "Hub", + "leftField": "inSetB", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "HasSetB", + "rightAsset": "Target", + "rightField": "setB", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "Hub", + "leftField": "siblingOf", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "HubSibling", + "rightAsset": "Hub", + "rightField": "siblings", + "rightMultiplicity": { + "max": null, + "min": 0 + } + } + ], + "categories": [ + { + "meta": {}, + "name": "Test" + } + ], + "defines": { + "id": "test", + "version": "0.0.0" + }, + "formatVersion": "1.0.0" +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden/set_ops_collect_left.json b/core/maltoolbox-language/tests/golden/set_ops_collect_left.json new file mode 100644 index 00000000..a685ce42 --- /dev/null +++ b/core/maltoolbox-language/tests/golden/set_ops_collect_left.json @@ -0,0 +1,166 @@ +{ + "assets": [ + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "check", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "lhs": { + "lhs": { + "name": "setA", + "type": "field" + }, + "rhs": { + "name": "setB", + "type": "field" + }, + "type": "difference" + }, + "rhs": { + "name": "next", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "Origin", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "reach", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "Node", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "reach", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "Target", + "superAsset": null, + "variables": [] + } + ], + "associations": [ + { + "leftAsset": "Origin", + "leftField": "inSetA", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "HasSetA", + "rightAsset": "Node", + "rightField": "setA", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "Origin", + "leftField": "inSetB", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "HasSetB", + "rightAsset": "Node", + "rightField": "setB", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "Node", + "leftField": "nodeOf", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "HasNext", + "rightAsset": "Target", + "rightField": "next", + "rightMultiplicity": { + "max": null, + "min": 0 + } + } + ], + "categories": [ + { + "meta": {}, + "name": "Test" + } + ], + "defines": { + "id": "test", + "version": "0.0.0" + }, + "formatVersion": "1.0.0" +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden/shared_assoc_sibling.json b/core/maltoolbox-language/tests/golden/shared_assoc_sibling.json new file mode 100644 index 00000000..8c54130d --- /dev/null +++ b/core/maltoolbox-language/tests/golden/shared_assoc_sibling.json @@ -0,0 +1,114 @@ +{ + "assets": [ + { + "attackSteps": [], + "category": "Test", + "isAbstract": true, + "meta": {}, + "name": "Base", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "onlyA", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "target", + "type": "field" + }, + "rhs": { + "name": "reach", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "SiblingA", + "superAsset": "Base", + "variables": [] + }, + { + "attackSteps": [], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "SiblingB", + "superAsset": "Base", + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "reach", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "Target", + "superAsset": null, + "variables": [] + } + ], + "associations": [ + { + "leftAsset": "Base", + "leftField": "base", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "BaseTarget", + "rightAsset": "Target", + "rightField": "target", + "rightMultiplicity": { + "max": null, + "min": 0 + } + } + ], + "categories": [ + { + "meta": {}, + "name": "Test" + } + ], + "defines": { + "id": "test", + "version": "0.0.0" + }, + "formatVersion": "1.0.0" +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden/subtype_attack_step.json b/core/maltoolbox-language/tests/golden/subtype_attack_step.json new file mode 100644 index 00000000..001afc7a --- /dev/null +++ b/core/maltoolbox-language/tests/golden/subtype_attack_step.json @@ -0,0 +1,164 @@ +{ + "assets": [ + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "base_step1", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "base_step2", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "BaseAsset", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "subasset_step1", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "SubAsset", + "superAsset": "BaseAsset", + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "other_step1", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "stepExpression": { + "name": "field1", + "type": "field" + }, + "subType": "SubAsset", + "type": "subType" + }, + "rhs": { + "name": "subasset_step1", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "stepExpression": { + "name": "field1", + "type": "field" + }, + "subType": "SubAsset", + "type": "subType" + }, + "rhs": { + "name": "base_step2", + "type": "attackStep" + }, + "type": "collect" + }, + { + "lhs": { + "name": "field1", + "type": "field" + }, + "rhs": { + "name": "base_step1", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "OtherAsset", + "superAsset": null, + "variables": [] + } + ], + "associations": [ + { + "leftAsset": "BaseAsset", + "leftField": "field1", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "SubtypeTestAssoc", + "rightAsset": "OtherAsset", + "rightField": "field2", + "rightMultiplicity": { + "max": null, + "min": 0 + } + } + ], + "categories": [ + { + "meta": {}, + "name": "Test" + } + ], + "defines": { + "id": "test", + "version": "0.0.0" + }, + "formatVersion": "1.0.0" +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden/transitive.json b/core/maltoolbox-language/tests/golden/transitive.json new file mode 100644 index 00000000..ef4f59d2 --- /dev/null +++ b/core/maltoolbox-language/tests/golden/transitive.json @@ -0,0 +1,75 @@ +{ + "assets": [ + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "test_step", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "stepExpression": { + "name": "field2", + "type": "field" + }, + "type": "transitive" + }, + "rhs": { + "name": "test_step", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "TestAsset", + "superAsset": null, + "variables": [] + } + ], + "associations": [ + { + "leftAsset": "TestAsset", + "leftField": "field1", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "TransitiveTestAssoc", + "rightAsset": "TestAsset", + "rightField": "field2", + "rightMultiplicity": { + "max": null, + "min": 0 + } + } + ], + "categories": [ + { + "meta": {}, + "name": "Test" + } + ], + "defines": { + "id": "test", + "version": "0.0.0" + }, + "formatVersion": "1.0.0" +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden/transitive_advanced.json b/core/maltoolbox-language/tests/golden/transitive_advanced.json new file mode 100644 index 00000000..b1ab6003 --- /dev/null +++ b/core/maltoolbox-language/tests/golden/transitive_advanced.json @@ -0,0 +1,98 @@ +{ + "assets": [ + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "test_step", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "stepExpression": { + "lhs": { + "name": "fieldA2", + "type": "field" + }, + "rhs": { + "name": "fieldB2", + "type": "field" + }, + "type": "intersection" + }, + "type": "transitive" + }, + "rhs": { + "name": "test_step", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "Test", + "isAbstract": false, + "meta": {}, + "name": "TestAsset", + "superAsset": null, + "variables": [] + } + ], + "associations": [ + { + "leftAsset": "TestAsset", + "leftField": "fieldA1", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "TransitiveTestAssocA", + "rightAsset": "TestAsset", + "rightField": "fieldA2", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "TestAsset", + "leftField": "fieldB1", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "TransitiveTestAssocB", + "rightAsset": "TestAsset", + "rightField": "fieldB2", + "rightMultiplicity": { + "max": null, + "min": 0 + } + } + ], + "categories": [ + { + "meta": {}, + "name": "Test" + } + ], + "defines": { + "id": "test", + "version": "0.0.0" + }, + "formatVersion": "1.0.0" +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden/wiperLang.json b/core/maltoolbox-language/tests/golden/wiperLang.json new file mode 100644 index 00000000..8c46982d --- /dev/null +++ b/core/maltoolbox-language/tests/golden/wiperLang.json @@ -0,0 +1,508 @@ +{ + "assets": [ + { + "attackSteps": [], + "category": "WiperLang", + "isAbstract": false, + "meta": {}, + "name": "Internet", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [], + "category": "WiperLang", + "isAbstract": true, + "meta": {}, + "name": "Host", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [], + "category": "WiperLang", + "isAbstract": false, + "meta": {}, + "name": "C2Server", + "superAsset": "Host", + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": { + "overrides": true, + "stepExpressions": [ + { + "base": { + "name": "self", + "type": "field" + }, + "targets": [ + { + "stepExpression": { + "name": "malware", + "type": "field" + }, + "subType": "Wiper", + "type": "subType" + } + ], + "type": "dyn_sentence" + } + ] + }, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "infect", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "name": "malware", + "type": "field" + }, + "rhs": { + "name": "activate", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "WiperLang", + "isAbstract": false, + "meta": {}, + "name": "Device", + "superAsset": "Host", + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "activate", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "name": "trigger", + "type": "attackStep" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "trigger", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": { + "overrides": true, + "stepExpressions": [ + { + "base": { + "name": "self", + "type": "field" + }, + "targets": [ + { + "stepExpression": { + "name": "victim", + "type": "field" + }, + "subType": "Device", + "type": "subType" + } + ], + "type": "dyn_sentence" + } + ] + }, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "test", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "WiperLang", + "isAbstract": true, + "meta": {}, + "name": "Malware", + "superAsset": null, + "variables": [] + }, + { + "attackSteps": [ + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "activate", + "reaches": { + "overrides": false, + "stepExpressions": [ + { + "name": "exfiltrate", + "type": "attackStep" + }, + { + "name": "propagate", + "type": "attackStep" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": { + "overrides": true, + "stepExpressions": [ + { + "base": { + "lhs": { + "name": "victim", + "type": "field" + }, + "rhs": { + "name": "data", + "type": "field" + }, + "type": "collect" + }, + "targets": [ + { + "operand": { + "lhs": { + "lhs": { + "lhs": { + "name": "victim", + "type": "field" + }, + "rhs": { + "name": "inet", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "stepExpression": { + "name": "hosts", + "type": "field" + }, + "subType": "C2Server", + "type": "subType" + }, + "type": "collect" + }, + "rhs": { + "name": "data", + "type": "field" + }, + "type": "collect" + }, + "type": "assoc_op" + } + ], + "type": "dyn_sentence" + } + ] + }, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "exfiltrate", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": { + "overrides": true, + "stepExpressions": [ + { + "base": { + "lhs": { + "name": "victim", + "type": "field" + }, + "rhs": { + "name": "sendTo", + "type": "field" + }, + "type": "collect" + }, + "targets": [ + { + "stepExpression": { + "name": "malware", + "type": "field" + }, + "subType": "Wiper", + "type": "subType" + } + ], + "type": "dyn_sentence" + } + ] + }, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "propagate", + "reaches": { + "overrides": true, + "stepExpressions": [ + { + "lhs": { + "lhs": { + "lhs": { + "name": "victim", + "type": "field" + }, + "rhs": { + "name": "sendTo", + "type": "field" + }, + "type": "collect" + }, + "rhs": { + "stepExpression": { + "name": "malware", + "type": "field" + }, + "subType": "Wiper", + "type": "subType" + }, + "type": "collect" + }, + "rhs": { + "name": "activate", + "type": "attackStep" + }, + "type": "collect" + } + ] + }, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": null, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "trigger", + "reaches": null, + "remove_reaches": { + "overrides": true, + "stepExpressions": [ + { + "base": { + "name": "victim", + "type": "field" + }, + "targets": [ + { + "name": "malware", + "type": "field" + }, + { + "operand": { + "name": "data", + "type": "field" + }, + "type": "assoc_op" + } + ], + "type": "dyn_sentence" + } + ] + }, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + }, + { + "append_reaches": { + "overrides": false, + "stepExpressions": [ + { + "base": { + "name": "self", + "type": "field" + }, + "targets": [ + { + "stepExpression": { + "name": "victim", + "type": "field" + }, + "subType": "C2Server", + "type": "subType" + } + ], + "type": "dyn_sentence" + } + ] + }, + "causal_mode": null, + "detectors": {}, + "meta": {}, + "name": "test", + "reaches": null, + "remove_reaches": null, + "requires": null, + "risk": null, + "tags": [], + "ttc": null, + "type": "or" + } + ], + "category": "WiperLang", + "isAbstract": false, + "meta": {}, + "name": "Wiper", + "superAsset": "Malware", + "variables": [] + }, + { + "attackSteps": [], + "category": "WiperLang", + "isAbstract": false, + "meta": {}, + "name": "Data", + "superAsset": null, + "variables": [] + } + ], + "associations": [ + { + "leftAsset": "Internet", + "leftField": "inet", + "leftMultiplicity": { + "max": 1, + "min": 1 + }, + "meta": {}, + "name": "contains", + "rightAsset": "Host", + "rightField": "hosts", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "Host", + "leftField": "receiveFrom", + "leftMultiplicity": { + "max": null, + "min": 0 + }, + "meta": {}, + "name": "neighbor", + "rightAsset": "Host", + "rightField": "sendTo", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "Host", + "leftField": "node", + "leftMultiplicity": { + "max": 1, + "min": 1 + }, + "meta": {}, + "name": "store", + "rightAsset": "Data", + "rightField": "data", + "rightMultiplicity": { + "max": null, + "min": 0 + } + }, + { + "leftAsset": "Host", + "leftField": "victim", + "leftMultiplicity": { + "max": 1, + "min": 1 + }, + "meta": {}, + "name": "infect", + "rightAsset": "Malware", + "rightField": "malware", + "rightMultiplicity": { + "max": null, + "min": 0 + } + } + ], + "categories": [ + { + "meta": {}, + "name": "WiperLang" + } + ], + "defines": { + "id": "org.mal-lang.wiperLang", + "version": "1.0.0" + }, + "formatVersion": "1.0.0" +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden_graph/actions_effects_lang.json b/core/maltoolbox-language/tests/golden_graph/actions_effects_lang.json new file mode 100644 index 00000000..7f28309d --- /dev/null +++ b/core/maltoolbox-language/tests/golden_graph/actions_effects_lang.json @@ -0,0 +1,205 @@ +{ + "AssetA": { + "associations": { + "b_of_A": { + "info": {}, + "left": { + "asset": "AssetA", + "fieldname": "a", + "max": null, + "min": 0 + }, + "name": "AssocAB", + "right": { + "asset": "AssetB", + "fieldname": "b_of_A", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "attack": { + "asset": "AssetA", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "attack", + "overrides": true, + "own_children": { + "AssetB:attack": [ + { + "AssocAB": { + "asset type": "AssetB", + "fieldname": "b_of_A" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "AssetA", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "AssetB": { + "associations": { + "a": { + "info": {}, + "left": { + "asset": "AssetA", + "fieldname": "a", + "max": null, + "min": 0 + }, + "name": "AssocAB", + "right": { + "asset": "AssetB", + "fieldname": "b_of_A", + "max": null, + "min": 0 + } + }, + "b": { + "info": {}, + "left": { + "asset": "AssetB", + "fieldname": "b", + "max": null, + "min": 0 + }, + "name": "AssocBB", + "right": { + "asset": "AssetB", + "fieldname": "b_of_B", + "max": null, + "min": 0 + } + }, + "b_of_B": { + "info": {}, + "left": { + "asset": "AssetB", + "fieldname": "b", + "max": null, + "min": 0 + }, + "name": "AssocBB", + "right": { + "asset": "AssetB", + "fieldname": "b_of_B", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "attack": { + "asset": "AssetB", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "attack", + "overrides": true, + "own_children": { + "AssetB:attack": [ + { + "AssocBB": { + "asset type": "AssetB", + "fieldname": "b_of_B" + }, + "type": "field" + } + ] + }, + "own_parents": { + "AssetA:attack": [ + { + "AssocAB": { + "asset type": "AssetA", + "fieldname": "a" + }, + "type": "field" + } + ], + "AssetB:attack": [ + { + "AssocBB": { + "asset type": "AssetB", + "fieldname": "b" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "hack": { + "asset": "AssetB", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "hack", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "test": { + "asset": "AssetB", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "test", + "overrides": true, + "own_children": { + "AssetB:test": [ + { + "AssocBB": { + "asset type": "AssetB", + "fieldname": "b_of_B" + }, + "type": "field" + } + ] + }, + "own_parents": { + "AssetB:test": [ + { + "AssocBB": { + "asset type": "AssetB", + "fieldname": "b" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "AssetB", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "metadata": { + "id": "test-actions-effects", + "version": "0.0.0" + } +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden_graph/assocChainLang.json b/core/maltoolbox-language/tests/golden_graph/assocChainLang.json new file mode 100644 index 00000000..bbb8554d --- /dev/null +++ b/core/maltoolbox-language/tests/golden_graph/assocChainLang.json @@ -0,0 +1,4004 @@ +{ + "A": { + "associations": { + "b": { + "info": {}, + "left": { + "asset": "A", + "fieldname": "a", + "max": null, + "min": 0 + }, + "name": "A2B", + "right": { + "asset": "B", + "fieldname": "b", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "access": { + "asset": "A", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "access", + "overrides": true, + "own_children": { + "A:reach": [ + null + ], + "B:reach": [ + { + "A2B": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + } + ], + "C:reach": [ + { + "collect": { + "left": { + "A2B": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "D:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "A2B": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "E:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "A2B": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "F:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "A2B": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "G:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "A2B": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "H:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "A2B": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "G2H": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "I:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "A2B": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "G2H": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "H2I": { + "asset type": "I", + "fieldname": "i" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "reach": { + "asset": "A", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "reach", + "overrides": true, + "own_children": { + "B:reach": [ + { + "A2B": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + } + ], + "C:reach": [ + { + "collect": { + "left": { + "A2B": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "D:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "A2B": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "E:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "A2B": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "F:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "A2B": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "G:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "A2B": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "H:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "A2B": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "G2H": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "I:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "A2B": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "G2H": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "H2I": { + "asset type": "I", + "fieldname": "i" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "own_parents": { + "A:access": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "A", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "B": { + "associations": { + "a": { + "info": {}, + "left": { + "asset": "A", + "fieldname": "a", + "max": null, + "min": 0 + }, + "name": "A2B", + "right": { + "asset": "B", + "fieldname": "b", + "max": null, + "min": 0 + } + }, + "c": { + "info": {}, + "left": { + "asset": "B", + "fieldname": "b", + "max": null, + "min": 0 + }, + "name": "B2C", + "right": { + "asset": "C", + "fieldname": "c", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "reach": { + "asset": "B", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "reach", + "overrides": true, + "own_children": { + "C:reach": [ + { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + ], + "D:reach": [ + { + "collect": { + "left": { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "E:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "F:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "G:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "H:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "G2H": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "I:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "B2C": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "G2H": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "H2I": { + "asset type": "I", + "fieldname": "i" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "own_parents": { + "A:access": [ + { + "A2B": { + "asset type": "A", + "fieldname": "a" + }, + "type": "field" + } + ], + "A:reach": [ + { + "A2B": { + "asset type": "A", + "fieldname": "a" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "B", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "C": { + "associations": { + "b": { + "info": {}, + "left": { + "asset": "B", + "fieldname": "b", + "max": null, + "min": 0 + }, + "name": "B2C", + "right": { + "asset": "C", + "fieldname": "c", + "max": null, + "min": 0 + } + }, + "d": { + "info": {}, + "left": { + "asset": "C", + "fieldname": "c", + "max": null, + "min": 0 + }, + "name": "C2D", + "right": { + "asset": "D", + "fieldname": "d", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "reach": { + "asset": "C", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "reach", + "overrides": true, + "own_children": { + "D:reach": [ + { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + ], + "E:reach": [ + { + "collect": { + "left": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "F:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "G:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "H:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "G2H": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "I:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "C2D": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "G2H": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "H2I": { + "asset type": "I", + "fieldname": "i" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "own_parents": { + "A:access": [ + { + "collect": { + "left": { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "A2B": { + "asset type": "A", + "fieldname": "a" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "A:reach": [ + { + "collect": { + "left": { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "A2B": { + "asset type": "A", + "fieldname": "a" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "B:reach": [ + { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "C", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "D": { + "associations": { + "c": { + "info": {}, + "left": { + "asset": "C", + "fieldname": "c", + "max": null, + "min": 0 + }, + "name": "C2D", + "right": { + "asset": "D", + "fieldname": "d", + "max": null, + "min": 0 + } + }, + "e": { + "info": {}, + "left": { + "asset": "D", + "fieldname": "d", + "max": null, + "min": 0 + }, + "name": "D2E", + "right": { + "asset": "E", + "fieldname": "e", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "reach": { + "asset": "D", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "reach", + "overrides": true, + "own_children": { + "E:reach": [ + { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + ], + "F:reach": [ + { + "collect": { + "left": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "G:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "H:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "G2H": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "I:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "D2E": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "G2H": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "H2I": { + "asset type": "I", + "fieldname": "i" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "own_parents": { + "A:access": [ + { + "collect": { + "left": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "A2B": { + "asset type": "A", + "fieldname": "a" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "A:reach": [ + { + "collect": { + "left": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "A2B": { + "asset type": "A", + "fieldname": "a" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "B:reach": [ + { + "collect": { + "left": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "C:reach": [ + { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "D", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "E": { + "associations": { + "d": { + "info": {}, + "left": { + "asset": "D", + "fieldname": "d", + "max": null, + "min": 0 + }, + "name": "D2E", + "right": { + "asset": "E", + "fieldname": "e", + "max": null, + "min": 0 + } + }, + "f": { + "info": {}, + "left": { + "asset": "E", + "fieldname": "e", + "max": null, + "min": 0 + }, + "name": "E2F", + "right": { + "asset": "F", + "fieldname": "f", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "reach": { + "asset": "E", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "reach", + "overrides": true, + "own_children": { + "F:reach": [ + { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + ], + "G:reach": [ + { + "collect": { + "left": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "H:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "G2H": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "I:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "collect": { + "left": { + "E2F": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "G2H": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "H2I": { + "asset type": "I", + "fieldname": "i" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "own_parents": { + "A:access": [ + { + "collect": { + "left": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "A2B": { + "asset type": "A", + "fieldname": "a" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "A:reach": [ + { + "collect": { + "left": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "A2B": { + "asset type": "A", + "fieldname": "a" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "B:reach": [ + { + "collect": { + "left": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "C:reach": [ + { + "collect": { + "left": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "D:reach": [ + { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "E", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "F": { + "associations": { + "e": { + "info": {}, + "left": { + "asset": "E", + "fieldname": "e", + "max": null, + "min": 0 + }, + "name": "E2F", + "right": { + "asset": "F", + "fieldname": "f", + "max": null, + "min": 0 + } + }, + "g": { + "info": {}, + "left": { + "asset": "F", + "fieldname": "f", + "max": null, + "min": 0 + }, + "name": "F2G", + "right": { + "asset": "G", + "fieldname": "g", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "reach": { + "asset": "F", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "reach", + "overrides": true, + "own_children": { + "G:reach": [ + { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + ], + "H:reach": [ + { + "collect": { + "left": { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + }, + "right": { + "G2H": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "I:reach": [ + { + "collect": { + "left": { + "collect": { + "left": { + "F2G": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + }, + "right": { + "G2H": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "H2I": { + "asset type": "I", + "fieldname": "i" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "own_parents": { + "A:access": [ + { + "collect": { + "left": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "A2B": { + "asset type": "A", + "fieldname": "a" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "A:reach": [ + { + "collect": { + "left": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "A2B": { + "asset type": "A", + "fieldname": "a" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "B:reach": [ + { + "collect": { + "left": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "C:reach": [ + { + "collect": { + "left": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "D:reach": [ + { + "collect": { + "left": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "E:reach": [ + { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "F", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "G": { + "associations": { + "f": { + "info": {}, + "left": { + "asset": "F", + "fieldname": "f", + "max": null, + "min": 0 + }, + "name": "F2G", + "right": { + "asset": "G", + "fieldname": "g", + "max": null, + "min": 0 + } + }, + "h": { + "info": {}, + "left": { + "asset": "G", + "fieldname": "g", + "max": null, + "min": 0 + }, + "name": "G2H", + "right": { + "asset": "H", + "fieldname": "h", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "reach": { + "asset": "G", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "reach", + "overrides": true, + "own_children": { + "H:reach": [ + { + "G2H": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + } + ], + "I:reach": [ + { + "collect": { + "left": { + "G2H": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + }, + "right": { + "H2I": { + "asset type": "I", + "fieldname": "i" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "own_parents": { + "A:access": [ + { + "collect": { + "left": { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "A2B": { + "asset type": "A", + "fieldname": "a" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "A:reach": [ + { + "collect": { + "left": { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "A2B": { + "asset type": "A", + "fieldname": "a" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "B:reach": [ + { + "collect": { + "left": { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "C:reach": [ + { + "collect": { + "left": { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "D:reach": [ + { + "collect": { + "left": { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "E:reach": [ + { + "collect": { + "left": { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "F:reach": [ + { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "G", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "H": { + "associations": { + "g": { + "info": {}, + "left": { + "asset": "G", + "fieldname": "g", + "max": null, + "min": 0 + }, + "name": "G2H", + "right": { + "asset": "H", + "fieldname": "h", + "max": null, + "min": 0 + } + }, + "i": { + "info": {}, + "left": { + "asset": "H", + "fieldname": "h", + "max": null, + "min": 0 + }, + "name": "H2I", + "right": { + "asset": "I", + "fieldname": "i", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "reach": { + "asset": "H", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "reach", + "overrides": true, + "own_children": { + "I:reach": [ + { + "H2I": { + "asset type": "I", + "fieldname": "i" + }, + "type": "field" + } + ] + }, + "own_parents": { + "A:access": [ + { + "collect": { + "left": { + "G2H": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "A2B": { + "asset type": "A", + "fieldname": "a" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "A:reach": [ + { + "collect": { + "left": { + "G2H": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "A2B": { + "asset type": "A", + "fieldname": "a" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "B:reach": [ + { + "collect": { + "left": { + "G2H": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "C:reach": [ + { + "collect": { + "left": { + "G2H": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "D:reach": [ + { + "collect": { + "left": { + "G2H": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "E:reach": [ + { + "collect": { + "left": { + "G2H": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "F:reach": [ + { + "collect": { + "left": { + "G2H": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + }, + "right": { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "G:reach": [ + { + "G2H": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "H", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "I": { + "associations": { + "h": { + "info": {}, + "left": { + "asset": "H", + "fieldname": "h", + "max": null, + "min": 0 + }, + "name": "H2I", + "right": { + "asset": "I", + "fieldname": "i", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "reach": { + "asset": "I", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "reach", + "overrides": false, + "own_children": {}, + "own_parents": { + "A:access": [ + { + "collect": { + "left": { + "H2I": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "G2H": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "A2B": { + "asset type": "A", + "fieldname": "a" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "A:reach": [ + { + "collect": { + "left": { + "H2I": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "G2H": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + }, + "right": { + "A2B": { + "asset type": "A", + "fieldname": "a" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "B:reach": [ + { + "collect": { + "left": { + "H2I": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "G2H": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + }, + "right": { + "B2C": { + "asset type": "B", + "fieldname": "b" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "C:reach": [ + { + "collect": { + "left": { + "H2I": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "G2H": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + }, + "right": { + "C2D": { + "asset type": "C", + "fieldname": "c" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "D:reach": [ + { + "collect": { + "left": { + "H2I": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "G2H": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + }, + "right": { + "D2E": { + "asset type": "D", + "fieldname": "d" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "E:reach": [ + { + "collect": { + "left": { + "H2I": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "G2H": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + }, + "right": { + "E2F": { + "asset type": "E", + "fieldname": "e" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "F:reach": [ + { + "collect": { + "left": { + "H2I": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "G2H": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + }, + "right": { + "F2G": { + "asset type": "F", + "fieldname": "f" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ], + "G:reach": [ + { + "collect": { + "left": { + "H2I": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + }, + "right": { + "G2H": { + "asset type": "G", + "fieldname": "g" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "H:reach": [ + { + "H2I": { + "asset type": "H", + "fieldname": "h" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "I", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "metadata": { + "id": "org.mal-lang.assocChainLang", + "version": "1.0.0" + } +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden_graph/association_lang.json b/core/maltoolbox-language/tests/golden_graph/association_lang.json new file mode 100644 index 00000000..7ab548a1 --- /dev/null +++ b/core/maltoolbox-language/tests/golden_graph/association_lang.json @@ -0,0 +1,594 @@ +{ + "AssetA": { + "associations": { + "b_of_A": { + "info": {}, + "left": { + "asset": "AssetA", + "fieldname": "a", + "max": null, + "min": 0 + }, + "name": "AssocAB", + "right": { + "asset": "AssetB", + "fieldname": "b_of_A", + "max": null, + "min": 0 + } + }, + "c_of_A": { + "info": {}, + "left": { + "asset": "AssetA", + "fieldname": "a", + "max": null, + "min": 0 + }, + "name": "AssocAC", + "right": { + "asset": "AssetC", + "fieldname": "c_of_A", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "attackB": { + "asset": "AssetA", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "attackB", + "overrides": true, + "own_children": { + "AssetB:hack": [ + { + "AssocAB": { + "asset type": "AssetB", + "fieldname": "b_of_A" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "attackC": { + "asset": "AssetA", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "attackC", + "overrides": true, + "own_children": { + "AssetC:hack": [ + { + "AssocAC": { + "asset type": "AssetC", + "fieldname": "c_of_A" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "AssetA", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "AssetB": { + "associations": { + "a": { + "info": {}, + "left": { + "asset": "AssetA", + "fieldname": "a", + "max": null, + "min": 0 + }, + "name": "AssocAB", + "right": { + "asset": "AssetB", + "fieldname": "b_of_A", + "max": null, + "min": 0 + } + }, + "b": { + "info": {}, + "left": { + "asset": "AssetB", + "fieldname": "b", + "max": null, + "min": 0 + }, + "name": "AssocBB", + "right": { + "asset": "AssetB", + "fieldname": "b_of_B", + "max": null, + "min": 0 + } + }, + "b_of_B": { + "info": {}, + "left": { + "asset": "AssetB", + "fieldname": "b", + "max": null, + "min": 0 + }, + "name": "AssocBB", + "right": { + "asset": "AssetB", + "fieldname": "b_of_B", + "max": null, + "min": 0 + } + }, + "c": { + "info": {}, + "left": { + "asset": "AssetC", + "fieldname": "c", + "max": null, + "min": 0 + }, + "name": "AssocCB", + "right": { + "asset": "AssetB", + "fieldname": "b_of_C", + "max": null, + "min": 0 + } + }, + "c_of_B": { + "info": {}, + "left": { + "asset": "AssetB", + "fieldname": "b", + "max": null, + "min": 0 + }, + "name": "AssocBC", + "right": { + "asset": "AssetC", + "fieldname": "c_of_B", + "max": null, + "min": 0 + } + }, + "d": { + "info": {}, + "left": { + "asset": "AssetD", + "fieldname": "d", + "max": null, + "min": 0 + }, + "name": "AssocDB", + "right": { + "asset": "AssetB", + "fieldname": "b_of_D", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "attackB": { + "asset": "AssetB", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "attackB", + "overrides": true, + "own_children": { + "AssetB:hack": [ + { + "AssocBB": { + "asset type": "AssetB", + "fieldname": "b_of_B" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "attackC": { + "asset": "AssetB", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "attackC", + "overrides": true, + "own_children": { + "AssetC:hack": [ + { + "AssocBC": { + "asset type": "AssetC", + "fieldname": "c_of_B" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "hack": { + "asset": "AssetB", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "hack", + "overrides": false, + "own_children": {}, + "own_parents": { + "AssetA:attackB": [ + { + "AssocAB": { + "asset type": "AssetA", + "fieldname": "a" + }, + "type": "field" + } + ], + "AssetB:attackB": [ + { + "AssocBB": { + "asset type": "AssetB", + "fieldname": "b" + }, + "type": "field" + } + ], + "AssetC:attackB": [ + { + "AssocCB": { + "asset type": "AssetC", + "fieldname": "c" + }, + "type": "field" + } + ], + "AssetD:attackB": [ + { + "AssocDB": { + "asset type": "AssetD", + "fieldname": "d" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "AssetB", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "AssetC": { + "associations": { + "a": { + "info": {}, + "left": { + "asset": "AssetA", + "fieldname": "a", + "max": null, + "min": 0 + }, + "name": "AssocAC", + "right": { + "asset": "AssetC", + "fieldname": "c_of_A", + "max": null, + "min": 0 + } + }, + "b": { + "info": {}, + "left": { + "asset": "AssetB", + "fieldname": "b", + "max": null, + "min": 0 + }, + "name": "AssocBC", + "right": { + "asset": "AssetC", + "fieldname": "c_of_B", + "max": null, + "min": 0 + } + }, + "b_of_C": { + "info": {}, + "left": { + "asset": "AssetC", + "fieldname": "c", + "max": null, + "min": 0 + }, + "name": "AssocCB", + "right": { + "asset": "AssetB", + "fieldname": "b_of_C", + "max": null, + "min": 0 + } + }, + "c": { + "info": {}, + "left": { + "asset": "AssetC", + "fieldname": "c", + "max": null, + "min": 0 + }, + "name": "AssocCC", + "right": { + "asset": "AssetC", + "fieldname": "c_of_C", + "max": null, + "min": 0 + } + }, + "c_of_C": { + "info": {}, + "left": { + "asset": "AssetC", + "fieldname": "c", + "max": null, + "min": 0 + }, + "name": "AssocCC", + "right": { + "asset": "AssetC", + "fieldname": "c_of_C", + "max": null, + "min": 0 + } + }, + "d": { + "info": {}, + "left": { + "asset": "AssetD", + "fieldname": "d", + "max": null, + "min": 0 + }, + "name": "AssocDC", + "right": { + "asset": "AssetC", + "fieldname": "c_of_D", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "attackB": { + "asset": "AssetC", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "attackB", + "overrides": true, + "own_children": { + "AssetB:hack": [ + { + "AssocCB": { + "asset type": "AssetB", + "fieldname": "b_of_C" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "attackC": { + "asset": "AssetC", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "attackC", + "overrides": true, + "own_children": { + "AssetC:hack": [ + { + "AssocCC": { + "asset type": "AssetC", + "fieldname": "c_of_C" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "hack": { + "asset": "AssetC", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "hack", + "overrides": false, + "own_children": {}, + "own_parents": { + "AssetA:attackC": [ + { + "AssocAC": { + "asset type": "AssetA", + "fieldname": "a" + }, + "type": "field" + } + ], + "AssetB:attackC": [ + { + "AssocBC": { + "asset type": "AssetB", + "fieldname": "b" + }, + "type": "field" + } + ], + "AssetC:attackC": [ + { + "AssocCC": { + "asset type": "AssetC", + "fieldname": "c" + }, + "type": "field" + } + ], + "AssetD:attackC": [ + { + "AssocDC": { + "asset type": "AssetD", + "fieldname": "d" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "AssetC", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "AssetD": { + "associations": { + "b_of_D": { + "info": {}, + "left": { + "asset": "AssetD", + "fieldname": "d", + "max": null, + "min": 0 + }, + "name": "AssocDB", + "right": { + "asset": "AssetB", + "fieldname": "b_of_D", + "max": null, + "min": 0 + } + }, + "c_of_D": { + "info": {}, + "left": { + "asset": "AssetD", + "fieldname": "d", + "max": null, + "min": 0 + }, + "name": "AssocDC", + "right": { + "asset": "AssetC", + "fieldname": "c_of_D", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "attackB": { + "asset": "AssetD", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "attackB", + "overrides": true, + "own_children": { + "AssetB:hack": [ + { + "AssocDB": { + "asset type": "AssetB", + "fieldname": "b_of_D" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "attackC": { + "asset": "AssetD", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "attackC", + "overrides": true, + "own_children": { + "AssetC:hack": [ + { + "AssocDC": { + "asset type": "AssetC", + "fieldname": "c_of_D" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "AssetD", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "metadata": { + "id": "test-associations", + "version": "0.0.0" + } +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden_graph/attackstep_inherit.json b/core/maltoolbox-language/tests/golden_graph/attackstep_inherit.json new file mode 100644 index 00000000..0f8e6d31 --- /dev/null +++ b/core/maltoolbox-language/tests/golden_graph/attackstep_inherit.json @@ -0,0 +1,186 @@ +{ + "AA": { + "associations": { + "c_of_A": { + "info": {}, + "left": { + "asset": "AA", + "fieldname": "a", + "max": null, + "min": 0 + }, + "name": "Assoc1", + "right": { + "asset": "CC", + "fieldname": "c_of_A", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "s1": { + "asset": "AA", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "s1", + "overrides": true, + "own_children": { + "CC:s2": [ + { + "Assoc1": { + "asset type": "CC", + "fieldname": "c_of_A" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "AA", + "sub_assets": [ + "BB" + ], + "super_asset": "", + "variables": {} + }, + "BB": { + "associations": { + "c_of_B": { + "info": {}, + "left": { + "asset": "BB", + "fieldname": "b", + "max": null, + "min": 0 + }, + "name": "Assoc2", + "right": { + "asset": "CC", + "fieldname": "c_of_B", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "s1": { + "asset": "BB", + "detectors": {}, + "info": {}, + "inherits": "AA:s1", + "name": "s1", + "overrides": false, + "own_children": { + "CC:s2": [ + { + "Assoc2": { + "asset type": "CC", + "fieldname": "c_of_B" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "BB", + "sub_assets": [], + "super_asset": "AA", + "variables": {} + }, + "CC": { + "associations": { + "a": { + "info": {}, + "left": { + "asset": "AA", + "fieldname": "a", + "max": null, + "min": 0 + }, + "name": "Assoc1", + "right": { + "asset": "CC", + "fieldname": "c_of_A", + "max": null, + "min": 0 + } + }, + "b": { + "info": {}, + "left": { + "asset": "BB", + "fieldname": "b", + "max": null, + "min": 0 + }, + "name": "Assoc2", + "right": { + "asset": "CC", + "fieldname": "c_of_B", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "s2": { + "asset": "CC", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "s2", + "overrides": false, + "own_children": {}, + "own_parents": { + "AA:s1": [ + { + "Assoc1": { + "asset type": "AA", + "fieldname": "a" + }, + "type": "field" + } + ], + "BB:s1": [ + { + "Assoc2": { + "asset type": "BB", + "fieldname": "b" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "CC", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "metadata": { + "id": "test", + "version": "0.0.0" + } +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden_graph/attackstep_override.json b/core/maltoolbox-language/tests/golden_graph/attackstep_override.json new file mode 100644 index 00000000..47eb40f5 --- /dev/null +++ b/core/maltoolbox-language/tests/golden_graph/attackstep_override.json @@ -0,0 +1,582 @@ +{ + "Child1": { + "associations": {}, + "attack_steps": { + "attack_step_with_child": { + "asset": "Child1", + "detectors": {}, + "info": {}, + "inherits": "EmptyParent:attack_step_with_child", + "name": "attack_step_with_child", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "attackstep": { + "asset": "Child1", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "attackstep", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "target1": { + "asset": "Child1", + "detectors": {}, + "info": {}, + "inherits": "EmptyParent:target1", + "name": "target1", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "target2": { + "asset": "Child1", + "detectors": {}, + "info": {}, + "inherits": "EmptyParent:target2", + "name": "target2", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "target3": { + "asset": "Child1", + "detectors": {}, + "info": {}, + "inherits": "EmptyParent:target3", + "name": "target3", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "target4": { + "asset": "Child1", + "detectors": {}, + "info": {}, + "inherits": "EmptyParent:target4", + "name": "target4", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "Child1", + "sub_assets": [ + "Child2" + ], + "super_asset": "EmptyParent", + "variables": {} + }, + "Child2": { + "associations": {}, + "attack_steps": { + "attack_step_with_child": { + "asset": "Child2", + "detectors": {}, + "info": {}, + "inherits": "Child1:attack_step_with_child", + "name": "attack_step_with_child", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "attackstep": { + "asset": "Child2", + "detectors": {}, + "info": {}, + "inherits": "Child1:attackstep", + "name": "attackstep", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "target1": { + "asset": "Child2", + "detectors": {}, + "info": {}, + "inherits": "Child1:target1", + "name": "target1", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "target2": { + "asset": "Child2", + "detectors": {}, + "info": {}, + "inherits": "Child1:target2", + "name": "target2", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "target3": { + "asset": "Child2", + "detectors": {}, + "info": {}, + "inherits": "Child1:target3", + "name": "target3", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "target4": { + "asset": "Child2", + "detectors": {}, + "info": {}, + "inherits": "Child1:target4", + "name": "target4", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "Child2", + "sub_assets": [ + "Child3" + ], + "super_asset": "Child1", + "variables": {} + }, + "Child3": { + "associations": {}, + "attack_steps": { + "attack_step_with_child": { + "asset": "Child3", + "detectors": {}, + "info": {}, + "inherits": "Child2:attack_step_with_child", + "name": "attack_step_with_child", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "attackstep": { + "asset": "Child3", + "detectors": {}, + "info": {}, + "inherits": "Child2:attackstep", + "name": "attackstep", + "overrides": false, + "own_children": { + "Child3:target1": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "target1": { + "asset": "Child3", + "detectors": {}, + "info": {}, + "inherits": "Child2:target1", + "name": "target1", + "overrides": false, + "own_children": {}, + "own_parents": { + "Child3:attackstep": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "target2": { + "asset": "Child3", + "detectors": {}, + "info": {}, + "inherits": "Child2:target2", + "name": "target2", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "target3": { + "asset": "Child3", + "detectors": {}, + "info": {}, + "inherits": "Child2:target3", + "name": "target3", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "target4": { + "asset": "Child3", + "detectors": {}, + "info": {}, + "inherits": "Child2:target4", + "name": "target4", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "Child3", + "sub_assets": [ + "Child4" + ], + "super_asset": "Child2", + "variables": {} + }, + "Child4": { + "associations": {}, + "attack_steps": { + "attack_step_with_child": { + "asset": "Child4", + "detectors": {}, + "info": {}, + "inherits": "Child3:attack_step_with_child", + "name": "attack_step_with_child", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "attackstep": { + "asset": "Child4", + "detectors": {}, + "info": {}, + "inherits": "Child3:attackstep", + "name": "attackstep", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "target1": { + "asset": "Child4", + "detectors": {}, + "info": {}, + "inherits": "Child3:target1", + "name": "target1", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "target2": { + "asset": "Child4", + "detectors": {}, + "info": {}, + "inherits": "Child3:target2", + "name": "target2", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "target3": { + "asset": "Child4", + "detectors": {}, + "info": {}, + "inherits": "Child3:target3", + "name": "target3", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "target4": { + "asset": "Child4", + "detectors": {}, + "info": {}, + "inherits": "Child3:target4", + "name": "target4", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "Child4", + "sub_assets": [ + "FinalChild" + ], + "super_asset": "Child3", + "variables": {} + }, + "EmptyParent": { + "associations": {}, + "attack_steps": { + "attack_step_with_child": { + "asset": "EmptyParent", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "attack_step_with_child", + "overrides": true, + "own_children": { + "EmptyParent:target1": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "target1": { + "asset": "EmptyParent", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "target1", + "overrides": false, + "own_children": {}, + "own_parents": { + "EmptyParent:attack_step_with_child": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "target2": { + "asset": "EmptyParent", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "target2", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "target3": { + "asset": "EmptyParent", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "target3", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "target4": { + "asset": "EmptyParent", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "target4", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "EmptyParent", + "sub_assets": [ + "Child1" + ], + "super_asset": "", + "variables": {} + }, + "FinalChild": { + "associations": {}, + "attack_steps": { + "attack_step_with_child": { + "asset": "FinalChild", + "detectors": {}, + "info": {}, + "inherits": "Child4:attack_step_with_child", + "name": "attack_step_with_child", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "attackstep": { + "asset": "FinalChild", + "detectors": {}, + "info": {}, + "inherits": "Child4:attackstep", + "name": "attackstep", + "overrides": false, + "own_children": { + "FinalChild:target1": [ + null + ], + "FinalChild:target2": [ + null + ], + "FinalChild:target3": [ + null + ], + "FinalChild:target4": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "target1": { + "asset": "FinalChild", + "detectors": {}, + "info": {}, + "inherits": "Child4:target1", + "name": "target1", + "overrides": false, + "own_children": {}, + "own_parents": { + "FinalChild:attackstep": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "target2": { + "asset": "FinalChild", + "detectors": {}, + "info": {}, + "inherits": "Child4:target2", + "name": "target2", + "overrides": false, + "own_children": {}, + "own_parents": { + "FinalChild:attackstep": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "target3": { + "asset": "FinalChild", + "detectors": {}, + "info": {}, + "inherits": "Child4:target3", + "name": "target3", + "overrides": false, + "own_children": {}, + "own_parents": { + "FinalChild:attackstep": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "target4": { + "asset": "FinalChild", + "detectors": {}, + "info": {}, + "inherits": "Child4:target4", + "name": "target4", + "overrides": false, + "own_children": {}, + "own_parents": { + "FinalChild:attackstep": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "FinalChild", + "sub_assets": [], + "super_asset": "Child4", + "variables": {} + }, + "metadata": { + "id": "test", + "version": "0.0.0" + } +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden_graph/detector_lang.json b/core/maltoolbox-language/tests/golden_graph/detector_lang.json new file mode 100644 index 00000000..d86eec38 --- /dev/null +++ b/core/maltoolbox-language/tests/golden_graph/detector_lang.json @@ -0,0 +1,346 @@ +{ + "Application": { + "associations": { + "computerOfApp": { + "info": {}, + "left": { + "asset": "Computer", + "fieldname": "computerOfApp", + "max": null, + "min": 0 + }, + "name": "appExecution", + "right": { + "asset": "Application", + "fieldname": "computerApps", + "max": null, + "min": 0 + } + }, + "dataOnApp": { + "info": {}, + "left": { + "asset": "Data", + "fieldname": "dataOnApp", + "max": null, + "min": 0 + }, + "name": "AppData", + "right": { + "asset": "Application", + "fieldname": "appWithData", + "max": null, + "min": 0 + } + }, + "fromApplications": { + "info": {}, + "left": { + "asset": "Application", + "fieldname": "fromApplications", + "max": null, + "min": 0 + }, + "name": "AppToAppCommunication", + "right": { + "asset": "Application", + "fieldname": "toApplications", + "max": null, + "min": 0 + } + }, + "toApplications": { + "info": {}, + "left": { + "asset": "Application", + "fieldname": "fromApplications", + "max": null, + "min": 0 + }, + "name": "AppToAppCommunication", + "right": { + "asset": "Application", + "fieldname": "toApplications", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "attemptExploit": { + "asset": "Application", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "attemptExploit", + "overrides": true, + "own_children": { + "Application:exploit": [ + null + ] + }, + "own_parents": { + "Application:exploit": [ + { + "AppToAppCommunication": { + "asset type": "Application", + "fieldname": "fromApplications" + }, + "type": "field" + } + ], + "Computer:authenticate": [ + { + "appExecution": { + "asset type": "Computer", + "fieldname": "computerOfApp" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": { + "arguments": [], + "name": "HardAndCertain", + "type": "function" + }, + "type": "or" + }, + "exploit": { + "asset": "Application", + "detectors": { + "logExploit": { + "context": { + "comp": { + "asset_type": "Computer", + "attack_step_name": "authenticate", + "expr": { + "appExecution": { + "asset type": "Computer", + "fieldname": "computerOfApp" + }, + "type": "field" + }, + "label": "comp" + } + }, + "name": "logExploit", + "tprate": 0.9, + "type": null + } + }, + "info": {}, + "inherits": null, + "name": "exploit", + "overrides": true, + "own_children": { + "Application:attemptExploit": [ + { + "AppToAppCommunication": { + "asset type": "Application", + "fieldname": "toApplications" + }, + "type": "field" + } + ], + "Data:read": [ + { + "AppData": { + "asset type": "Data", + "fieldname": "dataOnApp" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Application:attemptExploit": [ + null + ], + "Application:shutDown": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "and" + }, + "shutDown": { + "asset": "Application", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "shutDown", + "overrides": true, + "own_children": { + "Application:exploit": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "defense" + } + }, + "info": {}, + "is_abstract": false, + "name": "Application", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "Computer": { + "associations": { + "computerApps": { + "info": {}, + "left": { + "asset": "Computer", + "fieldname": "computerOfApp", + "max": null, + "min": 0 + }, + "name": "appExecution", + "right": { + "asset": "Application", + "fieldname": "computerApps", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "attemptAuthenticate": { + "asset": "Computer", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "attemptAuthenticate", + "overrides": true, + "own_children": { + "Computer:authenticate": [ + null + ] + }, + "own_parents": { + "Computer:physicalAccess": [ + null + ] + }, + "tags": [], + "ttc": { + "arguments": [], + "name": "HardAndCertain", + "type": "function" + }, + "type": "or" + }, + "authenticate": { + "asset": "Computer", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "authenticate", + "overrides": true, + "own_children": { + "Application:attemptExploit": [ + { + "appExecution": { + "asset type": "Application", + "fieldname": "computerApps" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Computer:attemptAuthenticate": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "and" + }, + "physicalAccess": { + "asset": "Computer", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "physicalAccess", + "overrides": true, + "own_children": { + "Computer:attemptAuthenticate": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "and" + } + }, + "info": {}, + "is_abstract": false, + "name": "Computer", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "Data": { + "associations": { + "appWithData": { + "info": {}, + "left": { + "asset": "Data", + "fieldname": "dataOnApp", + "max": null, + "min": 0 + }, + "name": "AppData", + "right": { + "asset": "Application", + "fieldname": "appWithData", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "read": { + "asset": "Data", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "read", + "overrides": false, + "own_children": {}, + "own_parents": { + "Application:exploit": [ + { + "AppData": { + "asset type": "Application", + "fieldname": "appWithData" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "Data", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "metadata": { + "id": "test-actions-effects", + "version": "0.0.0" + } +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden_graph/inherited_vars.json b/core/maltoolbox-language/tests/golden_graph/inherited_vars.json new file mode 100644 index 00000000..2aa852c5 --- /dev/null +++ b/core/maltoolbox-language/tests/golden_graph/inherited_vars.json @@ -0,0 +1,211 @@ +{ + "Child": { + "associations": { + "more_dummies": { + "info": {}, + "left": { + "asset": "Child", + "fieldname": "children", + "max": null, + "min": 0 + }, + "name": "Assoc2", + "right": { + "asset": "Dummy", + "fieldname": "more_dummies", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "hack": { + "asset": "Child", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "hack", + "overrides": true, + "own_children": { + "Dummy:step": [ + { + "type": "union", + "union": { + "left": { + "Assoc1": { + "asset type": "Dummy", + "fieldname": "dummies" + }, + "type": "field" + }, + "right": { + "Assoc2": { + "asset type": "Dummy", + "fieldname": "more_dummies" + }, + "type": "field" + } + } + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "Child", + "sub_assets": [], + "super_asset": "Parent", + "variables": { + "child_var": [ + "Dummy", + { + "type": "union", + "union": { + "left": { + "Assoc1": { + "asset type": "Dummy", + "fieldname": "dummies" + }, + "type": "field" + }, + "right": { + "Assoc2": { + "asset type": "Dummy", + "fieldname": "more_dummies" + }, + "type": "field" + } + } + } + ] + } + }, + "Dummy": { + "associations": { + "children": { + "info": {}, + "left": { + "asset": "Child", + "fieldname": "children", + "max": null, + "min": 0 + }, + "name": "Assoc2", + "right": { + "asset": "Dummy", + "fieldname": "more_dummies", + "max": null, + "min": 0 + } + }, + "parents": { + "info": {}, + "left": { + "asset": "Parent", + "fieldname": "parents", + "max": null, + "min": 0 + }, + "name": "Assoc1", + "right": { + "asset": "Dummy", + "fieldname": "dummies", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "step": { + "asset": "Dummy", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "step", + "overrides": false, + "own_children": {}, + "own_parents": { + "Child:hack": [ + { + "type": "union", + "union": { + "left": { + "Assoc1": { + "asset type": "Parent", + "fieldname": "parents" + }, + "type": "field" + }, + "right": { + "Assoc2": { + "asset type": "Child", + "fieldname": "children" + }, + "type": "field" + } + } + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "Dummy", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "Parent": { + "associations": { + "dummies": { + "info": {}, + "left": { + "asset": "Parent", + "fieldname": "parents", + "max": null, + "min": 0 + }, + "name": "Assoc1", + "right": { + "asset": "Dummy", + "fieldname": "dummies", + "max": null, + "min": 0 + } + } + }, + "attack_steps": {}, + "info": {}, + "is_abstract": false, + "name": "Parent", + "sub_assets": [ + "Child" + ], + "super_asset": "", + "variables": { + "parent_var": [ + "Dummy", + { + "Assoc1": { + "asset type": "Dummy", + "fieldname": "dummies" + }, + "type": "field" + } + ] + } + }, + "metadata": { + "id": "test", + "version": "0.0.0" + } +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden_graph/interleaved_vars.json b/core/maltoolbox-language/tests/golden_graph/interleaved_vars.json new file mode 100644 index 00000000..c424eb24 --- /dev/null +++ b/core/maltoolbox-language/tests/golden_graph/interleaved_vars.json @@ -0,0 +1,278 @@ +{ + "AssetA": { + "associations": { + "fieldB": { + "info": {}, + "left": { + "asset": "AssetA", + "fieldname": "fieldA", + "max": null, + "min": 0 + }, + "name": "InterleaveVarsAssoc", + "right": { + "asset": "AssetB", + "fieldname": "fieldB", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "testStepA": { + "asset": "AssetA", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "testStepA", + "overrides": true, + "own_children": { + "AssetA:testStepA": [ + { + "collect": { + "left": { + "InterleaveVarsAssoc": { + "asset type": "AssetB", + "fieldname": "fieldB" + }, + "type": "field" + }, + "right": { + "InterleaveVarsAssoc": { + "asset type": "AssetA", + "fieldname": "fieldA" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "AssetB:testStepB": [ + { + "InterleaveVarsAssoc": { + "asset type": "AssetB", + "fieldname": "fieldB" + }, + "type": "field" + } + ] + }, + "own_parents": { + "AssetA:testStepA": [ + { + "collect": { + "left": { + "InterleaveVarsAssoc": { + "asset type": "AssetB", + "fieldname": "fieldB" + }, + "type": "field" + }, + "right": { + "InterleaveVarsAssoc": { + "asset type": "AssetA", + "fieldname": "fieldA" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "AssetB:testStepB": [ + { + "InterleaveVarsAssoc": { + "asset type": "AssetB", + "fieldname": "fieldB" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "AssetA", + "sub_assets": [], + "super_asset": "", + "variables": { + "A1": [ + "AssetB", + { + "InterleaveVarsAssoc": { + "asset type": "AssetB", + "fieldname": "fieldB" + }, + "type": "field" + } + ], + "A2": [ + "AssetA", + { + "collect": { + "left": { + "InterleaveVarsAssoc": { + "asset type": "AssetB", + "fieldname": "fieldB" + }, + "type": "field" + }, + "right": { + "InterleaveVarsAssoc": { + "asset type": "AssetA", + "fieldname": "fieldA" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + } + }, + "AssetB": { + "associations": { + "fieldA": { + "info": {}, + "left": { + "asset": "AssetA", + "fieldname": "fieldA", + "max": null, + "min": 0 + }, + "name": "InterleaveVarsAssoc", + "right": { + "asset": "AssetB", + "fieldname": "fieldB", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "testStepB": { + "asset": "AssetB", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "testStepB", + "overrides": true, + "own_children": { + "AssetA:testStepA": [ + { + "InterleaveVarsAssoc": { + "asset type": "AssetA", + "fieldname": "fieldA" + }, + "type": "field" + } + ], + "AssetB:testStepB": [ + { + "collect": { + "left": { + "InterleaveVarsAssoc": { + "asset type": "AssetA", + "fieldname": "fieldA" + }, + "type": "field" + }, + "right": { + "InterleaveVarsAssoc": { + "asset type": "AssetB", + "fieldname": "fieldB" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "own_parents": { + "AssetA:testStepA": [ + { + "InterleaveVarsAssoc": { + "asset type": "AssetA", + "fieldname": "fieldA" + }, + "type": "field" + } + ], + "AssetB:testStepB": [ + { + "collect": { + "left": { + "InterleaveVarsAssoc": { + "asset type": "AssetA", + "fieldname": "fieldA" + }, + "type": "field" + }, + "right": { + "InterleaveVarsAssoc": { + "asset type": "AssetB", + "fieldname": "fieldB" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "AssetB", + "sub_assets": [], + "super_asset": "", + "variables": { + "B1": [ + "AssetA", + { + "InterleaveVarsAssoc": { + "asset type": "AssetA", + "fieldname": "fieldA" + }, + "type": "field" + } + ], + "B2": [ + "AssetB", + { + "collect": { + "left": { + "InterleaveVarsAssoc": { + "asset type": "AssetA", + "fieldname": "fieldA" + }, + "type": "field" + }, + "right": { + "InterleaveVarsAssoc": { + "asset type": "AssetB", + "fieldname": "fieldB" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + } + }, + "metadata": { + "id": "test", + "version": "0.0.0" + } +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden_graph/multiplicity_lang.json b/core/maltoolbox-language/tests/golden_graph/multiplicity_lang.json new file mode 100644 index 00000000..7d09edbb --- /dev/null +++ b/core/maltoolbox-language/tests/golden_graph/multiplicity_lang.json @@ -0,0 +1,217 @@ +{ + "File": { + "associations": { + "modifyingProcs": { + "info": {}, + "left": { + "asset": "Proc", + "fieldname": "modifyingProcs", + "max": null, + "min": 0 + }, + "name": "modifies", + "right": { + "asset": "File", + "fieldname": "openFiles", + "max": null, + "min": 0 + } + }, + "onHost": { + "info": {}, + "left": { + "asset": "Host", + "fieldname": "onHost", + "max": 1, + "min": 1 + }, + "name": "disk", + "right": { + "asset": "File", + "fieldname": "files", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "read": { + "asset": "File", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "read", + "overrides": false, + "own_children": {}, + "own_parents": { + "Host:access": [ + { + "disk": { + "asset type": "Host", + "fieldname": "onHost" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "File", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "Host": { + "associations": { + "files": { + "info": {}, + "left": { + "asset": "Host", + "fieldname": "onHost", + "max": 1, + "min": 1 + }, + "name": "disk", + "right": { + "asset": "File", + "fieldname": "files", + "max": null, + "min": 0 + } + }, + "procs": { + "info": {}, + "left": { + "asset": "Host", + "fieldname": "onHost", + "max": 1, + "min": 1 + }, + "name": "running", + "right": { + "asset": "Proc", + "fieldname": "procs", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "access": { + "asset": "Host", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "access", + "overrides": true, + "own_children": { + "File:read": [ + { + "disk": { + "asset type": "File", + "fieldname": "files" + }, + "type": "field" + } + ], + "Host:createRandomFiles": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "createRandomFiles": { + "asset": "Host", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "createRandomFiles", + "overrides": false, + "own_children": {}, + "own_parents": { + "Host:access": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "Host", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "Proc": { + "associations": { + "onHost": { + "info": {}, + "left": { + "asset": "Host", + "fieldname": "onHost", + "max": 1, + "min": 1 + }, + "name": "running", + "right": { + "asset": "Proc", + "fieldname": "procs", + "max": null, + "min": 0 + } + }, + "openFiles": { + "info": {}, + "left": { + "asset": "Proc", + "fieldname": "modifyingProcs", + "max": null, + "min": 0 + }, + "name": "modifies", + "right": { + "asset": "File", + "fieldname": "openFiles", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "createFile": { + "asset": "Proc", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "createFile", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "Proc", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "metadata": { + "id": "org.mal-lang.multiplicityLang", + "version": "1.0.0" + } +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden_graph/prob_dists.json b/core/maltoolbox-language/tests/golden_graph/prob_dists.json new file mode 100644 index 00000000..8c38a99a --- /dev/null +++ b/core/maltoolbox-language/tests/golden_graph/prob_dists.json @@ -0,0 +1,291 @@ +{ + "Dummy": { + "associations": {}, + "attack_steps": { + "berni": { + "asset": "Dummy", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "berni", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [ + 0.5 + ], + "name": "Bernoulli", + "type": "function" + }, + "type": "or" + }, + "composite": { + "asset": "Dummy", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "composite", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": { + "lhs": { + "arguments": [ + 0.1 + ], + "name": "Exponential", + "type": "function" + }, + "rhs": { + "arguments": [ + 0.5 + ], + "name": "Bernoulli", + "type": "function" + }, + "type": "multiplication" + }, + "type": "or" + }, + "compositeComplex": { + "asset": "Dummy", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "compositeComplex", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": { + "lhs": { + "arguments": [ + 0.5 + ], + "name": "Bernoulli", + "type": "function" + }, + "rhs": { + "lhs": { + "lhs": { + "lhs": { + "arguments": [ + 10.0, + 0.1 + ], + "name": "Binomial", + "type": "function" + }, + "rhs": { + "arguments": [ + 0.2 + ], + "name": "Exponential", + "type": "function" + }, + "type": "addition" + }, + "rhs": { + "lhs": { + "lhs": { + "arguments": [ + 0.1, + 0.2 + ], + "name": "Gamma", + "type": "function" + }, + "rhs": { + "arguments": [ + 5.0, + 0.5 + ], + "name": "LogNormal", + "type": "function" + }, + "type": "multiplication" + }, + "rhs": { + "lhs": { + "arguments": [ + 7.0, + 0.25 + ], + "name": "Pareto", + "type": "function" + }, + "rhs": { + "arguments": [ + 6.0, + 0.3 + ], + "name": "TruncatedNormal", + "type": "function" + }, + "type": "exponentiation" + }, + "type": "division" + }, + "type": "subtraction" + }, + "rhs": { + "arguments": [ + 0.1, + 0.9 + ], + "name": "Uniform", + "type": "function" + }, + "type": "addition" + }, + "type": "multiplication" + }, + "type": "or" + }, + "compositeComplex2": { + "asset": "Dummy", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "compositeComplex2", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": { + "lhs": { + "arguments": [ + 0.5 + ], + "name": "Bernoulli", + "type": "function" + }, + "rhs": { + "lhs": { + "lhs": { + "lhs": { + "lhs": { + "arguments": [ + 10.0, + 0.1 + ], + "name": "Binomial", + "type": "function" + }, + "rhs": { + "arguments": [ + 0.2 + ], + "name": "Exponential", + "type": "function" + }, + "type": "addition" + }, + "rhs": { + "lhs": { + "arguments": [ + 0.1, + 0.2 + ], + "name": "Gamma", + "type": "function" + }, + "rhs": { + "arguments": [ + 5.0, + 0.5 + ], + "name": "LogNormal", + "type": "function" + }, + "type": "multiplication" + }, + "type": "subtraction" + }, + "rhs": { + "lhs": { + "arguments": [ + 7.0, + 0.25 + ], + "name": "Pareto", + "type": "function" + }, + "rhs": { + "arguments": [ + 6.0, + 0.3 + ], + "name": "TruncatedNormal", + "type": "function" + }, + "type": "exponentiation" + }, + "type": "addition" + }, + "rhs": { + "arguments": [ + 0.1, + 0.9 + ], + "name": "Uniform", + "type": "function" + }, + "type": "addition" + }, + "type": "multiplication" + }, + "type": "or" + }, + "expo": { + "asset": "Dummy", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "expo", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [ + 0.1 + ], + "name": "Exponential", + "type": "function" + }, + "type": "or" + }, + "hardAndUncertain": { + "asset": "Dummy", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "hardAndUncertain", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "HardAndUncertain", + "type": "function" + }, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "Dummy", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "metadata": { + "id": "test", + "version": "0.0.0" + } +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden_graph/set_ops.json b/core/maltoolbox-language/tests/golden_graph/set_ops.json new file mode 100644 index 00000000..1e24b924 --- /dev/null +++ b/core/maltoolbox-language/tests/golden_graph/set_ops.json @@ -0,0 +1,276 @@ +{ + "Origin": { + "associations": { + "setA": { + "info": {}, + "left": { + "asset": "Origin", + "fieldname": "inSetA", + "max": null, + "min": 0 + }, + "name": "HasSetA", + "right": { + "asset": "Target", + "fieldname": "setA", + "max": null, + "min": 0 + } + }, + "setB": { + "info": {}, + "left": { + "asset": "Origin", + "fieldname": "inSetB", + "max": null, + "min": 0 + }, + "name": "HasSetB", + "right": { + "asset": "Target", + "fieldname": "setB", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "check": { + "asset": "Origin", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "check", + "overrides": true, + "own_children": { + "Target:differenceResult": [ + { + "difference": { + "left": { + "HasSetA": { + "asset type": "Target", + "fieldname": "setA" + }, + "type": "field" + }, + "right": { + "HasSetB": { + "asset type": "Target", + "fieldname": "setB" + }, + "type": "field" + } + }, + "type": "difference" + } + ], + "Target:intersectionResult": [ + { + "intersection": { + "left": { + "HasSetA": { + "asset type": "Target", + "fieldname": "setA" + }, + "type": "field" + }, + "right": { + "HasSetB": { + "asset type": "Target", + "fieldname": "setB" + }, + "type": "field" + } + }, + "type": "intersection" + } + ], + "Target:unionResult": [ + { + "type": "union", + "union": { + "left": { + "HasSetA": { + "asset type": "Target", + "fieldname": "setA" + }, + "type": "field" + }, + "right": { + "HasSetB": { + "asset type": "Target", + "fieldname": "setB" + }, + "type": "field" + } + } + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "Origin", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "Target": { + "associations": { + "inSetA": { + "info": {}, + "left": { + "asset": "Origin", + "fieldname": "inSetA", + "max": null, + "min": 0 + }, + "name": "HasSetA", + "right": { + "asset": "Target", + "fieldname": "setA", + "max": null, + "min": 0 + } + }, + "inSetB": { + "info": {}, + "left": { + "asset": "Origin", + "fieldname": "inSetB", + "max": null, + "min": 0 + }, + "name": "HasSetB", + "right": { + "asset": "Target", + "fieldname": "setB", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "differenceResult": { + "asset": "Target", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "differenceResult", + "overrides": false, + "own_children": {}, + "own_parents": { + "Origin:check": [ + { + "difference": { + "left": { + "HasSetA": { + "asset type": "Origin", + "fieldname": "inSetA" + }, + "type": "field" + }, + "right": { + "HasSetB": { + "asset type": "Origin", + "fieldname": "inSetB" + }, + "type": "field" + } + }, + "type": "difference" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "intersectionResult": { + "asset": "Target", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "intersectionResult", + "overrides": false, + "own_children": {}, + "own_parents": { + "Origin:check": [ + { + "intersection": { + "left": { + "HasSetA": { + "asset type": "Origin", + "fieldname": "inSetA" + }, + "type": "field" + }, + "right": { + "HasSetB": { + "asset type": "Origin", + "fieldname": "inSetB" + }, + "type": "field" + } + }, + "type": "intersection" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "unionResult": { + "asset": "Target", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "unionResult", + "overrides": false, + "own_children": {}, + "own_parents": { + "Origin:check": [ + { + "type": "union", + "union": { + "left": { + "HasSetA": { + "asset type": "Origin", + "fieldname": "inSetA" + }, + "type": "field" + }, + "right": { + "HasSetB": { + "asset type": "Origin", + "fieldname": "inSetB" + }, + "type": "field" + } + } + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "Target", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "metadata": { + "id": "test", + "version": "0.0.0" + } +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden_graph/set_ops_adv.json b/core/maltoolbox-language/tests/golden_graph/set_ops_adv.json new file mode 100644 index 00000000..b8168df9 --- /dev/null +++ b/core/maltoolbox-language/tests/golden_graph/set_ops_adv.json @@ -0,0 +1,324 @@ +{ + "Hub": { + "associations": { + "setA": { + "info": {}, + "left": { + "asset": "Hub", + "fieldname": "inSetA", + "max": null, + "min": 0 + }, + "name": "HasSetA", + "right": { + "asset": "Target", + "fieldname": "setA", + "max": null, + "min": 0 + } + }, + "setB": { + "info": {}, + "left": { + "asset": "Hub", + "fieldname": "inSetB", + "max": null, + "min": 0 + }, + "name": "HasSetB", + "right": { + "asset": "Target", + "fieldname": "setB", + "max": null, + "min": 0 + } + }, + "siblingOf": { + "info": {}, + "left": { + "asset": "Hub", + "fieldname": "siblingOf", + "max": null, + "min": 0 + }, + "name": "HubSibling", + "right": { + "asset": "Hub", + "fieldname": "siblings", + "max": null, + "min": 0 + } + }, + "siblings": { + "info": {}, + "left": { + "asset": "Hub", + "fieldname": "siblingOf", + "max": null, + "min": 0 + }, + "name": "HubSibling", + "right": { + "asset": "Hub", + "fieldname": "siblings", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "innerIntersection": { + "asset": "Hub", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "innerIntersection", + "overrides": true, + "own_children": { + "Target:intersectionResult": [ + { + "collect": { + "left": { + "HubSibling": { + "asset type": "Hub", + "fieldname": "siblings" + }, + "type": "field" + }, + "right": { + "intersection": { + "left": { + "HasSetA": { + "asset type": "Target", + "fieldname": "setA" + }, + "type": "field" + }, + "right": { + "HasSetB": { + "asset type": "Target", + "fieldname": "setB" + }, + "type": "field" + } + }, + "type": "intersection" + } + }, + "type": "collect" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "outerIntersection": { + "asset": "Hub", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "outerIntersection", + "overrides": true, + "own_children": { + "Target:intersectionResult": [ + { + "intersection": { + "left": { + "collect": { + "left": { + "HubSibling": { + "asset type": "Hub", + "fieldname": "siblings" + }, + "type": "field" + }, + "right": { + "HasSetA": { + "asset type": "Target", + "fieldname": "setA" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "collect": { + "left": { + "HubSibling": { + "asset type": "Hub", + "fieldname": "siblings" + }, + "type": "field" + }, + "right": { + "HasSetB": { + "asset type": "Target", + "fieldname": "setB" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "intersection" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "Hub", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "Target": { + "associations": { + "inSetA": { + "info": {}, + "left": { + "asset": "Hub", + "fieldname": "inSetA", + "max": null, + "min": 0 + }, + "name": "HasSetA", + "right": { + "asset": "Target", + "fieldname": "setA", + "max": null, + "min": 0 + } + }, + "inSetB": { + "info": {}, + "left": { + "asset": "Hub", + "fieldname": "inSetB", + "max": null, + "min": 0 + }, + "name": "HasSetB", + "right": { + "asset": "Target", + "fieldname": "setB", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "intersectionResult": { + "asset": "Target", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "intersectionResult", + "overrides": false, + "own_children": {}, + "own_parents": { + "Hub:innerIntersection": [ + { + "collect": { + "left": { + "intersection": { + "left": { + "HasSetA": { + "asset type": "Hub", + "fieldname": "inSetA" + }, + "type": "field" + }, + "right": { + "HasSetB": { + "asset type": "Hub", + "fieldname": "inSetB" + }, + "type": "field" + } + }, + "type": "intersection" + }, + "right": { + "HubSibling": { + "asset type": "Hub", + "fieldname": "siblingOf" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "Hub:outerIntersection": [ + { + "intersection": { + "left": { + "collect": { + "left": { + "HasSetA": { + "asset type": "Hub", + "fieldname": "inSetA" + }, + "type": "field" + }, + "right": { + "HubSibling": { + "asset type": "Hub", + "fieldname": "siblingOf" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "collect": { + "left": { + "HasSetB": { + "asset type": "Hub", + "fieldname": "inSetB" + }, + "type": "field" + }, + "right": { + "HubSibling": { + "asset type": "Hub", + "fieldname": "siblingOf" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "intersection" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "Target", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "metadata": { + "id": "test", + "version": "0.0.0" + } +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden_graph/set_ops_collect_left.json b/core/maltoolbox-language/tests/golden_graph/set_ops_collect_left.json new file mode 100644 index 00000000..d8807013 --- /dev/null +++ b/core/maltoolbox-language/tests/golden_graph/set_ops_collect_left.json @@ -0,0 +1,245 @@ +{ + "Node": { + "associations": { + "inSetA": { + "info": {}, + "left": { + "asset": "Origin", + "fieldname": "inSetA", + "max": null, + "min": 0 + }, + "name": "HasSetA", + "right": { + "asset": "Node", + "fieldname": "setA", + "max": null, + "min": 0 + } + }, + "inSetB": { + "info": {}, + "left": { + "asset": "Origin", + "fieldname": "inSetB", + "max": null, + "min": 0 + }, + "name": "HasSetB", + "right": { + "asset": "Node", + "fieldname": "setB", + "max": null, + "min": 0 + } + }, + "next": { + "info": {}, + "left": { + "asset": "Node", + "fieldname": "nodeOf", + "max": null, + "min": 0 + }, + "name": "HasNext", + "right": { + "asset": "Target", + "fieldname": "next", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "reach": { + "asset": "Node", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "reach", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "Node", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "Origin": { + "associations": { + "setA": { + "info": {}, + "left": { + "asset": "Origin", + "fieldname": "inSetA", + "max": null, + "min": 0 + }, + "name": "HasSetA", + "right": { + "asset": "Node", + "fieldname": "setA", + "max": null, + "min": 0 + } + }, + "setB": { + "info": {}, + "left": { + "asset": "Origin", + "fieldname": "inSetB", + "max": null, + "min": 0 + }, + "name": "HasSetB", + "right": { + "asset": "Node", + "fieldname": "setB", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "check": { + "asset": "Origin", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "check", + "overrides": true, + "own_children": { + "Target:reach": [ + { + "collect": { + "left": { + "difference": { + "left": { + "HasSetA": { + "asset type": "Node", + "fieldname": "setA" + }, + "type": "field" + }, + "right": { + "HasSetB": { + "asset type": "Node", + "fieldname": "setB" + }, + "type": "field" + } + }, + "type": "difference" + }, + "right": { + "HasNext": { + "asset type": "Target", + "fieldname": "next" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "Origin", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "Target": { + "associations": { + "nodeOf": { + "info": {}, + "left": { + "asset": "Node", + "fieldname": "nodeOf", + "max": null, + "min": 0 + }, + "name": "HasNext", + "right": { + "asset": "Target", + "fieldname": "next", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "reach": { + "asset": "Target", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "reach", + "overrides": false, + "own_children": {}, + "own_parents": { + "Origin:check": [ + { + "collect": { + "left": { + "HasNext": { + "asset type": "Node", + "fieldname": "nodeOf" + }, + "type": "field" + }, + "right": { + "difference": { + "left": { + "HasSetA": { + "asset type": "Origin", + "fieldname": "inSetA" + }, + "type": "field" + }, + "right": { + "HasSetB": { + "asset type": "Origin", + "fieldname": "inSetB" + }, + "type": "field" + } + }, + "type": "difference" + } + }, + "type": "collect" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "Target", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "metadata": { + "id": "test", + "version": "0.0.0" + } +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden_graph/shared_assoc_sibling.json b/core/maltoolbox-language/tests/golden_graph/shared_assoc_sibling.json new file mode 100644 index 00000000..717e1d24 --- /dev/null +++ b/core/maltoolbox-language/tests/golden_graph/shared_assoc_sibling.json @@ -0,0 +1,131 @@ +{ + "Base": { + "associations": { + "target": { + "info": {}, + "left": { + "asset": "Base", + "fieldname": "base", + "max": null, + "min": 0 + }, + "name": "BaseTarget", + "right": { + "asset": "Target", + "fieldname": "target", + "max": null, + "min": 0 + } + } + }, + "attack_steps": {}, + "info": {}, + "is_abstract": true, + "name": "Base", + "sub_assets": [ + "SiblingA", + "SiblingB" + ], + "super_asset": "", + "variables": {} + }, + "SiblingA": { + "associations": {}, + "attack_steps": { + "onlyA": { + "asset": "SiblingA", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "onlyA", + "overrides": true, + "own_children": { + "Target:reach": [ + { + "BaseTarget": { + "asset type": "Target", + "fieldname": "target" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "SiblingA", + "sub_assets": [], + "super_asset": "Base", + "variables": {} + }, + "SiblingB": { + "associations": {}, + "attack_steps": {}, + "info": {}, + "is_abstract": false, + "name": "SiblingB", + "sub_assets": [], + "super_asset": "Base", + "variables": {} + }, + "Target": { + "associations": { + "base": { + "info": {}, + "left": { + "asset": "Base", + "fieldname": "base", + "max": null, + "min": 0 + }, + "name": "BaseTarget", + "right": { + "asset": "Target", + "fieldname": "target", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "reach": { + "asset": "Target", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "reach", + "overrides": false, + "own_children": {}, + "own_parents": { + "SiblingA:onlyA": [ + { + "BaseTarget": { + "asset type": "Base", + "fieldname": "base" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "Target", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "metadata": { + "id": "test", + "version": "0.0.0" + } +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden_graph/subtype_attack_step.json b/core/maltoolbox-language/tests/golden_graph/subtype_attack_step.json new file mode 100644 index 00000000..1d13d412 --- /dev/null +++ b/core/maltoolbox-language/tests/golden_graph/subtype_attack_step.json @@ -0,0 +1,227 @@ +{ + "BaseAsset": { + "associations": { + "field2": { + "info": {}, + "left": { + "asset": "BaseAsset", + "fieldname": "field1", + "max": null, + "min": 0 + }, + "name": "SubtypeTestAssoc", + "right": { + "asset": "OtherAsset", + "fieldname": "field2", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "base_step1": { + "asset": "BaseAsset", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "base_step1", + "overrides": false, + "own_children": {}, + "own_parents": { + "OtherAsset:other_step1": [ + { + "SubtypeTestAssoc": { + "asset type": "OtherAsset", + "fieldname": "field2" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "base_step2": { + "asset": "BaseAsset", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "base_step2", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "BaseAsset", + "sub_assets": [ + "SubAsset" + ], + "super_asset": "", + "variables": {} + }, + "OtherAsset": { + "associations": { + "field1": { + "info": {}, + "left": { + "asset": "BaseAsset", + "fieldname": "field1", + "max": null, + "min": 0 + }, + "name": "SubtypeTestAssoc", + "right": { + "asset": "OtherAsset", + "fieldname": "field2", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "other_step1": { + "asset": "OtherAsset", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "other_step1", + "overrides": true, + "own_children": { + "BaseAsset:base_step1": [ + { + "SubtypeTestAssoc": { + "asset type": "BaseAsset", + "fieldname": "field1" + }, + "type": "field" + } + ], + "SubAsset:base_step2": [ + { + "expression": { + "SubtypeTestAssoc": { + "asset type": "BaseAsset", + "fieldname": "field1" + }, + "type": "field" + }, + "subType": "SubAsset", + "type": "subType" + } + ], + "SubAsset:subasset_step1": [ + { + "expression": { + "SubtypeTestAssoc": { + "asset type": "BaseAsset", + "fieldname": "field1" + }, + "type": "field" + }, + "subType": "SubAsset", + "type": "subType" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "OtherAsset", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "SubAsset": { + "associations": {}, + "attack_steps": { + "base_step1": { + "asset": "SubAsset", + "detectors": {}, + "info": {}, + "inherits": "BaseAsset:base_step1", + "name": "base_step1", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "base_step2": { + "asset": "SubAsset", + "detectors": {}, + "info": {}, + "inherits": "BaseAsset:base_step2", + "name": "base_step2", + "overrides": false, + "own_children": {}, + "own_parents": { + "OtherAsset:other_step1": [ + { + "expression": { + "SubtypeTestAssoc": { + "asset type": "OtherAsset", + "fieldname": "field2" + }, + "type": "field" + }, + "subType": "SubAsset", + "type": "subType" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "subasset_step1": { + "asset": "SubAsset", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "subasset_step1", + "overrides": false, + "own_children": {}, + "own_parents": { + "OtherAsset:other_step1": [ + { + "expression": { + "SubtypeTestAssoc": { + "asset type": "OtherAsset", + "fieldname": "field2" + }, + "type": "field" + }, + "subType": "SubAsset", + "type": "subType" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "SubAsset", + "sub_assets": [], + "super_asset": "BaseAsset", + "variables": {} + }, + "metadata": { + "id": "test", + "version": "0.0.0" + } +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden_graph/transitive.json b/core/maltoolbox-language/tests/golden_graph/transitive.json new file mode 100644 index 00000000..398173f4 --- /dev/null +++ b/core/maltoolbox-language/tests/golden_graph/transitive.json @@ -0,0 +1,89 @@ +{ + "TestAsset": { + "associations": { + "field1": { + "info": {}, + "left": { + "asset": "TestAsset", + "fieldname": "field1", + "max": null, + "min": 0 + }, + "name": "TransitiveTestAssoc", + "right": { + "asset": "TestAsset", + "fieldname": "field2", + "max": null, + "min": 0 + } + }, + "field2": { + "info": {}, + "left": { + "asset": "TestAsset", + "fieldname": "field1", + "max": null, + "min": 0 + }, + "name": "TransitiveTestAssoc", + "right": { + "asset": "TestAsset", + "fieldname": "field2", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "test_step": { + "asset": "TestAsset", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "test_step", + "overrides": true, + "own_children": { + "TestAsset:test_step": [ + { + "transitive": { + "TransitiveTestAssoc": { + "asset type": "TestAsset", + "fieldname": "field2" + }, + "type": "field" + }, + "type": "transitive" + } + ] + }, + "own_parents": { + "TestAsset:test_step": [ + { + "transitive": { + "TransitiveTestAssoc": { + "asset type": "TestAsset", + "fieldname": "field1" + }, + "type": "field" + }, + "type": "transitive" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "TestAsset", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "metadata": { + "id": "test", + "version": "0.0.0" + } +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden_graph/transitive_advanced.json b/core/maltoolbox-language/tests/golden_graph/transitive_advanced.json new file mode 100644 index 00000000..9f81f589 --- /dev/null +++ b/core/maltoolbox-language/tests/golden_graph/transitive_advanced.json @@ -0,0 +1,145 @@ +{ + "TestAsset": { + "associations": { + "fieldA1": { + "info": {}, + "left": { + "asset": "TestAsset", + "fieldname": "fieldA1", + "max": null, + "min": 0 + }, + "name": "TransitiveTestAssocA", + "right": { + "asset": "TestAsset", + "fieldname": "fieldA2", + "max": null, + "min": 0 + } + }, + "fieldA2": { + "info": {}, + "left": { + "asset": "TestAsset", + "fieldname": "fieldA1", + "max": null, + "min": 0 + }, + "name": "TransitiveTestAssocA", + "right": { + "asset": "TestAsset", + "fieldname": "fieldA2", + "max": null, + "min": 0 + } + }, + "fieldB1": { + "info": {}, + "left": { + "asset": "TestAsset", + "fieldname": "fieldB1", + "max": null, + "min": 0 + }, + "name": "TransitiveTestAssocB", + "right": { + "asset": "TestAsset", + "fieldname": "fieldB2", + "max": null, + "min": 0 + } + }, + "fieldB2": { + "info": {}, + "left": { + "asset": "TestAsset", + "fieldname": "fieldB1", + "max": null, + "min": 0 + }, + "name": "TransitiveTestAssocB", + "right": { + "asset": "TestAsset", + "fieldname": "fieldB2", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "test_step": { + "asset": "TestAsset", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "test_step", + "overrides": true, + "own_children": { + "TestAsset:test_step": [ + { + "transitive": { + "intersection": { + "left": { + "TransitiveTestAssocA": { + "asset type": "TestAsset", + "fieldname": "fieldA2" + }, + "type": "field" + }, + "right": { + "TransitiveTestAssocB": { + "asset type": "TestAsset", + "fieldname": "fieldB2" + }, + "type": "field" + } + }, + "type": "intersection" + }, + "type": "transitive" + } + ] + }, + "own_parents": { + "TestAsset:test_step": [ + { + "transitive": { + "intersection": { + "left": { + "TransitiveTestAssocA": { + "asset type": "TestAsset", + "fieldname": "fieldA1" + }, + "type": "field" + }, + "right": { + "TransitiveTestAssocB": { + "asset type": "TestAsset", + "fieldname": "fieldB1" + }, + "type": "field" + } + }, + "type": "intersection" + }, + "type": "transitive" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "TestAsset", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "metadata": { + "id": "test", + "version": "0.0.0" + } +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden_graph/wiperLang.json b/core/maltoolbox-language/tests/golden_graph/wiperLang.json new file mode 100644 index 00000000..f35a88ff --- /dev/null +++ b/core/maltoolbox-language/tests/golden_graph/wiperLang.json @@ -0,0 +1,452 @@ +{ + "C2Server": { + "associations": {}, + "attack_steps": {}, + "info": {}, + "is_abstract": false, + "name": "C2Server", + "sub_assets": [], + "super_asset": "Host", + "variables": {} + }, + "Data": { + "associations": { + "node": { + "info": {}, + "left": { + "asset": "Host", + "fieldname": "node", + "max": 1, + "min": 1 + }, + "name": "store", + "right": { + "asset": "Data", + "fieldname": "data", + "max": null, + "min": 0 + } + } + }, + "attack_steps": {}, + "info": {}, + "is_abstract": false, + "name": "Data", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "Device": { + "associations": {}, + "attack_steps": { + "infect": { + "asset": "Device", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "infect", + "overrides": true, + "own_children": { + "Malware:activate": [ + { + "infect": { + "asset type": "Malware", + "fieldname": "malware" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "Device", + "sub_assets": [], + "super_asset": "Host", + "variables": {} + }, + "Host": { + "associations": { + "data": { + "info": {}, + "left": { + "asset": "Host", + "fieldname": "node", + "max": 1, + "min": 1 + }, + "name": "store", + "right": { + "asset": "Data", + "fieldname": "data", + "max": null, + "min": 0 + } + }, + "inet": { + "info": {}, + "left": { + "asset": "Internet", + "fieldname": "inet", + "max": 1, + "min": 1 + }, + "name": "contains", + "right": { + "asset": "Host", + "fieldname": "hosts", + "max": null, + "min": 0 + } + }, + "malware": { + "info": {}, + "left": { + "asset": "Host", + "fieldname": "victim", + "max": 1, + "min": 1 + }, + "name": "infect", + "right": { + "asset": "Malware", + "fieldname": "malware", + "max": null, + "min": 0 + } + }, + "receiveFrom": { + "info": {}, + "left": { + "asset": "Host", + "fieldname": "receiveFrom", + "max": null, + "min": 0 + }, + "name": "neighbor", + "right": { + "asset": "Host", + "fieldname": "sendTo", + "max": null, + "min": 0 + } + }, + "sendTo": { + "info": {}, + "left": { + "asset": "Host", + "fieldname": "receiveFrom", + "max": null, + "min": 0 + }, + "name": "neighbor", + "right": { + "asset": "Host", + "fieldname": "sendTo", + "max": null, + "min": 0 + } + } + }, + "attack_steps": {}, + "info": {}, + "is_abstract": true, + "name": "Host", + "sub_assets": [ + "C2Server", + "Device" + ], + "super_asset": "", + "variables": {} + }, + "Internet": { + "associations": { + "hosts": { + "info": {}, + "left": { + "asset": "Internet", + "fieldname": "inet", + "max": 1, + "min": 1 + }, + "name": "contains", + "right": { + "asset": "Host", + "fieldname": "hosts", + "max": null, + "min": 0 + } + } + }, + "attack_steps": {}, + "info": {}, + "is_abstract": false, + "name": "Internet", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "Malware": { + "associations": { + "victim": { + "info": {}, + "left": { + "asset": "Host", + "fieldname": "victim", + "max": 1, + "min": 1 + }, + "name": "infect", + "right": { + "asset": "Malware", + "fieldname": "malware", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "activate": { + "asset": "Malware", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "activate", + "overrides": true, + "own_children": { + "Malware:trigger": [ + null + ] + }, + "own_parents": { + "Device:infect": [ + { + "infect": { + "asset type": "Host", + "fieldname": "victim" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "test": { + "asset": "Malware", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "test", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "trigger": { + "asset": "Malware", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "trigger", + "overrides": false, + "own_children": {}, + "own_parents": { + "Malware:activate": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": true, + "name": "Malware", + "sub_assets": [ + "Wiper" + ], + "super_asset": "", + "variables": {} + }, + "Wiper": { + "associations": {}, + "attack_steps": { + "activate": { + "asset": "Wiper", + "detectors": {}, + "info": {}, + "inherits": "Malware:activate", + "name": "activate", + "overrides": false, + "own_children": { + "Wiper:exfiltrate": [ + null + ], + "Wiper:propagate": [ + null + ] + }, + "own_parents": { + "Wiper:propagate": [ + { + "collect": { + "left": { + "expression": { + "infect": { + "asset type": "Host", + "fieldname": "victim" + }, + "type": "field" + }, + "subType": "Wiper", + "type": "subType" + }, + "right": { + "collect": { + "left": { + "neighbor": { + "asset type": "Host", + "fieldname": "receiveFrom" + }, + "type": "field" + }, + "right": { + "infect": { + "asset type": "Malware", + "fieldname": "malware" + }, + "type": "field" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "exfiltrate": { + "asset": "Wiper", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "exfiltrate", + "overrides": false, + "own_children": {}, + "own_parents": { + "Wiper:activate": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "propagate": { + "asset": "Wiper", + "detectors": {}, + "info": {}, + "inherits": null, + "name": "propagate", + "overrides": true, + "own_children": { + "Wiper:activate": [ + { + "collect": { + "left": { + "collect": { + "left": { + "infect": { + "asset type": "Host", + "fieldname": "victim" + }, + "type": "field" + }, + "right": { + "neighbor": { + "asset type": "Host", + "fieldname": "sendTo" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "expression": { + "infect": { + "asset type": "Malware", + "fieldname": "malware" + }, + "type": "field" + }, + "subType": "Wiper", + "type": "subType" + } + }, + "type": "collect" + } + ] + }, + "own_parents": { + "Wiper:activate": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "test": { + "asset": "Wiper", + "detectors": {}, + "info": {}, + "inherits": "Malware:test", + "name": "test", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "trigger": { + "asset": "Wiper", + "detectors": {}, + "info": {}, + "inherits": "Malware:trigger", + "name": "trigger", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": {}, + "is_abstract": false, + "name": "Wiper", + "sub_assets": [], + "super_asset": "Malware", + "variables": {} + }, + "metadata": { + "id": "org.mal-lang.wiperLang", + "version": "1.0.0" + } +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/golden_graph_mar/coreLang.json b/core/maltoolbox-language/tests/golden_graph_mar/coreLang.json new file mode 100644 index 00000000..163dd835 --- /dev/null +++ b/core/maltoolbox-language/tests/golden_graph_mar/coreLang.json @@ -0,0 +1,20072 @@ +{ + "Application": { + "associations": { + "appConnections": { + "info": { + "modeler": "ConnectionRules associated with Applications are practically expressing a traffic flow.", + "user": "An application can communicate over one or more (mesh type) bidirectional connections." + }, + "left": { + "asset": "Application", + "fieldname": "applications", + "max": null, + "min": 0 + }, + "name": "ApplicationConnection", + "right": { + "asset": "ConnectionRule", + "fieldname": "appConnections", + "max": null, + "min": 0 + } + }, + "appExecutedApps": { + "info": { + "mitre": "Used for M1048: Application Isolation and Sandboxing", + "user": "Sandboxing and containerization are represented through AppExecution. This application hosting can be nested. For example, application 1 runs application 2 which runs application 3. (Where application 1 is an OS, application 2 is a VM, and application 3 is app running in the VM.)" + }, + "left": { + "asset": "Application", + "fieldname": "hostApp", + "max": 1, + "min": 0 + }, + "name": "AppExecution", + "right": { + "asset": "Application", + "fieldname": "appExecutedApps", + "max": null, + "min": 0 + } + }, + "appSoftProduct": { + "info": { + "user": "An application can be associated with a software product that has some specific characteristics such as specific Vulnerabilities and deployment repository." + }, + "left": { + "asset": "SoftwareProduct", + "fieldname": "appSoftProduct", + "max": 1, + "min": 0 + }, + "name": "AppSoftwareProduct", + "right": { + "asset": "Application", + "fieldname": "softApplications", + "max": null, + "min": 0 + } + }, + "containedData": { + "info": { + "user": "An application should be able to contain some data." + }, + "left": { + "asset": "Data", + "fieldname": "containedData", + "max": null, + "min": 0 + }, + "name": "AppContainment", + "right": { + "asset": "Application", + "fieldname": "containingApp", + "max": null, + "min": 0 + } + }, + "dataDependedUpon": { + "info": { + "user": "Data can be specified as a dependence for an Application." + }, + "left": { + "asset": "Data", + "fieldname": "dataDependedUpon", + "max": null, + "min": 0 + }, + "name": "Dependence", + "right": { + "asset": "Application", + "fieldname": "dependentApps", + "max": null, + "min": 0 + } + }, + "executionPrivIAMs": { + "info": { + "user": "Every application executes on a system with privileges of a specified identity on the system. If the application is compromised then the privileges should be compromised." + }, + "left": { + "asset": "IAMObject", + "fieldname": "executionPrivIAMs", + "max": null, + "min": 0 + }, + "name": "ExecutionPrivilegeAccess", + "right": { + "asset": "Application", + "fieldname": "execPrivApps", + "max": null, + "min": 0 + } + }, + "highPrivAppIAMs": { + "info": { + "user": "High privilege application access on an Application results in the (full) access/compromise of the application and all the child applications." + }, + "left": { + "asset": "IAMObject", + "fieldname": "highPrivAppIAMs", + "max": null, + "min": 0 + }, + "name": "HighPrivilegeApplicationAccess", + "right": { + "asset": "Application", + "fieldname": "highPrivApps", + "max": null, + "min": 0 + } + }, + "hostApp": { + "info": { + "mitre": "Used for M1048: Application Isolation and Sandboxing", + "user": "Sandboxing and containerization are represented through AppExecution. This application hosting can be nested. For example, application 1 runs application 2 which runs application 3. (Where application 1 is an OS, application 2 is a VM, and application 3 is app running in the VM.)" + }, + "left": { + "asset": "Application", + "fieldname": "hostApp", + "max": 1, + "min": 0 + }, + "name": "AppExecution", + "right": { + "asset": "Application", + "fieldname": "appExecutedApps", + "max": null, + "min": 0 + } + }, + "hostHardware": { + "info": { + "user": "Hardware on which Applications are running. The host hardware is not mandatory to be specified." + }, + "left": { + "asset": "Hardware", + "fieldname": "hostHardware", + "max": 1, + "min": 0 + }, + "name": "SysExecution", + "right": { + "asset": "Application", + "fieldname": "sysExecutedApps", + "max": null, + "min": 0 + } + }, + "infoDependedUpon": { + "info": { + "user": "Information can be specified as a dependence for an Application." + }, + "left": { + "asset": "Information", + "fieldname": "infoDependedUpon", + "max": null, + "min": 0 + }, + "name": "Dependence", + "right": { + "asset": "Application", + "fieldname": "dependentApps", + "max": null, + "min": 0 + } + }, + "ingoingAppConnections": { + "info": { + "user": "Two or more applications can also be inter-connected via a uni-directional connection. In means that this is the server side application, awaiting incoming connections." + }, + "left": { + "asset": "Application", + "fieldname": "inApplications", + "max": null, + "min": 0 + }, + "name": "InApplicationConnection", + "right": { + "asset": "ConnectionRule", + "fieldname": "ingoingAppConnections", + "max": null, + "min": 0 + } + }, + "lowPrivAppIAMs": { + "info": { + "user": "Low privilege application access on an Application allows only the local interaction with the application and all the specified privileges." + }, + "left": { + "asset": "IAMObject", + "fieldname": "lowPrivAppIAMs", + "max": null, + "min": 0 + }, + "name": "LowPrivilegeApplicationAccess", + "right": { + "asset": "Application", + "fieldname": "lowPrivApps", + "max": null, + "min": 0 + } + }, + "managedRoutingFw": { + "info": { + "user": "A routing firewall can have an admin/manager interface (application) that is accessible from a network location." + }, + "left": { + "asset": "RoutingFirewall", + "fieldname": "managedRoutingFw", + "max": null, + "min": 0 + }, + "name": "ManagedBy", + "right": { + "asset": "Application", + "fieldname": "managerApp", + "max": 1, + "min": 0 + } + }, + "networks": { + "info": { + "developer": "This can equivalently be modeled using a ConnectionRule but this direct approach should be prefered in the case where the application is \u201clocated\u201d in the same network as the host on which it is running.", + "modeler": "This is DEPRECATED and should only be used by fully aware modelers.", + "user": "An application can communicate / be exposed on a network." + }, + "left": { + "asset": "Network", + "fieldname": "networks", + "max": null, + "min": 0 + }, + "name": "NetworkExposure", + "right": { + "asset": "Application", + "fieldname": "applications", + "max": null, + "min": 0 + } + }, + "outgoingAppConnections": { + "info": { + "user": "Two or more applications can also be inter-connected via a uni-directional connection. Out means that this is the client side application initiating connections." + }, + "left": { + "asset": "Application", + "fieldname": "outApplications", + "max": null, + "min": 0 + }, + "name": "OutApplicationConnection", + "right": { + "asset": "ConnectionRule", + "fieldname": "outgoingAppConnections", + "max": null, + "min": 0 + } + }, + "protectorIDPSs": { + "info": { + "user": "IDPSs can provide protection to other applications." + }, + "left": { + "asset": "IDPS", + "fieldname": "protectorIDPSs", + "max": null, + "min": 0 + }, + "name": "AppProtection", + "right": { + "asset": "Application", + "fieldname": "protectedApps", + "max": null, + "min": 0 + } + }, + "receivedData": { + "info": { + "user": "An application can also receive some data that are supposed to be transferred over a network or connection. This expresses an active connection." + }, + "left": { + "asset": "Data", + "fieldname": "receivedData", + "max": null, + "min": 0 + }, + "name": "ReceiveData", + "right": { + "asset": "Application", + "fieldname": "receiverApp", + "max": null, + "min": 0 + } + }, + "sentData": { + "info": { + "user": "An application can also send some data that are supposed to be transferred over a network or connection. This expresses an active connection." + }, + "left": { + "asset": "Data", + "fieldname": "sentData", + "max": null, + "min": 0 + }, + "name": "SendData", + "right": { + "asset": "Application", + "fieldname": "senderApp", + "max": null, + "min": 0 + } + }, + "vulnerabilities": { + "info": { + "user": "An Application can have multiple software vulnerabilities." + }, + "left": { + "asset": "SoftwareVulnerability", + "fieldname": "vulnerabilities", + "max": null, + "min": 0 + }, + "name": "ApplicationVulnerability", + "right": { + "asset": "Application", + "fieldname": "application", + "max": 1, + "min": 0 + } + } + }, + "attack_steps": { + "accessNetworkAndConnections": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "The attacker can access outgoing and bidirectional networks and connections associated with the application." + }, + "inherits": null, + "name": "accessNetworkAndConnections", + "overrides": true, + "own_children": { + "ConnectionRule:attemptAccessNetworksInspected": [ + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "appConnections" + }, + "type": "field" + }, + "right": { + "OutApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingAppConnections" + }, + "type": "field" + } + } + } + ], + "ConnectionRule:attemptAccessNetworksUninspected": [ + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "appConnections" + }, + "type": "field" + }, + "right": { + "OutApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingAppConnections" + }, + "type": "field" + } + } + } + ], + "ConnectionRule:attemptConnectToApplicationsInspected": [ + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "appConnections" + }, + "type": "field" + }, + "right": { + "OutApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingAppConnections" + }, + "type": "field" + } + } + } + ], + "ConnectionRule:attemptConnectToApplicationsUninspected": [ + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "appConnections" + }, + "type": "field" + }, + "right": { + "OutApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingAppConnections" + }, + "type": "field" + } + } + } + ], + "Network:accessInspected": [ + { + "NetworkExposure": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + } + ], + "Network:accessUninspected": [ + { + "NetworkExposure": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Application:fullAccess": [ + null + ], + "Application:specificAccess": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "applicationRespondConnectThroughData": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "After full access on the application received data can be used to try a connect through respond to the client side application." + }, + "inherits": null, + "name": "applicationRespondConnectThroughData", + "overrides": true, + "own_children": { + "Data:attemptApplicationRespondConnect": [ + { + "ReceiveData": { + "asset type": "Data", + "fieldname": "receivedData" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Application:successfulApplicationRespondConnectThroughData": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attackerUnsafeUserActivityCapability": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "The impacts of the user's unsafe actions are greatly limited if there are no channels through which the attacker can be reached by the unwitting user." + }, + "inherits": null, + "name": "attackerUnsafeUserActivityCapability", + "overrides": true, + "own_children": { + "Application:successfulUnsafeUserActivity": [ + null + ] + }, + "own_parents": { + "Application:attackerUnsafeUserActivityCapabilityWithReverseReach": [ + null + ], + "Application:attackerUnsafeUserActivityCapabilityWithoutReverseReach": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attackerUnsafeUserActivityCapabilityWithReverseReach": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "The attacker can be reached from this application and can therefore more easily exploit the unsafe user actions." + }, + "inherits": null, + "name": "attackerUnsafeUserActivityCapabilityWithReverseReach", + "overrides": true, + "own_children": { + "Application:attackerUnsafeUserActivityCapability": [ + null + ] + }, + "own_parents": { + "Application:attemptUnsafeUserActivity": [ + null + ], + "Application:reverseReach": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "attackerUnsafeUserActivityCapabilityWithoutReverseReach": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "The attacker cannot be reached from this application. This means that the autonomous malicious code must either be able to do reconnaissance on the fly or the reconnaissance was done beforehand and coded into it. This is much more complex and difficult than the situation where the attacker is reachable from the application which restricts the capabilities of being successful. Important Note: This step should never directly or indirectly lead to reverse reach as it would fulfil the attacker interactivity requirements it is missing." + }, + "inherits": null, + "name": "attackerUnsafeUserActivityCapabilityWithoutReverseReach", + "overrides": true, + "own_children": { + "Application:attackerUnsafeUserActivityCapability": [ + null + ] + }, + "own_parents": { + "Application:attemptUnsafeUserActivity": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": { + "arguments": [], + "name": "VeryHardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "attemptApplicationRespondConnectThroughData": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptApplicationRespondConnectThroughData", + "overrides": true, + "own_children": { + "Application:successfulApplicationRespondConnectThroughData": [ + null + ] + }, + "own_parents": { + "Application:fullAccess": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptAuthorizedApplicationRespondConnectThroughData": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptAuthorizedApplicationRespondConnectThroughData", + "overrides": true, + "own_children": { + "Application:successfulAuthorizedApplicationRespondConnectThroughData": [ + null + ] + }, + "own_parents": { + "Application:specificAccess": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptDeny": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptDeny", + "overrides": true, + "own_children": { + "Application:successfulDeny": [ + null + ] + }, + "own_parents": { + "Application:deny": [ + { + "AppExecution": { + "asset type": "Application", + "fieldname": "hostApp" + }, + "type": "field" + } + ], + "Application:denyFromLockout": [ + null + ], + "Application:denyFromNetworkingAsset": [ + null + ], + "Application:denyFromSoftProdVulnerability": [ + null + ], + "Application:fullAccess": [ + null + ], + "Hardware:deny": [ + { + "SysExecution": { + "asset type": "Hardware", + "fieldname": "hostHardware" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptDenyFromSoftProdVulnerability": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptDenyFromSoftProdVulnerability", + "overrides": true, + "own_children": { + "Application:denyFromSoftProdVulnerability": [ + null + ] + }, + "own_parents": { + "SoftwareProduct:denyApplications": [ + { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptFullAccessFromSupplyChainCompromise": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": null, + "name": "attemptFullAccessFromSupplyChainCompromise", + "overrides": true, + "own_children": { + "Application:bypassSupplyChainAuditing": [ + null + ], + "Application:fullAccessFromSupplyChainCompromise": [ + null + ] + }, + "own_parents": { + "SoftwareProduct:compromiseApplications": [ + { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptModify": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptModify", + "overrides": true, + "own_children": { + "Application:successfulModify": [ + null + ] + }, + "own_parents": { + "Application:fullAccess": [ + null + ], + "Application:modify": [ + { + "AppExecution": { + "asset type": "Application", + "fieldname": "hostApp" + }, + "type": "field" + } + ], + "Application:modifyFromSoftProdVulnerability": [ + null + ], + "Hardware:modify": [ + { + "SysExecution": { + "asset type": "Hardware", + "fieldname": "hostHardware" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptModifyFromSoftProdVulnerability": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptModifyFromSoftProdVulnerability", + "overrides": true, + "own_children": { + "Application:modifyFromSoftProdVulnerability": [ + null + ] + }, + "own_parents": { + "SoftwareProduct:modifyApplications": [ + { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptNetworkConnectFromResponse": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptNetworkConnectFromResponse", + "overrides": true, + "own_children": { + "Application:networkConnectFromResponse": [ + null + ] + }, + "own_parents": { + "Data:applicationRespondConnect": [ + { + "SendData": { + "asset type": "Data", + "fieldname": "sentData" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptRead": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptRead", + "overrides": true, + "own_children": { + "Application:successfulRead": [ + null + ] + }, + "own_parents": { + "Application:fullAccess": [ + null + ], + "Application:read": [ + { + "AppExecution": { + "asset type": "Application", + "fieldname": "hostApp" + }, + "type": "field" + } + ], + "Application:readFromSoftProdVulnerability": [ + null + ], + "Hardware:read": [ + { + "SysExecution": { + "asset type": "Hardware", + "fieldname": "hostHardware" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptReadFromSoftProdVulnerability": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptReadFromSoftProdVulnerability", + "overrides": true, + "own_children": { + "Application:readFromSoftProdVulnerability": [ + null + ] + }, + "own_parents": { + "SoftwareProduct:readApplications": [ + { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptReverseReach": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses.", + "modeler": "If the entry point of the attacker is an Application this attack step needs to be explicitly called on it by the modeler in order to have reverse reach start propagating from the Application to the rest of the assets in the model. This is the case because no other attack step on the Application asset triggers this attack step.", + "user": "The attacker is reachable from this Application therefore start propagating reverse reach from it. Reverse reach is used to determine whether or not the attacker can be reached by the user." + }, + "inherits": null, + "name": "attemptReverseReach", + "overrides": true, + "own_children": { + "Application:successfulReverseReach": [ + null + ] + }, + "own_parents": { + "Application:reverseReach": [ + { + "AppExecution": { + "asset type": "Application", + "fieldname": "hostApp" + }, + "type": "field" + } + ], + "ConnectionRule:reverseReach": [ + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "appConnections" + }, + "type": "field" + }, + "right": { + "OutApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingAppConnections" + }, + "type": "field" + } + } + } + ], + "Network:reverseReach": [ + { + "NetworkExposure": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + } + ] + }, + "tags": [ + "entrypoint" + ], + "ttc": null, + "type": "or" + }, + "attemptSoftwareProductAbuse": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": null, + "name": "attemptSoftwareProductAbuse", + "overrides": true, + "own_children": { + "Application:softwareProductAbuse": [ + null + ] + }, + "own_parents": { + "Application:useVulnerability": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptUnsafeUserActivity": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": null, + "name": "attemptUnsafeUserActivity", + "overrides": true, + "own_children": { + "Application:attackerUnsafeUserActivityCapabilityWithReverseReach": [ + null + ], + "Application:attackerUnsafeUserActivityCapabilityWithoutReverseReach": [ + null + ], + "Application:successfulUnsafeUserActivity": [ + null + ], + "IDPS:bypassEffectiveness": [ + { + "AppProtection": { + "asset type": "IDPS", + "fieldname": "protectorIDPSs" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Hardware:unsafeUserActivity": [ + { + "SysExecution": { + "asset type": "Hardware", + "fieldname": "hostHardware" + }, + "type": "field" + } + ], + "Identity:unsafeUserActivity": [ + { + "ExecutionPrivilegeAccess": { + "asset type": "IAMObject", + "fieldname": "executionPrivIAMs" + }, + "type": "field" + }, + { + "HighPrivilegeApplicationAccess": { + "asset type": "IAMObject", + "fieldname": "highPrivAppIAMs" + }, + "type": "field" + }, + { + "LowPrivilegeApplicationAccess": { + "asset type": "IAMObject", + "fieldname": "lowPrivAppIAMs" + }, + "type": "field" + } + ], + "Privileges:unsafeUserActivity": [ + { + "ExecutionPrivilegeAccess": { + "asset type": "IAMObject", + "fieldname": "executionPrivIAMs" + }, + "type": "field" + }, + { + "HighPrivilegeApplicationAccess": { + "asset type": "IAMObject", + "fieldname": "highPrivAppIAMs" + }, + "type": "field" + }, + { + "LowPrivilegeApplicationAccess": { + "asset type": "IAMObject", + "fieldname": "lowPrivAppIAMs" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptUseVulnerability": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": null, + "name": "attemptUseVulnerability", + "overrides": true, + "own_children": { + "Application:successfulUseVulnerability": [ + null + ], + "IDPS:bypassEffectiveness": [ + { + "AppProtection": { + "asset type": "IDPS", + "fieldname": "protectorIDPSs" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Application:bypassContainerization": [ + { + "AppExecution": { + "asset type": "Application", + "fieldname": "appExecutedApps" + }, + "type": "field" + } + ], + "Application:localConnect": [ + null + ], + "Application:networkConnectUninspected": [ + null + ], + "Application:specificAccess": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "authenticate": { + "asset": "Application", + "detectors": {}, + "info": { + "user": "The attacker is able to authenticate with the appropriate high-level privileges." + }, + "inherits": null, + "name": "authenticate", + "overrides": true, + "own_children": { + "Application:localAccess": [ + null + ], + "Application:networkAccess": [ + null + ], + "Application:softwareProductVulnerabilityHighPrivilegesAchieved": [ + null + ], + "SoftwareVulnerability:highPrivilegesAchieved": [ + { + "type": "union", + "union": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "vulnerabilities" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ] + }, + "own_parents": { + "IAMObject:assume": [ + { + "ExecutionPrivilegeAccess": { + "asset type": "IAMObject", + "fieldname": "executionPrivIAMs" + }, + "type": "field" + }, + { + "HighPrivilegeApplicationAccess": { + "asset type": "IAMObject", + "fieldname": "highPrivAppIAMs" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "authorizedApplicationRespondConnectThroughData": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "After specific access on the application received data can be used to try a connect through respond to the client side application. To succeed the attacker must also attain write privileges on the received data." + }, + "inherits": null, + "name": "authorizedApplicationRespondConnectThroughData", + "overrides": true, + "own_children": { + "Data:authorizedApplicationRespondConnectFromApplication": [ + { + "ReceiveData": { + "asset type": "Data", + "fieldname": "receivedData" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Application:successfulAuthorizedApplicationRespondConnectThroughData": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "bypassContainerization": { + "asset": "Application", + "detectors": {}, + "info": { + "user": "The attacker is able to break out of an application container/sandbox and try to exploit any vulnerability of the host application." + }, + "inherits": null, + "name": "bypassContainerization", + "overrides": true, + "own_children": { + "Application:attemptUseVulnerability": [ + { + "AppExecution": { + "asset type": "Application", + "fieldname": "hostApp" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Application:specificAccess": [ + null + ] + }, + "tags": [], + "ttc": { + "arguments": [], + "name": "HardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "bypassSupplyChainAuditing": { + "asset": "Application", + "detectors": {}, + "info": { + "user": "Supply chain auditing can be bypassed." + }, + "inherits": null, + "name": "bypassSupplyChainAuditing", + "overrides": true, + "own_children": { + "Application:supplyChainAuditingBypassed": [ + null + ] + }, + "own_parents": { + "Application:attemptFullAccessFromSupplyChainCompromise": [ + null + ] + }, + "tags": [], + "ttc": { + "arguments": [], + "name": "VeryHardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "deny": { + "asset": "Application", + "detectors": {}, + "info": { + "user": "The attacker can deny some or all functionality and data pertaining to this application/service as well as executed applications." + }, + "inherits": null, + "name": "deny", + "overrides": true, + "own_children": { + "Application:attemptDeny": [ + { + "AppExecution": { + "asset type": "Application", + "fieldname": "appExecutedApps" + }, + "type": "field" + } + ], + "Data:attemptDeny": [ + { + "AppContainment": { + "asset type": "Data", + "fieldname": "containedData" + }, + "type": "field" + }, + { + "SendData": { + "asset type": "Data", + "fieldname": "sentData" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Application:successfulDeny": [ + null + ], + "Data:deny": [ + { + "Dependence": { + "asset type": "Data", + "fieldname": "dataDependedUpon" + }, + "type": "field" + } + ], + "Information:deny": [ + { + "Dependence": { + "asset type": "Information", + "fieldname": "infoDependedUpon" + }, + "type": "field" + } + ], + "SoftwareVulnerability:deny": [ + { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "vulnerabilities" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "denyFromLockout": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "This is an intermediate attack step to only trigger deny on an application when all the executing access control roles are locked out." + }, + "inherits": null, + "name": "denyFromLockout", + "overrides": true, + "own_children": { + "Application:attemptDeny": [ + null + ] + }, + "own_parents": { + "IAMObject:lockout": [ + { + "ExecutionPrivilegeAccess": { + "asset type": "IAMObject", + "fieldname": "executionPrivIAMs" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "denyFromNetworkingAsset": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "This is an intermediate attack step to only allow deny on an application when all the connection rules and networks associated with it are denied, because an app can be serving on many different ports." + }, + "inherits": null, + "name": "denyFromNetworkingAsset", + "overrides": true, + "own_children": { + "Application:attemptDeny": [ + null + ] + }, + "own_parents": { + "ConnectionRule:deny": [ + { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "appConnections" + }, + "type": "field" + }, + "right": { + "InApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "ingoingAppConnections" + }, + "type": "field" + } + } + }, + "right": { + "OutApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingAppConnections" + }, + "type": "field" + } + } + } + ], + "Network:deny": [ + { + "type": "union", + "union": { + "left": { + "NetworkExposure": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "ApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "appConnections" + }, + "type": "field" + }, + "right": { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + }, + "right": { + "InNetworkConnection": { + "asset type": "Network", + "fieldname": "inNetworks" + }, + "type": "field" + } + } + }, + "right": { + "OutNetworkConnection": { + "asset type": "Network", + "fieldname": "outNetworks" + }, + "type": "field" + } + } + }, + "right": { + "DiodeInNetworkConnection": { + "asset type": "Network", + "fieldname": "diodeInNetworks" + }, + "type": "field" + } + } + } + }, + "type": "collect" + } + } + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "denyFromSoftProdVulnerability": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to handle SoftwareProduct existence." + }, + "inherits": null, + "name": "denyFromSoftProdVulnerability", + "overrides": true, + "own_children": { + "Application:attemptDeny": [ + null + ] + }, + "own_parents": { + "Application:attemptDenyFromSoftProdVulnerability": [ + null + ], + "Application:notPresent": [ + null + ], + "Application:softwareCheck": [ + null + ], + "Application:softwareProductAbuse": [ + null + ], + "Application:useVulnerability": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "fullAccess": { + "asset": "Application", + "detectors": {}, + "info": { + "modeler": "It is common in models for the attacker to be granted full access on an Application, representing their own tools, that is connected to the Internet to explore possible attack vectors.", + "user": "Full access on the Application means the attacker has complete control of the application and can perform any actions within its execution context." + }, + "inherits": null, + "name": "fullAccess", + "overrides": true, + "own_children": { + "Application:accessNetworkAndConnections": [ + null + ], + "Application:attemptApplicationRespondConnectThroughData": [ + null + ], + "Application:attemptDeny": [ + null + ], + "Application:attemptModify": [ + null + ], + "Application:attemptRead": [ + null + ], + "Application:localConnect": [ + { + "AppExecution": { + "asset type": "Application", + "fieldname": "hostApp" + }, + "type": "field" + } + ], + "Application:specificAccess": [ + null + ], + "Hardware:attemptSpreadWormThroughRemovableMedia": [ + { + "SysExecution": { + "asset type": "Hardware", + "fieldname": "hostHardware" + }, + "type": "field" + } + ], + "Hardware:attemptUseVulnerabilityFromSoftwareFullAccess": [ + { + "SysExecution": { + "asset type": "Hardware", + "fieldname": "hostHardware" + }, + "type": "field" + } + ], + "IAMObject:attemptAssume": [ + { + "ExecutionPrivilegeAccess": { + "asset type": "IAMObject", + "fieldname": "executionPrivIAMs" + }, + "type": "field" + } + ], + "RoutingFirewall:attemptModify": [ + { + "ManagedBy": { + "asset type": "RoutingFirewall", + "fieldname": "managedRoutingFw" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Application:fullAccessFromSupplyChainCompromise": [ + null + ], + "Application:localAccess": [ + null + ], + "Application:modify": [ + null + ], + "Application:networkAccess": [ + null + ], + "Data:write": [ + { + "Dependence": { + "asset type": "Data", + "fieldname": "dataDependedUpon" + }, + "type": "field" + } + ], + "Hardware:fullAccess": [ + { + "SysExecution": { + "asset type": "Hardware", + "fieldname": "hostHardware" + }, + "type": "field" + } + ], + "Information:write": [ + { + "Dependence": { + "asset type": "Information", + "fieldname": "infoDependedUpon" + }, + "type": "field" + } + ] + }, + "tags": [ + "entrypoint" + ], + "ttc": null, + "type": "or" + }, + "fullAccessFromSupplyChainCompromise": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step." + }, + "inherits": null, + "name": "fullAccessFromSupplyChainCompromise", + "overrides": true, + "own_children": { + "Application:fullAccess": [ + null + ] + }, + "own_parents": { + "Application:attemptFullAccessFromSupplyChainCompromise": [ + null + ], + "Application:notPresent": [ + null + ], + "Application:supplyChainAuditingBypassed": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "localAccess": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack steps for modeling the two requirements (reachability and authentication) for full access." + }, + "inherits": null, + "name": "localAccess", + "overrides": true, + "own_children": { + "Application:fullAccess": [ + null + ] + }, + "own_parents": { + "Application:authenticate": [ + null + ], + "Application:localConnect": [ + null + ], + "Application:notPresent": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "localConnect": { + "asset": "Application", + "detectors": {}, + "info": { + "user": "The attacker can interface with the Application without going through networking communications. This is achieved by gaining full access on a hosted Application, specific access on the hosting Application, physical access on the hardware on which the Application is running, or via unsafe user activity." + }, + "inherits": null, + "name": "localConnect", + "overrides": true, + "own_children": { + "Application:attemptUseVulnerability": [ + null + ], + "Application:localAccess": [ + null + ], + "Application:softwareProductVulnerabilityLocalAccessAchieved": [ + null + ], + "Application:specificAccessFromLocalConnection": [ + null + ], + "SoftwareVulnerability:localAccessAchieved": [ + { + "type": "union", + "union": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "vulnerabilities" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ] + }, + "own_parents": { + "Application:fullAccess": [ + { + "AppExecution": { + "asset type": "Application", + "fieldname": "appExecutedApps" + }, + "type": "field" + } + ], + "Application:specificAccess": [ + { + "AppExecution": { + "asset type": "Application", + "fieldname": "hostApp" + }, + "type": "field" + } + ], + "Application:unsafeUserActivity": [ + null + ], + "Hardware:physicalAccess": [ + { + "SysExecution": { + "asset type": "Hardware", + "fieldname": "hostHardware" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "modify": { + "asset": "Application", + "detectors": {}, + "info": { + "user": "The attacker can modify some or all of this service's (and executed by this) source code and/or data." + }, + "inherits": null, + "name": "modify", + "overrides": true, + "own_children": { + "Application:attemptModify": [ + { + "AppExecution": { + "asset type": "Application", + "fieldname": "appExecutedApps" + }, + "type": "field" + } + ], + "Application:fullAccess": [ + null + ], + "Data:attemptWrite": [ + { + "AppContainment": { + "asset type": "Data", + "fieldname": "containedData" + }, + "type": "field" + }, + { + "SendData": { + "asset type": "Data", + "fieldname": "sentData" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Application:successfulModify": [ + null + ], + "SoftwareVulnerability:modify": [ + { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "vulnerabilities" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "modifyFromSoftProdVulnerability": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to handle SoftwareProduct existence." + }, + "inherits": null, + "name": "modifyFromSoftProdVulnerability", + "overrides": true, + "own_children": { + "Application:attemptModify": [ + null + ] + }, + "own_parents": { + "Application:attemptModifyFromSoftProdVulnerability": [ + null + ], + "Application:notPresent": [ + null + ], + "Application:softwareCheck": [ + null + ], + "Application:softwareProductAbuse": [ + null + ], + "Application:useVulnerability": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "networkAccess": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack steps for modeling the two requirements (reachability and authentication) for full access." + }, + "inherits": null, + "name": "networkAccess", + "overrides": true, + "own_children": { + "Application:fullAccess": [ + null + ] + }, + "own_parents": { + "Application:authenticate": [ + null + ], + "Application:networkConnect": [ + null + ], + "Application:notPresent": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "networkConnect": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step." + }, + "inherits": null, + "name": "networkConnect", + "overrides": true, + "own_children": { + "Application:networkAccess": [ + null + ] + }, + "own_parents": { + "Application:networkConnectInspected": [ + null + ], + "Application:networkConnectUninspected": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "networkConnectFromResponse": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "An attacker may be able to respond to requests submitted by a client application." + }, + "inherits": null, + "name": "networkConnectFromResponse", + "overrides": true, + "own_children": { + "Application:networkConnectUninspected": [ + null + ] + }, + "own_parents": { + "Application:attemptNetworkConnectFromResponse": [ + null + ], + "Application:notPresent": [ + null + ], + "Application:reverseReach": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": { + "arguments": [], + "name": "VeryHardAndUncertain", + "type": "function" + }, + "type": "and" + }, + "networkConnectInspected": { + "asset": "Application", + "detectors": {}, + "info": { + "user": "If the network communications occur via any ConnectionRules that have their payload inspected the attacker can still authenticate, but they cannot trigger vulnerabilities." + }, + "inherits": null, + "name": "networkConnectInspected", + "overrides": true, + "own_children": { + "Application:networkConnect": [ + null + ], + "Application:specificAccessNetworkConnect": [ + null + ] + }, + "own_parents": { + "ConnectionRule:connectToApplicationsInspected": [ + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "appConnections" + }, + "type": "field" + }, + "right": { + "InApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "ingoingAppConnections" + }, + "type": "field" + } + } + } + ], + "ConnectionRule:connectToApplicationsUninspected": [ + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "appConnections" + }, + "type": "field" + }, + "right": { + "InApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "ingoingAppConnections" + }, + "type": "field" + } + } + } + ], + "Network:accessInspected": [ + { + "NetworkExposure": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "networkConnectUninspected": { + "asset": "Application", + "detectors": {}, + "info": { + "user": "An attacker can connect to any network exposed application and try to authenticate or trigger vulnerabilities." + }, + "inherits": null, + "name": "networkConnectUninspected", + "overrides": true, + "own_children": { + "Application:attemptUseVulnerability": [ + null + ], + "Application:networkConnect": [ + null + ], + "Application:softwareProductVulnerabilityNetworkAccessAchieved": [ + null + ], + "Application:specificAccessNetworkConnect": [ + null + ], + "SoftwareVulnerability:networkAccessAchieved": [ + { + "type": "union", + "union": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "vulnerabilities" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ] + }, + "own_parents": { + "Application:networkConnectFromResponse": [ + null + ], + "Application:unsafeUserActivity": [ + null + ], + "ConnectionRule:connectToApplicationsUninspected": [ + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "appConnections" + }, + "type": "field" + }, + "right": { + "InApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "ingoingAppConnections" + }, + "type": "field" + } + } + } + ], + "Network:accessUninspected": [ + { + "NetworkExposure": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + } + ], + "RoutingFirewall:fullAccess": [ + { + "collect": { + "left": { + "ApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "appConnections" + }, + "type": "field" + }, + "right": { + "FirewallConnectionRule": { + "asset type": "RoutingFirewall", + "fieldname": "routingFirewalls" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "notPresent": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "This defense allows for both speculation about the presence of a particular application and to suggest that if it is possible removing it may restrict the attacker's actions.", + "user": "This defense is used to reason about the attack vectors introduced by this asset. It should be used to model the possibility that the Application does not actually exist. Typically this is useful to see what attack steps are removed if a particular non-essential component(e.g. a plugin, library, feature, or service)." + }, + "inherits": null, + "name": "notPresent", + "overrides": true, + "own_children": { + "Application:denyFromSoftProdVulnerability": [ + null + ], + "Application:fullAccessFromSupplyChainCompromise": [ + null + ], + "Application:localAccess": [ + null + ], + "Application:modifyFromSoftProdVulnerability": [ + null + ], + "Application:networkAccess": [ + null + ], + "Application:networkConnectFromResponse": [ + null + ], + "Application:readFromSoftProdVulnerability": [ + null + ], + "Application:specificAccessFromLocalConnection": [ + null + ], + "Application:specificAccessFromNetworkConnection": [ + null + ], + "Application:successfulApplicationRespondConnectThroughData": [ + null + ], + "Application:successfulAuthorizedApplicationRespondConnectThroughData": [ + null + ], + "Application:successfulDeny": [ + null + ], + "Application:successfulModify": [ + null + ], + "Application:successfulRead": [ + null + ], + "Application:successfulReverseReach": [ + null + ], + "Application:successfulUnsafeUserActivity": [ + null + ], + "Application:successfulUseVulnerability": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "physicalAccessAchieved": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to propagate physical access throughout application nesting." + }, + "inherits": null, + "name": "physicalAccessAchieved", + "overrides": true, + "own_children": { + "Application:physicalAccessAchieved": [ + { + "AppExecution": { + "asset type": "Application", + "fieldname": "appExecutedApps" + }, + "type": "field" + } + ], + "Application:softwareProductVulnerabilityPhysicalAccessAchieved": [ + null + ], + "SoftwareVulnerability:physicalAccessAchieved": [ + { + "type": "union", + "union": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "vulnerabilities" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ] + }, + "own_parents": { + "Application:physicalAccessAchieved": [ + { + "AppExecution": { + "asset type": "Application", + "fieldname": "hostApp" + }, + "type": "field" + } + ], + "Hardware:hardwareModifications": [ + { + "SysExecution": { + "asset type": "Hardware", + "fieldname": "hostHardware" + }, + "type": "field" + } + ], + "Hardware:unsafeUserActivity": [ + { + "SysExecution": { + "asset type": "Hardware", + "fieldname": "hostHardware" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "read": { + "asset": "Application", + "detectors": {}, + "info": { + "user": "The attacker can read some or all of this service's (and executed by this) source code and/or data (both local and sent/received)." + }, + "inherits": null, + "name": "read", + "overrides": true, + "own_children": { + "Application:attemptRead": [ + { + "AppExecution": { + "asset type": "Application", + "fieldname": "appExecutedApps" + }, + "type": "field" + } + ], + "Data:attemptRead": [ + { + "AppContainment": { + "asset type": "Data", + "fieldname": "containedData" + }, + "type": "field" + }, + { + "SendData": { + "asset type": "Data", + "fieldname": "sentData" + }, + "type": "field" + }, + { + "ReceiveData": { + "asset type": "Data", + "fieldname": "receivedData" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Application:successfulRead": [ + null + ], + "SoftwareVulnerability:read": [ + { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "vulnerabilities" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "readFromSoftProdVulnerability": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to handle SoftwareProduct existence." + }, + "inherits": null, + "name": "readFromSoftProdVulnerability", + "overrides": true, + "own_children": { + "Application:attemptRead": [ + null + ] + }, + "own_parents": { + "Application:attemptReadFromSoftProdVulnerability": [ + null + ], + "Application:notPresent": [ + null + ], + "Application:softwareCheck": [ + null + ], + "Application:softwareProductAbuse": [ + null + ], + "Application:useVulnerability": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "reverseReach": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Reverse reach is used to determine whether or not the attacker can be reached by the user. Reverse reach propagates toward outgoing or bidirectional communications." + }, + "inherits": null, + "name": "reverseReach", + "overrides": true, + "own_children": { + "Application:attackerUnsafeUserActivityCapabilityWithReverseReach": [ + null + ], + "Application:attemptReverseReach": [ + { + "AppExecution": { + "asset type": "Application", + "fieldname": "appExecutedApps" + }, + "type": "field" + } + ], + "Application:networkConnectFromResponse": [ + null + ], + "ConnectionRule:attemptReverseReach": [ + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "appConnections" + }, + "type": "field" + }, + "right": { + "InApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "ingoingAppConnections" + }, + "type": "field" + } + } + } + ], + "Data:attemptReverseReach": [ + { + "AppContainment": { + "asset type": "Data", + "fieldname": "containedData" + }, + "type": "field" + }, + { + "SendData": { + "asset type": "Data", + "fieldname": "sentData" + }, + "type": "field" + }, + { + "ReceiveData": { + "asset type": "Data", + "fieldname": "receivedData" + }, + "type": "field" + } + ], + "Network:attemptReverseReach": [ + { + "NetworkExposure": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Application:successfulReverseReach": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "softwareCheck": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Only enable the following attacks steps if a SoftwareProduct is associated with this Application." + }, + "inherits": null, + "name": "softwareCheck", + "overrides": true, + "own_children": { + "Application:denyFromSoftProdVulnerability": [ + null + ], + "Application:modifyFromSoftProdVulnerability": [ + null + ], + "Application:readFromSoftProdVulnerability": [ + null + ] + }, + "own_parents": {}, + "requires": [ + { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + } + ], + "tags": [ + "hidden" + ], + "ttc": null, + "type": "exist" + }, + "softwareProductAbuse": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": null, + "name": "softwareProductAbuse", + "overrides": true, + "own_children": { + "Application:denyFromSoftProdVulnerability": [ + null + ], + "Application:modifyFromSoftProdVulnerability": [ + null + ], + "Application:readFromSoftProdVulnerability": [ + null + ] + }, + "own_parents": { + "Application:attemptSoftwareProductAbuse": [ + null + ], + "Application:softwareProductVulnerabilityHighPrivilegesAchieved": [ + null + ], + "Application:softwareProductVulnerabilityLocalAccessAchieved": [ + null + ], + "Application:softwareProductVulnerabilityLowPrivilegesAchieved": [ + null + ], + "Application:softwareProductVulnerabilityNetworkAccessAchieved": [ + null + ], + "Application:softwareProductVulnerabilityPhysicalAccessAchieved": [ + null + ], + "Application:softwareProductVulnerabilityUserInteractionAchieved": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "softwareProductVulnerabilityHighPrivilegesAchieved": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": null, + "name": "softwareProductVulnerabilityHighPrivilegesAchieved", + "overrides": true, + "own_children": { + "Application:softwareProductAbuse": [ + null + ], + "SoftwareVulnerability:highPrivilegesAchieved": [ + { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "own_parents": { + "Application:authenticate": [ + null + ], + "SoftwareVulnerability:highPrivilegesRequired": [ + { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "softwareProductVulnerabilityLocalAccessAchieved": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": null, + "name": "softwareProductVulnerabilityLocalAccessAchieved", + "overrides": true, + "own_children": { + "Application:softwareProductAbuse": [ + null + ], + "SoftwareVulnerability:localAccessAchieved": [ + { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "own_parents": { + "Application:localConnect": [ + null + ], + "SoftwareVulnerability:localAccessRequired": [ + { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "softwareProductVulnerabilityLowPrivilegesAchieved": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": null, + "name": "softwareProductVulnerabilityLowPrivilegesAchieved", + "overrides": true, + "own_children": { + "Application:softwareProductAbuse": [ + null + ], + "SoftwareVulnerability:lowPrivilegesAchieved": [ + { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "own_parents": { + "Application:specificAccessAuthenticate": [ + null + ], + "SoftwareVulnerability:lowPrivilegesRequired": [ + { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "softwareProductVulnerabilityNetworkAccessAchieved": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": null, + "name": "softwareProductVulnerabilityNetworkAccessAchieved", + "overrides": true, + "own_children": { + "Application:softwareProductAbuse": [ + null + ], + "SoftwareVulnerability:networkAccessAchieved": [ + { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "own_parents": { + "Application:networkConnectUninspected": [ + null + ], + "SoftwareVulnerability:networkAccessRequired": [ + { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "softwareProductVulnerabilityPhysicalAccessAchieved": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": null, + "name": "softwareProductVulnerabilityPhysicalAccessAchieved", + "overrides": true, + "own_children": { + "Application:softwareProductAbuse": [ + null + ], + "SoftwareVulnerability:physicalAccessAchieved": [ + { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "own_parents": { + "Application:physicalAccessAchieved": [ + null + ], + "SoftwareVulnerability:physicalAccessRequired": [ + { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "softwareProductVulnerabilityUserInteractionAchieved": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": null, + "name": "softwareProductVulnerabilityUserInteractionAchieved", + "overrides": true, + "own_children": { + "Application:softwareProductAbuse": [ + null + ], + "SoftwareVulnerability:userInteractionAchieved": [ + { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "own_parents": { + "SoftwareVulnerability:inherentUserInteraction": [ + { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "SoftwareVulnerability:userInteractionRequired": [ + { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "specificAccess": { + "asset": "Application", + "detectors": {}, + "info": { + "user": "The adversary is able to gain low-privilege access on the Application which allows them to access the networks and connections associated with it and locally connect to hosted Applications. Additionally, if they have the required privileges the attacker may also access data hosted, sent, or received by the Application." + }, + "inherits": null, + "name": "specificAccess", + "overrides": true, + "own_children": { + "Application:accessNetworkAndConnections": [ + null + ], + "Application:attemptAuthorizedApplicationRespondConnectThroughData": [ + null + ], + "Application:attemptUseVulnerability": [ + null + ], + "Application:bypassContainerization": [ + null + ], + "Application:localConnect": [ + { + "AppExecution": { + "asset type": "Application", + "fieldname": "appExecutedApps" + }, + "type": "field" + } + ], + "Application:specificAccessDelete": [ + null + ], + "Application:specificAccessModify": [ + null + ], + "Application:specificAccessRead": [ + null + ] + }, + "own_parents": { + "Application:fullAccess": [ + null + ], + "Application:specificAccessFromLocalConnection": [ + null + ], + "Application:specificAccessFromNetworkConnection": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "specificAccessAuthenticate": { + "asset": "Application", + "detectors": {}, + "info": { + "user": "The attacker is able to authenticate with the appropriate low-level privileges." + }, + "inherits": null, + "name": "specificAccessAuthenticate", + "overrides": true, + "own_children": { + "Application:softwareProductVulnerabilityLowPrivilegesAchieved": [ + null + ], + "Application:specificAccessFromLocalConnection": [ + null + ], + "Application:specificAccessFromNetworkConnection": [ + null + ], + "SoftwareVulnerability:lowPrivilegesAchieved": [ + { + "type": "union", + "union": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "vulnerabilities" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ] + }, + "own_parents": { + "IAMObject:assume": [ + { + "LowPrivilegeApplicationAccess": { + "asset type": "IAMObject", + "fieldname": "lowPrivAppIAMs" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "specificAccessDelete": { + "asset": "Application", + "detectors": {}, + "info": { + "user": "The attacker can delete some or all functionality and data pertaining to this application/service as well as executed applications, given the necessary permissions" + }, + "inherits": null, + "name": "specificAccessDelete", + "overrides": true, + "own_children": { + "Data:authorizedDeleteFromApplication": [ + { + "AppContainment": { + "asset type": "Data", + "fieldname": "containedData" + }, + "type": "field" + }, + { + "SendData": { + "asset type": "Data", + "fieldname": "sentData" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Application:specificAccess": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "specificAccessFromLocalConnection": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack steps for modeling the two requirements (reachability and authentication) for specific access." + }, + "inherits": null, + "name": "specificAccessFromLocalConnection", + "overrides": true, + "own_children": { + "Application:specificAccess": [ + null + ] + }, + "own_parents": { + "Application:localConnect": [ + null + ], + "Application:notPresent": [ + null + ], + "Application:specificAccessAuthenticate": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "specificAccessFromNetworkConnection": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack steps for modeling the two requirements (reachability and authentication) for specific access." + }, + "inherits": null, + "name": "specificAccessFromNetworkConnection", + "overrides": true, + "own_children": { + "Application:specificAccess": [ + null + ] + }, + "own_parents": { + "Application:notPresent": [ + null + ], + "Application:specificAccessAuthenticate": [ + null + ], + "Application:specificAccessNetworkConnect": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "specificAccessModify": { + "asset": "Application", + "detectors": {}, + "info": { + "user": "The attacker can modify the service's source code and/or sent Data, given the necessary permissions" + }, + "inherits": null, + "name": "specificAccessModify", + "overrides": true, + "own_children": { + "Data:authorizedWriteFromApplication": [ + { + "AppContainment": { + "asset type": "Data", + "fieldname": "containedData" + }, + "type": "field" + }, + { + "SendData": { + "asset type": "Data", + "fieldname": "sentData" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Application:specificAccess": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "specificAccessNetworkConnect": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step." + }, + "inherits": null, + "name": "specificAccessNetworkConnect", + "overrides": true, + "own_children": { + "Application:specificAccessFromNetworkConnection": [ + null + ] + }, + "own_parents": { + "Application:networkConnectInspected": [ + null + ], + "Application:networkConnectUninspected": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "specificAccessRead": { + "asset": "Application", + "detectors": {}, + "info": { + "user": "The attacker can read the service's source code and/or sent and received Data, given the necessary permissions" + }, + "inherits": null, + "name": "specificAccessRead", + "overrides": true, + "own_children": { + "Data:authorizedReadFromApplication": [ + { + "AppContainment": { + "asset type": "Data", + "fieldname": "containedData" + }, + "type": "field" + }, + { + "SendData": { + "asset type": "Data", + "fieldname": "sentData" + }, + "type": "field" + }, + { + "ReceiveData": { + "asset type": "Data", + "fieldname": "receivedData" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Application:specificAccess": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "successfulApplicationRespondConnectThroughData": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulApplicationRespondConnectThroughData", + "overrides": true, + "own_children": { + "Application:applicationRespondConnectThroughData": [ + null + ] + }, + "own_parents": { + "Application:attemptApplicationRespondConnectThroughData": [ + null + ], + "Application:notPresent": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulAuthorizedApplicationRespondConnectThroughData": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulAuthorizedApplicationRespondConnectThroughData", + "overrides": true, + "own_children": { + "Application:authorizedApplicationRespondConnectThroughData": [ + null + ] + }, + "own_parents": { + "Application:attemptAuthorizedApplicationRespondConnectThroughData": [ + null + ], + "Application:notPresent": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulDeny": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulDeny", + "overrides": true, + "own_children": { + "Application:deny": [ + null + ] + }, + "own_parents": { + "Application:attemptDeny": [ + null + ], + "Application:notPresent": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulModify": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulModify", + "overrides": true, + "own_children": { + "Application:modify": [ + null + ] + }, + "own_parents": { + "Application:attemptModify": [ + null + ], + "Application:notPresent": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulRead": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulRead", + "overrides": true, + "own_children": { + "Application:read": [ + null + ] + }, + "own_parents": { + "Application:attemptRead": [ + null + ], + "Application:notPresent": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulReverseReach": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulReverseReach", + "overrides": true, + "own_children": { + "Application:reverseReach": [ + null + ] + }, + "own_parents": { + "Application:attemptReverseReach": [ + null + ], + "Application:notPresent": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulUnsafeUserActivity": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulUnsafeUserActivity", + "overrides": true, + "own_children": { + "Application:unsafeUserActivity": [ + null + ] + }, + "own_parents": { + "Application:attackerUnsafeUserActivityCapability": [ + null + ], + "Application:attemptUnsafeUserActivity": [ + null + ], + "Application:notPresent": [ + null + ], + "IDPS:effectivenessBypassed": [ + { + "AppProtection": { + "asset type": "IDPS", + "fieldname": "protectorIDPSs" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulUseVulnerability": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulUseVulnerability", + "overrides": true, + "own_children": { + "Application:useVulnerability": [ + null + ] + }, + "own_parents": { + "Application:attemptUseVulnerability": [ + null + ], + "Application:notPresent": [ + null + ], + "IDPS:effectivenessBypassed": [ + { + "AppProtection": { + "asset type": "IDPS", + "fieldname": "protectorIDPSs" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "supplyChainAuditing": { + "asset": "Application", + "detectors": {}, + "info": { + "user": "Auditing inside the supply chain can detect possible supply chain attacks." + }, + "inherits": null, + "name": "supplyChainAuditing", + "overrides": true, + "own_children": { + "Application:supplyChainAuditingBypassed": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "defense" + }, + "supplyChainAuditingBypassed": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "The supply chain audit is bypassed either because it was not set or the attacker was able to circumvent it through additional effort." + }, + "inherits": null, + "name": "supplyChainAuditingBypassed", + "overrides": true, + "own_children": { + "Application:fullAccessFromSupplyChainCompromise": [ + null + ] + }, + "own_parents": { + "Application:bypassSupplyChainAuditing": [ + null + ], + "Application:supplyChainAuditing": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "unsafeUserActivity": { + "asset": "Application", + "detectors": {}, + "info": { + "user": "The unsafe actions of users on this application open it up to attacks and enable vulnerabilities that require user interaction." + }, + "inherits": null, + "name": "unsafeUserActivity", + "overrides": true, + "own_children": { + "Application:localConnect": [ + null + ], + "Application:networkConnectUninspected": [ + null + ], + "SoftwareVulnerability:userInteractionAchieved": [ + { + "type": "union", + "union": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "vulnerabilities" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ] + }, + "own_parents": { + "Application:successfulUnsafeUserActivity": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "useVulnerability": { + "asset": "Application", + "detectors": {}, + "info": { + "developer": "This includes vulnerabilities associated with the SoftwareProduct that this Application is running as well.", + "user": "Try to exploit the associated vulnerabilities." + }, + "inherits": null, + "name": "useVulnerability", + "overrides": true, + "own_children": { + "Application:attemptSoftwareProductAbuse": [ + null + ], + "Application:denyFromSoftProdVulnerability": [ + null + ], + "Application:modifyFromSoftProdVulnerability": [ + null + ], + "Application:readFromSoftProdVulnerability": [ + null + ], + "SoftwareVulnerability:attemptAbuse": [ + { + "type": "union", + "union": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "vulnerabilities" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ] + }, + "own_parents": { + "Application:successfulUseVulnerability": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": { + "modeler": "Applications are some of the most common assets in models due to their versatility and scope.", + "user": "An application models any type of software process or component, from firmware and operating systems to plugins and sessions." + }, + "is_abstract": false, + "name": "Application", + "sub_assets": [ + "IDPS", + "RoutingFirewall" + ], + "super_asset": "", + "variables": { + "allVulnerabilities": [ + "SoftwareVulnerability", + { + "type": "union", + "union": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "vulnerabilities" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "AppSoftwareProduct": { + "asset type": "SoftwareProduct", + "fieldname": "appSoftProduct" + }, + "type": "field" + }, + "right": { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ], + "ingoingApplicationConnections": [ + "ConnectionRule", + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "appConnections" + }, + "type": "field" + }, + "right": { + "InApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "ingoingAppConnections" + }, + "type": "field" + } + } + } + ], + "outgoingApplicationConnections": [ + "ConnectionRule", + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "appConnections" + }, + "type": "field" + }, + "right": { + "OutApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingAppConnections" + }, + "type": "field" + } + } + } + ] + } + }, + "ConnectionRule": { + "associations": { + "applications": { + "info": { + "modeler": "ConnectionRules associated with Applications are practically expressing a traffic flow.", + "user": "An application can communicate over one or more (mesh type) bidirectional connections." + }, + "left": { + "asset": "Application", + "fieldname": "applications", + "max": null, + "min": 0 + }, + "name": "ApplicationConnection", + "right": { + "asset": "ConnectionRule", + "fieldname": "appConnections", + "max": null, + "min": 0 + } + }, + "diodeInNetworks": { + "info": { + "user": "Two or more networks can also be inter-connected via a uni-directional connection that acts like a diode. That means that no responses can come back after connection initiation." + }, + "left": { + "asset": "Network", + "fieldname": "diodeInNetworks", + "max": null, + "min": 0 + }, + "name": "DiodeInNetworkConnection", + "right": { + "asset": "ConnectionRule", + "fieldname": "diodeIngoingNetConnections", + "max": null, + "min": 0 + } + }, + "inApplications": { + "info": { + "user": "Two or more applications can also be inter-connected via a uni-directional connection. In means that this is the server side application, awaiting incoming connections." + }, + "left": { + "asset": "Application", + "fieldname": "inApplications", + "max": null, + "min": 0 + }, + "name": "InApplicationConnection", + "right": { + "asset": "ConnectionRule", + "fieldname": "ingoingAppConnections", + "max": null, + "min": 0 + } + }, + "inNetworks": { + "info": { + "user": "Two or more networks can also be inter-connected via a uni-directional connection. In means that ingoing traffic to the network is allowed." + }, + "left": { + "asset": "Network", + "fieldname": "inNetworks", + "max": null, + "min": 0 + }, + "name": "InNetworkConnection", + "right": { + "asset": "ConnectionRule", + "fieldname": "ingoingNetConnections", + "max": null, + "min": 0 + } + }, + "networks": { + "info": { + "user": "Two or more networks can also be inter-connected via a bidirectional connection." + }, + "left": { + "asset": "Network", + "fieldname": "networks", + "max": null, + "min": 0 + }, + "name": "NetworkConnection", + "right": { + "asset": "ConnectionRule", + "fieldname": "netConnections", + "max": null, + "min": 0 + } + }, + "outApplications": { + "info": { + "user": "Two or more applications can also be inter-connected via a uni-directional connection. Out means that this is the client side application initiating connections." + }, + "left": { + "asset": "Application", + "fieldname": "outApplications", + "max": null, + "min": 0 + }, + "name": "OutApplicationConnection", + "right": { + "asset": "ConnectionRule", + "fieldname": "outgoingAppConnections", + "max": null, + "min": 0 + } + }, + "outNetworks": { + "info": { + "user": "Two or more networks can also be inter-connected via a uni-directional connection. Out means that outgoing traffic from the network is allowed." + }, + "left": { + "asset": "Network", + "fieldname": "outNetworks", + "max": null, + "min": 0 + }, + "name": "OutNetworkConnection", + "right": { + "asset": "ConnectionRule", + "fieldname": "outgoingNetConnections", + "max": null, + "min": 0 + } + }, + "routingFirewalls": { + "info": { + "user": "A routing firewall has routing or firewall rules specified by connections between networks or applications respectively." + }, + "left": { + "asset": "RoutingFirewall", + "fieldname": "routingFirewalls", + "max": 1, + "min": 0 + }, + "name": "FirewallConnectionRule", + "right": { + "asset": "ConnectionRule", + "fieldname": "connectionRules", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "accessNetworksInspected": { + "asset": "ConnectionRule", + "detectors": {}, + "info": { + "developer": "Access all networks that are associated with this ConnectionRule, but taking into account that the traffic payload is being inspected." + }, + "inherits": null, + "name": "accessNetworksInspected", + "overrides": true, + "own_children": { + "Network:accessInspected": [ + { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + }, + "right": { + "InNetworkConnection": { + "asset type": "Network", + "fieldname": "inNetworks" + }, + "type": "field" + } + } + }, + "right": { + "DiodeInNetworkConnection": { + "asset type": "Network", + "fieldname": "diodeInNetworks" + }, + "type": "field" + } + } + } + ] + }, + "own_parents": { + "ConnectionRule:accessNetworksUninspected": [ + null + ], + "ConnectionRule:successfulAccessNetworksInspected": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "accessNetworksUninspected": { + "asset": "ConnectionRule", + "detectors": {}, + "info": { + "developer": "Access all networks that are associated with this ConnectionRule, without any restriction due to inspection." + }, + "inherits": null, + "name": "accessNetworksUninspected", + "overrides": true, + "own_children": { + "ConnectionRule:accessNetworksInspected": [ + null + ], + "Network:accessUninspected": [ + { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + }, + "right": { + "InNetworkConnection": { + "asset type": "Network", + "fieldname": "inNetworks" + }, + "type": "field" + } + } + }, + "right": { + "DiodeInNetworkConnection": { + "asset type": "Network", + "fieldname": "diodeInNetworks" + }, + "type": "field" + } + } + } + ] + }, + "own_parents": { + "ConnectionRule:successfulAccessNetworksUninspected": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "attemptAccessNetworksInspected": { + "asset": "ConnectionRule", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": null, + "name": "attemptAccessNetworksInspected", + "overrides": true, + "own_children": { + "ConnectionRule:bypassRestricted": [ + null + ], + "ConnectionRule:successfulAccessNetworksInspected": [ + null + ] + }, + "own_parents": { + "Application:accessNetworkAndConnections": [ + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + }, + "right": { + "OutApplicationConnection": { + "asset type": "Application", + "fieldname": "outApplications" + }, + "type": "field" + } + } + } + ], + "Network:networkForwardingInspected": [ + { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + }, + "right": { + "OutNetworkConnection": { + "asset type": "Network", + "fieldname": "outNetworks" + }, + "type": "field" + } + } + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptAccessNetworksUninspected": { + "asset": "ConnectionRule", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": null, + "name": "attemptAccessNetworksUninspected", + "overrides": true, + "own_children": { + "ConnectionRule:bypassRestricted": [ + null + ], + "ConnectionRule:successfulAccessNetworksUninspected": [ + null + ] + }, + "own_parents": { + "Application:accessNetworkAndConnections": [ + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + }, + "right": { + "OutApplicationConnection": { + "asset type": "Application", + "fieldname": "outApplications" + }, + "type": "field" + } + } + } + ], + "Network:networkForwardingUninspected": [ + { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + }, + "right": { + "OutNetworkConnection": { + "asset type": "Network", + "fieldname": "outNetworks" + }, + "type": "field" + } + } + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptConnectToApplicationsInspected": { + "asset": "ConnectionRule", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": null, + "name": "attemptConnectToApplicationsInspected", + "overrides": true, + "own_children": { + "ConnectionRule:bypassPayloadInspection": [ + null + ], + "ConnectionRule:bypassRestricted": [ + null + ], + "ConnectionRule:connectToApplicationsInspected": [ + null + ] + }, + "own_parents": { + "Application:accessNetworkAndConnections": [ + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + }, + "right": { + "OutApplicationConnection": { + "asset type": "Application", + "fieldname": "outApplications" + }, + "type": "field" + } + } + } + ], + "Network:accessInspected": [ + { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + }, + "right": { + "OutNetworkConnection": { + "asset type": "Network", + "fieldname": "outNetworks" + }, + "type": "field" + } + } + } + ], + "Network:networkForwardingInspected": [ + { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + }, + "right": { + "OutNetworkConnection": { + "asset type": "Network", + "fieldname": "outNetworks" + }, + "type": "field" + } + } + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptConnectToApplicationsUninspected": { + "asset": "ConnectionRule", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": null, + "name": "attemptConnectToApplicationsUninspected", + "overrides": true, + "own_children": { + "ConnectionRule:bypassPayloadInspection": [ + null + ], + "ConnectionRule:bypassRestricted": [ + null + ], + "ConnectionRule:connectToApplicationsUninspected": [ + null + ] + }, + "own_parents": { + "Application:accessNetworkAndConnections": [ + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + }, + "right": { + "OutApplicationConnection": { + "asset type": "Application", + "fieldname": "outApplications" + }, + "type": "field" + } + } + } + ], + "Network:accessUninspected": [ + { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + }, + "right": { + "OutNetworkConnection": { + "asset type": "Network", + "fieldname": "outNetworks" + }, + "type": "field" + } + } + } + ], + "Network:networkForwardingUninspected": [ + { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + }, + "right": { + "OutNetworkConnection": { + "asset type": "Network", + "fieldname": "outNetworks" + }, + "type": "field" + } + } + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptDeny": { + "asset": "ConnectionRule", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": null, + "name": "attemptDeny", + "overrides": true, + "own_children": { + "ConnectionRule:bypassRestricted": [ + null + ], + "ConnectionRule:deny": [ + null + ] + }, + "own_parents": { + "Network:deny": [ + { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + }, + "right": { + "InNetworkConnection": { + "asset type": "Network", + "fieldname": "inNetworks" + }, + "type": "field" + } + } + }, + "right": { + "OutNetworkConnection": { + "asset type": "Network", + "fieldname": "outNetworks" + }, + "type": "field" + } + } + }, + "right": { + "DiodeInNetworkConnection": { + "asset type": "Network", + "fieldname": "diodeInNetworks" + }, + "type": "field" + } + } + } + ], + "RoutingFirewall:deny": [ + { + "FirewallConnectionRule": { + "asset type": "RoutingFirewall", + "fieldname": "routingFirewalls" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptReverseReach": { + "asset": "ConnectionRule", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": null, + "name": "attemptReverseReach", + "overrides": true, + "own_children": { + "ConnectionRule:bypassPayloadInspection": [ + null + ], + "ConnectionRule:bypassRestricted": [ + null + ], + "ConnectionRule:reverseReach": [ + null + ] + }, + "own_parents": { + "Application:reverseReach": [ + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + }, + "right": { + "InApplicationConnection": { + "asset type": "Application", + "fieldname": "inApplications" + }, + "type": "field" + } + } + } + ], + "Network:reverseReach": [ + { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + }, + "right": { + "InNetworkConnection": { + "asset type": "Network", + "fieldname": "inNetworks" + }, + "type": "field" + } + } + }, + "right": { + "DiodeInNetworkConnection": { + "asset type": "Network", + "fieldname": "diodeInNetworks" + }, + "type": "field" + } + } + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "bypassPayloadInspection": { + "asset": "ConnectionRule", + "detectors": {}, + "info": { + "user": "Payload inspection can be bypassed." + }, + "inherits": null, + "name": "bypassPayloadInspection", + "overrides": true, + "own_children": { + "ConnectionRule:payloadInspectionBypassed": [ + null + ] + }, + "own_parents": { + "ConnectionRule:attemptConnectToApplicationsInspected": [ + null + ], + "ConnectionRule:attemptConnectToApplicationsUninspected": [ + null + ], + "ConnectionRule:attemptReverseReach": [ + null + ] + }, + "tags": [], + "ttc": { + "arguments": [], + "name": "VeryHardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "bypassRestricted": { + "asset": "ConnectionRule", + "detectors": {}, + "info": { + "user": "The restricted defense can be bypassed." + }, + "inherits": null, + "name": "bypassRestricted", + "overrides": true, + "own_children": { + "ConnectionRule:restrictedBypassed": [ + null + ] + }, + "own_parents": { + "ConnectionRule:attemptAccessNetworksInspected": [ + null + ], + "ConnectionRule:attemptAccessNetworksUninspected": [ + null + ], + "ConnectionRule:attemptConnectToApplicationsInspected": [ + null + ], + "ConnectionRule:attemptConnectToApplicationsUninspected": [ + null + ], + "ConnectionRule:attemptDeny": [ + null + ], + "ConnectionRule:attemptReverseReach": [ + null + ] + }, + "tags": [], + "ttc": { + "arguments": [], + "name": "VeryHardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "connectToApplicationsInspected": { + "asset": "ConnectionRule", + "detectors": {}, + "info": { + "developer": "Connect to all the (server) Applications that are associated with this ConnectionRule, but taking into account that the traffic payload is being inspected. This attack step is used to allow legitimate traffic even when payload inspection is enabled on the connection." + }, + "inherits": null, + "name": "connectToApplicationsInspected", + "overrides": true, + "own_children": { + "Application:networkConnectInspected": [ + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + }, + "right": { + "InApplicationConnection": { + "asset type": "Application", + "fieldname": "inApplications" + }, + "type": "field" + } + } + } + ] + }, + "own_parents": { + "ConnectionRule:attemptConnectToApplicationsInspected": [ + null + ], + "ConnectionRule:restrictedBypassed": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "connectToApplicationsUninspected": { + "asset": "ConnectionRule", + "detectors": {}, + "info": { + "developer": "Connect to all the (server) Applications that are associated with this ConnectionRule, without any restriction due to inspection." + }, + "inherits": null, + "name": "connectToApplicationsUninspected", + "overrides": true, + "own_children": { + "Application:networkConnectInspected": [ + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + }, + "right": { + "InApplicationConnection": { + "asset type": "Application", + "fieldname": "inApplications" + }, + "type": "field" + } + } + } + ], + "Application:networkConnectUninspected": [ + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + }, + "right": { + "InApplicationConnection": { + "asset type": "Application", + "fieldname": "inApplications" + }, + "type": "field" + } + } + } + ] + }, + "own_parents": { + "ConnectionRule:attemptConnectToApplicationsUninspected": [ + null + ], + "ConnectionRule:payloadInspectionBypassed": [ + null + ], + "ConnectionRule:restrictedBypassed": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "deny": { + "asset": "ConnectionRule", + "detectors": {}, + "info": { + "user": "If a connection rule is denied it affects the applications communicating over it." + }, + "inherits": null, + "name": "deny", + "overrides": true, + "own_children": { + "Application:denyFromNetworkingAsset": [ + { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + }, + "right": { + "InApplicationConnection": { + "asset type": "Application", + "fieldname": "inApplications" + }, + "type": "field" + } + } + }, + "right": { + "OutApplicationConnection": { + "asset type": "Application", + "fieldname": "outApplications" + }, + "type": "field" + } + } + } + ] + }, + "own_parents": { + "ConnectionRule:attemptDeny": [ + null + ], + "ConnectionRule:restrictedBypassed": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "and" + }, + "payloadInspection": { + "asset": "ConnectionRule", + "detectors": {}, + "info": { + "mitre": "Used for M1037: Filter Network Traffic, M1021: Restrict Web-Based Content, M1031: Network Intrusion Prevention, M1057: Data Loss Prevention", + "user": "If enabled, then the traffic is considered to be inspected and filtered by an IDPS that can detect and stop malicious payloads, effectively allowing only legitimate communication(i.e. network-level vulnerabilities cannot be exploited, unsafe actions that the user may taken that would benefit the attacker are also prevented)." + }, + "inherits": null, + "name": "payloadInspection", + "overrides": true, + "own_children": { + "ConnectionRule:payloadInspectionBypassed": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "payloadInspectionBypassed": { + "asset": "ConnectionRule", + "detectors": {}, + "info": { + "developer": "Payload inspection is bypassed either because it was not set, the attacker was able to circumvent it through additional effort." + }, + "inherits": null, + "name": "payloadInspectionBypassed", + "overrides": true, + "own_children": { + "ConnectionRule:connectToApplicationsUninspected": [ + null + ], + "ConnectionRule:reverseReach": [ + null + ], + "ConnectionRule:successfulAccessNetworksUninspected": [ + null + ] + }, + "own_parents": { + "ConnectionRule:bypassPayloadInspection": [ + null + ], + "ConnectionRule:payloadInspection": [ + null + ], + "Data:bypassPayloadInspectionFromEncryptedData": [ + { + "intersection": { + "left": { + "type": "union", + "union": { + "left": { + "collect": { + "left": { + "ApplicationConnection": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + }, + "right": { + "SendData": { + "asset type": "Data", + "fieldname": "sentData" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "collect": { + "left": { + "OutApplicationConnection": { + "asset type": "Application", + "fieldname": "outApplications" + }, + "type": "field" + }, + "right": { + "SendData": { + "asset type": "Data", + "fieldname": "sentData" + }, + "type": "field" + } + }, + "type": "collect" + } + } + }, + "right": { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "collect": { + "left": { + "NetworkConnection": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + }, + "right": { + "DataInTransit": { + "asset type": "Data", + "fieldname": "transitData" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "collect": { + "left": { + "InNetworkConnection": { + "asset type": "Network", + "fieldname": "inNetworks" + }, + "type": "field" + }, + "right": { + "DataInTransit": { + "asset type": "Data", + "fieldname": "transitData" + }, + "type": "field" + } + }, + "type": "collect" + } + } + }, + "right": { + "collect": { + "left": { + "DiodeInNetworkConnection": { + "asset type": "Network", + "fieldname": "diodeInNetworks" + }, + "type": "field" + }, + "right": { + "DataInTransit": { + "asset type": "Data", + "fieldname": "transitData" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + }, + "type": "intersection" + }, + { + "intersection": { + "left": { + "type": "union", + "union": { + "left": { + "collect": { + "left": { + "ApplicationConnection": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + }, + "right": { + "ReceiveData": { + "asset type": "Data", + "fieldname": "receivedData" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "collect": { + "left": { + "InApplicationConnection": { + "asset type": "Application", + "fieldname": "inApplications" + }, + "type": "field" + }, + "right": { + "ReceiveData": { + "asset type": "Data", + "fieldname": "receivedData" + }, + "type": "field" + } + }, + "type": "collect" + } + } + }, + "right": { + "type": "union", + "union": { + "left": { + "collect": { + "left": { + "NetworkConnection": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + }, + "right": { + "DataInTransit": { + "asset type": "Data", + "fieldname": "transitData" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "collect": { + "left": { + "OutNetworkConnection": { + "asset type": "Network", + "fieldname": "outNetworks" + }, + "type": "field" + }, + "right": { + "DataInTransit": { + "asset type": "Data", + "fieldname": "transitData" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + }, + "type": "intersection" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "restricted": { + "asset": "ConnectionRule", + "detectors": {}, + "info": { + "user": "The restricted defense can be used to probabilistically model the likelihood of both the protocols required by the attack being enabled or the existence of the ConnectionRule altogether." + }, + "inherits": null, + "name": "restricted", + "overrides": true, + "own_children": { + "ConnectionRule:restrictedBypassed": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "restrictedBypassed": { + "asset": "ConnectionRule", + "detectors": {}, + "info": { + "developer": "The restricted defense is bypassed either because it was not set, the attacker was able to circumvent it through additional effort." + }, + "inherits": null, + "name": "restrictedBypassed", + "overrides": true, + "own_children": { + "ConnectionRule:connectToApplicationsInspected": [ + null + ], + "ConnectionRule:connectToApplicationsUninspected": [ + null + ], + "ConnectionRule:deny": [ + null + ], + "ConnectionRule:reverseReach": [ + null + ], + "ConnectionRule:successfulAccessNetworksInspected": [ + null + ], + "ConnectionRule:successfulAccessNetworksUninspected": [ + null + ] + }, + "own_parents": { + "ConnectionRule:bypassRestricted": [ + null + ], + "ConnectionRule:restricted": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "reverseReach": { + "asset": "ConnectionRule", + "detectors": {}, + "info": { + "developer": "Reverse reach is used to determine whether or not the attacker can be reached by the user. Reverse reach propagates via outgoing or bidirectional communications." + }, + "inherits": null, + "name": "reverseReach", + "overrides": true, + "own_children": { + "Application:attemptReverseReach": [ + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + }, + "right": { + "OutApplicationConnection": { + "asset type": "Application", + "fieldname": "outApplications" + }, + "type": "field" + } + } + } + ], + "Network:attemptReverseReach": [ + { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + }, + "right": { + "OutNetworkConnection": { + "asset type": "Network", + "fieldname": "outNetworks" + }, + "type": "field" + } + } + } + ] + }, + "own_parents": { + "ConnectionRule:attemptReverseReach": [ + null + ], + "ConnectionRule:payloadInspectionBypassed": [ + null + ], + "ConnectionRule:restrictedBypassed": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulAccessNetworksInspected": { + "asset": "ConnectionRule", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulAccessNetworksInspected", + "overrides": true, + "own_children": { + "ConnectionRule:accessNetworksInspected": [ + null + ] + }, + "own_parents": { + "ConnectionRule:attemptAccessNetworksInspected": [ + null + ], + "ConnectionRule:restrictedBypassed": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulAccessNetworksUninspected": { + "asset": "ConnectionRule", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulAccessNetworksUninspected", + "overrides": true, + "own_children": { + "ConnectionRule:accessNetworksUninspected": [ + null + ] + }, + "own_parents": { + "ConnectionRule:attemptAccessNetworksUninspected": [ + null + ], + "ConnectionRule:payloadInspectionBypassed": [ + null + ], + "ConnectionRule:restrictedBypassed": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + } + }, + "info": { + "developer": "This asset is mainly used to model the functionality of a network connection through a firewall. If Data is added, then it also models the data in transit.", + "user": "This asset is used to model firewall rules between Applications and/or Networks. The association selected (bi/uni-directional) only describes the connection initiation." + }, + "is_abstract": false, + "name": "ConnectionRule", + "sub_assets": [], + "super_asset": "", + "variables": { + "allApplications": [ + "Application", + { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + }, + "right": { + "InApplicationConnection": { + "asset type": "Application", + "fieldname": "inApplications" + }, + "type": "field" + } + } + }, + "right": { + "OutApplicationConnection": { + "asset type": "Application", + "fieldname": "outApplications" + }, + "type": "field" + } + } + } + ], + "receiverApplications": [ + "Application", + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + }, + "right": { + "InApplicationConnection": { + "asset type": "Application", + "fieldname": "inApplications" + }, + "type": "field" + } + } + } + ], + "senderApplications": [ + "Application", + { + "type": "union", + "union": { + "left": { + "ApplicationConnection": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + }, + "right": { + "OutApplicationConnection": { + "asset type": "Application", + "fieldname": "outApplications" + }, + "type": "field" + } + } + } + ] + } + }, + "Credentials": { + "associations": { + "credentials": { + "info": { + "user": "Credentials can be associated with other Credentials to depict conditional authentication procedures, such as multi-factor authentication." + }, + "left": { + "asset": "Credentials", + "fieldname": "credentials", + "max": null, + "min": 0 + }, + "name": "ConditionalAuthentication", + "right": { + "asset": "Credentials", + "fieldname": "requiredFactors", + "max": null, + "min": 0 + } + }, + "encryptedData": { + "info": { + "mitre": "Used for M1041: Encrypt Sensitive Information, M1043: Credential Access Protection", + "user": "Encrypted data can be associated with the relevant encryption credentials." + }, + "left": { + "asset": "Credentials", + "fieldname": "encryptCreds", + "max": 1, + "min": 0 + }, + "name": "EncryptionCredentials", + "right": { + "asset": "Data", + "fieldname": "encryptedData", + "max": null, + "min": 0 + } + }, + "hashes": { + "info": { + "user": "Credentials can be associated with their hashed counterparts. Multiple hashes can be generated for the same Credentials, but a hash is unique to one set of Credentials." + }, + "left": { + "asset": "Credentials", + "fieldname": "hashes", + "max": null, + "min": 0 + }, + "name": "HashedCredentials", + "right": { + "asset": "Credentials", + "fieldname": "origCreds", + "max": 1, + "min": 0 + } + }, + "identities": { + "info": {}, + "left": { + "asset": "Identity", + "fieldname": "identities", + "max": null, + "min": 0 + }, + "name": "IdentityCredentials", + "right": { + "asset": "Credentials", + "fieldname": "credentials", + "max": null, + "min": 0 + } + }, + "origCreds": { + "info": { + "user": "Credentials can be associated with their hashed counterparts. Multiple hashes can be generated for the same Credentials, but a hash is unique to one set of Credentials." + }, + "left": { + "asset": "Credentials", + "fieldname": "hashes", + "max": null, + "min": 0 + }, + "name": "HashedCredentials", + "right": { + "asset": "Credentials", + "fieldname": "origCreds", + "max": 1, + "min": 0 + } + }, + "requiredFactors": { + "info": { + "user": "Credentials can be associated with other Credentials to depict conditional authentication procedures, such as multi-factor authentication." + }, + "left": { + "asset": "Credentials", + "fieldname": "credentials", + "max": null, + "min": 0 + }, + "name": "ConditionalAuthentication", + "right": { + "asset": "Credentials", + "fieldname": "requiredFactors", + "max": null, + "min": 0 + } + }, + "signedData": { + "info": { + "mitre": "Used for M1045: Code Signing, M1046: Boot Integrity", + "user": "Signed data can be associated with the relevant signing credentials." + }, + "left": { + "asset": "Credentials", + "fieldname": "signingCreds", + "max": 1, + "min": 0 + }, + "name": "SigningCredentials", + "right": { + "asset": "Data", + "fieldname": "signedData", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "attemptCredentialTheft": { + "asset": "Credentials", + "detectors": {}, + "info": { + "developer": "The attacker is attempting to access the credentials." + }, + "inherits": null, + "name": "attemptCredentialTheft", + "overrides": true, + "own_children": { + "Credentials:attemptCredentialTheft": [ + { + "ConditionalAuthentication": { + "asset type": "Credentials", + "fieldname": "requiredFactors" + }, + "type": "field" + } + ], + "Credentials:credentialTheft": [ + null + ] + }, + "own_parents": { + "Credentials:attemptCredentialTheft": [ + { + "ConditionalAuthentication": { + "asset type": "Credentials", + "fieldname": "credentials" + }, + "type": "field" + } + ], + "User:credentialTheft": [ + { + "collect": { + "left": { + "IdentityCredentials": { + "asset type": "Identity", + "fieldname": "identities" + }, + "type": "field" + }, + "right": { + "UserAssignedIdentities": { + "asset type": "User", + "fieldname": "users" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptCredentialsReuse": { + "asset": "Credentials", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptCredentialsReuse", + "overrides": true, + "own_children": { + "Credentials:attemptCredentialsReuse": [ + { + "ConditionalAuthentication": { + "asset type": "Credentials", + "fieldname": "requiredFactors" + }, + "type": "field" + } + ], + "Credentials:credentialsReuse": [ + null + ] + }, + "own_parents": { + "Credentials:attemptCredentialsReuse": [ + { + "ConditionalAuthentication": { + "asset type": "Credentials", + "fieldname": "credentials" + }, + "type": "field" + } + ], + "User:passwordReuseCompromise": [ + { + "collect": { + "left": { + "IdentityCredentials": { + "asset type": "Identity", + "fieldname": "identities" + }, + "type": "field" + }, + "right": { + "UserAssignedIdentities": { + "asset type": "User", + "fieldname": "users" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptDeleteFromReplica": { + "asset": "Credentials", + "detectors": {}, + "info": { + "developer": "Information can be deleted only if all replicas have been deleted." + }, + "inherits": "Information:attemptDeleteFromReplica", + "name": "attemptDeleteFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "attemptDenyFromReplica": { + "asset": "Credentials", + "detectors": {}, + "info": { + "developer": "Information can be denied only if all replicas have been denied." + }, + "inherits": "Information:attemptDenyFromReplica", + "name": "attemptDenyFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "attemptExtractFromReplica": { + "asset": "Credentials", + "detectors": {}, + "info": { + "developer": "The attacker attempts to extract the information." + }, + "inherits": "Information:attemptExtractFromReplica", + "name": "attemptExtractFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptPropagateOneCredentialCompromised": { + "asset": "Credentials", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptPropagateOneCredentialCompromised", + "overrides": true, + "own_children": { + "Credentials:propagateOneCredentialCompromised": [ + null + ] + }, + "own_parents": { + "Credentials:use": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptReadFromReplica": { + "asset": "Credentials", + "detectors": {}, + "info": { + "developer": "Reading one replica allows the attacker to read all other replicas as well since the information contained in them is the same." + }, + "inherits": "Information:attemptReadFromReplica", + "name": "attemptReadFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptUse": { + "asset": "Credentials", + "detectors": {}, + "info": { + "developer": "In order to use these credentials the attacker may require additional factors." + }, + "inherits": null, + "name": "attemptUse", + "overrides": true, + "own_children": { + "Credentials:use": [ + null + ] + }, + "own_parents": { + "Credentials:credentialTheft": [ + null + ], + "Credentials:credentialsReuse": [ + null + ], + "Credentials:guessCredentials": [ + null + ], + "Credentials:read": [ + null + ], + "Credentials:use": [ + { + "HashedCredentials": { + "asset type": "Credentials", + "fieldname": "origCreds" + }, + "type": "field" + } + ], + "Credentials:useLeakedCredentials": [ + null + ], + "Credentials:write": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptWriteFromReplica": { + "asset": "Credentials", + "detectors": {}, + "info": { + "developer": "Information can be overwritten only if all replicas have been overwritten." + }, + "inherits": "Information:attemptWriteFromReplica", + "name": "attemptWriteFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "credentialTheft": { + "asset": "Credentials", + "detectors": {}, + "info": { + "developer": "The attacker is able to steal the credentials." + }, + "inherits": null, + "name": "credentialTheft", + "overrides": true, + "own_children": { + "Credentials:attemptUse": [ + null + ] + }, + "own_parents": { + "Credentials:attemptCredentialTheft": [ + null + ], + "Credentials:notPhishable": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "credentialsReuse": { + "asset": "Credentials", + "detectors": {}, + "info": { + "developer": "The user is reusing credentials which means that the attacker is able to compromise all of the non-unique credentials associated with this user." + }, + "inherits": null, + "name": "credentialsReuse", + "overrides": true, + "own_children": { + "Credentials:attemptUse": [ + null + ] + }, + "own_parents": { + "Credentials:attemptCredentialsReuse": [ + null + ], + "Credentials:unique": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "credsHashed": { + "asset": "Credentials", + "detectors": {}, + "info": { + "developer": "If there are no associated hashes the assumption is that the credentials are guessable without any additional requirements." + }, + "inherits": null, + "name": "credsHashed", + "overrides": true, + "own_children": { + "Credentials:guessCredentialsFromHash": [ + null + ] + }, + "own_parents": {}, + "requires": [ + { + "HashedCredentials": { + "asset type": "Credentials", + "fieldname": "hashes" + }, + "type": "field" + } + ], + "tags": [ + "hidden" + ], + "ttc": null, + "type": "notExist" + }, + "delete": { + "asset": "Credentials", + "detectors": {}, + "info": { + "user": "The attacker is able to delete the information." + }, + "inherits": "Information:delete", + "name": "delete", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "deny": { + "asset": "Credentials", + "detectors": {}, + "info": { + "user": "The attacker is able to deny the information." + }, + "inherits": "Information:deny", + "name": "deny", + "overrides": false, + "own_children": { + "Identity:lockoutFromCredentials": [ + { + "IdentityCredentials": { + "asset type": "Identity", + "fieldname": "identities" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "extract": { + "asset": "Credentials", + "detectors": {}, + "info": { + "user": "The attacker is able to extract the information. This means that they have a path available through which they can transfer the information back to a system that they control." + }, + "inherits": "Information:extract", + "name": "extract", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "guessCredentials": { + "asset": "Credentials", + "detectors": {}, + "info": { + "developer": "The attacker can try to just guess a set of credentials. The likelihood of succeeding is dependent on how strong the credentials are.", + "modeler": "The guessability of the Credentials is influenced by the notGuessable defense on this asset and the securityAwareness defense on the User associated with the Identity that these Credentials belong to. If either the User or Identity associations are missing the assumption is made that the crentials are guessable and only the notGuessable defense would play a role in restricting this attack step." + }, + "inherits": null, + "name": "guessCredentials", + "overrides": true, + "own_children": { + "Credentials:attemptUse": [ + null + ] + }, + "own_parents": { + "Credentials:guessCredentialsFromHash": [ + null + ], + "Credentials:notGuessable": [ + null + ], + "Credentials:weakCredentials": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": { + "arguments": [], + "name": "HardAndUncertain", + "type": "function" + }, + "type": "and" + }, + "guessCredentialsFromHash": { + "asset": "Credentials", + "detectors": {}, + "info": { + "developer": "If hashes are associated with this Credentials asset the assumption is that one of those hashes is required by the attacker to be able to perform a guessing attack." + }, + "inherits": null, + "name": "guessCredentialsFromHash", + "overrides": true, + "own_children": { + "Credentials:guessCredentials": [ + null + ] + }, + "own_parents": { + "Credentials:credsHashed": [ + null + ], + "Credentials:use": [ + { + "HashedCredentials": { + "asset type": "Credentials", + "fieldname": "hashes" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "missingIdentity": { + "asset": "Credentials", + "detectors": {}, + "info": { + "developer": "If there are no Identities asociated with these Credentials we make the worst case scenario assumption regarding the strength of the Credentials." + }, + "inherits": null, + "name": "missingIdentity", + "overrides": true, + "own_children": { + "Credentials:weakCredentials": [ + null + ] + }, + "own_parents": {}, + "requires": [ + { + "IdentityCredentials": { + "asset type": "Identity", + "fieldname": "identities" + }, + "type": "field" + } + ], + "tags": [ + "hidden" + ], + "ttc": null, + "type": "notExist" + }, + "notDisclosed": { + "asset": "Credentials", + "detectors": {}, + "info": { + "user": "Describes the case where the password/credential is leaked to some location, it can then be available to the attacker." + }, + "inherits": null, + "name": "notDisclosed", + "overrides": true, + "own_children": { + "Credentials:useLeakedCredentials": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "Enabled", + "type": "function" + }, + "type": "defense" + }, + "notGuessable": { + "asset": "Credentials", + "detectors": {}, + "info": { + "user": "Describes if the credentials can be guessed by the attacker(i.e. they represent a password or passphrase)." + }, + "inherits": null, + "name": "notGuessable", + "overrides": true, + "own_children": { + "Credentials:guessCredentials": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "Enabled", + "type": "function" + }, + "type": "defense" + }, + "notPhishable": { + "asset": "Credentials", + "detectors": {}, + "info": { + "user": "Describes if the credentials cannot be phished(they are biometric, e.g. fingerprints or iris scans, or physical, e.g. access cards or keys, elements)." + }, + "inherits": null, + "name": "notPhishable", + "overrides": true, + "own_children": { + "Credentials:credentialTheft": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "propagateOneCredentialCompromised": { + "asset": "Credentials", + "detectors": {}, + "info": { + "developer": "Intermediate step used to propagate the credentials reuse attack step." + }, + "inherits": null, + "name": "propagateOneCredentialCompromised", + "overrides": true, + "own_children": { + "User:oneCredentialCompromised": [ + { + "collect": { + "left": { + "collect": { + "left": { + "transitive": { + "ConditionalAuthentication": { + "asset type": "Credentials", + "fieldname": "credentials" + }, + "type": "field" + }, + "type": "transitive" + }, + "right": { + "IdentityCredentials": { + "asset type": "Identity", + "fieldname": "identities" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "UserAssignedIdentities": { + "asset type": "User", + "fieldname": "users" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "own_parents": { + "Credentials:attemptPropagateOneCredentialCompromised": [ + null + ], + "Credentials:unique": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "read": { + "asset": "Credentials", + "detectors": {}, + "info": { + "user": "The attacker is able to read the information. They may still be unable to extract it, which represents the adversary being able to read it themselves. This is the case if autonomous code is able to read it, but lack the required networking connections to transmit it back to the adversary." + }, + "inherits": "Information:read", + "name": "read", + "overrides": false, + "own_children": { + "Credentials:attemptUse": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "unique": { + "asset": "Credentials", + "detectors": {}, + "info": { + "user": "Describes if the credentials are known to be unique and therefore cannot be used in a credentials reuse attack." + }, + "inherits": null, + "name": "unique", + "overrides": true, + "own_children": { + "Credentials:credentialsReuse": [ + null + ], + "Credentials:propagateOneCredentialCompromised": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "Enabled", + "type": "function" + }, + "type": "defense" + }, + "use": { + "asset": "Credentials", + "detectors": {}, + "info": { + "user": "The attacker is using the credentials to perform a legitimate authentication." + }, + "inherits": null, + "name": "use", + "overrides": true, + "own_children": { + "Credentials:attemptPropagateOneCredentialCompromised": [ + null + ], + "Credentials:attemptUse": [ + { + "HashedCredentials": { + "asset type": "Credentials", + "fieldname": "hashes" + }, + "type": "field" + } + ], + "Credentials:guessCredentialsFromHash": [ + { + "HashedCredentials": { + "asset type": "Credentials", + "fieldname": "origCreds" + }, + "type": "field" + } + ], + "Credentials:use": [ + { + "ConditionalAuthentication": { + "asset type": "Credentials", + "fieldname": "credentials" + }, + "type": "field" + } + ], + "Data:accessDecryptedData": [ + { + "EncryptionCredentials": { + "asset type": "Data", + "fieldname": "encryptedData" + }, + "type": "field" + } + ], + "Data:accessSpoofedData": [ + { + "SigningCredentials": { + "asset type": "Data", + "fieldname": "signedData" + }, + "type": "field" + } + ], + "Identity:attemptAssume": [ + { + "IdentityCredentials": { + "asset type": "Identity", + "fieldname": "identities" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Credentials:attemptUse": [ + null + ], + "Credentials:use": [ + { + "ConditionalAuthentication": { + "asset type": "Credentials", + "fieldname": "requiredFactors" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "and" + }, + "useLeakedCredentials": { + "asset": "Credentials", + "detectors": {}, + "info": { + "user": "If the password/credential is leaked to some location, it can then be available to the attacker and therefore it can be used." + }, + "inherits": null, + "name": "useLeakedCredentials", + "overrides": true, + "own_children": { + "Credentials:attemptUse": [ + null + ] + }, + "own_parents": { + "Credentials:notDisclosed": [ + null + ] + }, + "tags": [], + "ttc": { + "arguments": [], + "name": "EasyAndCertain", + "type": "function" + }, + "type": "or" + }, + "weakCredentials": { + "asset": "Credentials", + "detectors": {}, + "info": { + "developer": "Intermediate step used to represent how weak the credentials the user employs are. This is inversely related to the securityAwareness defense on the User asset associated with the Identity to which these Credentials belong. If either the User or Identity associations are missing the assumption is made that the crentials are weak and therefore guessable by the attacker." + }, + "inherits": null, + "name": "weakCredentials", + "overrides": true, + "own_children": { + "Credentials:guessCredentials": [ + null + ], + "Credentials:weakCredentials": [ + { + "ConditionalAuthentication": { + "asset type": "Credentials", + "fieldname": "requiredFactors" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Credentials:missingIdentity": [ + null + ], + "Credentials:weakCredentials": [ + { + "ConditionalAuthentication": { + "asset type": "Credentials", + "fieldname": "credentials" + }, + "type": "field" + } + ], + "Identity:missingUser": [ + { + "IdentityCredentials": { + "asset type": "Identity", + "fieldname": "identities" + }, + "type": "field" + } + ], + "User:weakCredentials": [ + { + "collect": { + "left": { + "IdentityCredentials": { + "asset type": "Identity", + "fieldname": "identities" + }, + "type": "field" + }, + "right": { + "UserAssignedIdentities": { + "asset type": "User", + "fieldname": "users" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "write": { + "asset": "Credentials", + "detectors": {}, + "info": { + "user": "The attacker is able to write the information." + }, + "inherits": "Information:write", + "name": "write", + "overrides": false, + "own_children": { + "Credentials:attemptUse": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": { + "modeler": "Credentials represent a variety of access control mechanism(e.g. username and password pair, keycards, biometric data).", + "user": "Credentials can be used to get access to an Identity, but they can also be used as an encryption/signing key for Data." + }, + "is_abstract": false, + "name": "Credentials", + "sub_assets": [], + "super_asset": "Information", + "variables": {} + }, + "Data": { + "associations": { + "containedData": { + "info": { + "user": "Data can be contained inside other data." + }, + "left": { + "asset": "Data", + "fieldname": "containingData", + "max": null, + "min": 0 + }, + "name": "DataContainment", + "right": { + "asset": "Data", + "fieldname": "containedData", + "max": null, + "min": 0 + } + }, + "containingApp": { + "info": { + "user": "An application should be able to contain some data." + }, + "left": { + "asset": "Data", + "fieldname": "containedData", + "max": null, + "min": 0 + }, + "name": "AppContainment", + "right": { + "asset": "Application", + "fieldname": "containingApp", + "max": null, + "min": 0 + } + }, + "containingData": { + "info": { + "user": "Data can be contained inside other data." + }, + "left": { + "asset": "Data", + "fieldname": "containingData", + "max": null, + "min": 0 + }, + "name": "DataContainment", + "right": { + "asset": "Data", + "fieldname": "containedData", + "max": null, + "min": 0 + } + }, + "deletingIAMs": { + "info": { + "mitre": "Used for M1026: Privileged Account Management, M1025: Privileged Process Integrity, M1022: Restrict File and Directory Permissions, M1052: User Account Control, M1018: User Account Management, M1024: Restrict Registry Permissions, M1043: Credential Access Protection, M1057: Data Loss Prevention, M1039: Environment Variable Permissions" + }, + "left": { + "asset": "IAMObject", + "fieldname": "deletingIAMs", + "max": null, + "min": 0 + }, + "name": "DeletePrivileges", + "right": { + "asset": "Data", + "fieldname": "deletePrivData", + "max": null, + "min": 0 + } + }, + "dependentApps": { + "info": { + "user": "Data can be specified as a dependence for an Application." + }, + "left": { + "asset": "Data", + "fieldname": "dataDependedUpon", + "max": null, + "min": 0 + }, + "name": "Dependence", + "right": { + "asset": "Application", + "fieldname": "dependentApps", + "max": null, + "min": 0 + } + }, + "encryptCreds": { + "info": { + "mitre": "Used for M1041: Encrypt Sensitive Information, M1043: Credential Access Protection", + "user": "Encrypted data can be associated with the relevant encryption credentials." + }, + "left": { + "asset": "Credentials", + "fieldname": "encryptCreds", + "max": 1, + "min": 0 + }, + "name": "EncryptionCredentials", + "right": { + "asset": "Data", + "fieldname": "encryptedData", + "max": null, + "min": 0 + } + }, + "hardware": { + "info": { + "user": "Data can be hosted on hardware." + }, + "left": { + "asset": "Data", + "fieldname": "hostedData", + "max": null, + "min": 0 + }, + "name": "DataHosting", + "right": { + "asset": "Hardware", + "fieldname": "hardware", + "max": 1, + "min": 0 + } + }, + "information": { + "info": { + "user": "Data can contain information, as for example credentials." + }, + "left": { + "asset": "Data", + "fieldname": "containerData", + "max": null, + "min": 0 + }, + "name": "InfoContainment", + "right": { + "asset": "Information", + "fieldname": "information", + "max": null, + "min": 0 + } + }, + "readingIAMs": { + "info": {}, + "left": { + "asset": "IAMObject", + "fieldname": "readingIAMs", + "max": null, + "min": 0 + }, + "name": "ReadPrivileges", + "right": { + "asset": "Data", + "fieldname": "readPrivData", + "max": null, + "min": 0 + } + }, + "receiverApp": { + "info": { + "user": "An application can also receive some data that are supposed to be transferred over a network or connection. This expresses an active connection." + }, + "left": { + "asset": "Data", + "fieldname": "receivedData", + "max": null, + "min": 0 + }, + "name": "ReceiveData", + "right": { + "asset": "Application", + "fieldname": "receiverApp", + "max": null, + "min": 0 + } + }, + "replicatedInformation": { + "info": { + "mitre": "Used for M1053: Data Backup", + "user": "Information can be replicated across multiple data assets that offer redundancy." + }, + "left": { + "asset": "Data", + "fieldname": "dataReplicas", + "max": null, + "min": 0 + }, + "name": "Replica", + "right": { + "asset": "Information", + "fieldname": "replicatedInformation", + "max": null, + "min": 0 + } + }, + "senderApp": { + "info": { + "user": "An application can also send some data that are supposed to be transferred over a network or connection. This expresses an active connection." + }, + "left": { + "asset": "Data", + "fieldname": "sentData", + "max": null, + "min": 0 + }, + "name": "SendData", + "right": { + "asset": "Application", + "fieldname": "senderApp", + "max": null, + "min": 0 + } + }, + "signingCreds": { + "info": { + "mitre": "Used for M1045: Code Signing, M1046: Boot Integrity", + "user": "Signed data can be associated with the relevant signing credentials." + }, + "left": { + "asset": "Credentials", + "fieldname": "signingCreds", + "max": 1, + "min": 0 + }, + "name": "SigningCredentials", + "right": { + "asset": "Data", + "fieldname": "signedData", + "max": null, + "min": 0 + } + }, + "transitNetwork": { + "info": { + "user": "A network can also contain some data that are supposed to be network-wide available." + }, + "left": { + "asset": "Data", + "fieldname": "transitData", + "max": null, + "min": 0 + }, + "name": "DataInTransit", + "right": { + "asset": "Network", + "fieldname": "transitNetwork", + "max": null, + "min": 0 + } + }, + "writingIAMs": { + "info": {}, + "left": { + "asset": "IAMObject", + "fieldname": "writingIAMs", + "max": null, + "min": 0 + }, + "name": "WritePrivileges", + "right": { + "asset": "Data", + "fieldname": "writePrivData", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "accessDecryptedData": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to only allow effects of 'accessUnencryptedData' on data after compromising the encryption credentials or encryption is disabled." + }, + "inherits": null, + "name": "accessDecryptedData", + "overrides": true, + "own_children": { + "Data:applicationRespondConnect": [ + null + ], + "Data:successfulDelete": [ + null + ], + "Data:successfulRead": [ + null + ], + "Data:successfulWrite": [ + null + ] + }, + "own_parents": { + "Credentials:use": [ + { + "EncryptionCredentials": { + "asset type": "Credentials", + "fieldname": "encryptCreds" + }, + "type": "field" + } + ], + "Data:accessUnencryptedData": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "accessSpoofedData": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to only allow effects of 'accessUnsignedData' on data after compromising the signing credentials or signing is disabled." + }, + "inherits": null, + "name": "accessSpoofedData", + "overrides": true, + "own_children": { + "Data:applicationRespondConnect": [ + null + ], + "Data:successfulWrite": [ + null + ] + }, + "own_parents": { + "Credentials:use": [ + { + "SigningCredentials": { + "asset type": "Credentials", + "fieldname": "signingCreds" + }, + "type": "field" + } + ], + "Data:accessUnsignedData": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "accessUnencryptedData": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "If data are unencrypted then access them." + }, + "inherits": null, + "name": "accessUnencryptedData", + "overrides": true, + "own_children": { + "Data:accessDecryptedData": [ + null + ] + }, + "own_parents": { + "Data:dataEncrypted": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "accessUnsignedData": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "If data are unsigned then alter them." + }, + "inherits": null, + "name": "accessUnsignedData", + "overrides": true, + "own_children": { + "Data:accessSpoofedData": [ + null + ] + }, + "own_parents": { + "Data:dataSigned": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "applicationRespondConnect": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "In order to perform a respond connect attack the adversary must be able to write the received data." + }, + "inherits": null, + "name": "applicationRespondConnect", + "overrides": true, + "own_children": { + "Application:attemptNetworkConnectFromResponse": [ + { + "SendData": { + "asset type": "Application", + "fieldname": "senderApp" + }, + "type": "field" + } + ], + "Data:applicationRespondConnect": [ + { + "DataContainment": { + "asset type": "Data", + "fieldname": "containedData" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Data:accessDecryptedData": [ + null + ], + "Data:accessSpoofedData": [ + null + ], + "Data:applicationRespondConnect": [ + { + "DataContainment": { + "asset type": "Data", + "fieldname": "containingData" + }, + "type": "field" + } + ], + "Data:attemptApplicationRespondConnect": [ + null + ], + "Data:notPresent": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "attemptApplicationRespondConnect": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to handle defenses." + }, + "inherits": null, + "name": "attemptApplicationRespondConnect", + "overrides": true, + "own_children": { + "Data:applicationRespondConnect": [ + null + ] + }, + "own_parents": { + "Application:applicationRespondConnectThroughData": [ + { + "ReceiveData": { + "asset type": "Application", + "fieldname": "receiverApp" + }, + "type": "field" + } + ], + "Data:authorizedApplicationRespondConnect": [ + null + ], + "Network:adversaryInTheMiddle": [ + { + "DataInTransit": { + "asset type": "Network", + "fieldname": "transitNetwork" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptDelete": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptDelete", + "overrides": true, + "own_children": { + "Data:successfulDelete": [ + null + ] + }, + "own_parents": { + "Data:authorizedDelete": [ + null + ], + "Data:delete": [ + { + "DataContainment": { + "asset type": "Data", + "fieldname": "containingData" + }, + "type": "field" + } + ], + "Data:write": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptDeny": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptDeny", + "overrides": true, + "own_children": { + "Data:successfulDeny": [ + null + ] + }, + "own_parents": { + "Application:deny": [ + { + "AppContainment": { + "asset type": "Application", + "fieldname": "containingApp" + }, + "type": "field" + }, + { + "SendData": { + "asset type": "Application", + "fieldname": "senderApp" + }, + "type": "field" + } + ], + "Data:delete": [ + null + ], + "Data:deny": [ + { + "DataContainment": { + "asset type": "Data", + "fieldname": "containingData" + }, + "type": "field" + } + ], + "Data:denyFromLockout": [ + null + ], + "Hardware:deny": [ + { + "DataHosting": { + "asset type": "Hardware", + "fieldname": "hardware" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptRead": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptRead", + "overrides": true, + "own_children": { + "Data:successfulRead": [ + null + ] + }, + "own_parents": { + "Application:read": [ + { + "AppContainment": { + "asset type": "Application", + "fieldname": "containingApp" + }, + "type": "field" + }, + { + "SendData": { + "asset type": "Application", + "fieldname": "senderApp" + }, + "type": "field" + }, + { + "ReceiveData": { + "asset type": "Application", + "fieldname": "receiverApp" + }, + "type": "field" + } + ], + "Data:authorizedRead": [ + null + ], + "Data:read": [ + { + "DataContainment": { + "asset type": "Data", + "fieldname": "containingData" + }, + "type": "field" + } + ], + "Hardware:fullAccess": [ + { + "DataHosting": { + "asset type": "Hardware", + "fieldname": "hardware" + }, + "type": "field" + } + ], + "Hardware:read": [ + { + "DataHosting": { + "asset type": "Hardware", + "fieldname": "hardware" + }, + "type": "field" + } + ], + "Network:eavesdrop": [ + { + "DataInTransit": { + "asset type": "Network", + "fieldname": "transitNetwork" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptReverseReach": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptReverseReach", + "overrides": true, + "own_children": { + "Data:reverseReach": [ + null + ] + }, + "own_parents": { + "Application:reverseReach": [ + { + "AppContainment": { + "asset type": "Application", + "fieldname": "containingApp" + }, + "type": "field" + }, + { + "SendData": { + "asset type": "Application", + "fieldname": "senderApp" + }, + "type": "field" + }, + { + "ReceiveData": { + "asset type": "Application", + "fieldname": "receiverApp" + }, + "type": "field" + } + ], + "Data:reverseReach": [ + { + "DataContainment": { + "asset type": "Data", + "fieldname": "containingData" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptWrite": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptWrite", + "overrides": true, + "own_children": { + "Data:successfulWrite": [ + null + ] + }, + "own_parents": { + "Application:modify": [ + { + "AppContainment": { + "asset type": "Application", + "fieldname": "containingApp" + }, + "type": "field" + }, + { + "SendData": { + "asset type": "Application", + "fieldname": "senderApp" + }, + "type": "field" + } + ], + "Data:authorizedWrite": [ + null + ], + "Data:write": [ + { + "DataContainment": { + "asset type": "Data", + "fieldname": "containingData" + }, + "type": "field" + } + ], + "Hardware:fullAccess": [ + { + "DataHosting": { + "asset type": "Hardware", + "fieldname": "hardware" + }, + "type": "field" + } + ], + "Hardware:modify": [ + { + "DataHosting": { + "asset type": "Hardware", + "fieldname": "hardware" + }, + "type": "field" + } + ], + "Network:adversaryInTheMiddle": [ + { + "DataInTransit": { + "asset type": "Network", + "fieldname": "transitNetwork" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "authorizedAccessFromApplication": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "Try to gain access to the data through permissions." + }, + "inherits": null, + "name": "authorizedAccessFromApplication", + "overrides": true, + "own_children": { + "Data:authorizedDeleteFromApplication": [ + null + ], + "Data:authorizedReadFromApplication": [ + null + ], + "Data:authorizedWriteFromApplication": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "authorizedApplicationRespondConnect": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "In order to perform a respond connect attack the adversary must be able to write the received data. If they only have specific access on the Application we need to see if they also have the required permissions." + }, + "inherits": null, + "name": "authorizedApplicationRespondConnect", + "overrides": true, + "own_children": { + "Data:attemptApplicationRespondConnect": [ + null + ] + }, + "own_parents": { + "Data:authorizedApplicationRespondConnectFromApplication": [ + null + ], + "Data:authorizedApplicationRespondConnectFromIAM": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "authorizedApplicationRespondConnectFromApplication": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "The adversary can attempt a respond connect attack from an Application, but they still require write permissions in order to perform it." + }, + "inherits": null, + "name": "authorizedApplicationRespondConnectFromApplication", + "overrides": true, + "own_children": { + "Data:authorizedApplicationRespondConnect": [ + null + ] + }, + "own_parents": { + "Application:authorizedApplicationRespondConnectThroughData": [ + { + "ReceiveData": { + "asset type": "Application", + "fieldname": "receiverApp" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "authorizedApplicationRespondConnectFromIAM": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "The attacker has obtained the access control permissions required to modify the incoming requests." + }, + "inherits": null, + "name": "authorizedApplicationRespondConnectFromIAM", + "overrides": true, + "own_children": { + "Data:authorizedApplicationRespondConnect": [ + null + ] + }, + "own_parents": { + "IAMObject:assume": [ + { + "WritePrivileges": { + "asset type": "IAMObject", + "fieldname": "writingIAMs" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "authorizedDelete": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "An attacker is able to delete the Data through authorized permissions, this requires both access to the Data and the adequate permissions." + }, + "inherits": null, + "name": "authorizedDelete", + "overrides": true, + "own_children": { + "Data:attemptDelete": [ + null + ] + }, + "own_parents": { + "Data:authorizedDeleteFromApplication": [ + null + ], + "Data:authorizedDeleteFromIAM": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "authorizedDeleteFromApplication": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "The attacker can reach the data via an Application, but they still require the adequate permissions to delete it." + }, + "inherits": null, + "name": "authorizedDeleteFromApplication", + "overrides": true, + "own_children": { + "Data:authorizedDelete": [ + null + ] + }, + "own_parents": { + "Application:specificAccessDelete": [ + { + "AppContainment": { + "asset type": "Application", + "fieldname": "containingApp" + }, + "type": "field" + }, + { + "SendData": { + "asset type": "Application", + "fieldname": "senderApp" + }, + "type": "field" + } + ], + "Data:authorizedAccessFromApplication": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "authorizedDeleteFromIAM": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "The attacker has obtained the access control permissions required to delete the data." + }, + "inherits": null, + "name": "authorizedDeleteFromIAM", + "overrides": true, + "own_children": { + "Data:authorizedDelete": [ + null + ] + }, + "own_parents": { + "IAMObject:assume": [ + { + "DeletePrivileges": { + "asset type": "IAMObject", + "fieldname": "deletingIAMs" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "authorizedRead": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "An attacker is able to read the Data through authorized permissions, this requires both access to the Data and the adequate permissions." + }, + "inherits": null, + "name": "authorizedRead", + "overrides": true, + "own_children": { + "Data:attemptRead": [ + null + ] + }, + "own_parents": { + "Data:authorizedReadFromApplication": [ + null + ], + "Data:authorizedReadFromIAM": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "authorizedReadFromApplication": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "The attacker can reach the data via an Application, but they still require the adequate permissions to read it." + }, + "inherits": null, + "name": "authorizedReadFromApplication", + "overrides": true, + "own_children": { + "Data:authorizedRead": [ + null + ] + }, + "own_parents": { + "Application:specificAccessRead": [ + { + "AppContainment": { + "asset type": "Application", + "fieldname": "containingApp" + }, + "type": "field" + }, + { + "SendData": { + "asset type": "Application", + "fieldname": "senderApp" + }, + "type": "field" + }, + { + "ReceiveData": { + "asset type": "Application", + "fieldname": "receiverApp" + }, + "type": "field" + } + ], + "Data:authorizedAccessFromApplication": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "authorizedReadFromIAM": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "The attacker has obtained the access control permissions required to read the data." + }, + "inherits": null, + "name": "authorizedReadFromIAM", + "overrides": true, + "own_children": { + "Data:authorizedRead": [ + null + ] + }, + "own_parents": { + "IAMObject:assume": [ + { + "ReadPrivileges": { + "asset type": "IAMObject", + "fieldname": "readingIAMs" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "authorizedWrite": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "An attacker is able to write the Data through authorized permissions, this requires both access to the Data and the adequate permissions." + }, + "inherits": null, + "name": "authorizedWrite", + "overrides": true, + "own_children": { + "Data:attemptWrite": [ + null + ] + }, + "own_parents": { + "Data:authorizedWriteFromApplication": [ + null + ], + "Data:authorizedWriteFromIAM": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "authorizedWriteFromApplication": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "The attacker can reach the data via an Application, but they still require the adequate permissions to write it." + }, + "inherits": null, + "name": "authorizedWriteFromApplication", + "overrides": true, + "own_children": { + "Data:authorizedWrite": [ + null + ] + }, + "own_parents": { + "Application:specificAccessModify": [ + { + "AppContainment": { + "asset type": "Application", + "fieldname": "containingApp" + }, + "type": "field" + }, + { + "SendData": { + "asset type": "Application", + "fieldname": "senderApp" + }, + "type": "field" + } + ], + "Data:authorizedAccessFromApplication": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "authorizedWriteFromIAM": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "The attacker has obtained the access control permissions required to write the data." + }, + "inherits": null, + "name": "authorizedWriteFromIAM", + "overrides": true, + "own_children": { + "Data:authorizedWrite": [ + null + ] + }, + "own_parents": { + "IAMObject:assume": [ + { + "WritePrivileges": { + "asset type": "IAMObject", + "fieldname": "writingIAMs" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "bypassPayloadInspectionFromEncryptedData": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "The connection rules that the encrypted data traverse over cannot provide payload inspection since they are assumed to permit encrypted traffic. This implies that the attacker could simply encrypt their own communications to evade inspection." + }, + "inherits": null, + "name": "bypassPayloadInspectionFromEncryptedData", + "overrides": true, + "own_children": { + "ConnectionRule:payloadInspectionBypassed": [ + { + "intersection": { + "left": { + "type": "union", + "union": { + "left": { + "collect": { + "left": { + "SendData": { + "asset type": "Application", + "fieldname": "senderApp" + }, + "type": "field" + }, + "right": { + "ApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "appConnections" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "collect": { + "left": { + "SendData": { + "asset type": "Application", + "fieldname": "senderApp" + }, + "type": "field" + }, + "right": { + "OutApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingAppConnections" + }, + "type": "field" + } + }, + "type": "collect" + } + } + }, + "right": { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "collect": { + "left": { + "DataInTransit": { + "asset type": "Network", + "fieldname": "transitNetwork" + }, + "type": "field" + }, + "right": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "collect": { + "left": { + "DataInTransit": { + "asset type": "Network", + "fieldname": "transitNetwork" + }, + "type": "field" + }, + "right": { + "InNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "ingoingNetConnections" + }, + "type": "field" + } + }, + "type": "collect" + } + } + }, + "right": { + "collect": { + "left": { + "DataInTransit": { + "asset type": "Network", + "fieldname": "transitNetwork" + }, + "type": "field" + }, + "right": { + "DiodeInNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "diodeIngoingNetConnections" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + }, + "type": "intersection" + }, + { + "intersection": { + "left": { + "type": "union", + "union": { + "left": { + "collect": { + "left": { + "ReceiveData": { + "asset type": "Application", + "fieldname": "receiverApp" + }, + "type": "field" + }, + "right": { + "ApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "appConnections" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "collect": { + "left": { + "ReceiveData": { + "asset type": "Application", + "fieldname": "receiverApp" + }, + "type": "field" + }, + "right": { + "InApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "ingoingAppConnections" + }, + "type": "field" + } + }, + "type": "collect" + } + } + }, + "right": { + "type": "union", + "union": { + "left": { + "collect": { + "left": { + "DataInTransit": { + "asset type": "Network", + "fieldname": "transitNetwork" + }, + "type": "field" + }, + "right": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "collect": { + "left": { + "DataInTransit": { + "asset type": "Network", + "fieldname": "transitNetwork" + }, + "type": "field" + }, + "right": { + "OutNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingNetConnections" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + }, + "type": "intersection" + } + ] + }, + "own_parents": { + "Data:payloadInspectionBypassFromEncryptedData": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "dataEncrypted": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "If the data are encrypted then accessing them requires the associated encryption credentials/key. Data will be considered encrypted if there is at least one Credentials instance associated with it. Otherwise, 'accessUnencryptedData' is triggered." + }, + "inherits": null, + "name": "dataEncrypted", + "overrides": true, + "own_children": { + "Data:accessUnencryptedData": [ + null + ] + }, + "own_parents": {}, + "requires": [ + { + "EncryptionCredentials": { + "asset type": "Credentials", + "fieldname": "encryptCreds" + }, + "type": "field" + } + ], + "tags": [ + "hidden" + ], + "ttc": null, + "type": "notExist" + }, + "dataSigned": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "If the data are signed then writing them requires the associated signing credentials/key. Data will be considered signed if there is at least one Credentials asset associated with it. Otherwise, 'accessUnsignedData' is triggered." + }, + "inherits": null, + "name": "dataSigned", + "overrides": true, + "own_children": { + "Data:accessUnsignedData": [ + null + ] + }, + "own_parents": {}, + "requires": [ + { + "SigningCredentials": { + "asset type": "Credentials", + "fieldname": "signingCreds" + }, + "type": "field" + } + ], + "tags": [ + "hidden" + ], + "ttc": null, + "type": "notExist" + }, + "delete": { + "asset": "Data", + "detectors": {}, + "info": { + "user": "The attacker can delete the data." + }, + "inherits": null, + "name": "delete", + "overrides": true, + "own_children": { + "Data:attemptDelete": [ + { + "DataContainment": { + "asset type": "Data", + "fieldname": "containedData" + }, + "type": "field" + } + ], + "Data:attemptDeny": [ + null + ], + "Information:attemptDeleteFromReplica": [ + { + "Replica": { + "asset type": "Information", + "fieldname": "replicatedInformation" + }, + "type": "field" + } + ], + "Information:delete": [ + { + "InfoContainment": { + "asset type": "Information", + "fieldname": "information" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Data:successfulDelete": [ + null + ], + "Information:delete": [ + { + "Replica": { + "asset type": "Information", + "fieldname": "replicatedInformation" + }, + "type": "field" + }, + { + "InfoContainment": { + "asset type": "Information", + "fieldname": "information" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "deny": { + "asset": "Data", + "detectors": {}, + "info": { + "user": "If a DoS is performed data are denied, it has the same effects as deleting the data." + }, + "inherits": null, + "name": "deny", + "overrides": true, + "own_children": { + "Application:deny": [ + { + "Dependence": { + "asset type": "Application", + "fieldname": "dependentApps" + }, + "type": "field" + } + ], + "Data:attemptDeny": [ + { + "DataContainment": { + "asset type": "Data", + "fieldname": "containedData" + }, + "type": "field" + } + ], + "Information:attemptDenyFromReplica": [ + { + "Replica": { + "asset type": "Information", + "fieldname": "replicatedInformation" + }, + "type": "field" + } + ], + "Information:deny": [ + { + "InfoContainment": { + "asset type": "Information", + "fieldname": "information" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Data:successfulDeny": [ + null + ], + "Information:deny": [ + { + "Replica": { + "asset type": "Information", + "fieldname": "replicatedInformation" + }, + "type": "field" + }, + { + "InfoContainment": { + "asset type": "Information", + "fieldname": "information" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "denyFromLockout": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "This is an intermediate attack step to only trigger deny on data when all the access control roles that can read them are locked out." + }, + "inherits": null, + "name": "denyFromLockout", + "overrides": true, + "own_children": { + "Data:attemptDeny": [ + null + ] + }, + "own_parents": { + "IAMObject:lockout": [ + { + "ReadPrivileges": { + "asset type": "IAMObject", + "fieldname": "readingIAMs" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "extract": { + "asset": "Data", + "detectors": {}, + "info": { + "user": "The attacker can extract the data. The read attack step represents just being able to make use of the data for further attack steps without the attacker obtaining it." + }, + "inherits": null, + "name": "extract", + "overrides": true, + "own_children": { + "Information:attemptExtractFromReplica": [ + { + "Replica": { + "asset type": "Information", + "fieldname": "replicatedInformation" + }, + "type": "field" + } + ], + "Information:extract": [ + { + "InfoContainment": { + "asset type": "Information", + "fieldname": "information" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Data:notPresent": [ + null + ], + "Data:read": [ + null + ], + "Data:reverseReach": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "and" + }, + "notPresent": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "Enabling this defense should make the asset behave as if it did not exist, as such all of its impactful attack steps should be disabled by it.", + "user": "This defense is used to reason about the attack vectors introduced by this asset. It should be used to model the possibility of data not existing on the associated container (i.e. Hardware, Application, Network, etc.)." + }, + "inherits": null, + "name": "notPresent", + "overrides": true, + "own_children": { + "Data:applicationRespondConnect": [ + null + ], + "Data:extract": [ + null + ], + "Data:reverseReach": [ + null + ], + "Data:successfulDelete": [ + null + ], + "Data:successfulDeny": [ + null + ], + "Data:successfulRead": [ + null + ], + "Data:successfulWrite": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "payloadInspectionBypassFromEncryptedData": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "If the data are encrypted then payload inspection cannot be performed on the connection rules they are transmitted over." + }, + "inherits": null, + "name": "payloadInspectionBypassFromEncryptedData", + "overrides": true, + "own_children": { + "Data:bypassPayloadInspectionFromEncryptedData": [ + null + ] + }, + "own_parents": {}, + "requires": [ + { + "EncryptionCredentials": { + "asset type": "Credentials", + "fieldname": "encryptCreds" + }, + "type": "field" + } + ], + "tags": [ + "hidden" + ], + "ttc": null, + "type": "exist" + }, + "read": { + "asset": "Data", + "detectors": {}, + "info": { + "user": "The attacker can read the data. This means that the data can be used for further attack steps, but they are not necessarily obtained by the attacker. The extract attack step covers the scenario where the attacker actually collects the data." + }, + "inherits": null, + "name": "read", + "overrides": true, + "own_children": { + "Data:attemptRead": [ + { + "DataContainment": { + "asset type": "Data", + "fieldname": "containedData" + }, + "type": "field" + } + ], + "Data:extract": [ + null + ], + "Information:attemptReadFromReplica": [ + { + "Replica": { + "asset type": "Information", + "fieldname": "replicatedInformation" + }, + "type": "field" + } + ], + "Information:read": [ + { + "InfoContainment": { + "asset type": "Information", + "fieldname": "information" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Data:successfulRead": [ + null + ], + "Information:read": [ + { + "Replica": { + "asset type": "Information", + "fieldname": "replicatedInformation" + }, + "type": "field" + }, + { + "InfoContainment": { + "asset type": "Information", + "fieldname": "information" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "reverseReach": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "Reverse reach for Data is used to determine whether or not the data can be transferred to attacker." + }, + "inherits": null, + "name": "reverseReach", + "overrides": true, + "own_children": { + "Data:attemptReverseReach": [ + { + "DataContainment": { + "asset type": "Data", + "fieldname": "containedData" + }, + "type": "field" + } + ], + "Data:extract": [ + null + ] + }, + "own_parents": { + "Data:attemptReverseReach": [ + null + ], + "Data:notPresent": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulDelete": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulDelete", + "overrides": true, + "own_children": { + "Data:delete": [ + null + ] + }, + "own_parents": { + "Data:accessDecryptedData": [ + null + ], + "Data:attemptDelete": [ + null + ], + "Data:notPresent": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulDeny": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulDeny", + "overrides": true, + "own_children": { + "Data:deny": [ + null + ] + }, + "own_parents": { + "Data:attemptDeny": [ + null + ], + "Data:notPresent": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulRead": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulRead", + "overrides": true, + "own_children": { + "Data:read": [ + null + ] + }, + "own_parents": { + "Data:accessDecryptedData": [ + null + ], + "Data:attemptRead": [ + null + ], + "Data:notPresent": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulWrite": { + "asset": "Data", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulWrite", + "overrides": true, + "own_children": { + "Data:write": [ + null + ] + }, + "own_parents": { + "Data:accessDecryptedData": [ + null + ], + "Data:accessSpoofedData": [ + null + ], + "Data:attemptWrite": [ + null + ], + "Data:notPresent": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "write": { + "asset": "Data", + "detectors": {}, + "info": { + "user": "The attacker can write to the location of the data, effectively modifying or deleting it." + }, + "inherits": null, + "name": "write", + "overrides": true, + "own_children": { + "Application:fullAccess": [ + { + "Dependence": { + "asset type": "Application", + "fieldname": "dependentApps" + }, + "type": "field" + } + ], + "Data:attemptDelete": [ + null + ], + "Data:attemptWrite": [ + { + "DataContainment": { + "asset type": "Data", + "fieldname": "containedData" + }, + "type": "field" + } + ], + "Information:attemptWriteFromReplica": [ + { + "Replica": { + "asset type": "Information", + "fieldname": "replicatedInformation" + }, + "type": "field" + } + ], + "Information:write": [ + { + "InfoContainment": { + "asset type": "Information", + "fieldname": "information" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Data:successfulWrite": [ + null + ], + "Information:write": [ + { + "Replica": { + "asset type": "Information", + "fieldname": "replicatedInformation" + }, + "type": "field" + }, + { + "InfoContainment": { + "asset type": "Information", + "fieldname": "information" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": { + "user": "Any form of data that can be stored or transmitted." + }, + "is_abstract": false, + "name": "Data", + "sub_assets": [], + "super_asset": "", + "variables": { + "receiverIngoingApplicationConnections": [ + "ConnectionRule", + { + "type": "union", + "union": { + "left": { + "collect": { + "left": { + "ReceiveData": { + "asset type": "Application", + "fieldname": "receiverApp" + }, + "type": "field" + }, + "right": { + "ApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "appConnections" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "collect": { + "left": { + "ReceiveData": { + "asset type": "Application", + "fieldname": "receiverApp" + }, + "type": "field" + }, + "right": { + "InApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "ingoingAppConnections" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ], + "senderOutgoingApplicationConnections": [ + "ConnectionRule", + { + "type": "union", + "union": { + "left": { + "collect": { + "left": { + "SendData": { + "asset type": "Application", + "fieldname": "senderApp" + }, + "type": "field" + }, + "right": { + "ApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "appConnections" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "collect": { + "left": { + "SendData": { + "asset type": "Application", + "fieldname": "senderApp" + }, + "type": "field" + }, + "right": { + "OutApplicationConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingAppConnections" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ], + "transitNetworkInboundAllowedConnections": [ + "ConnectionRule", + { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "collect": { + "left": { + "DataInTransit": { + "asset type": "Network", + "fieldname": "transitNetwork" + }, + "type": "field" + }, + "right": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "collect": { + "left": { + "DataInTransit": { + "asset type": "Network", + "fieldname": "transitNetwork" + }, + "type": "field" + }, + "right": { + "InNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "ingoingNetConnections" + }, + "type": "field" + } + }, + "type": "collect" + } + } + }, + "right": { + "collect": { + "left": { + "DataInTransit": { + "asset type": "Network", + "fieldname": "transitNetwork" + }, + "type": "field" + }, + "right": { + "DiodeInNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "diodeIngoingNetConnections" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ], + "transitNetworkOutboundAllowedConnections": [ + "ConnectionRule", + { + "type": "union", + "union": { + "left": { + "collect": { + "left": { + "DataInTransit": { + "asset type": "Network", + "fieldname": "transitNetwork" + }, + "type": "field" + }, + "right": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "collect": { + "left": { + "DataInTransit": { + "asset type": "Network", + "fieldname": "transitNetwork" + }, + "type": "field" + }, + "right": { + "OutNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingNetConnections" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ] + } + }, + "Group": { + "associations": { + "childGroups": { + "info": {}, + "left": { + "asset": "Group", + "fieldname": "parentGroup", + "max": null, + "min": 0 + }, + "name": "MemberOf", + "right": { + "asset": "Group", + "fieldname": "childGroups", + "max": null, + "min": 0 + } + }, + "groupIds": { + "info": {}, + "left": { + "asset": "Group", + "fieldname": "memberOf", + "max": null, + "min": 0 + }, + "name": "MemberOf", + "right": { + "asset": "Identity", + "fieldname": "groupIds", + "max": null, + "min": 0 + } + }, + "parentGroup": { + "info": {}, + "left": { + "asset": "Group", + "fieldname": "parentGroup", + "max": null, + "min": 0 + }, + "name": "MemberOf", + "right": { + "asset": "Group", + "fieldname": "childGroups", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "assume": { + "asset": "Group", + "detectors": {}, + "info": { + "developer": "The parent groups should be compromised because all the privileges of the parent groups are inherited on the children groups but lower children groups should not be compromised because lower levels might have inherited plus additional privileges.", + "user": "When an attacker is able to assume an IAM asset the privileges associated with it can always be exploited, other behaviour is asset specific." + }, + "inherits": "IAMObject:assume", + "name": "assume", + "overrides": false, + "own_children": { + "Group:attemptAssume": [ + { + "MemberOf": { + "asset type": "Group", + "fieldname": "parentGroup" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "attemptAssume": { + "asset": "Group", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "IAMObject:attemptAssume", + "name": "attemptAssume", + "overrides": false, + "own_children": {}, + "own_parents": { + "Group:assume": [ + { + "MemberOf": { + "asset type": "Group", + "fieldname": "childGroups" + }, + "type": "field" + } + ], + "Identity:assume": [ + { + "MemberOf": { + "asset type": "Identity", + "fieldname": "groupIds" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptDeleteFromReplica": { + "asset": "Group", + "detectors": {}, + "info": { + "developer": "Information can be deleted only if all replicas have been deleted." + }, + "inherits": "IAMObject:attemptDeleteFromReplica", + "name": "attemptDeleteFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "attemptDenyFromReplica": { + "asset": "Group", + "detectors": {}, + "info": { + "developer": "Information can be denied only if all replicas have been denied." + }, + "inherits": "IAMObject:attemptDenyFromReplica", + "name": "attemptDenyFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "attemptExtractFromReplica": { + "asset": "Group", + "detectors": {}, + "info": { + "developer": "The attacker attempts to extract the information." + }, + "inherits": "IAMObject:attemptExtractFromReplica", + "name": "attemptExtractFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptLockout": { + "asset": "Group", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "IAMObject:attemptLockout", + "name": "attemptLockout", + "overrides": false, + "own_children": {}, + "own_parents": { + "Group:lockoutFromMembers": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptReadFromReplica": { + "asset": "Group", + "detectors": {}, + "info": { + "developer": "Reading one replica allows the attacker to read all other replicas as well since the information contained in them is the same." + }, + "inherits": "IAMObject:attemptReadFromReplica", + "name": "attemptReadFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptWriteFromReplica": { + "asset": "Group", + "detectors": {}, + "info": { + "developer": "Information can be overwritten only if all replicas have been overwritten." + }, + "inherits": "IAMObject:attemptWriteFromReplica", + "name": "attemptWriteFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "delete": { + "asset": "Group", + "detectors": {}, + "info": { + "user": "The attacker is able to delete the information." + }, + "inherits": "IAMObject:delete", + "name": "delete", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "deny": { + "asset": "Group", + "detectors": {}, + "info": { + "user": "The attacker is able to deny the information." + }, + "inherits": "IAMObject:deny", + "name": "deny", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "extract": { + "asset": "Group", + "detectors": {}, + "info": { + "user": "The attacker is able to extract the information. This means that they have a path available through which they can transfer the information back to a system that they control." + }, + "inherits": "IAMObject:extract", + "name": "extract", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "lockout": { + "asset": "Group", + "detectors": {}, + "info": { + "user": "The Identity, Group, or Privileges has been locked out and cannot be used by legitimate users. This means that the applications that they can execute and data they can read might also be denied if all of the access roles that could execute or read them, respectively, have been locked out." + }, + "inherits": "IAMObject:lockout", + "name": "lockout", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "lockoutFromMembers": { + "asset": "Group", + "detectors": {}, + "info": { + "developer": "If all of the Identities belonging to a Group are lockedout we assume that the Group as a whole is lockedout." + }, + "inherits": null, + "name": "lockoutFromMembers", + "overrides": true, + "own_children": { + "Group:attemptLockout": [ + null + ] + }, + "own_parents": { + "Identity:lockout": [ + { + "MemberOf": { + "asset type": "Identity", + "fieldname": "groupIds" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "notPresent": { + "asset": "Group", + "detectors": {}, + "info": { + "developer": "Enabling this defense should make the asset behave as if it did not exist, as such all of its impactful attack steps should be disabled by it.", + "mitre": "Used for M1026: Privileged Account Management, M1025: Privileged Process Integrity, M1022: Restrict File and Directory Permissions, M1052: User Account Control, M1018: User Account Management, M1024: Restrict Registry Permissions, M1043: Credential Access Protection, M1057: Data Loss Prevention", + "user": "This defense is used to reason about the attack vectors introduced by this asset. It should be used to model the possibility that the IAM object does not actually exist." + }, + "inherits": "IAMObject:notPresent", + "name": "notPresent", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "read": { + "asset": "Group", + "detectors": {}, + "info": { + "user": "The attacker is able to read the information. They may still be unable to extract it, which represents the adversary being able to read it themselves. This is the case if autonomous code is able to read it, but lack the required networking connections to transmit it back to the adversary." + }, + "inherits": "IAMObject:read", + "name": "read", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "successfulAssume": { + "asset": "Group", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "IAMObject:successfulAssume", + "name": "successfulAssume", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulLockout": { + "asset": "Group", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "IAMObject:successfulLockout", + "name": "successfulLockout", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "write": { + "asset": "Group", + "detectors": {}, + "info": { + "user": "The attacker is able to write the information." + }, + "inherits": "IAMObject:write", + "name": "write", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": { + "modeler": "Groups can be used instead of nested identities to make the model more intuitive and clearer to understand.", + "user": "A group is a way to group together identities and/or other groups. This allows the expression of hierarchical IAM (structured inheritance)." + }, + "is_abstract": false, + "name": "Group", + "sub_assets": [], + "super_asset": "IAMObject", + "variables": {} + }, + "Hardware": { + "associations": { + "hostedData": { + "info": { + "user": "Data can be hosted on hardware." + }, + "left": { + "asset": "Data", + "fieldname": "hostedData", + "max": null, + "min": 0 + }, + "name": "DataHosting", + "right": { + "asset": "Hardware", + "fieldname": "hardware", + "max": 1, + "min": 0 + } + }, + "physicalZones": { + "info": { + "user": "Access on PhysicalZone will give physicalAccess on the deployed Hardware, but access to the hardware does not provide access to the physical zone (e.g. iLOs)." + }, + "left": { + "asset": "PhysicalZone", + "fieldname": "physicalZones", + "max": null, + "min": 0 + }, + "name": "ZoneInclusion", + "right": { + "asset": "Hardware", + "fieldname": "hardwareSystems", + "max": null, + "min": 0 + } + }, + "sysExecutedApps": { + "info": { + "user": "Hardware on which Applications are running. The host hardware is not mandatory to be specified." + }, + "left": { + "asset": "Hardware", + "fieldname": "hostHardware", + "max": 1, + "min": 0 + }, + "name": "SysExecution", + "right": { + "asset": "Application", + "fieldname": "sysExecutedApps", + "max": null, + "min": 0 + } + }, + "users": { + "info": { + "user": "A user may have access to different hardware systems, rigs, or components." + }, + "left": { + "asset": "User", + "fieldname": "users", + "max": null, + "min": 0 + }, + "name": "HardwareAccess", + "right": { + "asset": "Hardware", + "fieldname": "hardwareSystems", + "max": null, + "min": 0 + } + }, + "vulnerabilities": { + "info": { + "user": "A Hardware system/component can have multiple hardware vulnerabilities." + }, + "left": { + "asset": "HardwareVulnerability", + "fieldname": "vulnerabilities", + "max": null, + "min": 0 + }, + "name": "hardwareVulnerability", + "right": { + "asset": "Hardware", + "fieldname": "hardware", + "max": 1, + "min": 0 + } + } + }, + "attack_steps": { + "attemptHardwareModifications": { + "asset": "Hardware", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": null, + "name": "attemptHardwareModifications", + "overrides": true, + "own_children": { + "Hardware:bypassHardwareModificationsProtection": [ + null + ], + "Hardware:successfulHardwareModifications": [ + null + ] + }, + "own_parents": { + "Hardware:physicalAccess": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptSpreadWormThroughRemovableMedia": { + "asset": "Hardware", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": null, + "name": "attemptSpreadWormThroughRemovableMedia", + "overrides": true, + "own_children": { + "Hardware:bypassHardwareModificationsProtection": [ + null + ], + "Hardware:successfulSpreadWormThroughRemovableMedia": [ + null + ] + }, + "own_parents": { + "Application:fullAccess": [ + { + "SysExecution": { + "asset type": "Application", + "fieldname": "sysExecutedApps" + }, + "type": "field" + } + ], + "Hardware:fullAccess": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptSupplyChainAttack": { + "asset": "Hardware", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses.", + "modeler": "Triggering this attack step is the only way to model hardware supply chain attacks in the language, therefore scenarios interested in those aspects need to employ it as an entry point.", + "user": "Adversaries may perform supply chain compromise attacks to gain control of hardware before it is put into use." + }, + "inherits": null, + "name": "attemptSupplyChainAttack", + "overrides": true, + "own_children": { + "Hardware:bypassSupplyChainAuditing": [ + null + ], + "Hardware:successfulSupplyChainAttack": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "entrypoint" + ], + "ttc": null, + "type": "or" + }, + "attemptUnsafeUserActivity": { + "asset": "Hardware", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": null, + "name": "attemptUnsafeUserActivity", + "overrides": true, + "own_children": { + "Hardware:bypassHardwareModificationsProtection": [ + null + ], + "Hardware:successfulUnsafeUserActivity": [ + null + ] + }, + "own_parents": { + "User:deliverMaliciousRemovableMedia": [ + { + "collect": { + "left": { + "ZoneInclusion": { + "asset type": "PhysicalZone", + "fieldname": "physicalZones" + }, + "type": "field" + }, + "right": { + "ZoneAccess": { + "asset type": "User", + "fieldname": "users" + }, + "type": "field" + } + }, + "type": "collect" + }, + { + "HardwareAccess": { + "asset type": "User", + "fieldname": "users" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptUseVulnerabilityFromPhysicalAccess": { + "asset": "Hardware", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": null, + "name": "attemptUseVulnerabilityFromPhysicalAccess", + "overrides": true, + "own_children": { + "Hardware:bypassHardwareModificationsProtection": [ + null + ], + "Hardware:successfulUseVulnerabilityFromPhysicalAccess": [ + null + ] + }, + "own_parents": { + "Hardware:hardwareModifications": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptUseVulnerabilityFromSoftwareFullAccess": { + "asset": "Hardware", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": null, + "name": "attemptUseVulnerabilityFromSoftwareFullAccess", + "overrides": true, + "own_children": { + "Hardware:useVulnerability": [ + null + ] + }, + "own_parents": { + "Application:fullAccess": [ + { + "SysExecution": { + "asset type": "Application", + "fieldname": "sysExecutedApps" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "bypassHardwareModificationsProtection": { + "asset": "Hardware", + "detectors": {}, + "info": { + "user": "Hardware modifications protection can be bypassed." + }, + "inherits": null, + "name": "bypassHardwareModificationsProtection", + "overrides": true, + "own_children": { + "Hardware:hardwareModificationsProtectionBypassed": [ + null + ] + }, + "own_parents": { + "Hardware:attemptHardwareModifications": [ + null + ], + "Hardware:attemptSpreadWormThroughRemovableMedia": [ + null + ], + "Hardware:attemptUnsafeUserActivity": [ + null + ], + "Hardware:attemptUseVulnerabilityFromPhysicalAccess": [ + null + ] + }, + "tags": [], + "ttc": { + "arguments": [], + "name": "VeryHardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "bypassSupplyChainAuditing": { + "asset": "Hardware", + "detectors": {}, + "info": { + "user": "Supply chain auditing can be bypassed." + }, + "inherits": null, + "name": "bypassSupplyChainAuditing", + "overrides": true, + "own_children": { + "Hardware:supplyChainAuditingBypassed": [ + null + ] + }, + "own_parents": { + "Hardware:attemptSupplyChainAttack": [ + null + ] + }, + "tags": [], + "ttc": { + "arguments": [], + "name": "VeryHardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "deny": { + "asset": "Hardware", + "detectors": {}, + "info": { + "user": "Denial of service (DoS) attack on hardware leads to DoS on all the applications running on it and hosted data." + }, + "inherits": null, + "name": "deny", + "overrides": true, + "own_children": { + "Application:attemptDeny": [ + { + "SysExecution": { + "asset type": "Application", + "fieldname": "sysExecutedApps" + }, + "type": "field" + } + ], + "Data:attemptDeny": [ + { + "DataHosting": { + "asset type": "Data", + "fieldname": "hostedData" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Hardware:fullAccess": [ + null + ], + "Hardware:physicalAccess": [ + null + ], + "HardwareVulnerability:deny": [ + { + "hardwareVulnerability": { + "asset type": "HardwareVulnerability", + "fieldname": "vulnerabilities" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "fullAccess": { + "asset": "Hardware", + "detectors": {}, + "info": { + "user": "Full access on a piece of hardware grants full access on the applications running on it and access to the hosted data." + }, + "inherits": null, + "name": "fullAccess", + "overrides": true, + "own_children": { + "Application:fullAccess": [ + { + "SysExecution": { + "asset type": "Application", + "fieldname": "sysExecutedApps" + }, + "type": "field" + } + ], + "Data:attemptRead": [ + { + "DataHosting": { + "asset type": "Data", + "fieldname": "hostedData" + }, + "type": "field" + } + ], + "Data:attemptWrite": [ + { + "DataHosting": { + "asset type": "Data", + "fieldname": "hostedData" + }, + "type": "field" + } + ], + "Hardware:attemptSpreadWormThroughRemovableMedia": [ + null + ], + "Hardware:deny": [ + null + ] + }, + "own_parents": { + "Hardware:modify": [ + null + ], + "Hardware:supplyChainAttack": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "hardwareModifications": { + "asset": "Hardware", + "detectors": {}, + "info": { + "developer": "Attacker is able to modify the hardware setup. They could then exploit physical vulnerabilities or software vulnerabilities that require physical access." + }, + "inherits": null, + "name": "hardwareModifications", + "overrides": true, + "own_children": { + "Application:physicalAccessAchieved": [ + { + "SysExecution": { + "asset type": "Application", + "fieldname": "sysExecutedApps" + }, + "type": "field" + } + ], + "Hardware:attemptUseVulnerabilityFromPhysicalAccess": [ + null + ], + "HardwareVulnerability:physicalAccessAchieved": [ + { + "hardwareVulnerability": { + "asset type": "HardwareVulnerability", + "fieldname": "vulnerabilities" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Hardware:successfulHardwareModifications": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "hardwareModificationsProtection": { + "asset": "Hardware", + "detectors": {}, + "info": { + "developer": "But it does not protect against denial of service attacks or local connect on hosted applications.", + "user": "This defense protects against unauthorized modifications to the hardware that would allow an attacker to exploit a hardware vulnerability or a user to connect malicious devices to it." + }, + "inherits": null, + "name": "hardwareModificationsProtection", + "overrides": true, + "own_children": { + "Hardware:hardwareModificationsProtectionBypassed": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "defense" + }, + "hardwareModificationsProtectionBypassed": { + "asset": "Hardware", + "detectors": {}, + "info": { + "developer": "Hardware modifications protection is bypassed either because it was not set or the attacker was able to circumvent it through additional effort." + }, + "inherits": null, + "name": "hardwareModificationsProtectionBypassed", + "overrides": true, + "own_children": { + "Hardware:successfulHardwareModifications": [ + null + ], + "Hardware:successfulSpreadWormThroughRemovableMedia": [ + null + ], + "Hardware:successfulUnsafeUserActivity": [ + null + ], + "Hardware:successfulUseVulnerabilityFromPhysicalAccess": [ + null + ] + }, + "own_parents": { + "Hardware:bypassHardwareModificationsProtection": [ + null + ], + "Hardware:hardwareModificationsProtection": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "modify": { + "asset": "Hardware", + "detectors": {}, + "info": { + "user": "Modify on hardware gives the attacker full access to the hardware and leads to a modify/write on all the applications running on it and hosted data." + }, + "inherits": null, + "name": "modify", + "overrides": true, + "own_children": { + "Application:attemptModify": [ + { + "SysExecution": { + "asset type": "Application", + "fieldname": "sysExecutedApps" + }, + "type": "field" + } + ], + "Data:attemptWrite": [ + { + "DataHosting": { + "asset type": "Data", + "fieldname": "hostedData" + }, + "type": "field" + } + ], + "Hardware:fullAccess": [ + null + ] + }, + "own_parents": { + "HardwareVulnerability:modify": [ + { + "hardwareVulnerability": { + "asset type": "HardwareVulnerability", + "fieldname": "vulnerabilities" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "physicalAccess": { + "asset": "Hardware", + "detectors": {}, + "info": { + "modeler": "This attack step can be used as an entry point when analyzing scenarios that involve gaining physical access to assets.", + "user": "Attacker has physical access to the location where the hardware is physically deployed. They could then deny the hardware, locally connect to the hosted applications, and exploit potential physical vulnerabilities." + }, + "inherits": null, + "name": "physicalAccess", + "overrides": true, + "own_children": { + "Application:localConnect": [ + { + "SysExecution": { + "asset type": "Application", + "fieldname": "sysExecutedApps" + }, + "type": "field" + } + ], + "Hardware:attemptHardwareModifications": [ + null + ], + "Hardware:deny": [ + null + ] + }, + "own_parents": { + "PhysicalZone:gainPhysicalAccess": [ + { + "ZoneInclusion": { + "asset type": "PhysicalZone", + "fieldname": "physicalZones" + }, + "type": "field" + } + ] + }, + "tags": [ + "entrypoint" + ], + "ttc": null, + "type": "or" + }, + "read": { + "asset": "Hardware", + "detectors": {}, + "info": { + "user": "Read on hardware leads to a read on all the applications running on it and hosted data." + }, + "inherits": null, + "name": "read", + "overrides": true, + "own_children": { + "Application:attemptRead": [ + { + "SysExecution": { + "asset type": "Application", + "fieldname": "sysExecutedApps" + }, + "type": "field" + } + ], + "Data:attemptRead": [ + { + "DataHosting": { + "asset type": "Data", + "fieldname": "hostedData" + }, + "type": "field" + } + ] + }, + "own_parents": { + "HardwareVulnerability:read": [ + { + "hardwareVulnerability": { + "asset type": "HardwareVulnerability", + "fieldname": "vulnerabilities" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "spreadWormThroughRemovableMedia": { + "asset": "Hardware", + "detectors": {}, + "info": { + "developer": "Try to spread malware through removable media that the users connect to the hardware system." + }, + "inherits": null, + "name": "spreadWormThroughRemovableMedia", + "overrides": true, + "own_children": { + "User:attemptDeliverMaliciousRemovableMedia": [ + { + "type": "union", + "union": { + "left": { + "HardwareAccess": { + "asset type": "User", + "fieldname": "users" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "ZoneInclusion": { + "asset type": "PhysicalZone", + "fieldname": "physicalZones" + }, + "type": "field" + }, + "right": { + "ZoneAccess": { + "asset type": "User", + "fieldname": "users" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ] + }, + "own_parents": { + "Hardware:successfulSpreadWormThroughRemovableMedia": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "successfulHardwareModifications": { + "asset": "Hardware", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulHardwareModifications", + "overrides": true, + "own_children": { + "Hardware:hardwareModifications": [ + null + ] + }, + "own_parents": { + "Hardware:attemptHardwareModifications": [ + null + ], + "Hardware:hardwareModificationsProtectionBypassed": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulSpreadWormThroughRemovableMedia": { + "asset": "Hardware", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulSpreadWormThroughRemovableMedia", + "overrides": true, + "own_children": { + "Hardware:spreadWormThroughRemovableMedia": [ + null + ] + }, + "own_parents": { + "Hardware:attemptSpreadWormThroughRemovableMedia": [ + null + ], + "Hardware:hardwareModificationsProtectionBypassed": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulSupplyChainAttack": { + "asset": "Hardware", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulSupplyChainAttack", + "overrides": true, + "own_children": { + "Hardware:supplyChainAttack": [ + null + ] + }, + "own_parents": { + "Hardware:attemptSupplyChainAttack": [ + null + ], + "Hardware:supplyChainAuditingBypassed": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulUnsafeUserActivity": { + "asset": "Hardware", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulUnsafeUserActivity", + "overrides": true, + "own_children": { + "Hardware:unsafeUserActivity": [ + null + ] + }, + "own_parents": { + "Hardware:attemptUnsafeUserActivity": [ + null + ], + "Hardware:hardwareModificationsProtectionBypassed": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulUseVulnerabilityFromPhysicalAccess": { + "asset": "Hardware", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulUseVulnerabilityFromPhysicalAccess", + "overrides": true, + "own_children": { + "Hardware:useVulnerability": [ + null + ] + }, + "own_parents": { + "Hardware:attemptUseVulnerabilityFromPhysicalAccess": [ + null + ], + "Hardware:hardwareModificationsProtectionBypassed": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "supplyChainAttack": { + "asset": "Hardware", + "detectors": {}, + "info": { + "modeler": "The Hardware supply chain attack represents an attacker being able to tamper with the hardware/firmware before the deployment of the product and not alterations made to the hardware/firmware once it is operating.", + "user": "A supply chain compromise means that the attacker has gained full access of the hardware." + }, + "inherits": null, + "name": "supplyChainAttack", + "overrides": true, + "own_children": { + "Hardware:fullAccess": [ + null + ] + }, + "own_parents": { + "Hardware:successfulSupplyChainAttack": [ + null + ] + }, + "tags": [], + "ttc": { + "arguments": [], + "name": "VeryHardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "supplyChainAuditing": { + "asset": "Hardware", + "detectors": {}, + "info": { + "user": "Auditing inside the hardware supply chain can detect possible supply chain attacks." + }, + "inherits": null, + "name": "supplyChainAuditing", + "overrides": true, + "own_children": { + "Hardware:supplyChainAuditingBypassed": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "defense" + }, + "supplyChainAuditingBypassed": { + "asset": "Hardware", + "detectors": {}, + "info": { + "developer": "Supply chain auditing is bypassed either because it was not set or the attacker was able to circumvent it through additional effort." + }, + "inherits": null, + "name": "supplyChainAuditingBypassed", + "overrides": true, + "own_children": { + "Hardware:successfulSupplyChainAttack": [ + null + ] + }, + "own_parents": { + "Hardware:bypassSupplyChainAuditing": [ + null + ], + "Hardware:supplyChainAuditing": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "unsafeUserActivity": { + "asset": "Hardware", + "detectors": {}, + "info": { + "developer": "Currently only represents connecting a malicious removable media drive.", + "user": "A user is performing unsafe actions on the hardware. This exposes the Applications running on top of the hardware and fulfils the physical access requirement of SoftwareVulnerabilities associated with them." + }, + "inherits": null, + "name": "unsafeUserActivity", + "overrides": true, + "own_children": { + "Application:attemptUnsafeUserActivity": [ + { + "SysExecution": { + "asset type": "Application", + "fieldname": "sysExecutedApps" + }, + "type": "field" + } + ], + "Application:physicalAccessAchieved": [ + { + "SysExecution": { + "asset type": "Application", + "fieldname": "sysExecutedApps" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Hardware:successfulUnsafeUserActivity": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "useVulnerability": { + "asset": "Hardware", + "detectors": {}, + "info": { + "user": "The attacker is able to use the associated vulnerabilities, usually as a result of obtaining physical access." + }, + "inherits": null, + "name": "useVulnerability", + "overrides": true, + "own_children": { + "HardwareVulnerability:attemptAbuse": [ + { + "hardwareVulnerability": { + "asset type": "HardwareVulnerability", + "fieldname": "vulnerabilities" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Hardware:attemptUseVulnerabilityFromSoftwareFullAccess": [ + null + ], + "Hardware:successfulUseVulnerabilityFromPhysicalAccess": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": { + "user": "Specifies the hardware on which applications can run." + }, + "is_abstract": false, + "name": "Hardware", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "HardwareVulnerability": { + "associations": { + "hardware": { + "info": { + "user": "A Hardware system/component can have multiple hardware vulnerabilities." + }, + "left": { + "asset": "HardwareVulnerability", + "fieldname": "vulnerabilities", + "max": null, + "min": 0 + }, + "name": "hardwareVulnerability", + "right": { + "asset": "Hardware", + "fieldname": "hardware", + "max": 1, + "min": 0 + } + } + }, + "attack_steps": { + "abuse": { + "asset": "HardwareVulnerability", + "detectors": {}, + "info": { + "user": "The attacker is trying to abuse a vulnerability in an asset." + }, + "inherits": "Vulnerability:abuse", + "name": "abuse", + "overrides": false, + "own_children": {}, + "own_parents": { + "HardwareVulnerability:physicalAccessAchieved": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "and" + }, + "attemptAbuse": { + "asset": "HardwareVulnerability", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "Vulnerability:attemptAbuse", + "name": "attemptAbuse", + "overrides": false, + "own_children": {}, + "own_parents": { + "Hardware:useVulnerability": [ + { + "hardwareVulnerability": { + "asset type": "Hardware", + "fieldname": "hardware" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptExploit": { + "asset": "HardwareVulnerability", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptExploit", + "overrides": true, + "own_children": { + "HardwareVulnerability:exploitTrivially": [ + null + ], + "HardwareVulnerability:exploitWithEffort": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "hidden", + "Override" + ], + "ttc": null, + "type": "or" + }, + "availabilityImpactLimitations": { + "asset": "HardwareVulnerability", + "detectors": {}, + "info": { + "modeler": "Analogous defense to the SoftwareVulnerability impact limitations.", + "user": "Limits the impact on availability of the vulnerability." + }, + "inherits": null, + "name": "availabilityImpactLimitations", + "overrides": true, + "own_children": { + "HardwareVulnerability:deny": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "suppress" + ], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "confidentialityImpactLimitations": { + "asset": "HardwareVulnerability", + "detectors": {}, + "info": { + "modeler": "Analogous defense to the SoftwareVulnerability impact limitations.", + "user": "Limits the impact on confidentiality of the vulnerability." + }, + "inherits": null, + "name": "confidentialityImpactLimitations", + "overrides": true, + "own_children": { + "HardwareVulnerability:read": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "suppress" + ], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "deny": { + "asset": "HardwareVulnerability", + "detectors": {}, + "info": { + "user": "Denial-of-Service attack is an attack in which a machine or hardware component is rendered unavailable to its intended users by temporarily or indefinitely disrupting it." + }, + "inherits": null, + "name": "deny", + "overrides": true, + "own_children": { + "Hardware:deny": [ + { + "hardwareVulnerability": { + "asset type": "Hardware", + "fieldname": "hardware" + }, + "type": "field" + } + ] + }, + "own_parents": { + "HardwareVulnerability:availabilityImpactLimitations": [ + null + ], + "HardwareVulnerability:impact": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "and" + }, + "effortRequiredToExploit": { + "asset": "HardwareVulnerability", + "detectors": {}, + "info": { + "modeler": "The expectation is that the attacker has prolonged or recurring access to the targeted hardware in order to repeatedly/continuously try the exploit.", + "user": "The vulnerability requires additional effort(time) from the attacker in order to be successful." + }, + "inherits": null, + "name": "effortRequiredToExploit", + "overrides": true, + "own_children": { + "HardwareVulnerability:exploitTrivially": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "suppress" + ], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "exploit": { + "asset": "HardwareVulnerability", + "detectors": {}, + "info": { + "user": "Attacker is employing an exploit that makes use of this vulnerability." + }, + "inherits": "Vulnerability:exploit", + "name": "exploit", + "overrides": false, + "own_children": {}, + "own_parents": { + "HardwareVulnerability:exploitTrivially": [ + null + ], + "HardwareVulnerability:exploitWithEffort": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "exploitTrivially": { + "asset": "HardwareVulnerability", + "detectors": {}, + "info": { + "developer": "The exploit is trivial and requires insignificant effort from the attacker in order to be successful." + }, + "inherits": null, + "name": "exploitTrivially", + "overrides": true, + "own_children": { + "HardwareVulnerability:exploit": [ + null + ] + }, + "own_parents": { + "HardwareVulnerability:attemptExploit": [ + null + ], + "HardwareVulnerability:effortRequiredToExploit": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "exploitWithEffort": { + "asset": "HardwareVulnerability", + "detectors": {}, + "info": { + "developer": "The attacker must expend effort in order to successfully exploit the vulnerability." + }, + "inherits": null, + "name": "exploitWithEffort", + "overrides": true, + "own_children": { + "HardwareVulnerability:exploit": [ + null + ] + }, + "own_parents": { + "HardwareVulnerability:attemptExploit": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": { + "arguments": [ + 0.01 + ], + "name": "Exponential", + "type": "function" + }, + "type": "and" + }, + "impact": { + "asset": "HardwareVulnerability", + "detectors": {}, + "info": { + "developer": "The actual impact caused by successfully exploiting the vulnerability." + }, + "inherits": null, + "name": "impact", + "overrides": true, + "own_children": { + "HardwareVulnerability:deny": [ + null + ], + "HardwareVulnerability:modify": [ + null + ], + "HardwareVulnerability:read": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "integrityImpactLimitations": { + "asset": "HardwareVulnerability", + "detectors": {}, + "info": { + "modeler": "Analogous defense to the SoftwareVulnerability impact limitations.", + "user": "Limits the impact on integrity of the vulnerability." + }, + "inherits": null, + "name": "integrityImpactLimitations", + "overrides": true, + "own_children": { + "HardwareVulnerability:modify": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "suppress" + ], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "modify": { + "asset": "HardwareVulnerability", + "detectors": {}, + "info": { + "user": "The attacker can modify some or all of the associated hardware's behaviour." + }, + "inherits": null, + "name": "modify", + "overrides": true, + "own_children": { + "Hardware:modify": [ + { + "hardwareVulnerability": { + "asset type": "Hardware", + "fieldname": "hardware" + }, + "type": "field" + } + ] + }, + "own_parents": { + "HardwareVulnerability:impact": [ + null + ], + "HardwareVulnerability:integrityImpactLimitations": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "and" + }, + "notPresent": { + "asset": "HardwareVulnerability", + "detectors": {}, + "info": { + "developer": "Enabling this defense should make the asset behave as if it did not exist, as such all of its impactful attack steps should be disabled by it.", + "mitre": "Used for M1049:Antivirus/Antimalware, M1050: Exploit Protection, M1040: Behavior Prevention on Endpoint, M1038: Execution Prevention, M1051: Update Software, M1016: Vulnerability Scanning, M1021: Restrict Web-Based Content, M1042: Disable or Remove Feature or Program, M1013: Application Developer Guidance", + "user": "This defense is used to reason about the attack vectors introduced by this asset. This is particularly useful for vulnerabilities where it is common to explore the possibility of patching them out." + }, + "inherits": "Vulnerability:notPresent", + "name": "notPresent", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "physicalAccessAchieved": { + "asset": "HardwareVulnerability", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model physical access requirement." + }, + "inherits": null, + "name": "physicalAccessAchieved", + "overrides": true, + "own_children": { + "HardwareVulnerability:abuse": [ + null + ] + }, + "own_parents": { + "Hardware:hardwareModifications": [ + { + "hardwareVulnerability": { + "asset type": "Hardware", + "fieldname": "hardware" + }, + "type": "field" + } + ], + "HardwareVulnerability:physicalAccessRequired": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "physicalAccessRequired": { + "asset": "HardwareVulnerability", + "detectors": {}, + "info": { + "modeler": "Some hardware vulnerabilities require physical access to the components, while some can be exploited from software.", + "user": "Physical access is required to abuse the vulnerability." + }, + "inherits": null, + "name": "physicalAccessRequired", + "overrides": true, + "own_children": { + "HardwareVulnerability:physicalAccessAchieved": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "suppress" + ], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "read": { + "asset": "HardwareVulnerability", + "detectors": {}, + "info": { + "user": "The attacker can read some or all of the associated hardware's data." + }, + "inherits": null, + "name": "read", + "overrides": true, + "own_children": { + "Hardware:read": [ + { + "hardwareVulnerability": { + "asset type": "Hardware", + "fieldname": "hardware" + }, + "type": "field" + } + ] + }, + "own_parents": { + "HardwareVulnerability:confidentialityImpactLimitations": [ + null + ], + "HardwareVulnerability:impact": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "and" + } + }, + "info": { + "user": "A vulnerability that affects hardware." + }, + "is_abstract": false, + "name": "HardwareVulnerability", + "sub_assets": [], + "super_asset": "Vulnerability", + "variables": {} + }, + "IAMObject": { + "associations": { + "deletePrivData": { + "info": { + "mitre": "Used for M1026: Privileged Account Management, M1025: Privileged Process Integrity, M1022: Restrict File and Directory Permissions, M1052: User Account Control, M1018: User Account Management, M1024: Restrict Registry Permissions, M1043: Credential Access Protection, M1057: Data Loss Prevention, M1039: Environment Variable Permissions" + }, + "left": { + "asset": "IAMObject", + "fieldname": "deletingIAMs", + "max": null, + "min": 0 + }, + "name": "DeletePrivileges", + "right": { + "asset": "Data", + "fieldname": "deletePrivData", + "max": null, + "min": 0 + } + }, + "execPrivApps": { + "info": { + "user": "Every application executes on a system with privileges of a specified identity on the system. If the application is compromised then the privileges should be compromised." + }, + "left": { + "asset": "IAMObject", + "fieldname": "executionPrivIAMs", + "max": null, + "min": 0 + }, + "name": "ExecutionPrivilegeAccess", + "right": { + "asset": "Application", + "fieldname": "execPrivApps", + "max": null, + "min": 0 + } + }, + "highPrivApps": { + "info": { + "user": "High privilege application access on an Application results in the (full) access/compromise of the application and all the child applications." + }, + "left": { + "asset": "IAMObject", + "fieldname": "highPrivAppIAMs", + "max": null, + "min": 0 + }, + "name": "HighPrivilegeApplicationAccess", + "right": { + "asset": "Application", + "fieldname": "highPrivApps", + "max": null, + "min": 0 + } + }, + "lowPrivApps": { + "info": { + "user": "Low privilege application access on an Application allows only the local interaction with the application and all the specified privileges." + }, + "left": { + "asset": "IAMObject", + "fieldname": "lowPrivAppIAMs", + "max": null, + "min": 0 + }, + "name": "LowPrivilegeApplicationAccess", + "right": { + "asset": "Application", + "fieldname": "lowPrivApps", + "max": null, + "min": 0 + } + }, + "managedIAMs": { + "info": { + "user": "Identities, Groups, and Privileges may have account management roles for other Identities, Groups, and Privileges." + }, + "left": { + "asset": "IAMObject", + "fieldname": "managers", + "max": null, + "min": 0 + }, + "name": "AccountManagement", + "right": { + "asset": "IAMObject", + "fieldname": "managedIAMs", + "max": null, + "min": 0 + } + }, + "managers": { + "info": { + "user": "Identities, Groups, and Privileges may have account management roles for other Identities, Groups, and Privileges." + }, + "left": { + "asset": "IAMObject", + "fieldname": "managers", + "max": null, + "min": 0 + }, + "name": "AccountManagement", + "right": { + "asset": "IAMObject", + "fieldname": "managedIAMs", + "max": null, + "min": 0 + } + }, + "readPrivData": { + "info": {}, + "left": { + "asset": "IAMObject", + "fieldname": "readingIAMs", + "max": null, + "min": 0 + }, + "name": "ReadPrivileges", + "right": { + "asset": "Data", + "fieldname": "readPrivData", + "max": null, + "min": 0 + } + }, + "subprivileges": { + "info": { + "user": "Identities, Groups, and Privileges may have a subset of Privileges that we logically separate to model specific scenarios, such as partial lockouts or speculating about possible privileges." + }, + "left": { + "asset": "IAMObject", + "fieldname": "IAMOwners", + "max": null, + "min": 0 + }, + "name": "HasPrivileges", + "right": { + "asset": "Privileges", + "fieldname": "subprivileges", + "max": null, + "min": 0 + } + }, + "writePrivData": { + "info": {}, + "left": { + "asset": "IAMObject", + "fieldname": "writingIAMs", + "max": null, + "min": 0 + }, + "name": "WritePrivileges", + "right": { + "asset": "Data", + "fieldname": "writePrivData", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "assume": { + "asset": "IAMObject", + "detectors": {}, + "info": { + "user": "When an attacker is able to assume an IAM asset the privileges associated with it can always be exploited, other behaviour is asset specific." + }, + "inherits": null, + "name": "assume", + "overrides": true, + "own_children": { + "Application:authenticate": [ + { + "ExecutionPrivilegeAccess": { + "asset type": "Application", + "fieldname": "execPrivApps" + }, + "type": "field" + }, + { + "HighPrivilegeApplicationAccess": { + "asset type": "Application", + "fieldname": "highPrivApps" + }, + "type": "field" + } + ], + "Application:specificAccessAuthenticate": [ + { + "LowPrivilegeApplicationAccess": { + "asset type": "Application", + "fieldname": "lowPrivApps" + }, + "type": "field" + } + ], + "Data:authorizedApplicationRespondConnectFromIAM": [ + { + "WritePrivileges": { + "asset type": "Data", + "fieldname": "writePrivData" + }, + "type": "field" + } + ], + "Data:authorizedDeleteFromIAM": [ + { + "DeletePrivileges": { + "asset type": "Data", + "fieldname": "deletePrivData" + }, + "type": "field" + } + ], + "Data:authorizedReadFromIAM": [ + { + "ReadPrivileges": { + "asset type": "Data", + "fieldname": "readPrivData" + }, + "type": "field" + } + ], + "Data:authorizedWriteFromIAM": [ + { + "WritePrivileges": { + "asset type": "Data", + "fieldname": "writePrivData" + }, + "type": "field" + } + ], + "IAMObject:attemptAssume": [ + { + "AccountManagement": { + "asset type": "IAMObject", + "fieldname": "managedIAMs" + }, + "type": "field" + } + ], + "Privileges:attemptAssume": [ + { + "HasPrivileges": { + "asset type": "Privileges", + "fieldname": "subprivileges" + }, + "type": "field" + } + ] + }, + "own_parents": { + "IAMObject:successfulAssume": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "attemptAssume": { + "asset": "IAMObject", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptAssume", + "overrides": true, + "own_children": { + "IAMObject:successfulAssume": [ + null + ] + }, + "own_parents": { + "Application:fullAccess": [ + { + "ExecutionPrivilegeAccess": { + "asset type": "Application", + "fieldname": "execPrivApps" + }, + "type": "field" + } + ], + "IAMObject:assume": [ + { + "AccountManagement": { + "asset type": "IAMObject", + "fieldname": "managers" + }, + "type": "field" + } + ], + "IAMObject:write": [ + null + ], + "Privileges:assume": [ + { + "HasPrivileges": { + "asset type": "Privileges", + "fieldname": "subprivileges" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptDeleteFromReplica": { + "asset": "IAMObject", + "detectors": {}, + "info": { + "developer": "Information can be deleted only if all replicas have been deleted." + }, + "inherits": "Information:attemptDeleteFromReplica", + "name": "attemptDeleteFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "attemptDenyFromReplica": { + "asset": "IAMObject", + "detectors": {}, + "info": { + "developer": "Information can be denied only if all replicas have been denied." + }, + "inherits": "Information:attemptDenyFromReplica", + "name": "attemptDenyFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "attemptExtractFromReplica": { + "asset": "IAMObject", + "detectors": {}, + "info": { + "developer": "The attacker attempts to extract the information." + }, + "inherits": "Information:attemptExtractFromReplica", + "name": "attemptExtractFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptLockout": { + "asset": "IAMObject", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptLockout", + "overrides": true, + "own_children": { + "IAMObject:successfulLockout": [ + null + ] + }, + "own_parents": { + "IAMObject:deny": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptReadFromReplica": { + "asset": "IAMObject", + "detectors": {}, + "info": { + "developer": "Reading one replica allows the attacker to read all other replicas as well since the information contained in them is the same." + }, + "inherits": "Information:attemptReadFromReplica", + "name": "attemptReadFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptWriteFromReplica": { + "asset": "IAMObject", + "detectors": {}, + "info": { + "developer": "Information can be overwritten only if all replicas have been overwritten." + }, + "inherits": "Information:attemptWriteFromReplica", + "name": "attemptWriteFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "delete": { + "asset": "IAMObject", + "detectors": {}, + "info": { + "user": "The attacker is able to delete the information." + }, + "inherits": "Information:delete", + "name": "delete", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "deny": { + "asset": "IAMObject", + "detectors": {}, + "info": { + "user": "The attacker is able to deny the information." + }, + "inherits": "Information:deny", + "name": "deny", + "overrides": false, + "own_children": { + "IAMObject:attemptLockout": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "extract": { + "asset": "IAMObject", + "detectors": {}, + "info": { + "user": "The attacker is able to extract the information. This means that they have a path available through which they can transfer the information back to a system that they control." + }, + "inherits": "Information:extract", + "name": "extract", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "lockout": { + "asset": "IAMObject", + "detectors": {}, + "info": { + "user": "The Identity, Group, or Privileges has been locked out and cannot be used by legitimate users. This means that the applications that they can execute and data they can read might also be denied if all of the access roles that could execute or read them, respectively, have been locked out." + }, + "inherits": null, + "name": "lockout", + "overrides": true, + "own_children": { + "Application:denyFromLockout": [ + { + "ExecutionPrivilegeAccess": { + "asset type": "Application", + "fieldname": "execPrivApps" + }, + "type": "field" + } + ], + "Data:denyFromLockout": [ + { + "ReadPrivileges": { + "asset type": "Data", + "fieldname": "readPrivData" + }, + "type": "field" + } + ], + "Privileges:lockout": [ + { + "HasPrivileges": { + "asset type": "Privileges", + "fieldname": "subprivileges" + }, + "type": "field" + } + ] + }, + "own_parents": { + "IAMObject:successfulLockout": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "notPresent": { + "asset": "IAMObject", + "detectors": {}, + "info": { + "developer": "Enabling this defense should make the asset behave as if it did not exist, as such all of its impactful attack steps should be disabled by it.", + "mitre": "Used for M1026: Privileged Account Management, M1025: Privileged Process Integrity, M1022: Restrict File and Directory Permissions, M1052: User Account Control, M1018: User Account Management, M1024: Restrict Registry Permissions, M1043: Credential Access Protection, M1057: Data Loss Prevention", + "user": "This defense is used to reason about the attack vectors introduced by this asset. It should be used to model the possibility that the IAM object does not actually exist." + }, + "inherits": null, + "name": "notPresent", + "overrides": true, + "own_children": { + "IAMObject:successfulAssume": [ + null + ], + "IAMObject:successfulLockout": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "read": { + "asset": "IAMObject", + "detectors": {}, + "info": { + "user": "The attacker is able to read the information. They may still be unable to extract it, which represents the adversary being able to read it themselves. This is the case if autonomous code is able to read it, but lack the required networking connections to transmit it back to the adversary." + }, + "inherits": "Information:read", + "name": "read", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "successfulAssume": { + "asset": "IAMObject", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulAssume", + "overrides": true, + "own_children": { + "IAMObject:assume": [ + null + ] + }, + "own_parents": { + "IAMObject:attemptAssume": [ + null + ], + "IAMObject:notPresent": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulLockout": { + "asset": "IAMObject", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulLockout", + "overrides": true, + "own_children": { + "IAMObject:lockout": [ + null + ] + }, + "own_parents": { + "IAMObject:attemptLockout": [ + null + ], + "IAMObject:notPresent": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "write": { + "asset": "IAMObject", + "detectors": {}, + "info": { + "user": "The attacker is able to write the information." + }, + "inherits": "Information:write", + "name": "write", + "overrides": false, + "own_children": { + "IAMObject:attemptAssume": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": { + "user": "An IAM object represents the base logic shared by all assets used for Identity and Access Management roles(Identity, Group, Privileges)." + }, + "is_abstract": true, + "name": "IAMObject", + "sub_assets": [ + "Identity", + "Privileges", + "Group" + ], + "super_asset": "Information", + "variables": {} + }, + "IDPS": { + "associations": { + "protectedApps": { + "info": { + "user": "IDPSs can provide protection to other applications." + }, + "left": { + "asset": "IDPS", + "fieldname": "protectorIDPSs", + "max": null, + "min": 0 + }, + "name": "AppProtection", + "right": { + "asset": "Application", + "fieldname": "protectedApps", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "accessNetworkAndConnections": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "The attacker can access outgoing and bidirectional networks and connections associated with the application." + }, + "inherits": "Application:accessNetworkAndConnections", + "name": "accessNetworkAndConnections", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "applicationRespondConnectThroughData": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "After full access on the application received data can be used to try a connect through respond to the client side application." + }, + "inherits": "Application:applicationRespondConnectThroughData", + "name": "applicationRespondConnectThroughData", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attackerUnsafeUserActivityCapability": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "The impacts of the user's unsafe actions are greatly limited if there are no channels through which the attacker can be reached by the unwitting user." + }, + "inherits": "Application:attackerUnsafeUserActivityCapability", + "name": "attackerUnsafeUserActivityCapability", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attackerUnsafeUserActivityCapabilityWithReverseReach": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "The attacker can be reached from this application and can therefore more easily exploit the unsafe user actions." + }, + "inherits": "Application:attackerUnsafeUserActivityCapabilityWithReverseReach", + "name": "attackerUnsafeUserActivityCapabilityWithReverseReach", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "attackerUnsafeUserActivityCapabilityWithoutReverseReach": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "The attacker cannot be reached from this application. This means that the autonomous malicious code must either be able to do reconnaissance on the fly or the reconnaissance was done beforehand and coded into it. This is much more complex and difficult than the situation where the attacker is reachable from the application which restricts the capabilities of being successful. Important Note: This step should never directly or indirectly lead to reverse reach as it would fulfil the attacker interactivity requirements it is missing." + }, + "inherits": "Application:attackerUnsafeUserActivityCapabilityWithoutReverseReach", + "name": "attackerUnsafeUserActivityCapabilityWithoutReverseReach", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": { + "arguments": [], + "name": "VeryHardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "attemptApplicationRespondConnectThroughData": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "Application:attemptApplicationRespondConnectThroughData", + "name": "attemptApplicationRespondConnectThroughData", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptAuthorizedApplicationRespondConnectThroughData": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "Application:attemptAuthorizedApplicationRespondConnectThroughData", + "name": "attemptAuthorizedApplicationRespondConnectThroughData", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptDeny": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "Application:attemptDeny", + "name": "attemptDeny", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptDenyFromSoftProdVulnerability": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "Application:attemptDenyFromSoftProdVulnerability", + "name": "attemptDenyFromSoftProdVulnerability", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptFullAccessFromSupplyChainCompromise": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": "Application:attemptFullAccessFromSupplyChainCompromise", + "name": "attemptFullAccessFromSupplyChainCompromise", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptModify": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "Application:attemptModify", + "name": "attemptModify", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptModifyFromSoftProdVulnerability": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "Application:attemptModifyFromSoftProdVulnerability", + "name": "attemptModifyFromSoftProdVulnerability", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptNetworkConnectFromResponse": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "Application:attemptNetworkConnectFromResponse", + "name": "attemptNetworkConnectFromResponse", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptRead": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "Application:attemptRead", + "name": "attemptRead", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptReadFromSoftProdVulnerability": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "Application:attemptReadFromSoftProdVulnerability", + "name": "attemptReadFromSoftProdVulnerability", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptReverseReach": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses.", + "modeler": "If the entry point of the attacker is an Application this attack step needs to be explicitly called on it by the modeler in order to have reverse reach start propagating from the Application to the rest of the assets in the model. This is the case because no other attack step on the Application asset triggers this attack step.", + "user": "The attacker is reachable from this Application therefore start propagating reverse reach from it. Reverse reach is used to determine whether or not the attacker can be reached by the user." + }, + "inherits": "Application:attemptReverseReach", + "name": "attemptReverseReach", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "entrypoint" + ], + "ttc": null, + "type": "or" + }, + "attemptSoftwareProductAbuse": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": "Application:attemptSoftwareProductAbuse", + "name": "attemptSoftwareProductAbuse", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptUnsafeUserActivity": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": "Application:attemptUnsafeUserActivity", + "name": "attemptUnsafeUserActivity", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptUseVulnerability": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": "Application:attemptUseVulnerability", + "name": "attemptUseVulnerability", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "authenticate": { + "asset": "IDPS", + "detectors": {}, + "info": { + "user": "The attacker is able to authenticate with the appropriate high-level privileges." + }, + "inherits": "Application:authenticate", + "name": "authenticate", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "authorizedApplicationRespondConnectThroughData": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "After specific access on the application received data can be used to try a connect through respond to the client side application. To succeed the attacker must also attain write privileges on the received data." + }, + "inherits": "Application:authorizedApplicationRespondConnectThroughData", + "name": "authorizedApplicationRespondConnectThroughData", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "bypassContainerization": { + "asset": "IDPS", + "detectors": {}, + "info": { + "user": "The attacker is able to break out of an application container/sandbox and try to exploit any vulnerability of the host application." + }, + "inherits": "Application:bypassContainerization", + "name": "bypassContainerization", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "HardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "bypassEffectiveness": { + "asset": "IDPS", + "detectors": {}, + "info": { + "user": "The effectiveness of an IDPS can be bypassed." + }, + "inherits": null, + "name": "bypassEffectiveness", + "overrides": true, + "own_children": { + "IDPS:effectivenessBypassed": [ + null + ] + }, + "own_parents": { + "Application:attemptUnsafeUserActivity": [ + { + "AppProtection": { + "asset type": "Application", + "fieldname": "protectedApps" + }, + "type": "field" + } + ], + "Application:attemptUseVulnerability": [ + { + "AppProtection": { + "asset type": "Application", + "fieldname": "protectedApps" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": { + "arguments": [], + "name": "VeryHardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "bypassSupplyChainAuditing": { + "asset": "IDPS", + "detectors": {}, + "info": { + "user": "Supply chain auditing can be bypassed." + }, + "inherits": "Application:bypassSupplyChainAuditing", + "name": "bypassSupplyChainAuditing", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "VeryHardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "deny": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "The IDPS is no longer able to protect the apps assigned to it if it has been denied itself.", + "user": "The attacker can deny some or all functionality and data pertaining to this application/service as well as executed applications." + }, + "inherits": "Application:deny", + "name": "deny", + "overrides": false, + "own_children": { + "IDPS:effectivenessBypassed": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "denyFromLockout": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "This is an intermediate attack step to only trigger deny on an application when all the executing access control roles are locked out." + }, + "inherits": "Application:denyFromLockout", + "name": "denyFromLockout", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "denyFromNetworkingAsset": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "This is an intermediate attack step to only allow deny on an application when all the connection rules and networks associated with it are denied, because an app can be serving on many different ports." + }, + "inherits": "Application:denyFromNetworkingAsset", + "name": "denyFromNetworkingAsset", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "denyFromSoftProdVulnerability": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to handle SoftwareProduct existence." + }, + "inherits": "Application:denyFromSoftProdVulnerability", + "name": "denyFromSoftProdVulnerability", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "effectiveness": { + "asset": "IDPS", + "detectors": {}, + "info": { + "mitre": "Used for M1049: Antivirus/Antimalware, M1050: Exploit Protection, M1040: Behavior Prevention on Endpoint, M1038: Execution Prevention, M1051: Update Software, M1016: Vulnerability Scanning, M1021: Restrict Web-Based Content", + "modeler": "The notPresent defense should inversely impact the effectiveness defense. However, because this is not currently implemented in coreLang it is up to the modeler to properly take this into account when setting the values of these defenses.", + "user": "The effectiveness defense represents how capable the IDPS is to prevent disruptions from occurring on the associated applications." + }, + "inherits": null, + "name": "effectiveness", + "overrides": true, + "own_children": { + "IDPS:effectivenessBypassed": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "Enabled", + "type": "function" + }, + "type": "defense" + }, + "effectivenessBypassed": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "The protection of the IDPS has been bypassed either as a result of attacker activity or due to some internal property of the IDPS." + }, + "inherits": null, + "name": "effectivenessBypassed", + "overrides": true, + "own_children": { + "Application:successfulUnsafeUserActivity": [ + { + "AppProtection": { + "asset type": "Application", + "fieldname": "protectedApps" + }, + "type": "field" + } + ], + "Application:successfulUseVulnerability": [ + { + "AppProtection": { + "asset type": "Application", + "fieldname": "protectedApps" + }, + "type": "field" + } + ] + }, + "own_parents": { + "IDPS:bypassEffectiveness": [ + null + ], + "IDPS:deny": [ + null + ], + "IDPS:effectiveness": [ + null + ], + "IDPS:fullAccess": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "fullAccess": { + "asset": "IDPS", + "detectors": {}, + "info": { + "modeler": "It is common in models for the attacker to be granted full access on an Application, representing their own tools, that is connected to the Internet to explore possible attack vectors.", + "user": "Full access on the Application means the attacker has complete control of the application and can perform any actions within its execution context." + }, + "inherits": "Application:fullAccess", + "name": "fullAccess", + "overrides": false, + "own_children": { + "IDPS:effectivenessBypassed": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "entrypoint" + ], + "ttc": null, + "type": "or" + }, + "fullAccessFromSupplyChainCompromise": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step." + }, + "inherits": "Application:fullAccessFromSupplyChainCompromise", + "name": "fullAccessFromSupplyChainCompromise", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "localAccess": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack steps for modeling the two requirements (reachability and authentication) for full access." + }, + "inherits": "Application:localAccess", + "name": "localAccess", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "localConnect": { + "asset": "IDPS", + "detectors": {}, + "info": { + "user": "The attacker can interface with the Application without going through networking communications. This is achieved by gaining full access on a hosted Application, specific access on the hosting Application, physical access on the hardware on which the Application is running, or via unsafe user activity." + }, + "inherits": "Application:localConnect", + "name": "localConnect", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "modify": { + "asset": "IDPS", + "detectors": {}, + "info": { + "user": "The attacker can modify some or all of this service's (and executed by this) source code and/or data." + }, + "inherits": "Application:modify", + "name": "modify", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "modifyFromSoftProdVulnerability": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to handle SoftwareProduct existence." + }, + "inherits": "Application:modifyFromSoftProdVulnerability", + "name": "modifyFromSoftProdVulnerability", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "networkAccess": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack steps for modeling the two requirements (reachability and authentication) for full access." + }, + "inherits": "Application:networkAccess", + "name": "networkAccess", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "networkConnect": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step." + }, + "inherits": "Application:networkConnect", + "name": "networkConnect", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "networkConnectFromResponse": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "An attacker may be able to respond to requests submitted by a client application." + }, + "inherits": "Application:networkConnectFromResponse", + "name": "networkConnectFromResponse", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": { + "arguments": [], + "name": "VeryHardAndUncertain", + "type": "function" + }, + "type": "and" + }, + "networkConnectInspected": { + "asset": "IDPS", + "detectors": {}, + "info": { + "user": "If the network communications occur via any ConnectionRules that have their payload inspected the attacker can still authenticate, but they cannot trigger vulnerabilities." + }, + "inherits": "Application:networkConnectInspected", + "name": "networkConnectInspected", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "networkConnectUninspected": { + "asset": "IDPS", + "detectors": {}, + "info": { + "user": "An attacker can connect to any network exposed application and try to authenticate or trigger vulnerabilities." + }, + "inherits": "Application:networkConnectUninspected", + "name": "networkConnectUninspected", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "notPresent": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "This defense allows for both speculation about the presence of a particular application and to suggest that if it is possible removing it may restrict the attacker's actions.", + "modeler": "As per the comment for the effectiveness defense, the notPresent defense does not function as desired for the IDPS asset and should not be used. The effectiveness defense should be adjusted to reflect the likelihood that the IDPS is present instead.", + "user": "This defense is used to reason about the attack vectors introduced by this asset. It should be used to model the possibility that the Application does not actually exist. Typically this is useful to see what attack steps are removed if a particular non-essential component(e.g. a plugin, library, feature, or service)." + }, + "inherits": "Application:notPresent", + "name": "notPresent", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "Override", + "hidden" + ], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "physicalAccessAchieved": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to propagate physical access throughout application nesting." + }, + "inherits": "Application:physicalAccessAchieved", + "name": "physicalAccessAchieved", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "read": { + "asset": "IDPS", + "detectors": {}, + "info": { + "user": "The attacker can read some or all of this service's (and executed by this) source code and/or data (both local and sent/received)." + }, + "inherits": "Application:read", + "name": "read", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "readFromSoftProdVulnerability": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to handle SoftwareProduct existence." + }, + "inherits": "Application:readFromSoftProdVulnerability", + "name": "readFromSoftProdVulnerability", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "reverseReach": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Reverse reach is used to determine whether or not the attacker can be reached by the user. Reverse reach propagates toward outgoing or bidirectional communications." + }, + "inherits": "Application:reverseReach", + "name": "reverseReach", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "softwareCheck": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Only enable the following attacks steps if a SoftwareProduct is associated with this Application." + }, + "inherits": "Application:softwareCheck", + "name": "softwareCheck", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "exist" + }, + "softwareProductAbuse": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": "Application:softwareProductAbuse", + "name": "softwareProductAbuse", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "softwareProductVulnerabilityHighPrivilegesAchieved": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": "Application:softwareProductVulnerabilityHighPrivilegesAchieved", + "name": "softwareProductVulnerabilityHighPrivilegesAchieved", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "softwareProductVulnerabilityLocalAccessAchieved": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": "Application:softwareProductVulnerabilityLocalAccessAchieved", + "name": "softwareProductVulnerabilityLocalAccessAchieved", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "softwareProductVulnerabilityLowPrivilegesAchieved": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": "Application:softwareProductVulnerabilityLowPrivilegesAchieved", + "name": "softwareProductVulnerabilityLowPrivilegesAchieved", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "softwareProductVulnerabilityNetworkAccessAchieved": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": "Application:softwareProductVulnerabilityNetworkAccessAchieved", + "name": "softwareProductVulnerabilityNetworkAccessAchieved", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "softwareProductVulnerabilityPhysicalAccessAchieved": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": "Application:softwareProductVulnerabilityPhysicalAccessAchieved", + "name": "softwareProductVulnerabilityPhysicalAccessAchieved", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "softwareProductVulnerabilityUserInteractionAchieved": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": "Application:softwareProductVulnerabilityUserInteractionAchieved", + "name": "softwareProductVulnerabilityUserInteractionAchieved", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "specificAccess": { + "asset": "IDPS", + "detectors": {}, + "info": { + "user": "The adversary is able to gain low-privilege access on the Application which allows them to access the networks and connections associated with it and locally connect to hosted Applications. Additionally, if they have the required privileges the attacker may also access data hosted, sent, or received by the Application." + }, + "inherits": "Application:specificAccess", + "name": "specificAccess", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "specificAccessAuthenticate": { + "asset": "IDPS", + "detectors": {}, + "info": { + "user": "The attacker is able to authenticate with the appropriate low-level privileges." + }, + "inherits": "Application:specificAccessAuthenticate", + "name": "specificAccessAuthenticate", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "specificAccessDelete": { + "asset": "IDPS", + "detectors": {}, + "info": { + "user": "The attacker can delete some or all functionality and data pertaining to this application/service as well as executed applications, given the necessary permissions" + }, + "inherits": "Application:specificAccessDelete", + "name": "specificAccessDelete", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "specificAccessFromLocalConnection": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack steps for modeling the two requirements (reachability and authentication) for specific access." + }, + "inherits": "Application:specificAccessFromLocalConnection", + "name": "specificAccessFromLocalConnection", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "specificAccessFromNetworkConnection": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack steps for modeling the two requirements (reachability and authentication) for specific access." + }, + "inherits": "Application:specificAccessFromNetworkConnection", + "name": "specificAccessFromNetworkConnection", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "specificAccessModify": { + "asset": "IDPS", + "detectors": {}, + "info": { + "user": "The attacker can modify the service's source code and/or sent Data, given the necessary permissions" + }, + "inherits": "Application:specificAccessModify", + "name": "specificAccessModify", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "specificAccessNetworkConnect": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step." + }, + "inherits": "Application:specificAccessNetworkConnect", + "name": "specificAccessNetworkConnect", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "specificAccessRead": { + "asset": "IDPS", + "detectors": {}, + "info": { + "user": "The attacker can read the service's source code and/or sent and received Data, given the necessary permissions" + }, + "inherits": "Application:specificAccessRead", + "name": "specificAccessRead", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "successfulApplicationRespondConnectThroughData": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "Application:successfulApplicationRespondConnectThroughData", + "name": "successfulApplicationRespondConnectThroughData", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulAuthorizedApplicationRespondConnectThroughData": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "Application:successfulAuthorizedApplicationRespondConnectThroughData", + "name": "successfulAuthorizedApplicationRespondConnectThroughData", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulDeny": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "Application:successfulDeny", + "name": "successfulDeny", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulModify": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "Application:successfulModify", + "name": "successfulModify", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulRead": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "Application:successfulRead", + "name": "successfulRead", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulReverseReach": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "Application:successfulReverseReach", + "name": "successfulReverseReach", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulUnsafeUserActivity": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "Application:successfulUnsafeUserActivity", + "name": "successfulUnsafeUserActivity", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulUseVulnerability": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "Application:successfulUseVulnerability", + "name": "successfulUseVulnerability", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "supplyChainAuditing": { + "asset": "IDPS", + "detectors": {}, + "info": { + "user": "Auditing inside the supply chain can detect possible supply chain attacks." + }, + "inherits": "Application:supplyChainAuditing", + "name": "supplyChainAuditing", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "defense" + }, + "supplyChainAuditingBypassed": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "The supply chain audit is bypassed either because it was not set or the attacker was able to circumvent it through additional effort." + }, + "inherits": "Application:supplyChainAuditingBypassed", + "name": "supplyChainAuditingBypassed", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "unsafeUserActivity": { + "asset": "IDPS", + "detectors": {}, + "info": { + "user": "The unsafe actions of users on this application open it up to attacks and enable vulnerabilities that require user interaction." + }, + "inherits": "Application:unsafeUserActivity", + "name": "unsafeUserActivity", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "useVulnerability": { + "asset": "IDPS", + "detectors": {}, + "info": { + "developer": "This includes vulnerabilities associated with the SoftwareProduct that this Application is running as well.", + "user": "Try to exploit the associated vulnerabilities." + }, + "inherits": "Application:useVulnerability", + "name": "useVulnerability", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": { + "user": "An IDPS(Intrusion Detection and Prevention System) is tasked with protecting other applications from malicious activity, such as exploiting vulnerabilities or unsafe user actions." + }, + "is_abstract": false, + "name": "IDPS", + "sub_assets": [], + "super_asset": "Application", + "variables": {} + }, + "Identity": { + "associations": { + "childId": { + "info": { + "user": "Starting from a parent Identity, the child Identities can be assumed due to inheritance." + }, + "left": { + "asset": "Identity", + "fieldname": "parentId", + "max": null, + "min": 0 + }, + "name": "CanAssume", + "right": { + "asset": "Identity", + "fieldname": "childId", + "max": null, + "min": 0 + } + }, + "credentials": { + "info": {}, + "left": { + "asset": "Identity", + "fieldname": "identities", + "max": null, + "min": 0 + }, + "name": "IdentityCredentials", + "right": { + "asset": "Credentials", + "fieldname": "credentials", + "max": null, + "min": 0 + } + }, + "memberOf": { + "info": {}, + "left": { + "asset": "Group", + "fieldname": "memberOf", + "max": null, + "min": 0 + }, + "name": "MemberOf", + "right": { + "asset": "Identity", + "fieldname": "groupIds", + "max": null, + "min": 0 + } + }, + "parentId": { + "info": { + "user": "Starting from a parent Identity, the child Identities can be assumed due to inheritance." + }, + "left": { + "asset": "Identity", + "fieldname": "parentId", + "max": null, + "min": 0 + }, + "name": "CanAssume", + "right": { + "asset": "Identity", + "fieldname": "childId", + "max": null, + "min": 0 + } + }, + "users": { + "info": { + "user": "A user may have different identities associated with it." + }, + "left": { + "asset": "User", + "fieldname": "users", + "max": null, + "min": 0 + }, + "name": "UserAssignedIdentities", + "right": { + "asset": "Identity", + "fieldname": "userIds", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "assume": { + "asset": "Identity", + "detectors": {}, + "info": { + "developer": "This is both legitimate and illegitimate access! Also assume all the privileges of the parent identities (on the above level/inherited by this identity) because those represent the group of (inherited) roles.", + "user": "When an attacker is able to assume an IAM asset the privileges associated with it can always be exploited, other behaviour is asset specific." + }, + "inherits": "IAMObject:assume", + "name": "assume", + "overrides": false, + "own_children": { + "Group:attemptAssume": [ + { + "MemberOf": { + "asset type": "Group", + "fieldname": "memberOf" + }, + "type": "field" + } + ], + "Identity:attemptAssume": [ + { + "CanAssume": { + "asset type": "Identity", + "fieldname": "parentId" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "attemptAssume": { + "asset": "Identity", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "IAMObject:attemptAssume", + "name": "attemptAssume", + "overrides": false, + "own_children": {}, + "own_parents": { + "Credentials:use": [ + { + "IdentityCredentials": { + "asset type": "Credentials", + "fieldname": "credentials" + }, + "type": "field" + } + ], + "Identity:assume": [ + { + "CanAssume": { + "asset type": "Identity", + "fieldname": "childId" + }, + "type": "field" + } + ], + "User:deliverMaliciousRemovableMedia": [ + { + "UserAssignedIdentities": { + "asset type": "User", + "fieldname": "users" + }, + "type": "field" + } + ], + "User:unsafeUserActivity": [ + { + "UserAssignedIdentities": { + "asset type": "User", + "fieldname": "users" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptDeleteFromReplica": { + "asset": "Identity", + "detectors": {}, + "info": { + "developer": "Information can be deleted only if all replicas have been deleted." + }, + "inherits": "IAMObject:attemptDeleteFromReplica", + "name": "attemptDeleteFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "attemptDenyFromReplica": { + "asset": "Identity", + "detectors": {}, + "info": { + "developer": "Information can be denied only if all replicas have been denied." + }, + "inherits": "IAMObject:attemptDenyFromReplica", + "name": "attemptDenyFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "attemptExtractFromReplica": { + "asset": "Identity", + "detectors": {}, + "info": { + "developer": "The attacker attempts to extract the information." + }, + "inherits": "IAMObject:attemptExtractFromReplica", + "name": "attemptExtractFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptLockout": { + "asset": "Identity", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "IAMObject:attemptLockout", + "name": "attemptLockout", + "overrides": false, + "own_children": {}, + "own_parents": { + "Identity:lockoutFromCredentials": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptReadFromReplica": { + "asset": "Identity", + "detectors": {}, + "info": { + "developer": "Reading one replica allows the attacker to read all other replicas as well since the information contained in them is the same." + }, + "inherits": "IAMObject:attemptReadFromReplica", + "name": "attemptReadFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptUnsafeUserActivity": { + "asset": "Identity", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptUnsafeUserActivity", + "overrides": true, + "own_children": { + "Identity:successfulUnsafeUserActivity": [ + null + ] + }, + "own_parents": { + "User:unsafeUserActivity": [ + { + "UserAssignedIdentities": { + "asset type": "User", + "fieldname": "users" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptWriteFromReplica": { + "asset": "Identity", + "detectors": {}, + "info": { + "developer": "Information can be overwritten only if all replicas have been overwritten." + }, + "inherits": "IAMObject:attemptWriteFromReplica", + "name": "attemptWriteFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "delete": { + "asset": "Identity", + "detectors": {}, + "info": { + "user": "The attacker is able to delete the information." + }, + "inherits": "IAMObject:delete", + "name": "delete", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "deny": { + "asset": "Identity", + "detectors": {}, + "info": { + "user": "The attacker is able to deny the information." + }, + "inherits": "IAMObject:deny", + "name": "deny", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "extract": { + "asset": "Identity", + "detectors": {}, + "info": { + "user": "The attacker is able to extract the information. This means that they have a path available through which they can transfer the information back to a system that they control." + }, + "inherits": "IAMObject:extract", + "name": "extract", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "lockout": { + "asset": "Identity", + "detectors": {}, + "info": { + "user": "The Identity, Group, or Privileges has been locked out and cannot be used by legitimate users. This means that the applications that they can execute and data they can read might also be denied if all of the access roles that could execute or read them, respectively, have been locked out." + }, + "inherits": "IAMObject:lockout", + "name": "lockout", + "overrides": false, + "own_children": { + "Group:lockoutFromMembers": [ + { + "MemberOf": { + "asset type": "Group", + "fieldname": "memberOf" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "lockoutFromCredentials": { + "asset": "Identity", + "detectors": {}, + "info": { + "developer": "Only lockout an identity if all of the Credentials that could be used to authenticate have been denied." + }, + "inherits": null, + "name": "lockoutFromCredentials", + "overrides": true, + "own_children": { + "Identity:attemptLockout": [ + null + ] + }, + "own_parents": { + "Credentials:deny": [ + { + "IdentityCredentials": { + "asset type": "Credentials", + "fieldname": "credentials" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "missingUser": { + "asset": "Identity", + "detectors": {}, + "info": { + "developer": "If there are no Users asociated with this Identity we make the worst case scenario assumption regarding the strength of the Credentials belonging to it." + }, + "inherits": null, + "name": "missingUser", + "overrides": true, + "own_children": { + "Credentials:weakCredentials": [ + { + "IdentityCredentials": { + "asset type": "Credentials", + "fieldname": "credentials" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "requires": [ + { + "UserAssignedIdentities": { + "asset type": "User", + "fieldname": "users" + }, + "type": "field" + } + ], + "tags": [ + "hidden" + ], + "ttc": null, + "type": "notExist" + }, + "notPresent": { + "asset": "Identity", + "detectors": {}, + "info": { + "developer": "Enabling this defense should make the asset behave as if it did not exist, as such all of its impactful attack steps should be disabled by it.", + "mitre": "Used for M1026: Privileged Account Management, M1025: Privileged Process Integrity, M1022: Restrict File and Directory Permissions, M1052: User Account Control, M1018: User Account Management, M1024: Restrict Registry Permissions, M1043: Credential Access Protection, M1057: Data Loss Prevention", + "user": "This defense is used to reason about the attack vectors introduced by this asset. It should be used to model the possibility that the IAM object does not actually exist." + }, + "inherits": "IAMObject:notPresent", + "name": "notPresent", + "overrides": false, + "own_children": { + "Identity:successfulUnsafeUserActivity": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "read": { + "asset": "Identity", + "detectors": {}, + "info": { + "user": "The attacker is able to read the information. They may still be unable to extract it, which represents the adversary being able to read it themselves. This is the case if autonomous code is able to read it, but lack the required networking connections to transmit it back to the adversary." + }, + "inherits": "IAMObject:read", + "name": "read", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "successfulAssume": { + "asset": "Identity", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "IAMObject:successfulAssume", + "name": "successfulAssume", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulLockout": { + "asset": "Identity", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "IAMObject:successfulLockout", + "name": "successfulLockout", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulUnsafeUserActivity": { + "asset": "Identity", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulUnsafeUserActivity", + "overrides": true, + "own_children": { + "Identity:unsafeUserActivity": [ + null + ] + }, + "own_parents": { + "Identity:attemptUnsafeUserActivity": [ + null + ], + "Identity:notPresent": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "unsafeUserActivity": { + "asset": "Identity", + "detectors": {}, + "info": { + "user": "Propagate unsafe user activity to any Applications or subset of privileges associated with this Identity." + }, + "inherits": null, + "name": "unsafeUserActivity", + "overrides": true, + "own_children": { + "Application:attemptUnsafeUserActivity": [ + { + "ExecutionPrivilegeAccess": { + "asset type": "Application", + "fieldname": "execPrivApps" + }, + "type": "field" + }, + { + "HighPrivilegeApplicationAccess": { + "asset type": "Application", + "fieldname": "highPrivApps" + }, + "type": "field" + }, + { + "LowPrivilegeApplicationAccess": { + "asset type": "Application", + "fieldname": "lowPrivApps" + }, + "type": "field" + } + ], + "Privileges:attemptUnsafeUserActivity": [ + { + "HasPrivileges": { + "asset type": "Privileges", + "fieldname": "subprivileges" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Identity:successfulUnsafeUserActivity": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "write": { + "asset": "Identity", + "detectors": {}, + "info": { + "user": "The attacker is able to write the information." + }, + "inherits": "IAMObject:write", + "name": "write", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": { + "developer": "An identity can be visualised as a group of assumable roles that can be associated with many credentials.", + "user": "An identity models an IAM identity that should then be associated with privileges on other instances." + }, + "is_abstract": false, + "name": "Identity", + "sub_assets": [], + "super_asset": "IAMObject", + "variables": {} + }, + "Information": { + "associations": { + "containerData": { + "info": { + "user": "Data can contain information, as for example credentials." + }, + "left": { + "asset": "Data", + "fieldname": "containerData", + "max": null, + "min": 0 + }, + "name": "InfoContainment", + "right": { + "asset": "Information", + "fieldname": "information", + "max": null, + "min": 0 + } + }, + "dataReplicas": { + "info": { + "mitre": "Used for M1053: Data Backup", + "user": "Information can be replicated across multiple data assets that offer redundancy." + }, + "left": { + "asset": "Data", + "fieldname": "dataReplicas", + "max": null, + "min": 0 + }, + "name": "Replica", + "right": { + "asset": "Information", + "fieldname": "replicatedInformation", + "max": null, + "min": 0 + } + }, + "dependentApps": { + "info": { + "user": "Information can be specified as a dependence for an Application." + }, + "left": { + "asset": "Information", + "fieldname": "infoDependedUpon", + "max": null, + "min": 0 + }, + "name": "Dependence", + "right": { + "asset": "Application", + "fieldname": "dependentApps", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "attemptDeleteFromReplica": { + "asset": "Information", + "detectors": {}, + "info": { + "developer": "Information can be deleted only if all replicas have been deleted." + }, + "inherits": null, + "name": "attemptDeleteFromReplica", + "overrides": true, + "own_children": { + "Information:delete": [ + null + ] + }, + "own_parents": { + "Data:delete": [ + { + "Replica": { + "asset type": "Data", + "fieldname": "dataReplicas" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "attemptDenyFromReplica": { + "asset": "Information", + "detectors": {}, + "info": { + "developer": "Information can be denied only if all replicas have been denied." + }, + "inherits": null, + "name": "attemptDenyFromReplica", + "overrides": true, + "own_children": { + "Information:deny": [ + null + ] + }, + "own_parents": { + "Data:deny": [ + { + "Replica": { + "asset type": "Data", + "fieldname": "dataReplicas" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "attemptExtractFromReplica": { + "asset": "Information", + "detectors": {}, + "info": { + "developer": "The attacker attempts to extract the information." + }, + "inherits": null, + "name": "attemptExtractFromReplica", + "overrides": true, + "own_children": { + "Information:extract": [ + null + ] + }, + "own_parents": { + "Data:extract": [ + { + "Replica": { + "asset type": "Data", + "fieldname": "dataReplicas" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptReadFromReplica": { + "asset": "Information", + "detectors": {}, + "info": { + "developer": "Reading one replica allows the attacker to read all other replicas as well since the information contained in them is the same." + }, + "inherits": null, + "name": "attemptReadFromReplica", + "overrides": true, + "own_children": { + "Information:read": [ + null + ] + }, + "own_parents": { + "Data:read": [ + { + "Replica": { + "asset type": "Data", + "fieldname": "dataReplicas" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptWriteFromReplica": { + "asset": "Information", + "detectors": {}, + "info": { + "developer": "Information can be overwritten only if all replicas have been overwritten." + }, + "inherits": null, + "name": "attemptWriteFromReplica", + "overrides": true, + "own_children": { + "Information:write": [ + null + ] + }, + "own_parents": { + "Data:write": [ + { + "Replica": { + "asset type": "Data", + "fieldname": "dataReplicas" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "delete": { + "asset": "Information", + "detectors": {}, + "info": { + "user": "The attacker is able to delete the information." + }, + "inherits": null, + "name": "delete", + "overrides": true, + "own_children": { + "Data:delete": [ + { + "Replica": { + "asset type": "Data", + "fieldname": "dataReplicas" + }, + "type": "field" + }, + { + "InfoContainment": { + "asset type": "Data", + "fieldname": "containerData" + }, + "type": "field" + } + ], + "Information:deny": [ + null + ] + }, + "own_parents": { + "Data:delete": [ + { + "InfoContainment": { + "asset type": "Data", + "fieldname": "containerData" + }, + "type": "field" + } + ], + "Information:attemptDeleteFromReplica": [ + null + ], + "Information:write": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "deny": { + "asset": "Information", + "detectors": {}, + "info": { + "user": "The attacker is able to deny the information." + }, + "inherits": null, + "name": "deny", + "overrides": true, + "own_children": { + "Application:deny": [ + { + "Dependence": { + "asset type": "Application", + "fieldname": "dependentApps" + }, + "type": "field" + } + ], + "Data:deny": [ + { + "Replica": { + "asset type": "Data", + "fieldname": "dataReplicas" + }, + "type": "field" + }, + { + "InfoContainment": { + "asset type": "Data", + "fieldname": "containerData" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Data:deny": [ + { + "InfoContainment": { + "asset type": "Data", + "fieldname": "containerData" + }, + "type": "field" + } + ], + "Information:attemptDenyFromReplica": [ + null + ], + "Information:delete": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "extract": { + "asset": "Information", + "detectors": {}, + "info": { + "user": "The attacker is able to extract the information. This means that they have a path available through which they can transfer the information back to a system that they control." + }, + "inherits": null, + "name": "extract", + "overrides": false, + "own_children": {}, + "own_parents": { + "Data:extract": [ + { + "InfoContainment": { + "asset type": "Data", + "fieldname": "containerData" + }, + "type": "field" + } + ], + "Information:attemptExtractFromReplica": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "read": { + "asset": "Information", + "detectors": {}, + "info": { + "user": "The attacker is able to read the information. They may still be unable to extract it, which represents the adversary being able to read it themselves. This is the case if autonomous code is able to read it, but lack the required networking connections to transmit it back to the adversary." + }, + "inherits": null, + "name": "read", + "overrides": true, + "own_children": { + "Data:read": [ + { + "Replica": { + "asset type": "Data", + "fieldname": "dataReplicas" + }, + "type": "field" + }, + { + "InfoContainment": { + "asset type": "Data", + "fieldname": "containerData" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Data:read": [ + { + "InfoContainment": { + "asset type": "Data", + "fieldname": "containerData" + }, + "type": "field" + } + ], + "Information:attemptReadFromReplica": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "write": { + "asset": "Information", + "detectors": {}, + "info": { + "user": "The attacker is able to write the information." + }, + "inherits": null, + "name": "write", + "overrides": true, + "own_children": { + "Application:fullAccess": [ + { + "Dependence": { + "asset type": "Application", + "fieldname": "dependentApps" + }, + "type": "field" + } + ], + "Data:write": [ + { + "Replica": { + "asset type": "Data", + "fieldname": "dataReplicas" + }, + "type": "field" + }, + { + "InfoContainment": { + "asset type": "Data", + "fieldname": "containerData" + }, + "type": "field" + } + ], + "Information:delete": [ + null + ] + }, + "own_parents": { + "Data:write": [ + { + "InfoContainment": { + "asset type": "Data", + "fieldname": "containerData" + }, + "type": "field" + } + ], + "Information:attemptWriteFromReplica": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": { + "user": "Represents any type of information that might be contained inside Data. This include general purpose information, but also serves as the basis for more specialized types, such as Credentials and SoftwareProduct. Information represents the abstract concept, while Data represent an actual instance of information stored or transmitted digitally." + }, + "is_abstract": false, + "name": "Information", + "sub_assets": [ + "SoftwareProduct", + "IAMObject", + "Credentials" + ], + "super_asset": "", + "variables": {} + }, + "Network": { + "associations": { + "applications": { + "info": { + "developer": "This can equivalently be modeled using a ConnectionRule but this direct approach should be prefered in the case where the application is \u201clocated\u201d in the same network as the host on which it is running.", + "modeler": "This is DEPRECATED and should only be used by fully aware modelers.", + "user": "An application can communicate / be exposed on a network." + }, + "left": { + "asset": "Network", + "fieldname": "networks", + "max": null, + "min": 0 + }, + "name": "NetworkExposure", + "right": { + "asset": "Application", + "fieldname": "applications", + "max": null, + "min": 0 + } + }, + "diodeIngoingNetConnections": { + "info": { + "user": "Two or more networks can also be inter-connected via a uni-directional connection that acts like a diode. That means that no responses can come back after connection initiation." + }, + "left": { + "asset": "Network", + "fieldname": "diodeInNetworks", + "max": null, + "min": 0 + }, + "name": "DiodeInNetworkConnection", + "right": { + "asset": "ConnectionRule", + "fieldname": "diodeIngoingNetConnections", + "max": null, + "min": 0 + } + }, + "ingoingNetConnections": { + "info": { + "user": "Two or more networks can also be inter-connected via a uni-directional connection. In means that ingoing traffic to the network is allowed." + }, + "left": { + "asset": "Network", + "fieldname": "inNetworks", + "max": null, + "min": 0 + }, + "name": "InNetworkConnection", + "right": { + "asset": "ConnectionRule", + "fieldname": "ingoingNetConnections", + "max": null, + "min": 0 + } + }, + "netConnections": { + "info": { + "user": "Two or more networks can also be inter-connected via a bidirectional connection." + }, + "left": { + "asset": "Network", + "fieldname": "networks", + "max": null, + "min": 0 + }, + "name": "NetworkConnection", + "right": { + "asset": "ConnectionRule", + "fieldname": "netConnections", + "max": null, + "min": 0 + } + }, + "outgoingNetConnections": { + "info": { + "user": "Two or more networks can also be inter-connected via a uni-directional connection. Out means that outgoing traffic from the network is allowed." + }, + "left": { + "asset": "Network", + "fieldname": "outNetworks", + "max": null, + "min": 0 + }, + "name": "OutNetworkConnection", + "right": { + "asset": "ConnectionRule", + "fieldname": "outgoingNetConnections", + "max": null, + "min": 0 + } + }, + "physicalZones": { + "info": { + "user": "Access on PhysicalZone will give physicalAccess on the deployed networks, but access to the network does not provide access to the physical zone." + }, + "left": { + "asset": "PhysicalZone", + "fieldname": "physicalZones", + "max": null, + "min": 0 + }, + "name": "ZoneInclusion", + "right": { + "asset": "Network", + "fieldname": "networks", + "max": null, + "min": 0 + } + }, + "transitData": { + "info": { + "user": "A network can also contain some data that are supposed to be network-wide available." + }, + "left": { + "asset": "Data", + "fieldname": "transitData", + "max": null, + "min": 0 + }, + "name": "DataInTransit", + "right": { + "asset": "Network", + "fieldname": "transitNetwork", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "accessControlBypassed": { + "asset": "Network", + "detectors": {}, + "info": { + "developer": "Access control is bypassed either because it was not set, the attacker was able to circumvent it through additional effort." + }, + "inherits": null, + "name": "accessControlBypassed", + "overrides": true, + "own_children": { + "Network:successfulAccessInspectedFromPhysicalAccess": [ + null + ], + "Network:successfulAccessUninspectedFromPhysicalAccess": [ + null + ] + }, + "own_parents": { + "Network:bypassAccessControl": [ + null + ], + "Network:networkAccessControl": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "accessInspected": { + "asset": "Network", + "detectors": {}, + "info": { + "user": "Inspected access provides connect to all reachable applications, but with limitations." + }, + "inherits": null, + "name": "accessInspected", + "overrides": true, + "own_children": { + "Application:networkConnectInspected": [ + { + "NetworkExposure": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + } + ], + "ConnectionRule:attemptConnectToApplicationsInspected": [ + { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + }, + "right": { + "OutNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingNetConnections" + }, + "type": "field" + } + } + } + ], + "Network:accessNetworkData": [ + null + ], + "Network:deny": [ + null + ], + "Network:networkForwardingInspected": [ + null + ] + }, + "own_parents": { + "Application:accessNetworkAndConnections": [ + { + "NetworkExposure": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + } + ], + "ConnectionRule:accessNetworksInspected": [ + { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + }, + "right": { + "InNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "ingoingNetConnections" + }, + "type": "field" + } + } + }, + "right": { + "DiodeInNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "diodeIngoingNetConnections" + }, + "type": "field" + } + } + } + ], + "Network:successfulAccessInspectedFromPhysicalAccess": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "accessNetworkData": { + "asset": "Network", + "detectors": {}, + "info": { + "developer": "Access also the data that are network-wide available." + }, + "inherits": null, + "name": "accessNetworkData", + "overrides": true, + "own_children": { + "Network:attemptAdversaryInTheMiddle": [ + null + ], + "Network:attemptEavesdrop": [ + null + ] + }, + "own_parents": { + "Network:accessInspected": [ + null + ], + "Network:accessUninspected": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "accessUninspected": { + "asset": "Network", + "detectors": {}, + "info": { + "user": "Uninspected access provides connect to all reachable applications without any restrictions." + }, + "inherits": null, + "name": "accessUninspected", + "overrides": true, + "own_children": { + "Application:networkConnectUninspected": [ + { + "NetworkExposure": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + } + ], + "ConnectionRule:attemptConnectToApplicationsUninspected": [ + { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + }, + "right": { + "OutNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingNetConnections" + }, + "type": "field" + } + } + } + ], + "Network:accessNetworkData": [ + null + ], + "Network:attemptReverseReach": [ + null + ], + "Network:deny": [ + null + ], + "Network:networkForwardingUninspected": [ + null + ] + }, + "own_parents": { + "Application:accessNetworkAndConnections": [ + { + "NetworkExposure": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + } + ], + "ConnectionRule:accessNetworksUninspected": [ + { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + }, + "right": { + "InNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "ingoingNetConnections" + }, + "type": "field" + } + } + }, + "right": { + "DiodeInNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "diodeIngoingNetConnections" + }, + "type": "field" + } + } + } + ], + "Network:successfulAccessUninspectedFromPhysicalAccess": [ + null + ], + "RoutingFirewall:fullAccess": [ + { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "collect": { + "left": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + }, + "right": { + "FirewallConnectionRule": { + "asset type": "RoutingFirewall", + "fieldname": "routingFirewalls" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "collect": { + "left": { + "OutNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingNetConnections" + }, + "type": "field" + }, + "right": { + "FirewallConnectionRule": { + "asset type": "RoutingFirewall", + "fieldname": "routingFirewalls" + }, + "type": "field" + } + }, + "type": "collect" + } + } + }, + "right": { + "collect": { + "left": { + "InNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "ingoingNetConnections" + }, + "type": "field" + }, + "right": { + "FirewallConnectionRule": { + "asset type": "RoutingFirewall", + "fieldname": "routingFirewalls" + }, + "type": "field" + } + }, + "type": "collect" + } + } + }, + "right": { + "collect": { + "left": { + "DiodeInNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "diodeIngoingNetConnections" + }, + "type": "field" + }, + "right": { + "FirewallConnectionRule": { + "asset type": "RoutingFirewall", + "fieldname": "routingFirewalls" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "adversaryInTheMiddle": { + "asset": "Network", + "detectors": {}, + "info": { + "user": "An attacker that performs a AitM attack on a network tries to read and modify all the transferred data over that network." + }, + "inherits": null, + "name": "adversaryInTheMiddle", + "overrides": true, + "own_children": { + "Data:attemptApplicationRespondConnect": [ + { + "DataInTransit": { + "asset type": "Data", + "fieldname": "transitData" + }, + "type": "field" + } + ], + "Data:attemptWrite": [ + { + "DataInTransit": { + "asset type": "Data", + "fieldname": "transitData" + }, + "type": "field" + } + ], + "Network:eavesdrop": [ + null + ] + }, + "own_parents": { + "Network:successfulAdversaryInTheMiddle": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "adversaryInTheMiddleDefense": { + "asset": "Network", + "detectors": {}, + "info": { + "user": "This defense protects against adversary-in-the-middle (AitM) attacks that are originating either form the network layer (like ARP spoofing) or by manipulating the higher network layers (like DNS poisoning)." + }, + "inherits": null, + "name": "adversaryInTheMiddleDefense", + "overrides": true, + "own_children": { + "Network:adversaryInTheMiddleDefenseBypassed": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "defense" + }, + "adversaryInTheMiddleDefenseBypassed": { + "asset": "Network", + "detectors": {}, + "info": { + "developer": "The adversary in the middle defense is bypassed either because it was not set, the attacker was able to circumvent it through additional effort." + }, + "inherits": null, + "name": "adversaryInTheMiddleDefenseBypassed", + "overrides": true, + "own_children": { + "Network:successfulAdversaryInTheMiddle": [ + null + ] + }, + "own_parents": { + "Network:adversaryInTheMiddleDefense": [ + null + ], + "Network:bypassAdversaryInTheMiddleDefense": [ + null + ], + "Network:bypassAdversaryInTheMiddleDefenseFromPhysicalAccess": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptAccessInspectedFromPhysicalAccess": { + "asset": "Network", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": null, + "name": "attemptAccessInspectedFromPhysicalAccess", + "overrides": true, + "own_children": { + "Network:bypassAccessControl": [ + null + ], + "Network:successfulAccessInspectedFromPhysicalAccess": [ + null + ] + }, + "own_parents": { + "Network:physicalAccess": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptAccessUninspectedFromPhysicalAccess": { + "asset": "Network", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": null, + "name": "attemptAccessUninspectedFromPhysicalAccess", + "overrides": true, + "own_children": { + "Network:bypassAccessControl": [ + null + ], + "Network:successfulAccessUninspectedFromPhysicalAccess": [ + null + ] + }, + "own_parents": { + "Network:physicalAccess": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptAdversaryInTheMiddle": { + "asset": "Network", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": null, + "name": "attemptAdversaryInTheMiddle", + "overrides": true, + "own_children": { + "Network:bypassAdversaryInTheMiddleDefense": [ + null + ], + "Network:successfulAdversaryInTheMiddle": [ + null + ] + }, + "own_parents": { + "Network:accessNetworkData": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptEavesdrop": { + "asset": "Network", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": null, + "name": "attemptEavesdrop", + "overrides": true, + "own_children": { + "Network:bypassEavesdropDefense": [ + null + ], + "Network:successfulEavesdrop": [ + null + ] + }, + "own_parents": { + "Network:accessNetworkData": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptReverseReach": { + "asset": "Network", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptReverseReach", + "overrides": true, + "own_children": { + "Network:reverseReach": [ + null + ] + }, + "own_parents": { + "Application:reverseReach": [ + { + "NetworkExposure": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + } + ], + "ConnectionRule:reverseReach": [ + { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + }, + "right": { + "OutNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingNetConnections" + }, + "type": "field" + } + } + } + ], + "Network:accessUninspected": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "bypassAccessControl": { + "asset": "Network", + "detectors": {}, + "info": { + "user": "If access control is enabled, the attacker can still try to bypass it and gain access on the network." + }, + "inherits": null, + "name": "bypassAccessControl", + "overrides": true, + "own_children": { + "Network:accessControlBypassed": [ + null + ] + }, + "own_parents": { + "Network:attemptAccessInspectedFromPhysicalAccess": [ + null + ], + "Network:attemptAccessUninspectedFromPhysicalAccess": [ + null + ] + }, + "tags": [], + "ttc": { + "arguments": [], + "name": "VeryHardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "bypassAdversaryInTheMiddleDefense": { + "asset": "Network", + "detectors": {}, + "info": { + "user": "The adversary in the middle defense can be bypassed." + }, + "inherits": null, + "name": "bypassAdversaryInTheMiddleDefense", + "overrides": true, + "own_children": { + "Network:adversaryInTheMiddleDefenseBypassed": [ + null + ] + }, + "own_parents": { + "Network:attemptAdversaryInTheMiddle": [ + null + ] + }, + "tags": [], + "ttc": { + "arguments": [], + "name": "VeryHardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "bypassAdversaryInTheMiddleDefenseFromPhysicalAccess": { + "asset": "Network", + "detectors": {}, + "info": { + "user": "The adversary in the middle defense can be bypassed more easily if the attacker has physical access to the network. This attack step will add its impact to the unconditional bypass." + }, + "inherits": null, + "name": "bypassAdversaryInTheMiddleDefenseFromPhysicalAccess", + "overrides": true, + "own_children": { + "Network:adversaryInTheMiddleDefenseBypassed": [ + null + ] + }, + "own_parents": { + "Network:physicalAccess": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "bypassEavesdropDefense": { + "asset": "Network", + "detectors": {}, + "info": { + "user": "The eavesdrop defense can be bypassed." + }, + "inherits": null, + "name": "bypassEavesdropDefense", + "overrides": true, + "own_children": { + "Network:eavesdropDefenseBypassed": [ + null + ] + }, + "own_parents": { + "Network:attemptEavesdrop": [ + null + ] + }, + "tags": [], + "ttc": { + "arguments": [], + "name": "VeryHardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "bypassEavesdropDefenseFromPhysicalAccess": { + "asset": "Network", + "detectors": {}, + "info": { + "user": "The eavesdrop defense can be bypassed more easily if the attacker has physical access to the network. This attack step will add its impact to the unconditional bypass." + }, + "inherits": null, + "name": "bypassEavesdropDefenseFromPhysicalAccess", + "overrides": true, + "own_children": { + "Network:eavesdropDefenseBypassed": [ + null + ] + }, + "own_parents": { + "Network:physicalAccess": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "deny": { + "asset": "Network", + "detectors": {}, + "info": { + "user": "If a DoS is performed it affects, the applications communicating over the network as well as the connected application." + }, + "inherits": null, + "name": "deny", + "overrides": true, + "own_children": { + "Application:denyFromNetworkingAsset": [ + { + "type": "union", + "union": { + "left": { + "NetworkExposure": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + }, + "right": { + "InNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "ingoingNetConnections" + }, + "type": "field" + } + } + }, + "right": { + "OutNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingNetConnections" + }, + "type": "field" + } + } + }, + "right": { + "DiodeInNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "diodeIngoingNetConnections" + }, + "type": "field" + } + } + }, + "right": { + "ApplicationConnection": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ], + "ConnectionRule:attemptDeny": [ + { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + }, + "right": { + "InNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "ingoingNetConnections" + }, + "type": "field" + } + } + }, + "right": { + "OutNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingNetConnections" + }, + "type": "field" + } + } + }, + "right": { + "DiodeInNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "diodeIngoingNetConnections" + }, + "type": "field" + } + } + } + ] + }, + "own_parents": { + "Network:accessInspected": [ + null + ], + "Network:accessUninspected": [ + null + ], + "Network:physicalAccess": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "eavesdrop": { + "asset": "Network", + "detectors": {}, + "info": { + "user": "An attacker that performs an eavesdrop attack on a network tries to access all the transferred data over that network." + }, + "inherits": null, + "name": "eavesdrop", + "overrides": true, + "own_children": { + "Data:attemptRead": [ + { + "DataInTransit": { + "asset type": "Data", + "fieldname": "transitData" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Network:adversaryInTheMiddle": [ + null + ], + "Network:successfulEavesdrop": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "eavesdropDefense": { + "asset": "Network", + "detectors": {}, + "info": { + "modeler": "If this defense is enabled, attacks such as ARP spoofing should however, still be possible.", + "user": "This defense protects from eavesdrop attacks. If this defense is disabled, then it is equivalent to the network being considered a broadcast network." + }, + "inherits": null, + "name": "eavesdropDefense", + "overrides": true, + "own_children": { + "Network:eavesdropDefenseBypassed": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "defense" + }, + "eavesdropDefenseBypassed": { + "asset": "Network", + "detectors": {}, + "info": { + "developer": "The Eavesdrop defense is bypassed either because it was not set, the attacker was able to circumvent it through additional effort." + }, + "inherits": null, + "name": "eavesdropDefenseBypassed", + "overrides": true, + "own_children": { + "Network:successfulEavesdrop": [ + null + ] + }, + "own_parents": { + "Network:bypassEavesdropDefense": [ + null + ], + "Network:bypassEavesdropDefenseFromPhysicalAccess": [ + null + ], + "Network:eavesdropDefense": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "networkAccessControl": { + "asset": "Network", + "detectors": {}, + "info": { + "user": "Access control protects from unauthorized physical access on the network." + }, + "inherits": null, + "name": "networkAccessControl", + "overrides": true, + "own_children": { + "Network:accessControlBypassed": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "defense" + }, + "networkForwardingInspected": { + "asset": "Network", + "detectors": {}, + "info": { + "developer": "By using the allowed connections (connection rules), inspected forwarding from one network to another network or applications can happen." + }, + "inherits": null, + "name": "networkForwardingInspected", + "overrides": true, + "own_children": { + "ConnectionRule:attemptAccessNetworksInspected": [ + { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + }, + "right": { + "OutNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingNetConnections" + }, + "type": "field" + } + } + } + ], + "ConnectionRule:attemptConnectToApplicationsInspected": [ + { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + }, + "right": { + "OutNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingNetConnections" + }, + "type": "field" + } + } + } + ] + }, + "own_parents": { + "Network:accessInspected": [ + null + ], + "Network:networkForwardingUninspected": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "networkForwardingUninspected": { + "asset": "Network", + "detectors": {}, + "info": { + "developer": "By using the allowed connections (connection rules), uninspected forwarding from one network to another network or applications can happen." + }, + "inherits": null, + "name": "networkForwardingUninspected", + "overrides": true, + "own_children": { + "ConnectionRule:attemptAccessNetworksUninspected": [ + { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + }, + "right": { + "OutNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingNetConnections" + }, + "type": "field" + } + } + } + ], + "ConnectionRule:attemptConnectToApplicationsUninspected": [ + { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + }, + "right": { + "OutNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingNetConnections" + }, + "type": "field" + } + } + } + ], + "Network:networkForwardingInspected": [ + null + ] + }, + "own_parents": { + "Network:accessUninspected": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "physicalAccess": { + "asset": "Network", + "detectors": {}, + "info": { + "modeler": "This attack step can be used as an entry point when analyzing scenarios that involve gaining physical access to assets.", + "user": "Attacker has physical access on the network. This means they can cut wires/fibers, connect using iLOs, eavesdrop and get proper network access." + }, + "inherits": null, + "name": "physicalAccess", + "overrides": true, + "own_children": { + "Network:attemptAccessInspectedFromPhysicalAccess": [ + null + ], + "Network:attemptAccessUninspectedFromPhysicalAccess": [ + null + ], + "Network:bypassAdversaryInTheMiddleDefenseFromPhysicalAccess": [ + null + ], + "Network:bypassEavesdropDefenseFromPhysicalAccess": [ + null + ], + "Network:deny": [ + null + ] + }, + "own_parents": { + "PhysicalZone:gainPhysicalAccess": [ + { + "ZoneInclusion": { + "asset type": "PhysicalZone", + "fieldname": "physicalZones" + }, + "type": "field" + } + ] + }, + "tags": [ + "entrypoint" + ], + "ttc": null, + "type": "or" + }, + "reverseReach": { + "asset": "Network", + "detectors": {}, + "info": { + "developer": "Reverse reach is used to determine whether or not the attacker can be reached by the user. Reverse reach propagates toward outgoing or bidirectional communications." + }, + "inherits": null, + "name": "reverseReach", + "overrides": true, + "own_children": { + "Application:attemptReverseReach": [ + { + "NetworkExposure": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + } + ], + "ConnectionRule:attemptReverseReach": [ + { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + }, + "right": { + "InNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "ingoingNetConnections" + }, + "type": "field" + } + } + }, + "right": { + "DiodeInNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "diodeIngoingNetConnections" + }, + "type": "field" + } + } + } + ] + }, + "own_parents": { + "Network:attemptReverseReach": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulAccessInspectedFromPhysicalAccess": { + "asset": "Network", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulAccessInspectedFromPhysicalAccess", + "overrides": true, + "own_children": { + "Network:accessInspected": [ + null + ] + }, + "own_parents": { + "Network:accessControlBypassed": [ + null + ], + "Network:attemptAccessInspectedFromPhysicalAccess": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulAccessUninspectedFromPhysicalAccess": { + "asset": "Network", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulAccessUninspectedFromPhysicalAccess", + "overrides": true, + "own_children": { + "Network:accessUninspected": [ + null + ] + }, + "own_parents": { + "Network:accessControlBypassed": [ + null + ], + "Network:attemptAccessUninspectedFromPhysicalAccess": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulAdversaryInTheMiddle": { + "asset": "Network", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulAdversaryInTheMiddle", + "overrides": true, + "own_children": { + "Network:adversaryInTheMiddle": [ + null + ] + }, + "own_parents": { + "Network:adversaryInTheMiddleDefenseBypassed": [ + null + ], + "Network:attemptAdversaryInTheMiddle": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulEavesdrop": { + "asset": "Network", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulEavesdrop", + "overrides": true, + "own_children": { + "Network:eavesdrop": [ + null + ] + }, + "own_parents": { + "Network:attemptEavesdrop": [ + null + ], + "Network:eavesdropDefenseBypassed": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + } + }, + "info": { + "developer": "The network asset tries to cover all the levels of the OSI stack in a very abstract and compact way (i.e. it tries to represent all the OSI layers). For example it will try to cover both ARP attacks (that are Level 2) but also DNS/HTTP spoofing attacks (that are Level 7).", + "user": "A network (zone) is a set of network accessible applications and data." + }, + "is_abstract": false, + "name": "Network", + "sub_assets": [], + "super_asset": "", + "variables": { + "allNetConnections": [ + "ConnectionRule", + { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + }, + "right": { + "InNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "ingoingNetConnections" + }, + "type": "field" + } + } + }, + "right": { + "OutNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingNetConnections" + }, + "type": "field" + } + } + }, + "right": { + "DiodeInNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "diodeIngoingNetConnections" + }, + "type": "field" + } + } + } + ], + "allowedApplicationConnectionsApplications": [ + "Application", + { + "type": "union", + "union": { + "left": { + "NetworkExposure": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + }, + "right": { + "InNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "ingoingNetConnections" + }, + "type": "field" + } + } + }, + "right": { + "OutNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingNetConnections" + }, + "type": "field" + } + } + }, + "right": { + "DiodeInNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "diodeIngoingNetConnections" + }, + "type": "field" + } + } + }, + "right": { + "ApplicationConnection": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ], + "inboundAllowedConnections": [ + "ConnectionRule", + { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + }, + "right": { + "InNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "ingoingNetConnections" + }, + "type": "field" + } + } + }, + "right": { + "DiodeInNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "diodeIngoingNetConnections" + }, + "type": "field" + } + } + } + ], + "outboundAllowedConnections": [ + "ConnectionRule", + { + "type": "union", + "union": { + "left": { + "NetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "netConnections" + }, + "type": "field" + }, + "right": { + "OutNetworkConnection": { + "asset type": "ConnectionRule", + "fieldname": "outgoingNetConnections" + }, + "type": "field" + } + } + } + ] + } + }, + "PhysicalZone": { + "associations": { + "hardwareSystems": { + "info": { + "user": "Access on PhysicalZone will give physicalAccess on the deployed Hardware, but access to the hardware does not provide access to the physical zone (e.g. iLOs)." + }, + "left": { + "asset": "PhysicalZone", + "fieldname": "physicalZones", + "max": null, + "min": 0 + }, + "name": "ZoneInclusion", + "right": { + "asset": "Hardware", + "fieldname": "hardwareSystems", + "max": null, + "min": 0 + } + }, + "networks": { + "info": { + "user": "Access on PhysicalZone will give physicalAccess on the deployed networks, but access to the network does not provide access to the physical zone." + }, + "left": { + "asset": "PhysicalZone", + "fieldname": "physicalZones", + "max": null, + "min": 0 + }, + "name": "ZoneInclusion", + "right": { + "asset": "Network", + "fieldname": "networks", + "max": null, + "min": 0 + } + }, + "users": { + "info": { + "user": "A user may have access to different physical zones." + }, + "left": { + "asset": "User", + "fieldname": "users", + "max": null, + "min": 0 + }, + "name": "ZoneAccess", + "right": { + "asset": "PhysicalZone", + "fieldname": "physicalZones", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "gainPhysicalAccess": { + "asset": "PhysicalZone", + "detectors": {}, + "info": { + "modeler": "This attack step can be used as an entry point when analyzing scenarios that involve gaining physical access to assets.", + "user": "By gaining access on a physical zone, physical access on the housed hardware and networks is also attained." + }, + "inherits": null, + "name": "gainPhysicalAccess", + "overrides": true, + "own_children": { + "Hardware:physicalAccess": [ + { + "ZoneInclusion": { + "asset type": "Hardware", + "fieldname": "hardwareSystems" + }, + "type": "field" + } + ], + "Network:physicalAccess": [ + { + "ZoneInclusion": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "tags": [ + "entrypoint" + ], + "ttc": null, + "type": "or" + } + }, + "info": { + "user": "A physical zone is the location where hardware systems and networks are physically deployed." + }, + "is_abstract": false, + "name": "PhysicalZone", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "Privileges": { + "associations": { + "IAMOwners": { + "info": { + "user": "Identities, Groups, and Privileges may have a subset of Privileges that we logically separate to model specific scenarios, such as partial lockouts or speculating about possible privileges." + }, + "left": { + "asset": "IAMObject", + "fieldname": "IAMOwners", + "max": null, + "min": 0 + }, + "name": "HasPrivileges", + "right": { + "asset": "Privileges", + "fieldname": "subprivileges", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "assume": { + "asset": "Privileges", + "detectors": {}, + "info": { + "developer": "Assume identity/group the privileges are associated with since the privileges are simply an extension of it.", + "user": "When an attacker is able to assume an IAM asset the privileges associated with it can always be exploited, other behaviour is asset specific." + }, + "inherits": "IAMObject:assume", + "name": "assume", + "overrides": false, + "own_children": { + "IAMObject:attemptAssume": [ + { + "HasPrivileges": { + "asset type": "IAMObject", + "fieldname": "IAMOwners" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "attemptAssume": { + "asset": "Privileges", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "IAMObject:attemptAssume", + "name": "attemptAssume", + "overrides": false, + "own_children": {}, + "own_parents": { + "IAMObject:assume": [ + { + "HasPrivileges": { + "asset type": "IAMObject", + "fieldname": "IAMOwners" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptDeleteFromReplica": { + "asset": "Privileges", + "detectors": {}, + "info": { + "developer": "Information can be deleted only if all replicas have been deleted." + }, + "inherits": "IAMObject:attemptDeleteFromReplica", + "name": "attemptDeleteFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "attemptDenyFromReplica": { + "asset": "Privileges", + "detectors": {}, + "info": { + "developer": "Information can be denied only if all replicas have been denied." + }, + "inherits": "IAMObject:attemptDenyFromReplica", + "name": "attemptDenyFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "attemptExtractFromReplica": { + "asset": "Privileges", + "detectors": {}, + "info": { + "developer": "The attacker attempts to extract the information." + }, + "inherits": "IAMObject:attemptExtractFromReplica", + "name": "attemptExtractFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptLockout": { + "asset": "Privileges", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "IAMObject:attemptLockout", + "name": "attemptLockout", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptReadFromReplica": { + "asset": "Privileges", + "detectors": {}, + "info": { + "developer": "Reading one replica allows the attacker to read all other replicas as well since the information contained in them is the same." + }, + "inherits": "IAMObject:attemptReadFromReplica", + "name": "attemptReadFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptUnsafeUserActivity": { + "asset": "Privileges", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptUnsafeUserActivity", + "overrides": true, + "own_children": { + "Privileges:successfulUnsafeUserActivity": [ + null + ] + }, + "own_parents": { + "Identity:unsafeUserActivity": [ + { + "HasPrivileges": { + "asset type": "IAMObject", + "fieldname": "IAMOwners" + }, + "type": "field" + } + ], + "Privileges:unsafeUserActivity": [ + { + "HasPrivileges": { + "asset type": "IAMObject", + "fieldname": "IAMOwners" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptWriteFromReplica": { + "asset": "Privileges", + "detectors": {}, + "info": { + "developer": "Information can be overwritten only if all replicas have been overwritten." + }, + "inherits": "IAMObject:attemptWriteFromReplica", + "name": "attemptWriteFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "delete": { + "asset": "Privileges", + "detectors": {}, + "info": { + "user": "The attacker is able to delete the information." + }, + "inherits": "IAMObject:delete", + "name": "delete", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "deny": { + "asset": "Privileges", + "detectors": {}, + "info": { + "user": "The attacker is able to deny the information." + }, + "inherits": "IAMObject:deny", + "name": "deny", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "extract": { + "asset": "Privileges", + "detectors": {}, + "info": { + "user": "The attacker is able to extract the information. This means that they have a path available through which they can transfer the information back to a system that they control." + }, + "inherits": "IAMObject:extract", + "name": "extract", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "lockout": { + "asset": "Privileges", + "detectors": {}, + "info": { + "user": "The Identity, Group, or Privileges has been locked out and cannot be used by legitimate users. This means that the applications that they can execute and data they can read might also be denied if all of the access roles that could execute or read them, respectively, have been locked out." + }, + "inherits": "IAMObject:lockout", + "name": "lockout", + "overrides": false, + "own_children": {}, + "own_parents": { + "IAMObject:lockout": [ + { + "HasPrivileges": { + "asset type": "IAMObject", + "fieldname": "IAMOwners" + }, + "type": "field" + } + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "notPresent": { + "asset": "Privileges", + "detectors": {}, + "info": { + "developer": "Enabling this defense should make the asset behave as if it did not exist, as such all of its impactful attack steps should be disabled by it.", + "mitre": "Used for M1026: Privileged Account Management, M1025: Privileged Process Integrity, M1022: Restrict File and Directory Permissions, M1052: User Account Control, M1018: User Account Management, M1024: Restrict Registry Permissions, M1043: Credential Access Protection, M1057: Data Loss Prevention", + "user": "This defense is used to reason about the attack vectors introduced by this asset. It should be used to model the possibility that the IAM object does not actually exist." + }, + "inherits": "IAMObject:notPresent", + "name": "notPresent", + "overrides": false, + "own_children": { + "Privileges:successfulUnsafeUserActivity": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "read": { + "asset": "Privileges", + "detectors": {}, + "info": { + "user": "The attacker is able to read the information. They may still be unable to extract it, which represents the adversary being able to read it themselves. This is the case if autonomous code is able to read it, but lack the required networking connections to transmit it back to the adversary." + }, + "inherits": "IAMObject:read", + "name": "read", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "successfulAssume": { + "asset": "Privileges", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "IAMObject:successfulAssume", + "name": "successfulAssume", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulLockout": { + "asset": "Privileges", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "IAMObject:successfulLockout", + "name": "successfulLockout", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulUnsafeUserActivity": { + "asset": "Privileges", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulUnsafeUserActivity", + "overrides": true, + "own_children": { + "Privileges:unsafeUserActivity": [ + null + ] + }, + "own_parents": { + "Privileges:attemptUnsafeUserActivity": [ + null + ], + "Privileges:notPresent": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "unsafeUserActivity": { + "asset": "Privileges", + "detectors": {}, + "info": { + "user": "Propagate unsafe user activity to any Applications or subset of privileges associated with these Privileges." + }, + "inherits": null, + "name": "unsafeUserActivity", + "overrides": true, + "own_children": { + "Application:attemptUnsafeUserActivity": [ + { + "ExecutionPrivilegeAccess": { + "asset type": "Application", + "fieldname": "execPrivApps" + }, + "type": "field" + }, + { + "HighPrivilegeApplicationAccess": { + "asset type": "Application", + "fieldname": "highPrivApps" + }, + "type": "field" + }, + { + "LowPrivilegeApplicationAccess": { + "asset type": "Application", + "fieldname": "lowPrivApps" + }, + "type": "field" + } + ], + "Privileges:attemptUnsafeUserActivity": [ + { + "HasPrivileges": { + "asset type": "Privileges", + "fieldname": "subprivileges" + }, + "type": "field" + } + ] + }, + "own_parents": { + "Privileges:successfulUnsafeUserActivity": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "write": { + "asset": "Privileges", + "detectors": {}, + "info": { + "user": "The attacker is able to write the information." + }, + "inherits": "IAMObject:write", + "name": "write", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": { + "modeler": "The privileges asset is not meant to be used normally when assigning permissions. Identities and Groups should be directly associated with the relevant assets. The purpose of the Privileges asset is to be used as a speculative asset when reasoning about additional privileges that a particular Group or Identity may have as a result of either poor configurations or the attacker's actions.", + "user": "A privileges asset can be associated with another IAMObject to reason about a specific subset of given privileges on Applications and Data." + }, + "is_abstract": false, + "name": "Privileges", + "sub_assets": [], + "super_asset": "IAMObject", + "variables": {} + }, + "RoutingFirewall": { + "associations": { + "connectionRules": { + "info": { + "user": "A routing firewall has routing or firewall rules specified by connections between networks or applications respectively." + }, + "left": { + "asset": "RoutingFirewall", + "fieldname": "routingFirewalls", + "max": 1, + "min": 0 + }, + "name": "FirewallConnectionRule", + "right": { + "asset": "ConnectionRule", + "fieldname": "connectionRules", + "max": null, + "min": 0 + } + }, + "managerApp": { + "info": { + "user": "A routing firewall can have an admin/manager interface (application) that is accessible from a network location." + }, + "left": { + "asset": "RoutingFirewall", + "fieldname": "managedRoutingFw", + "max": null, + "min": 0 + }, + "name": "ManagedBy", + "right": { + "asset": "Application", + "fieldname": "managerApp", + "max": 1, + "min": 0 + } + } + }, + "attack_steps": { + "accessNetworkAndConnections": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "The attacker can access outgoing and bidirectional networks and connections associated with the application." + }, + "inherits": "Application:accessNetworkAndConnections", + "name": "accessNetworkAndConnections", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "applicationRespondConnectThroughData": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "After full access on the application received data can be used to try a connect through respond to the client side application." + }, + "inherits": "Application:applicationRespondConnectThroughData", + "name": "applicationRespondConnectThroughData", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attackerUnsafeUserActivityCapability": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "The impacts of the user's unsafe actions are greatly limited if there are no channels through which the attacker can be reached by the unwitting user." + }, + "inherits": "Application:attackerUnsafeUserActivityCapability", + "name": "attackerUnsafeUserActivityCapability", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attackerUnsafeUserActivityCapabilityWithReverseReach": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "The attacker can be reached from this application and can therefore more easily exploit the unsafe user actions." + }, + "inherits": "Application:attackerUnsafeUserActivityCapabilityWithReverseReach", + "name": "attackerUnsafeUserActivityCapabilityWithReverseReach", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "attackerUnsafeUserActivityCapabilityWithoutReverseReach": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "The attacker cannot be reached from this application. This means that the autonomous malicious code must either be able to do reconnaissance on the fly or the reconnaissance was done beforehand and coded into it. This is much more complex and difficult than the situation where the attacker is reachable from the application which restricts the capabilities of being successful. Important Note: This step should never directly or indirectly lead to reverse reach as it would fulfil the attacker interactivity requirements it is missing." + }, + "inherits": "Application:attackerUnsafeUserActivityCapabilityWithoutReverseReach", + "name": "attackerUnsafeUserActivityCapabilityWithoutReverseReach", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": { + "arguments": [], + "name": "VeryHardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "attemptApplicationRespondConnectThroughData": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "Application:attemptApplicationRespondConnectThroughData", + "name": "attemptApplicationRespondConnectThroughData", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptAuthorizedApplicationRespondConnectThroughData": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "Application:attemptAuthorizedApplicationRespondConnectThroughData", + "name": "attemptAuthorizedApplicationRespondConnectThroughData", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptDeny": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "Application:attemptDeny", + "name": "attemptDeny", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptDenyFromSoftProdVulnerability": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "Application:attemptDenyFromSoftProdVulnerability", + "name": "attemptDenyFromSoftProdVulnerability", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptFullAccessFromSupplyChainCompromise": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": "Application:attemptFullAccessFromSupplyChainCompromise", + "name": "attemptFullAccessFromSupplyChainCompromise", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptModify": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "Application:attemptModify", + "name": "attemptModify", + "overrides": false, + "own_children": {}, + "own_parents": { + "Application:fullAccess": [ + { + "ManagedBy": { + "asset type": "Application", + "fieldname": "managerApp" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptModifyFromSoftProdVulnerability": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "Application:attemptModifyFromSoftProdVulnerability", + "name": "attemptModifyFromSoftProdVulnerability", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptNetworkConnectFromResponse": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "Application:attemptNetworkConnectFromResponse", + "name": "attemptNetworkConnectFromResponse", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptRead": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "Application:attemptRead", + "name": "attemptRead", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptReadFromSoftProdVulnerability": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "Application:attemptReadFromSoftProdVulnerability", + "name": "attemptReadFromSoftProdVulnerability", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptReverseReach": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses.", + "modeler": "If the entry point of the attacker is an Application this attack step needs to be explicitly called on it by the modeler in order to have reverse reach start propagating from the Application to the rest of the assets in the model. This is the case because no other attack step on the Application asset triggers this attack step.", + "user": "The attacker is reachable from this Application therefore start propagating reverse reach from it. Reverse reach is used to determine whether or not the attacker can be reached by the user." + }, + "inherits": "Application:attemptReverseReach", + "name": "attemptReverseReach", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "entrypoint" + ], + "ttc": null, + "type": "or" + }, + "attemptSoftwareProductAbuse": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": "Application:attemptSoftwareProductAbuse", + "name": "attemptSoftwareProductAbuse", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptUnsafeUserActivity": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": "Application:attemptUnsafeUserActivity", + "name": "attemptUnsafeUserActivity", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptUseVulnerability": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses." + }, + "inherits": "Application:attemptUseVulnerability", + "name": "attemptUseVulnerability", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "authenticate": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "user": "The attacker is able to authenticate with the appropriate high-level privileges." + }, + "inherits": "Application:authenticate", + "name": "authenticate", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "authorizedApplicationRespondConnectThroughData": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "After specific access on the application received data can be used to try a connect through respond to the client side application. To succeed the attacker must also attain write privileges on the received data." + }, + "inherits": "Application:authorizedApplicationRespondConnectThroughData", + "name": "authorizedApplicationRespondConnectThroughData", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "bypassContainerization": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "user": "The attacker is able to break out of an application container/sandbox and try to exploit any vulnerability of the host application." + }, + "inherits": "Application:bypassContainerization", + "name": "bypassContainerization", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "HardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "bypassSupplyChainAuditing": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "user": "Supply chain auditing can be bypassed." + }, + "inherits": "Application:bypassSupplyChainAuditing", + "name": "bypassSupplyChainAuditing", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "VeryHardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "deny": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "user": "A DoS attack should cascade on the connected networks and associated connections" + }, + "inherits": null, + "name": "deny", + "overrides": true, + "own_children": { + "ConnectionRule:attemptDeny": [ + { + "FirewallConnectionRule": { + "asset type": "ConnectionRule", + "fieldname": "connectionRules" + }, + "type": "field" + } + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "denyFromLockout": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "This is an intermediate attack step to only trigger deny on an application when all the executing access control roles are locked out." + }, + "inherits": "Application:denyFromLockout", + "name": "denyFromLockout", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "denyFromNetworkingAsset": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "This is an intermediate attack step to only allow deny on an application when all the connection rules and networks associated with it are denied, because an app can be serving on many different ports." + }, + "inherits": "Application:denyFromNetworkingAsset", + "name": "denyFromNetworkingAsset", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "denyFromSoftProdVulnerability": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to handle SoftwareProduct existence." + }, + "inherits": "Application:denyFromSoftProdVulnerability", + "name": "denyFromSoftProdVulnerability", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "fullAccess": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "If full access is achieved on RoutingFirewall then it is 0wned and all connections can be allowed! This can happen by compromising the manager application.", + "modeler": "It is common in models for the attacker to be granted full access on an Application, representing their own tools, that is connected to the Internet to explore possible attack vectors.", + "user": "Full access on the Application means the attacker has complete control of the application and can perform any actions within its execution context." + }, + "inherits": "Application:fullAccess", + "name": "fullAccess", + "overrides": false, + "own_children": { + "Application:networkConnectUninspected": [ + { + "collect": { + "left": { + "FirewallConnectionRule": { + "asset type": "ConnectionRule", + "fieldname": "connectionRules" + }, + "type": "field" + }, + "right": { + "ApplicationConnection": { + "asset type": "Application", + "fieldname": "applications" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "Network:accessUninspected": [ + { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "type": "union", + "union": { + "left": { + "collect": { + "left": { + "FirewallConnectionRule": { + "asset type": "ConnectionRule", + "fieldname": "connectionRules" + }, + "type": "field" + }, + "right": { + "NetworkConnection": { + "asset type": "Network", + "fieldname": "networks" + }, + "type": "field" + } + }, + "type": "collect" + }, + "right": { + "collect": { + "left": { + "FirewallConnectionRule": { + "asset type": "ConnectionRule", + "fieldname": "connectionRules" + }, + "type": "field" + }, + "right": { + "OutNetworkConnection": { + "asset type": "Network", + "fieldname": "outNetworks" + }, + "type": "field" + } + }, + "type": "collect" + } + } + }, + "right": { + "collect": { + "left": { + "FirewallConnectionRule": { + "asset type": "ConnectionRule", + "fieldname": "connectionRules" + }, + "type": "field" + }, + "right": { + "InNetworkConnection": { + "asset type": "Network", + "fieldname": "inNetworks" + }, + "type": "field" + } + }, + "type": "collect" + } + } + }, + "right": { + "collect": { + "left": { + "FirewallConnectionRule": { + "asset type": "ConnectionRule", + "fieldname": "connectionRules" + }, + "type": "field" + }, + "right": { + "DiodeInNetworkConnection": { + "asset type": "Network", + "fieldname": "diodeInNetworks" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ] + }, + "own_parents": {}, + "tags": [ + "entrypoint" + ], + "ttc": null, + "type": "or" + }, + "fullAccessFromSupplyChainCompromise": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step." + }, + "inherits": "Application:fullAccessFromSupplyChainCompromise", + "name": "fullAccessFromSupplyChainCompromise", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "localAccess": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack steps for modeling the two requirements (reachability and authentication) for full access." + }, + "inherits": "Application:localAccess", + "name": "localAccess", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "localConnect": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "user": "The attacker can interface with the Application without going through networking communications. This is achieved by gaining full access on a hosted Application, specific access on the hosting Application, physical access on the hardware on which the Application is running, or via unsafe user activity." + }, + "inherits": "Application:localConnect", + "name": "localConnect", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "modify": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "user": "The attacker can modify some or all of this service's (and executed by this) source code and/or data." + }, + "inherits": "Application:modify", + "name": "modify", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "modifyFromSoftProdVulnerability": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to handle SoftwareProduct existence." + }, + "inherits": "Application:modifyFromSoftProdVulnerability", + "name": "modifyFromSoftProdVulnerability", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "networkAccess": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack steps for modeling the two requirements (reachability and authentication) for full access." + }, + "inherits": "Application:networkAccess", + "name": "networkAccess", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "networkConnect": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step." + }, + "inherits": "Application:networkConnect", + "name": "networkConnect", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "networkConnectFromResponse": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "An attacker may be able to respond to requests submitted by a client application." + }, + "inherits": "Application:networkConnectFromResponse", + "name": "networkConnectFromResponse", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": { + "arguments": [], + "name": "VeryHardAndUncertain", + "type": "function" + }, + "type": "and" + }, + "networkConnectInspected": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "user": "If the network communications occur via any ConnectionRules that have their payload inspected the attacker can still authenticate, but they cannot trigger vulnerabilities." + }, + "inherits": "Application:networkConnectInspected", + "name": "networkConnectInspected", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "networkConnectUninspected": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "user": "An attacker can connect to any network exposed application and try to authenticate or trigger vulnerabilities." + }, + "inherits": "Application:networkConnectUninspected", + "name": "networkConnectUninspected", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "notPresent": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "This defense allows for both speculation about the presence of a particular application and to suggest that if it is possible removing it may restrict the attacker's actions.", + "user": "This defense is used to reason about the attack vectors introduced by this asset. It should be used to model the possibility that the Application does not actually exist. Typically this is useful to see what attack steps are removed if a particular non-essential component(e.g. a plugin, library, feature, or service)." + }, + "inherits": "Application:notPresent", + "name": "notPresent", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "physicalAccessAchieved": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to propagate physical access throughout application nesting." + }, + "inherits": "Application:physicalAccessAchieved", + "name": "physicalAccessAchieved", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "read": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "user": "The attacker can read some or all of this service's (and executed by this) source code and/or data (both local and sent/received)." + }, + "inherits": "Application:read", + "name": "read", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "readFromSoftProdVulnerability": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to handle SoftwareProduct existence." + }, + "inherits": "Application:readFromSoftProdVulnerability", + "name": "readFromSoftProdVulnerability", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "reverseReach": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Reverse reach is used to determine whether or not the attacker can be reached by the user. Reverse reach propagates toward outgoing or bidirectional communications." + }, + "inherits": "Application:reverseReach", + "name": "reverseReach", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "softwareCheck": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Only enable the following attacks steps if a SoftwareProduct is associated with this Application." + }, + "inherits": "Application:softwareCheck", + "name": "softwareCheck", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "exist" + }, + "softwareProductAbuse": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": "Application:softwareProductAbuse", + "name": "softwareProductAbuse", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "softwareProductVulnerabilityHighPrivilegesAchieved": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": "Application:softwareProductVulnerabilityHighPrivilegesAchieved", + "name": "softwareProductVulnerabilityHighPrivilegesAchieved", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "softwareProductVulnerabilityLocalAccessAchieved": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": "Application:softwareProductVulnerabilityLocalAccessAchieved", + "name": "softwareProductVulnerabilityLocalAccessAchieved", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "softwareProductVulnerabilityLowPrivilegesAchieved": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": "Application:softwareProductVulnerabilityLowPrivilegesAchieved", + "name": "softwareProductVulnerabilityLowPrivilegesAchieved", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "softwareProductVulnerabilityNetworkAccessAchieved": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": "Application:softwareProductVulnerabilityNetworkAccessAchieved", + "name": "softwareProductVulnerabilityNetworkAccessAchieved", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "softwareProductVulnerabilityPhysicalAccessAchieved": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": "Application:softwareProductVulnerabilityPhysicalAccessAchieved", + "name": "softwareProductVulnerabilityPhysicalAccessAchieved", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "softwareProductVulnerabilityUserInteractionAchieved": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step used to conditionally trigger SoftwareProduct vulnerabilities separately for each application." + }, + "inherits": "Application:softwareProductVulnerabilityUserInteractionAchieved", + "name": "softwareProductVulnerabilityUserInteractionAchieved", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "specificAccess": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "user": "The adversary is able to gain low-privilege access on the Application which allows them to access the networks and connections associated with it and locally connect to hosted Applications. Additionally, if they have the required privileges the attacker may also access data hosted, sent, or received by the Application." + }, + "inherits": "Application:specificAccess", + "name": "specificAccess", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "specificAccessAuthenticate": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "user": "The attacker is able to authenticate with the appropriate low-level privileges." + }, + "inherits": "Application:specificAccessAuthenticate", + "name": "specificAccessAuthenticate", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "specificAccessDelete": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "user": "The attacker can delete some or all functionality and data pertaining to this application/service as well as executed applications, given the necessary permissions" + }, + "inherits": "Application:specificAccessDelete", + "name": "specificAccessDelete", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "specificAccessFromLocalConnection": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack steps for modeling the two requirements (reachability and authentication) for specific access." + }, + "inherits": "Application:specificAccessFromLocalConnection", + "name": "specificAccessFromLocalConnection", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "specificAccessFromNetworkConnection": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack steps for modeling the two requirements (reachability and authentication) for specific access." + }, + "inherits": "Application:specificAccessFromNetworkConnection", + "name": "specificAccessFromNetworkConnection", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "specificAccessModify": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "user": "The attacker can modify the service's source code and/or sent Data, given the necessary permissions" + }, + "inherits": "Application:specificAccessModify", + "name": "specificAccessModify", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "specificAccessNetworkConnect": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step." + }, + "inherits": "Application:specificAccessNetworkConnect", + "name": "specificAccessNetworkConnect", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "specificAccessRead": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "user": "The attacker can read the service's source code and/or sent and received Data, given the necessary permissions" + }, + "inherits": "Application:specificAccessRead", + "name": "specificAccessRead", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "successfulApplicationRespondConnectThroughData": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "Application:successfulApplicationRespondConnectThroughData", + "name": "successfulApplicationRespondConnectThroughData", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulAuthorizedApplicationRespondConnectThroughData": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "Application:successfulAuthorizedApplicationRespondConnectThroughData", + "name": "successfulAuthorizedApplicationRespondConnectThroughData", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulDeny": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "Application:successfulDeny", + "name": "successfulDeny", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulModify": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "Application:successfulModify", + "name": "successfulModify", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulRead": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "Application:successfulRead", + "name": "successfulRead", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulReverseReach": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "Application:successfulReverseReach", + "name": "successfulReverseReach", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulUnsafeUserActivity": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "Application:successfulUnsafeUserActivity", + "name": "successfulUnsafeUserActivity", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulUseVulnerability": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": "Application:successfulUseVulnerability", + "name": "successfulUseVulnerability", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "supplyChainAuditing": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "user": "Auditing inside the supply chain can detect possible supply chain attacks." + }, + "inherits": "Application:supplyChainAuditing", + "name": "supplyChainAuditing", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "defense" + }, + "supplyChainAuditingBypassed": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "The supply chain audit is bypassed either because it was not set or the attacker was able to circumvent it through additional effort." + }, + "inherits": "Application:supplyChainAuditingBypassed", + "name": "supplyChainAuditingBypassed", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "unsafeUserActivity": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "user": "The unsafe actions of users on this application open it up to attacks and enable vulnerabilities that require user interaction." + }, + "inherits": "Application:unsafeUserActivity", + "name": "unsafeUserActivity", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "useVulnerability": { + "asset": "RoutingFirewall", + "detectors": {}, + "info": { + "developer": "This includes vulnerabilities associated with the SoftwareProduct that this Application is running as well.", + "user": "Try to exploit the associated vulnerabilities." + }, + "inherits": "Application:useVulnerability", + "name": "useVulnerability", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + } + }, + "info": { + "user": "A routing firewall specifies a router with firewall capabilities that connects many networks." + }, + "is_abstract": false, + "name": "RoutingFirewall", + "sub_assets": [], + "super_asset": "Application", + "variables": {} + }, + "SoftwareProduct": { + "associations": { + "softApplications": { + "info": { + "user": "An application can be associated with a software product that has some specific characteristics such as specific Vulnerabilities and deployment repository." + }, + "left": { + "asset": "SoftwareProduct", + "fieldname": "appSoftProduct", + "max": 1, + "min": 0 + }, + "name": "AppSoftwareProduct", + "right": { + "asset": "Application", + "fieldname": "softApplications", + "max": null, + "min": 0 + } + }, + "softProductVulnerabilities": { + "info": { + "user": "A SoftwareProduct can have multiple software vulnerabilities." + }, + "left": { + "asset": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities", + "max": null, + "min": 0 + }, + "name": "ApplicationVulnerability", + "right": { + "asset": "SoftwareProduct", + "fieldname": "softwareProduct", + "max": 1, + "min": 0 + } + } + }, + "attack_steps": { + "attemptDeleteFromReplica": { + "asset": "SoftwareProduct", + "detectors": {}, + "info": { + "developer": "Information can be deleted only if all replicas have been deleted." + }, + "inherits": "Information:attemptDeleteFromReplica", + "name": "attemptDeleteFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "attemptDenyFromReplica": { + "asset": "SoftwareProduct", + "detectors": {}, + "info": { + "developer": "Information can be denied only if all replicas have been denied." + }, + "inherits": "Information:attemptDenyFromReplica", + "name": "attemptDenyFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "attemptExtractFromReplica": { + "asset": "SoftwareProduct", + "detectors": {}, + "info": { + "developer": "The attacker attempts to extract the information." + }, + "inherits": "Information:attemptExtractFromReplica", + "name": "attemptExtractFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptReadFromReplica": { + "asset": "SoftwareProduct", + "detectors": {}, + "info": { + "developer": "Reading one replica allows the attacker to read all other replicas as well since the information contained in them is the same." + }, + "inherits": "Information:attemptReadFromReplica", + "name": "attemptReadFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptWriteFromReplica": { + "asset": "SoftwareProduct", + "detectors": {}, + "info": { + "developer": "Information can be overwritten only if all replicas have been overwritten." + }, + "inherits": "Information:attemptWriteFromReplica", + "name": "attemptWriteFromReplica", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "compromiseApplications": { + "asset": "SoftwareProduct", + "detectors": {}, + "info": { + "user": "If the origin of a software product is compromised (modified maliciously) this is interpreted as a software supply chain attack and therefore the associated applications should also be fully compromised." + }, + "inherits": null, + "name": "compromiseApplications", + "overrides": true, + "own_children": { + "Application:attemptFullAccessFromSupplyChainCompromise": [ + { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + ] + }, + "own_parents": { + "SoftwareProduct:write": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "delete": { + "asset": "SoftwareProduct", + "detectors": {}, + "info": { + "user": "The attacker is able to delete the information." + }, + "inherits": "Information:delete", + "name": "delete", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "deny": { + "asset": "SoftwareProduct", + "detectors": {}, + "info": { + "user": "The attacker is able to deny the information." + }, + "inherits": "Information:deny", + "name": "deny", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "denyApplications": { + "asset": "SoftwareProduct", + "detectors": {}, + "info": { + "developer": "This is an intermediary attack step to propagate deny on all of the associated applications if the vulnerability has an availability impact." + }, + "inherits": null, + "name": "denyApplications", + "overrides": true, + "own_children": { + "Application:attemptDenyFromSoftProdVulnerability": [ + { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + ] + }, + "own_parents": { + "SoftwareVulnerability:deny": [ + { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "extract": { + "asset": "SoftwareProduct", + "detectors": {}, + "info": { + "user": "The attacker is able to extract the information. This means that they have a path available through which they can transfer the information back to a system that they control." + }, + "inherits": "Information:extract", + "name": "extract", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "modifyApplications": { + "asset": "SoftwareProduct", + "detectors": {}, + "info": { + "developer": "This is an intermediary attack step to propagate modify on all of the associated applications if the vulnerability has an integrity impact." + }, + "inherits": null, + "name": "modifyApplications", + "overrides": true, + "own_children": { + "Application:attemptModifyFromSoftProdVulnerability": [ + { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + ] + }, + "own_parents": { + "SoftwareVulnerability:modify": [ + { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "read": { + "asset": "SoftwareProduct", + "detectors": {}, + "info": { + "user": "The attacker is able to read the information. They may still be unable to extract it, which represents the adversary being able to read it themselves. This is the case if autonomous code is able to read it, but lack the required networking connections to transmit it back to the adversary." + }, + "inherits": "Information:read", + "name": "read", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "or" + }, + "readApplications": { + "asset": "SoftwareProduct", + "detectors": {}, + "info": { + "developer": "This is an intermediary attack step to propagate read on all of the associated applications if the vulnerability has a confidentiality impact." + }, + "inherits": null, + "name": "readApplications", + "overrides": true, + "own_children": { + "Application:attemptReadFromSoftProdVulnerability": [ + { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + ] + }, + "own_parents": { + "SoftwareVulnerability:read": [ + { + "ApplicationVulnerability": { + "asset type": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities" + }, + "type": "field" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "write": { + "asset": "SoftwareProduct", + "detectors": {}, + "info": { + "user": "The attacker is able to write the information." + }, + "inherits": "Information:write", + "name": "write", + "overrides": false, + "own_children": { + "SoftwareProduct:compromiseApplications": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "Override" + ], + "ttc": null, + "type": "or" + } + }, + "info": { + "developer": "This asset was introduced in order to improve the usability of the language and enable software supply chain attacks.", + "user": "A software product that certain applications are using which represents a specific software package, potentially even a version, which can be associated with certain vulnerabilities or have its deployment repositories compromised in a software supply chain attack." + }, + "is_abstract": false, + "name": "SoftwareProduct", + "sub_assets": [], + "super_asset": "Information", + "variables": {} + }, + "SoftwareVulnerability": { + "associations": { + "application": { + "info": { + "user": "An Application can have multiple software vulnerabilities." + }, + "left": { + "asset": "SoftwareVulnerability", + "fieldname": "vulnerabilities", + "max": null, + "min": 0 + }, + "name": "ApplicationVulnerability", + "right": { + "asset": "Application", + "fieldname": "application", + "max": 1, + "min": 0 + } + }, + "softwareProduct": { + "info": { + "user": "A SoftwareProduct can have multiple software vulnerabilities." + }, + "left": { + "asset": "SoftwareVulnerability", + "fieldname": "softProductVulnerabilities", + "max": null, + "min": 0 + }, + "name": "ApplicationVulnerability", + "right": { + "asset": "SoftwareProduct", + "fieldname": "softwareProduct", + "max": 1, + "min": 0 + } + } + }, + "attack_steps": { + "abuse": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "user": "The attacker is trying to abuse a vulnerability in an asset." + }, + "inherits": "Vulnerability:abuse", + "name": "abuse", + "overrides": false, + "own_children": {}, + "own_parents": { + "SoftwareVulnerability:highPrivilegesAchieved": [ + null + ], + "SoftwareVulnerability:localAccessAchieved": [ + null + ], + "SoftwareVulnerability:lowPrivilegesAchieved": [ + null + ], + "SoftwareVulnerability:networkAccessAchieved": [ + null + ], + "SoftwareVulnerability:physicalAccessAchieved": [ + null + ], + "SoftwareVulnerability:userInteractionAchieved": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "and" + }, + "attemptAbuse": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": "Vulnerability:attemptAbuse", + "name": "attemptAbuse", + "overrides": false, + "own_children": { + "SoftwareVulnerability:inherentUserInteraction": [ + null + ] + }, + "own_parents": { + "Application:useVulnerability": [ + { + "type": "union", + "union": { + "left": { + "ApplicationVulnerability": { + "asset type": "Application", + "fieldname": "application" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + }, + "right": { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ] + }, + "tags": [ + "hidden", + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptExploit": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptExploit", + "overrides": true, + "own_children": { + "SoftwareVulnerability:exploitTrivially": [ + null + ], + "SoftwareVulnerability:exploitWithEffort": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "hidden", + "Override" + ], + "ttc": null, + "type": "or" + }, + "availabilityImpactLimitations": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "modeler": "https://www.first.org/cvss/v3.1/specification-document#2-3-3-Availability-A", + "user": "Limits the impact on availability of the vulnerability." + }, + "inherits": null, + "name": "availabilityImpactLimitations", + "overrides": true, + "own_children": { + "SoftwareVulnerability:deny": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "suppress" + ], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "confidentialityImpactLimitations": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "modeler": "https://www.first.org/cvss/v3.1/specification-document#2-3-1-Confidentiality-C", + "user": "Limits the impact on confidentiality of the vulnerability." + }, + "inherits": null, + "name": "confidentialityImpactLimitations", + "overrides": true, + "own_children": { + "SoftwareVulnerability:read": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "suppress" + ], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "deny": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "user": "Denial-of-Service attack is an attack in which an application is rendered unavailable to its intended users by temporarily or indefinitely disrupting it." + }, + "inherits": null, + "name": "deny", + "overrides": true, + "own_children": { + "Application:deny": [ + { + "ApplicationVulnerability": { + "asset type": "Application", + "fieldname": "application" + }, + "type": "field" + } + ], + "SoftwareProduct:denyApplications": [ + { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + } + ] + }, + "own_parents": { + "SoftwareVulnerability:availabilityImpactLimitations": [ + null + ], + "SoftwareVulnerability:impact": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "and" + }, + "exploit": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "user": "Attacker is employing an exploit that makes use of this vulnerability." + }, + "inherits": "Vulnerability:exploit", + "name": "exploit", + "overrides": false, + "own_children": {}, + "own_parents": { + "SoftwareVulnerability:exploitTrivially": [ + null + ], + "SoftwareVulnerability:exploitWithEffort": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "exploitTrivially": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "developer": "The exploit is trivial and requires insignificant effort from the attacker in order to be successful." + }, + "inherits": null, + "name": "exploitTrivially", + "overrides": true, + "own_children": { + "SoftwareVulnerability:exploit": [ + null + ] + }, + "own_parents": { + "SoftwareVulnerability:attemptExploit": [ + null + ], + "SoftwareVulnerability:highComplexityExploitRequired": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "exploitWithEffort": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "developer": "The attacker must expend effort in order to successfully exploit the vulnerability." + }, + "inherits": null, + "name": "exploitWithEffort", + "overrides": true, + "own_children": { + "SoftwareVulnerability:exploit": [ + null + ] + }, + "own_parents": { + "SoftwareVulnerability:attemptExploit": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": { + "arguments": [ + 0.01 + ], + "name": "Exponential", + "type": "function" + }, + "type": "and" + }, + "highComplexityExploitRequired": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "developer": "A vulnerability that has high complexity due to its intrinsic properties that are beyond the attacker's control.", + "modeler": "https://www.first.org/cvss/v3.1/specification-document#2-1-2-Attack-Complexity-AC", + "user": "The vulnerability relies on a high complexity exploit that requires the attacker to expend additional effort (time) in order to successfully make use of it." + }, + "inherits": null, + "name": "highComplexityExploitRequired", + "overrides": true, + "own_children": { + "SoftwareVulnerability:exploitTrivially": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "suppress" + ], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "highPrivilegesAchieved": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model high privileges requirement." + }, + "inherits": null, + "name": "highPrivilegesAchieved", + "overrides": true, + "own_children": { + "SoftwareVulnerability:abuse": [ + null + ] + }, + "own_parents": { + "Application:authenticate": [ + { + "type": "union", + "union": { + "left": { + "ApplicationVulnerability": { + "asset type": "Application", + "fieldname": "application" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + }, + "right": { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ], + "Application:softwareProductVulnerabilityHighPrivilegesAchieved": [ + { + "collect": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + }, + "right": { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "SoftwareVulnerability:highPrivilegesRequired": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "highPrivilegesRequired": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "modeler": "https://www.first.org/cvss/v3.1/specification-document#2-1-3-Privileges-Required-PR", + "user": "High privileges are required to abuse the vulnerability." + }, + "inherits": null, + "name": "highPrivilegesRequired", + "overrides": true, + "own_children": { + "Application:softwareProductVulnerabilityHighPrivilegesAchieved": [ + { + "collect": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + }, + "right": { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "SoftwareVulnerability:highPrivilegesAchieved": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "suppress" + ], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "impact": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "developer": "The actual impact caused by successfully exploiting the vulnerability." + }, + "inherits": null, + "name": "impact", + "overrides": true, + "own_children": { + "SoftwareVulnerability:deny": [ + null + ], + "SoftwareVulnerability:modify": [ + null + ], + "SoftwareVulnerability:read": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "inherentUserInteraction": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "modeler": "Different software vulnerabilities that require user interaction to exploit can vary substantially in how likely those interactions are. They can range from simply using a shortcut or starting a service to enabling very specific features that the user would never perform without attacker influence. The probability assigned to this attack step should be tuned to fit the description of the vulnerability.", + "user": "The user may fulfil the user interaction requirement through their regular activities, without requiring the attacker to induce them to do so." + }, + "inherits": null, + "name": "inherentUserInteraction", + "overrides": true, + "own_children": { + "Application:softwareProductVulnerabilityUserInteractionAchieved": [ + { + "collect": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + }, + "right": { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "SoftwareVulnerability:userInteractionAchieved": [ + null + ] + }, + "own_parents": { + "SoftwareVulnerability:attemptAbuse": [ + null + ] + }, + "tags": [], + "ttc": { + "arguments": [], + "name": "HardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "integrityImpactLimitations": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "modeler": "https://www.first.org/cvss/v3.1/specification-document#2-3-2-Integrity-I", + "user": "Limits the impact on integrity of the vulnerability." + }, + "inherits": null, + "name": "integrityImpactLimitations", + "overrides": true, + "own_children": { + "SoftwareVulnerability:modify": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "suppress" + ], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "localAccessAchieved": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model local access requirement." + }, + "inherits": null, + "name": "localAccessAchieved", + "overrides": true, + "own_children": { + "SoftwareVulnerability:abuse": [ + null + ] + }, + "own_parents": { + "Application:localConnect": [ + { + "type": "union", + "union": { + "left": { + "ApplicationVulnerability": { + "asset type": "Application", + "fieldname": "application" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + }, + "right": { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ], + "Application:softwareProductVulnerabilityLocalAccessAchieved": [ + { + "collect": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + }, + "right": { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "SoftwareVulnerability:localAccessRequired": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "localAccessRequired": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "modeler": "https://www.first.org/cvss/v3.1/specification-document#2-1-1-Attack-Vector-AV", + "user": "Local access is required to abuse the vulnerability." + }, + "inherits": null, + "name": "localAccessRequired", + "overrides": true, + "own_children": { + "Application:softwareProductVulnerabilityLocalAccessAchieved": [ + { + "collect": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + }, + "right": { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "SoftwareVulnerability:localAccessAchieved": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "suppress" + ], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "lowPrivilegesAchieved": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model low privileges requirement." + }, + "inherits": null, + "name": "lowPrivilegesAchieved", + "overrides": true, + "own_children": { + "SoftwareVulnerability:abuse": [ + null + ] + }, + "own_parents": { + "Application:softwareProductVulnerabilityLowPrivilegesAchieved": [ + { + "collect": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + }, + "right": { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "Application:specificAccessAuthenticate": [ + { + "type": "union", + "union": { + "left": { + "ApplicationVulnerability": { + "asset type": "Application", + "fieldname": "application" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + }, + "right": { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ], + "SoftwareVulnerability:lowPrivilegesRequired": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "lowPrivilegesRequired": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "modeler": "https://www.first.org/cvss/v3.1/specification-document#2-1-3-Privileges-Required-PR", + "user": "Low privileges are required to abuse the vulnerability." + }, + "inherits": null, + "name": "lowPrivilegesRequired", + "overrides": true, + "own_children": { + "Application:softwareProductVulnerabilityLowPrivilegesAchieved": [ + { + "collect": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + }, + "right": { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "SoftwareVulnerability:lowPrivilegesAchieved": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "suppress" + ], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "modify": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "user": "The attacker can modify some or all of the associated application's data and/or source code." + }, + "inherits": null, + "name": "modify", + "overrides": true, + "own_children": { + "Application:modify": [ + { + "ApplicationVulnerability": { + "asset type": "Application", + "fieldname": "application" + }, + "type": "field" + } + ], + "SoftwareProduct:modifyApplications": [ + { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + } + ] + }, + "own_parents": { + "SoftwareVulnerability:impact": [ + null + ], + "SoftwareVulnerability:integrityImpactLimitations": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "and" + }, + "networkAccessAchieved": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model network access requirement." + }, + "inherits": null, + "name": "networkAccessAchieved", + "overrides": true, + "own_children": { + "SoftwareVulnerability:abuse": [ + null + ] + }, + "own_parents": { + "Application:networkConnectUninspected": [ + { + "type": "union", + "union": { + "left": { + "ApplicationVulnerability": { + "asset type": "Application", + "fieldname": "application" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + }, + "right": { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ], + "Application:softwareProductVulnerabilityNetworkAccessAchieved": [ + { + "collect": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + }, + "right": { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "SoftwareVulnerability:networkAccessRequired": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "networkAccessRequired": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "modeler": "https://www.first.org/cvss/v3.1/specification-document#2-1-1-Attack-Vector-AV", + "user": "Network access is required to abuse the vulnerability." + }, + "inherits": null, + "name": "networkAccessRequired", + "overrides": true, + "own_children": { + "Application:softwareProductVulnerabilityNetworkAccessAchieved": [ + { + "collect": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + }, + "right": { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "SoftwareVulnerability:networkAccessAchieved": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "suppress" + ], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "notPresent": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "developer": "Enabling this defense should make the asset behave as if it did not exist, as such all of its impactful attack steps should be disabled by it.", + "mitre": "Used for M1049:Antivirus/Antimalware, M1050: Exploit Protection, M1040: Behavior Prevention on Endpoint, M1038: Execution Prevention, M1051: Update Software, M1016: Vulnerability Scanning, M1021: Restrict Web-Based Content, M1042: Disable or Remove Feature or Program, M1013: Application Developer Guidance", + "user": "This defense is used to reason about the attack vectors introduced by this asset. This is particularly useful for vulnerabilities where it is common to explore the possibility of patching them out." + }, + "inherits": "Vulnerability:notPresent", + "name": "notPresent", + "overrides": false, + "own_children": {}, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "physicalAccessAchieved": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model physical access requirement." + }, + "inherits": null, + "name": "physicalAccessAchieved", + "overrides": true, + "own_children": { + "SoftwareVulnerability:abuse": [ + null + ] + }, + "own_parents": { + "Application:physicalAccessAchieved": [ + { + "type": "union", + "union": { + "left": { + "ApplicationVulnerability": { + "asset type": "Application", + "fieldname": "application" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + }, + "right": { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ], + "Application:softwareProductVulnerabilityPhysicalAccessAchieved": [ + { + "collect": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + }, + "right": { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "SoftwareVulnerability:physicalAccessRequired": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "physicalAccessRequired": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "modeler": "https://www.first.org/cvss/v3.1/specification-document#2-1-1-Attack-Vector-AV", + "user": "Physical access is required to abuse the vulnerability." + }, + "inherits": null, + "name": "physicalAccessRequired", + "overrides": true, + "own_children": { + "Application:softwareProductVulnerabilityPhysicalAccessAchieved": [ + { + "collect": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + }, + "right": { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "SoftwareVulnerability:physicalAccessAchieved": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "suppress" + ], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + }, + "read": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "user": "The attacker can read some or all of the associated applications's code and data." + }, + "inherits": null, + "name": "read", + "overrides": true, + "own_children": { + "Application:read": [ + { + "ApplicationVulnerability": { + "asset type": "Application", + "fieldname": "application" + }, + "type": "field" + } + ], + "SoftwareProduct:readApplications": [ + { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + } + ] + }, + "own_parents": { + "SoftwareVulnerability:confidentialityImpactLimitations": [ + null + ], + "SoftwareVulnerability:impact": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "and" + }, + "userInteractionAchieved": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model user interaction requirement." + }, + "inherits": null, + "name": "userInteractionAchieved", + "overrides": true, + "own_children": { + "SoftwareVulnerability:abuse": [ + null + ] + }, + "own_parents": { + "Application:softwareProductVulnerabilityUserInteractionAchieved": [ + { + "collect": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + }, + "right": { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "Application:unsafeUserActivity": [ + { + "type": "union", + "union": { + "left": { + "ApplicationVulnerability": { + "asset type": "Application", + "fieldname": "application" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + }, + "right": { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ], + "SoftwareVulnerability:inherentUserInteraction": [ + null + ], + "SoftwareVulnerability:userInteractionRequired": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "userInteractionRequired": { + "asset": "SoftwareVulnerability", + "detectors": {}, + "info": { + "modeler": "https://www.first.org/cvss/v3.1/specification-document#2-1-4-User-Interaction-UI", + "user": "Some exploits require user interaction. This defense should specify the probability of such interaction on the given application." + }, + "inherits": null, + "name": "userInteractionRequired", + "overrides": true, + "own_children": { + "Application:softwareProductVulnerabilityUserInteractionAchieved": [ + { + "collect": { + "left": { + "ApplicationVulnerability": { + "asset type": "SoftwareProduct", + "fieldname": "softwareProduct" + }, + "type": "field" + }, + "right": { + "AppSoftwareProduct": { + "asset type": "Application", + "fieldname": "softApplications" + }, + "type": "field" + } + }, + "type": "collect" + } + ], + "SoftwareVulnerability:userInteractionAchieved": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "suppress" + ], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + } + }, + "info": { + "developer": "Vulnerabilities in coreLang were designed to be in line with the 3.1 CVSS specification(https://www.first.org/cvss/specification-document#Common-Vulnerability-Scoring-System-version-3-1-Specification-Document). The defenses on the SoftwareVulnerability asset represent 7 of the base metrics scores, all of them except the scope metric. The temporal and environmental metrics are beyond the language's scope.", + "user": "A vulnerability that affects software (i.e. Applications or SoftwareProducts)." + }, + "is_abstract": false, + "name": "SoftwareVulnerability", + "sub_assets": [], + "super_asset": "Vulnerability", + "variables": {} + }, + "User": { + "associations": { + "hardwareSystems": { + "info": { + "user": "A user may have access to different hardware systems, rigs, or components." + }, + "left": { + "asset": "User", + "fieldname": "users", + "max": null, + "min": 0 + }, + "name": "HardwareAccess", + "right": { + "asset": "Hardware", + "fieldname": "hardwareSystems", + "max": null, + "min": 0 + } + }, + "physicalZones": { + "info": { + "user": "A user may have access to different physical zones." + }, + "left": { + "asset": "User", + "fieldname": "users", + "max": null, + "min": 0 + }, + "name": "ZoneAccess", + "right": { + "asset": "PhysicalZone", + "fieldname": "physicalZones", + "max": null, + "min": 0 + } + }, + "userIds": { + "info": { + "user": "A user may have different identities associated with it." + }, + "left": { + "asset": "User", + "fieldname": "users", + "max": null, + "min": 0 + }, + "name": "UserAssignedIdentities", + "right": { + "asset": "Identity", + "fieldname": "userIds", + "max": null, + "min": 0 + } + } + }, + "attack_steps": { + "attemptDeliverMaliciousRemovableMedia": { + "asset": "User", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses.", + "modeler": "Social engineering is modeled by having the attacker trigger this attack step on hthe target Users.", + "user": "An attacker can try to deliver a removable media drive(i.e. USB drive) containing malicious code to a location accessible to the target users." + }, + "inherits": null, + "name": "attemptDeliverMaliciousRemovableMedia", + "overrides": true, + "own_children": { + "User:bypassSecurityAwareness": [ + null + ], + "User:successfulDeliverMaliciousRemovableMedia": [ + null + ] + }, + "own_parents": { + "Hardware:spreadWormThroughRemovableMedia": [ + { + "type": "union", + "union": { + "left": { + "HardwareAccess": { + "asset type": "Hardware", + "fieldname": "hardwareSystems" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "ZoneAccess": { + "asset type": "PhysicalZone", + "fieldname": "physicalZones" + }, + "type": "field" + }, + "right": { + "ZoneInclusion": { + "asset type": "Hardware", + "fieldname": "hardwareSystems" + }, + "type": "field" + } + }, + "type": "collect" + } + } + } + ] + }, + "tags": [ + "entrypoint" + ], + "ttc": null, + "type": "or" + }, + "attemptSocialEngineering": { + "asset": "User", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses and trigger bypasses.", + "modeler": "Social engineering is modeled by having the attacker trigger this attack step on the target Users.", + "user": "An attacker can try to perform social engineering techniques such as phishing and inducing the user to perform malicious actions." + }, + "inherits": null, + "name": "attemptSocialEngineering", + "overrides": true, + "own_children": { + "User:bypassSecurityAwareness": [ + null + ], + "User:successfulSocialEngineering": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "entrypoint" + ], + "ttc": null, + "type": "or" + }, + "bypassSecurityAwareness": { + "asset": "User", + "detectors": {}, + "info": { + "user": "Security awareness can be bypassed." + }, + "inherits": null, + "name": "bypassSecurityAwareness", + "overrides": true, + "own_children": { + "User:securityAwarenessBypassed": [ + null + ] + }, + "own_parents": { + "User:attemptDeliverMaliciousRemovableMedia": [ + null + ], + "User:attemptSocialEngineering": [ + null + ] + }, + "tags": [], + "ttc": { + "arguments": [], + "name": "VeryHardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "credentialTheft": { + "asset": "User", + "detectors": {}, + "info": { + "developer": "Trick the user into providing their credentials.", + "modeler": "Distribution: Bernoulli(0.5) * Exponential(0.1), source: Sommestad (2011) Password authentication attacks: a survey of attacks and when they will succeed, suggest to use Bernoulli(0.05)" + }, + "inherits": null, + "name": "credentialTheft", + "overrides": true, + "own_children": { + "Credentials:attemptCredentialTheft": [ + { + "collect": { + "left": { + "UserAssignedIdentities": { + "asset type": "Identity", + "fieldname": "userIds" + }, + "type": "field" + }, + "right": { + "IdentityCredentials": { + "asset type": "Credentials", + "fieldname": "credentials" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "own_parents": { + "User:socialEngineering": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": { + "arguments": [], + "name": "HardAndUncertain", + "type": "function" + }, + "type": "or" + }, + "deliverMaliciousRemovableMedia": { + "asset": "User", + "detectors": {}, + "info": { + "developer": "The user is assumed to connect the removable media containing the malicious code to all of the hardware systems that they have access to. Since this is expected to unwittingly happen as part of their normal operations the attack exposes their regular security context." + }, + "inherits": null, + "name": "deliverMaliciousRemovableMedia", + "overrides": true, + "own_children": { + "Hardware:attemptUnsafeUserActivity": [ + { + "collect": { + "left": { + "ZoneAccess": { + "asset type": "PhysicalZone", + "fieldname": "physicalZones" + }, + "type": "field" + }, + "right": { + "ZoneInclusion": { + "asset type": "Hardware", + "fieldname": "hardwareSystems" + }, + "type": "field" + } + }, + "type": "collect" + }, + { + "HardwareAccess": { + "asset type": "Hardware", + "fieldname": "hardwareSystems" + }, + "type": "field" + } + ], + "Identity:attemptAssume": [ + { + "UserAssignedIdentities": { + "asset type": "Identity", + "fieldname": "userIds" + }, + "type": "field" + } + ] + }, + "own_parents": { + "User:successfulDeliverMaliciousRemovableMedia": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": { + "arguments": [ + 0.01 + ], + "name": "Exponential", + "type": "function" + }, + "type": "or" + }, + "noPasswordReuse": { + "asset": "User", + "detectors": {}, + "info": { + "user": "If one credential of that user is compromised there is a probability that all other credentials of that user are also compromised." + }, + "inherits": null, + "name": "noPasswordReuse", + "overrides": true, + "own_children": { + "User:passwordReuseCompromise": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "Enabled", + "type": "function" + }, + "type": "defense" + }, + "noRemovableMediaUsage": { + "asset": "User", + "detectors": {}, + "info": { + "user": "The user does not utilise removable media and therefore the attack steps associated with it cannot be reached." + }, + "inherits": null, + "name": "noRemovableMediaUsage", + "overrides": true, + "own_children": { + "User:successfulDeliverMaliciousRemovableMedia": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "Enabled", + "type": "function" + }, + "type": "defense" + }, + "oneCredentialCompromised": { + "asset": "User", + "detectors": {}, + "info": { + "developer": "This intermediate attack step is needed in order to block passwordReuseCompromise when no other credential is first compromised." + }, + "inherits": null, + "name": "oneCredentialCompromised", + "overrides": true, + "own_children": { + "User:passwordReuseCompromise": [ + null + ] + }, + "own_parents": { + "Credentials:propagateOneCredentialCompromised": [ + { + "collect": { + "left": { + "UserAssignedIdentities": { + "asset type": "Identity", + "fieldname": "userIds" + }, + "type": "field" + }, + "right": { + "collect": { + "left": { + "IdentityCredentials": { + "asset type": "Credentials", + "fieldname": "credentials" + }, + "type": "field" + }, + "right": { + "transitive": { + "ConditionalAuthentication": { + "asset type": "Credentials", + "fieldname": "requiredFactors" + }, + "type": "field" + }, + "type": "transitive" + } + }, + "type": "collect" + } + }, + "type": "collect" + } + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "passwordReuseCompromise": { + "asset": "User", + "detectors": {}, + "info": { + "developer": "If one reused credential of that user is compromised then, all other credentials of that user can also be compromised." + }, + "inherits": null, + "name": "passwordReuseCompromise", + "overrides": true, + "own_children": { + "Credentials:attemptCredentialsReuse": [ + { + "collect": { + "left": { + "UserAssignedIdentities": { + "asset type": "Identity", + "fieldname": "userIds" + }, + "type": "field" + }, + "right": { + "IdentityCredentials": { + "asset type": "Credentials", + "fieldname": "credentials" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "own_parents": { + "User:noPasswordReuse": [ + null + ], + "User:oneCredentialCompromised": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "securityAwareness": { + "asset": "User", + "detectors": {}, + "info": { + "user": "The security awareness of the user makes it less likely that social engineering would be successful and reduces the likelihood that the user will engage in unsafe behaviour." + }, + "inherits": null, + "name": "securityAwareness", + "overrides": true, + "own_children": { + "User:securityAwarenessBypassed": [ + null + ], + "User:weakCredentials": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": null, + "type": "defense" + }, + "securityAwarenessBypassed": { + "asset": "User", + "detectors": {}, + "info": { + "developer": "Security awareness is bypassed either because it was not set, the attacker was able to circumvent it through additional effort, or we simply assume that everyone has an off day every now and then." + }, + "inherits": null, + "name": "securityAwarenessBypassed", + "overrides": true, + "own_children": { + "User:successfulDeliverMaliciousRemovableMedia": [ + null + ], + "User:successfulSocialEngineering": [ + null + ] + }, + "own_parents": { + "User:bypassSecurityAwareness": [ + null + ], + "User:securityAwareness": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "socialEngineering": { + "asset": "User", + "detectors": {}, + "info": { + "developer": "Currently social engineering entails phishing credentials and tricking the user into unsafe actions that expose Applications to the attacker. In the future, other social engineering techniques should be added to this attack step." + }, + "inherits": null, + "name": "socialEngineering", + "overrides": true, + "own_children": { + "User:credentialTheft": [ + null + ], + "User:unsafeUserActivity": [ + null + ] + }, + "own_parents": { + "User:successfulSocialEngineering": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "successfulDeliverMaliciousRemovableMedia": { + "asset": "User", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulDeliverMaliciousRemovableMedia", + "overrides": true, + "own_children": { + "User:deliverMaliciousRemovableMedia": [ + null + ] + }, + "own_parents": { + "User:attemptDeliverMaliciousRemovableMedia": [ + null + ], + "User:noRemovableMediaUsage": [ + null + ], + "User:securityAwarenessBypassed": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "successfulSocialEngineering": { + "asset": "User", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to model defenses." + }, + "inherits": null, + "name": "successfulSocialEngineering", + "overrides": true, + "own_children": { + "User:socialEngineering": [ + null + ] + }, + "own_parents": { + "User:attemptSocialEngineering": [ + null + ], + "User:securityAwarenessBypassed": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "and" + }, + "unsafeUserActivity": { + "asset": "User", + "detectors": {}, + "info": { + "developer": "The user can engage in unsafe behaviour that could allow the attacker to gain access to the applications the user has access to.", + "modeler": "If the attacker is not able to reach (or be reached) via any of the Applications this garnered access does not materialise into any actual attack vectors." + }, + "inherits": null, + "name": "unsafeUserActivity", + "overrides": true, + "own_children": { + "Identity:attemptAssume": [ + { + "UserAssignedIdentities": { + "asset type": "Identity", + "fieldname": "userIds" + }, + "type": "field" + } + ], + "Identity:attemptUnsafeUserActivity": [ + { + "UserAssignedIdentities": { + "asset type": "Identity", + "fieldname": "userIds" + }, + "type": "field" + } + ] + }, + "own_parents": { + "User:socialEngineering": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": { + "arguments": [ + 0.03 + ], + "name": "Exponential", + "type": "function" + }, + "type": "or" + }, + "weakCredentials": { + "asset": "User", + "detectors": {}, + "info": { + "developer": "Intermediate attack step that defines the strength of all of the Credentials associated with the user. This is inversely proportional with the user's securityAwareness defense." + }, + "inherits": null, + "name": "weakCredentials", + "overrides": true, + "own_children": { + "Credentials:weakCredentials": [ + { + "collect": { + "left": { + "UserAssignedIdentities": { + "asset type": "Identity", + "fieldname": "userIds" + }, + "type": "field" + }, + "right": { + "IdentityCredentials": { + "asset type": "Credentials", + "fieldname": "credentials" + }, + "type": "field" + } + }, + "type": "collect" + } + ] + }, + "own_parents": { + "User:securityAwareness": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + } + }, + "info": { + "user": "This asset represents the user themselves. It is the suitable attack surface for social engineering attacks." + }, + "is_abstract": false, + "name": "User", + "sub_assets": [], + "super_asset": "", + "variables": {} + }, + "Vulnerability": { + "associations": {}, + "attack_steps": { + "abuse": { + "asset": "Vulnerability", + "detectors": {}, + "info": { + "user": "The attacker is trying to abuse a vulnerability in an asset." + }, + "inherits": null, + "name": "abuse", + "overrides": true, + "own_children": { + "Vulnerability:attemptExploit": [ + null + ] + }, + "own_parents": { + "Vulnerability:attemptAbuse": [ + null + ], + "Vulnerability:notPresent": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "and" + }, + "attemptAbuse": { + "asset": "Vulnerability", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptAbuse", + "overrides": true, + "own_children": { + "Vulnerability:abuse": [ + null + ] + }, + "own_parents": {}, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "attemptExploit": { + "asset": "Vulnerability", + "detectors": {}, + "info": { + "developer": "Intermediate attack step to allow for defenses." + }, + "inherits": null, + "name": "attemptExploit", + "overrides": true, + "own_children": { + "Vulnerability:exploit": [ + null + ] + }, + "own_parents": { + "Vulnerability:abuse": [ + null + ] + }, + "tags": [ + "hidden" + ], + "ttc": null, + "type": "or" + }, + "exploit": { + "asset": "Vulnerability", + "detectors": {}, + "info": { + "user": "Attacker is employing an exploit that makes use of this vulnerability." + }, + "inherits": null, + "name": "exploit", + "overrides": true, + "own_children": { + "Vulnerability:impact": [ + null + ] + }, + "own_parents": { + "Vulnerability:attemptExploit": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "impact": { + "asset": "Vulnerability", + "detectors": {}, + "info": { + "user": "Each vulnerability has an impact (of different levels) on the vulnerable component." + }, + "inherits": null, + "name": "impact", + "overrides": false, + "own_children": {}, + "own_parents": { + "Vulnerability:exploit": [ + null + ] + }, + "tags": [], + "ttc": null, + "type": "or" + }, + "notPresent": { + "asset": "Vulnerability", + "detectors": {}, + "info": { + "developer": "Enabling this defense should make the asset behave as if it did not exist, as such all of its impactful attack steps should be disabled by it.", + "mitre": "Used for M1049:Antivirus/Antimalware, M1050: Exploit Protection, M1040: Behavior Prevention on Endpoint, M1038: Execution Prevention, M1051: Update Software, M1016: Vulnerability Scanning, M1021: Restrict Web-Based Content, M1042: Disable or Remove Feature or Program, M1013: Application Developer Guidance", + "user": "This defense is used to reason about the attack vectors introduced by this asset. This is particularly useful for vulnerabilities where it is common to explore the possibility of patching them out." + }, + "inherits": null, + "name": "notPresent", + "overrides": true, + "own_children": { + "Vulnerability:abuse": [ + null + ] + }, + "own_parents": {}, + "tags": [], + "ttc": { + "arguments": [], + "name": "Disabled", + "type": "function" + }, + "type": "defense" + } + }, + "info": { + "user": "Vulnerability refers to the inability (of a system, application, etc.) to withstand the effects of a hostile environment." + }, + "is_abstract": true, + "name": "Vulnerability", + "sub_assets": [ + "SoftwareVulnerability", + "HardwareVulnerability" + ], + "super_asset": "", + "variables": {} + }, + "metadata": { + "id": "org.mal-lang.coreLang", + "version": "1.0.0" + } +} \ No newline at end of file diff --git a/core/maltoolbox-language/tests/graph_golden.rs b/core/maltoolbox-language/tests/graph_golden.rs new file mode 100644 index 00000000..1b5702c4 --- /dev/null +++ b/core/maltoolbox-language/tests/graph_golden.rs @@ -0,0 +1,69 @@ +//! Diffs the Rust LanguageGraph builder's output against golden JSON +//! produced by the real Python mal-toolbox (`language_graph_to_dict`), +//! for every .mal fixture that compiles standalone. + +use maltoolbox_language::{compile_file, generate_graph, language_graph_to_dict}; +use std::fs; +use std::path::Path; + +fn fixtures_dir() -> &'static Path { + Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")) +} + +fn golden_dir() -> &'static Path { + Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/golden_graph")) +} + +#[test] +fn matches_python_oracle_for_all_fixtures() { + let mut stems: Vec = fs::read_dir(golden_dir()) + .expect("golden_graph dir should exist") + .filter_map(|entry| entry.ok()) + .filter_map(|entry| { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("json") { + path.file_stem().map(|s| s.to_string_lossy().into_owned()) + } else { + None + } + }) + .collect(); + stems.sort(); + assert!(!stems.is_empty(), "expected at least one golden fixture"); + + let mut failures = Vec::new(); + + for stem in &stems { + let mal_path = fixtures_dir().join(format!("{stem}.mal")); + let golden_path = golden_dir().join(format!("{stem}.json")); + + let expected: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&golden_path).unwrap()).unwrap(); + + let result = compile_file(&mal_path) + .map_err(|e| e.to_string()) + .and_then(|spec| generate_graph(spec).map_err(|e| e.to_string())) + .and_then(|graph| language_graph_to_dict(&graph).map_err(|e| e.to_string())); + + match result { + Ok(actual) => { + if actual != expected { + failures.push(format!( + "{stem}: mismatch\n--- expected ---\n{}\n--- actual ---\n{}", + serde_json::to_string_pretty(&expected).unwrap(), + serde_json::to_string_pretty(&actual).unwrap(), + )); + } + } + Err(e) => failures.push(format!("{stem}: error: {e}")), + } + } + + assert!( + failures.is_empty(), + "{} of {} fixtures failed:\n\n{}", + failures.len(), + stems.len(), + failures.join("\n\n") + ); +} diff --git a/core/maltoolbox-language/tests/parse_smoke.rs b/core/maltoolbox-language/tests/parse_smoke.rs new file mode 100644 index 00000000..ce7bd6e2 --- /dev/null +++ b/core/maltoolbox-language/tests/parse_smoke.rs @@ -0,0 +1,13 @@ +use maltoolbox_language::new_parser; + +#[test] +fn parses_wiper_lang_without_errors() { + let source = include_str!("fixtures/wiperLang.mal"); + let mut parser = new_parser(); + let tree = parser.parse(source, None).expect("parser did not run"); + assert!( + !tree.root_node().has_error(), + "parse tree contains errors:\n{}", + tree.root_node().to_sexp() + ); +} diff --git a/core/maltoolbox-language/tests/test_compiler.rs b/core/maltoolbox-language/tests/test_compiler.rs new file mode 100644 index 00000000..45a3d181 --- /dev/null +++ b/core/maltoolbox-language/tests/test_compiler.rs @@ -0,0 +1,322 @@ +//! Port of `tests/language/test_compiler.py`. +//! +//! `test_compiler_non_existing_step` is ported in `test_semantic.rs` +//! (`reaches_to_nonexisting_step_rejected`) alongside the rest of the +//! semantic-analyzer test suite, now that `mal_analyzer.py` is ported +//! (see `src/compiler/semantic.rs`). +//! +//! `test_compile_wiperlang`'s model-effect assertions are ported as +//! `compile_wiperlang_model_effects`, and `test_compile_multiplicity_lang` +//! as `compile_multiplicity_lang`, now that model effects +//! (`append_reaches`/`remove_reaches`, `src/graph/model_effect.rs`) are +//! ported. Everything else here is a pure parser/langgraph-level check. + +use std::path::Path; + +use maltoolbox_language::{compile_file, from_mal_spec}; + +fn fixtures_dir() -> &'static Path { + Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")) +} + +fn compile_lang(dir: &Path, src: &str) -> Result { + let path = dir.join("test.mal"); + std::fs::write(&path, src).unwrap(); + compile_file(&path).map_err(|e| e.to_string()) +} + +fn tmp_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "maltoolbox-compiler-test-{tag}-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn illegal_asset_names_are_rejected() { + for asset_id in ["asset-name", "asset.name", "asset name", "asset$"] { + let dir = tmp_dir(&format!( + "illegal-{}", + asset_id.replace([' ', '.', '$'], "_") + )); + let lang = format!( + "#id: \"test-lang\"\n#version: \"0.0.0\"\n\ncategory TestCategory {{\n asset {asset_id} {{\n | step1\n }}\n}}\n" + ); + let result = compile_lang(&dir, &lang); + assert!( + result.is_err(), + "expected '{asset_id}' to be rejected, got {result:?}" + ); + std::fs::remove_dir_all(&dir).ok(); + } +} + +#[test] +fn valid_asset_name_control() { + let dir = tmp_dir("valid"); + let lang = r#" + #id: "test-lang" + #version: "0.0.0" + + category TestCategory { + asset Valid_Asset { + | step1 + } + } + "#; + compile_lang(&dir, lang).expect("valid asset name should compile"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn compile_actions_effects() { + let lang_graph = + from_mal_spec(fixtures_dir().join("actions_effects_lang.mal")).expect("compile"); + + let asset_a = lang_graph.asset_id("AssetA").expect("AssetA"); + let attack_step_id = lang_graph.asset(asset_a).attack_steps["attack"]; + assert_eq!( + lang_graph.step(attack_step_id).causal_mode, + Some(maltoolbox_language::graph::attack_step::CausalMode::Action) + ); + + let asset_b = lang_graph.asset_id("AssetB").expect("AssetB"); + let hack_id = lang_graph.asset(asset_b).attack_steps["hack"]; + assert_eq!( + lang_graph.step(hack_id).causal_mode, + Some(maltoolbox_language::graph::attack_step::CausalMode::Effect) + ); + let attack_id = lang_graph.asset(asset_b).attack_steps["attack"]; + assert_eq!( + lang_graph.step(attack_id).causal_mode, + Some(maltoolbox_language::graph::attack_step::CausalMode::Action) + ); + let test_id = lang_graph.asset(asset_b).attack_steps["test"]; + assert_eq!(lang_graph.step(test_id).causal_mode, None); +} + +/// Port of `test_compile_wiperlang`, limited to the parts that don't +/// depend on model effects (see module docs): the raw langspec shape for +/// `reaches`, and the resulting language graph's `own_children`/ +/// `own_parents` links (including through inheritance). +#[test] +fn compile_wiperlang_reaches_and_children() { + let spec = compile_file(fixtures_dir().join("wiperLang.mal")).expect("compile"); + + let assets = spec["assets"].as_array().unwrap(); + let device_asset = assets + .iter() + .find(|a| a["name"] == "Device") + .expect("Device asset"); + let infect_step = device_asset["attackSteps"] + .as_array() + .unwrap() + .iter() + .find(|s| s["name"] == "infect") + .expect("infect step"); + + assert_eq!( + infect_step["reaches"]["stepExpressions"][0]["lhs"]["name"], + "malware" + ); + assert_eq!( + infect_step["reaches"]["stepExpressions"][0]["rhs"]["name"], + "activate" + ); + + let lang_graph = maltoolbox_language::generate_graph(spec).expect("build graph"); + + let device = lang_graph.asset_id("Device").expect("Device"); + let wiper = lang_graph.asset_id("Wiper").expect("Wiper"); + + let wiper_activate = lang_graph.asset(wiper).attack_steps["activate"]; + let wiper_exfiltrate = lang_graph.asset(wiper).attack_steps["exfiltrate"]; + let wiper_propagate = lang_graph.asset(wiper).attack_steps["propagate"]; + assert!(lang_graph + .step(wiper_activate) + .own_children + .contains_key(&wiper_exfiltrate)); + assert!(lang_graph + .step(wiper_activate) + .own_children + .contains_key(&wiper_propagate)); + + // Wiper's `activate` overrides Malware's and inherits from it; Malware's + // own `activate` still links to `trigger` and is itself a child of + // Device:infect (the "malware" field resolves to the declared asset + // type Malware, not the Wiper subtype, since "infect"'s reaches has no + // subtype filter - unlike "propagate", which explicitly filters to + // `malware[Wiper]` and so links to Wiper:activate specifically). + let malware = lang_graph + .asset(wiper) + .own_super_asset + .expect("Wiper extends Malware"); + let malware_activate = lang_graph.asset(malware).attack_steps["activate"]; + let malware_trigger = lang_graph.asset(malware).attack_steps["trigger"]; + assert_eq!( + lang_graph.step(wiper_activate).inherits, + Some(malware_activate) + ); + assert!(lang_graph + .step(malware_activate) + .own_children + .contains_key(&malware_trigger)); + + let device_infect = lang_graph.asset(device).attack_steps["infect"]; + assert!(lang_graph + .step(device_infect) + .own_children + .contains_key(&malware_activate)); + assert!(lang_graph + .step(malware_activate) + .own_parents + .contains_key(&device_infect)); + assert!(lang_graph + .step(wiper_activate) + .own_parents + .contains_key(&wiper_propagate)); +} + +/// Port of `test_compile_wiperlang`'s model-effect assertions. +#[test] +fn compile_wiperlang_model_effects() { + use maltoolbox_language::graph::model_effect::AssocTraversalElem; + + let lang_graph = + maltoolbox_language::from_mal_spec(fixtures_dir().join("wiperLang.mal")).expect("compile"); + + let device = lang_graph.asset_id("Device").expect("Device"); + let wiper = lang_graph.asset_id("Wiper").expect("Wiper"); + + let device_infect = lang_graph.asset(device).attack_steps["infect"]; + let device_infect_effects = lang_graph + .step(device_infect) + .own_additive_model_effects + .clone(); + match &device_infect_effects[0].base[0] { + AssocTraversalElem::Traversal(t) => assert_eq!(t.field_name, "self"), + other => panic!("expected a plain traversal, got {other:?}"), + } + let target = &device_infect_effects[0].targets[0]; + assert!(!target.assoc_op); + match &target.assoc_traversal[0] { + AssocTraversalElem::Traversal(t) => { + assert_eq!(t.field_name, "malware"); + assert_eq!(t.asset_filter, Some(wiper)); + } + other => panic!("expected a plain traversal, got {other:?}"), + } + + let wiper_exfiltrate = lang_graph.asset(wiper).attack_steps["exfiltrate"]; + let wiper_exfiltrate_effects = lang_graph + .step(wiper_exfiltrate) + .own_additive_model_effects + .clone(); + let effect = &wiper_exfiltrate_effects[0]; + match &effect.base[0] { + AssocTraversalElem::Traversal(t) => assert_eq!(t.field_name, "victim"), + other => panic!("expected a plain traversal, got {other:?}"), + } + match &effect.base[1] { + AssocTraversalElem::Traversal(t) => assert_eq!(t.field_name, "data"), + other => panic!("expected a plain traversal, got {other:?}"), + } + + let target = &effect.targets[0]; + assert!(target.assoc_op); + let c2server = lang_graph.asset_id("C2Server").expect("C2Server"); + let expect_field = + |elem: &AssocTraversalElem, + expected_name: &str, + expected_filter: Option| { + match elem { + AssocTraversalElem::Traversal(t) => { + assert_eq!(t.field_name, expected_name); + assert_eq!(t.asset_filter, expected_filter); + } + other => panic!("expected a plain traversal, got {other:?}"), + } + }; + expect_field(&target.assoc_traversal[0], "victim", None); + expect_field(&target.assoc_traversal[1], "inet", None); + expect_field(&target.assoc_traversal[2], "hosts", Some(c2server)); + expect_field(&target.assoc_traversal[3], "data", None); +} + +/// Port of `test_compile_multiplicity_lang`. +#[test] +fn compile_multiplicity_lang() { + use maltoolbox_language::graph::model_effect::{AssocTraversalElem, QuantityFilter}; + + let lang_graph = + maltoolbox_language::from_mal_spec(fixtures_dir().join("multiplicity_lang.mal")) + .expect("compile"); + + let host = lang_graph.asset_id("Host").expect("Host"); + let create_random_files = lang_graph.asset(host).attack_steps["createRandomFiles"]; + let effects = lang_graph + .step(create_random_files) + .additive_model_effects(&lang_graph); + match effects[0].targets[0] + .assoc_traversal + .last() + .expect("non-empty traversal") + { + AssocTraversalElem::Traversal(t) => { + assert_eq!(t.quantity_filter, Some(QuantityFilter::Range(4, 10))) + } + other => panic!("expected a plain traversal, got {other:?}"), + } + + // `createFile`'s `A> onHost / files, self / ~(openFiles - onHost.procs.openFiles):1` + // is two comma-separated dyn_sentences (hence two separate model + // effects, not two targets on one effect). The second's target is + // `~(...):1`: `~` is the assoc_op marker (not a transitive `*`), so + // the multiplicity lands on the outermost difference (`AssocSet`), + // the last element of the traversal chain either way. + let proc = lang_graph.asset_id("Proc").expect("Proc"); + let create_file = lang_graph.asset(proc).attack_steps["createFile"]; + let effects = lang_graph + .step(create_file) + .additive_model_effects(&lang_graph); + assert!(effects[1].targets[0].assoc_op); + match effects[1].targets[0] + .assoc_traversal + .last() + .expect("non-empty traversal") + { + AssocTraversalElem::Set(s) => assert_eq!(s.quantity_filter, Some(QuantityFilter::Exact(1))), + other => panic!("expected a set traversal, got {other:?}"), + } +} + +#[test] +fn compile_basic_dynamal_languages() { + compile_all_in_dir("dynamal_test_langs/basic"); +} + +#[test] +fn compile_intermediate_dynamal_languages() { + compile_all_in_dir("dynamal_test_langs/intermediate"); +} + +#[test] +fn compile_advanced_dynamal_languages() { + compile_all_in_dir("dynamal_test_langs/advanced"); +} + +fn compile_all_in_dir(rel: &str) { + let dir = fixtures_dir().join(rel); + let mut count = 0; + for entry in std::fs::read_dir(&dir).unwrap_or_else(|e| panic!("reading {dir:?}: {e}")) { + let path = entry.unwrap().path(); + if path.extension().and_then(|e| e.to_str()) != Some("mal") { + continue; + } + from_mal_spec(&path).unwrap_or_else(|e| panic!("failed to compile {path:?}: {e}")); + count += 1; + } + assert!(count > 0, "expected at least one .mal file in {dir:?}"); +} diff --git a/core/maltoolbox-language/tests/test_detectors.rs b/core/maltoolbox-language/tests/test_detectors.rs new file mode 100644 index 00000000..b4630320 --- /dev/null +++ b/core/maltoolbox-language/tests/test_detectors.rs @@ -0,0 +1,95 @@ +//! Port of `tests/language/test_detectors.py` (language-only subset). +//! +//! Whole-stack detector tests that build a `Model`/`AttackGraph` on top of +//! the compiled language moved to `maltoolbox-attackgraph`'s test suite, so +//! this crate doesn't need dev-dependencies on its own downstream crates. + +use maltoolbox_language::from_mal_spec; + +fn tmp_lang_file(tag: &str, src: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "maltoolbox-detector-test-{tag}-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("lang.mal"); + std::fs::write(&path, src).unwrap(); + path +} + +fn single_rate_lang(rate_clause: &str) -> String { + format!( + r#" + #id: "test-actions-effects" + #version: "0.0.0" + + category System{{ + asset Computer {{ + & physicalAccess + -> attemptAuthenticate + + | attemptAuthenticate [HardAndCertain] + -> authenticate + + & effect authenticate + -> computerApps.attemptExploit + }} + + asset Application {{ + # shutDown + -> exploit + + | attemptExploit [HardAndCertain] + -> exploit + + & effect exploit + ! logExploit (computerOfApp.authenticate) [{rate_clause}] + -> toApplications.attemptExploit, + dataOnApp.read + }} + + asset Data {{ + | read + }} + }} + + associations {{ + Computer [computerOfApp] * <-- appExecution --> * [computerApps] Application + Application [fromApplications] * <-- AppToAppCommunication --> * [toApplications] Application + Data [dataOnApp] * <-- AppData --> * [appWithData] Application + }} + "# + ) +} + +#[test] +fn only_tpr() { + let path = tmp_lang_file("only-tpr", &single_rate_lang("tpr: 0.1")); + let lang_graph = from_mal_spec(&path).expect("compile"); + let app = lang_graph.asset_id("Application").unwrap(); + let step = lang_graph.asset(app).attack_steps["exploit"]; + let det = &lang_graph.step(step).detectors["logExploit"]; + assert_eq!(det.tprate, Some(0.1)); + assert_eq!(det.fprate, None); +} + +#[test] +fn only_fpr() { + let path = tmp_lang_file("only-fpr", &single_rate_lang("fpr: 0.1")); + let lang_graph = from_mal_spec(&path).expect("compile"); + let app = lang_graph.asset_id("Application").unwrap(); + let step = lang_graph.asset(app).attack_steps["exploit"]; + let det = &lang_graph.step(step).detectors["logExploit"]; + assert_eq!(det.tprate, None); + assert_eq!(det.fprate, Some(0.1)); +} + +#[test] +fn wrong_labels_rejected() { + let path = tmp_lang_file("wrong-labels", &single_rate_lang("fnl: 0.9, nft: 0.1")); + let result = from_mal_spec(&path); + assert!( + result.is_err(), + "expected invalid tp/fp rate labels to be rejected" + ); +} diff --git a/core/maltoolbox-language/tests/test_languagegraph.rs b/core/maltoolbox-language/tests/test_languagegraph.rs new file mode 100644 index 00000000..b5e7e2b4 --- /dev/null +++ b/core/maltoolbox-language/tests/test_languagegraph.rs @@ -0,0 +1,356 @@ +//! Port of `tests/language/test_languagegraph.py`, minus: +//! - the pickle tests (no meaningful Rust equivalent to Python pickling) +//! - `test_load_from_git*` (network-dependent, `@pytest.mark.integration` +//! in the Python original too, and `language_graph_from_git_url` is +//! out of scope for this rewrite - see `graph::file` module docs) +//! - `test_languagegraph_save_load` (redundant with +//! `file_io_golden.rs`'s coreLang JSON round-trip, which covers the +//! same property) +//! - `test_mallib_mal` (disabled/commented out in the Python original +//! too) + +use std::path::Path; + +use maltoolbox_language::{ + compile_file, from_mal_spec, from_mar_archive, language_graph_to_dict, + save_language_graph_to_file, save_language_graph_to_mar_archive, +}; + +fn fixtures_dir() -> &'static Path { + Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")) +} + +fn tmp_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "maltoolbox-langgraph-test-{tag}-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn languagegraph_to_dict_detectors() { + let graph = from_mal_spec(fixtures_dir().join("detector_lang.mal")).expect("compile"); + let dir = tmp_dir("detectors"); + save_language_graph_to_file(&graph, dir.join("detector_lang.json")).expect("save"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn languagegraph_save_load_mar() { + let graph = + from_mar_archive(fixtures_dir().join("org.mal-lang.coreLang-1.0.0.mar")).expect("load"); + let dir = tmp_dir("save-load-mar"); + let path = dir.join("langgraph.mar"); + + save_language_graph_to_mar_archive(&graph, &path).expect("save .mar"); + let reloaded = from_mar_archive(&path).expect("reload .mar"); + + assert_eq!( + language_graph_to_dict(&graph).unwrap(), + language_graph_to_dict(&reloaded).unwrap() + ); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn corelang_with_union_different_assets_same_super_asset() { + // An attack step in IAMObject contains a union between Identity and + // Group, which should be allowed since they share the same super asset. + from_mar_archive(fixtures_dir().join("corelang-union-common-ancestor.mar")).expect("load"); +} + +#[test] +fn interleaved_vars() { + // Two interleaved variables (A2 contains B1, B2 contains A1) must + // resolve correctly. + let spec = compile_file(fixtures_dir().join("interleaved_vars.mal")).expect("compile"); + let graph = maltoolbox_language::generate_graph(spec).expect("build graph"); + + let asset_a = graph.asset_id("AssetA").expect("AssetA"); + let asset_b = graph.asset_id("AssetB").expect("AssetB"); + + let vars_a = &graph.asset(asset_a).own_variables; + let vars_b = &graph.asset(asset_b).own_variables; + assert!(vars_a.contains_key("A1")); + assert!(vars_a.contains_key("A2")); + assert!(vars_b.contains_key("B1")); + assert!(vars_b.contains_key("B2")); + + let (var_a2_target, var_a2_chain) = &vars_a["A2"]; + assert_eq!(*var_a2_target, asset_a); + let right_fieldname = |chain: &maltoolbox_language::graph::ExpressionsChain| match chain { + maltoolbox_language::graph::ExpressionsChain::Binary { right: Some(r), .. } => { + match r.as_ref() { + maltoolbox_language::graph::ExpressionsChain::Field { fieldname, .. } => { + fieldname.clone() + } + _ => panic!("expected right_link to be a Field chain"), + } + } + _ => panic!("expected a binary chain"), + }; + assert_eq!(right_fieldname(var_a2_chain.as_ref().unwrap()), "fieldA"); + + let (var_b2_target, var_b2_chain) = &vars_b["B2"]; + assert_eq!(*var_b2_target, asset_b); + assert_eq!(right_fieldname(var_b2_chain.as_ref().unwrap()), "fieldB"); +} + +#[test] +fn inherited_vars() { + let spec = compile_file(fixtures_dir().join("inherited_vars.mal")).expect("compile"); + maltoolbox_language::generate_graph(spec).expect("build graph"); +} + +#[test] +fn associations() { + let spec = compile_file(fixtures_dir().join("association_lang.mal")).expect("compile"); + let graph = maltoolbox_language::generate_graph(spec).expect("build graph"); + + let mut names: std::collections::HashSet = std::collections::HashSet::new(); + for &asset_id in &graph.asset_order { + for assoc in graph.asset(asset_id).own_associations.values() { + names.insert(assoc.name.clone()); + } + } + + let expected: std::collections::HashSet = [ + "AssocAC", "AssocAB", "AssocBC", "AssocBB", "AssocCC", "AssocCB", "AssocDC", "AssocDB", + ] + .into_iter() + .map(String::from) + .collect(); + assert_eq!(names, expected); +} + +#[test] +fn attackstep_inherit() { + let spec = compile_file(fixtures_dir().join("attackstep_inherit.mal")).expect("compile"); + let graph = maltoolbox_language::generate_graph(spec).expect("build graph"); + + let bb = graph.asset_id("BB").expect("BB"); + let cc = graph.asset_id("CC").expect("CC"); + let bb_s1 = graph.asset(bb).attack_steps["s1"]; + let cc_s2 = graph.asset(cc).attack_steps["s2"]; + + let children = graph.step(bb_s1).children(&graph); + assert!(children.contains_key(&cc_s2)); + assert_eq!(graph.step(bb_s1).own_children.len(), 1); + assert_eq!(children.len(), 1); + + let chains_to_cc_s2 = &children[&cc_s2]; + assert_eq!(chains_to_cc_s2.len(), 2); + let fieldname_of = |chain: &Option| match chain { + Some(maltoolbox_language::graph::ExpressionsChain::Field { fieldname, .. }) => { + fieldname.clone() + } + other => panic!("expected a Field chain, got {other:?}"), + }; + assert_eq!(fieldname_of(&chains_to_cc_s2[0]), "c_of_B"); + assert_eq!(fieldname_of(&chains_to_cc_s2[1]), "c_of_A"); +} + +#[test] +fn attackstep_override() { + let spec = compile_file(fixtures_dir().join("attackstep_override.mal")).expect("compile"); + let graph = maltoolbox_language::generate_graph(spec).expect("build graph"); + + for name in [ + "EmptyParent", + "Child1", + "Child2", + "Child3", + "Child4", + "FinalChild", + ] { + assert!(graph.asset_id(name).is_some(), "missing asset {name}"); + } + + let ep = graph.asset_id("EmptyParent").unwrap(); + let c1 = graph.asset_id("Child1").unwrap(); + let c2 = graph.asset_id("Child2").unwrap(); + let c3 = graph.asset_id("Child3").unwrap(); + let c4 = graph.asset_id("Child4").unwrap(); + let fc = graph.asset_id("FinalChild").unwrap(); + + let ep_target1 = graph.asset(ep).attack_steps["target1"]; + for step in ["target1", "target2", "target3", "target4"] { + assert!(graph.asset(ep).attack_steps.contains_key(step)); + } + + for step in [ + "attack_step_with_child", + "attackstep", + "target1", + "target2", + "target3", + "target4", + ] { + assert!(graph.asset(c1).attack_steps.contains_key(step)); + } + let c1_attackstep = graph.asset(c1).attack_steps["attackstep"]; + assert!(graph.step(c1_attackstep).own_children.is_empty()); + assert!(graph.step(c1_attackstep).children(&graph).is_empty()); + + // attack_step_with_child is defined in the parent with target1 as a child. + let c1_parent_attackstep = graph.asset(c1).attack_steps["attack_step_with_child"]; + assert!(graph.step(c1_parent_attackstep).own_children.is_empty()); + assert_eq!( + graph + .step(c1_parent_attackstep) + .children(&graph) + .keys() + .copied() + .collect::>(), + std::collections::HashSet::from([ep_target1]) + ); + + for step in [ + "attack_step_with_child", + "attackstep", + "target1", + "target2", + "target3", + "target4", + ] { + assert!(graph.asset(c2).attack_steps.contains_key(step)); + } + let c2_attackstep = graph.asset(c2).attack_steps["attackstep"]; + assert_eq!(graph.step(c2_attackstep).inherits, Some(c1_attackstep)); + assert!(graph.step(c2_attackstep).own_children.is_empty()); + + for step in [ + "attack_step_with_child", + "attackstep", + "target1", + "target2", + "target3", + "target4", + ] { + assert!(graph.asset(c3).attack_steps.contains_key(step)); + } + let c3_attackstep = graph.asset(c3).attack_steps["attackstep"]; + assert_eq!(graph.step(c3_attackstep).inherits, Some(c2_attackstep)); + let c3_target1 = graph.asset(c3).attack_steps["target1"]; + let c3_target2 = graph.asset(c3).attack_steps["target2"]; + let c3_target3 = graph.asset(c3).attack_steps["target3"]; + let c3_target4 = graph.asset(c3).attack_steps["target4"]; + assert!(graph + .step(c3_attackstep) + .own_children + .contains_key(&c3_target1)); + assert!(!graph + .step(c3_attackstep) + .own_children + .contains_key(&c3_target2)); + assert!(!graph + .step(c3_attackstep) + .own_children + .contains_key(&c3_target3)); + assert!(!graph + .step(c3_attackstep) + .own_children + .contains_key(&c3_target4)); + + for step in [ + "attack_step_with_child", + "attackstep", + "target1", + "target2", + "target3", + "target4", + ] { + assert!(graph.asset(c4).attack_steps.contains_key(step)); + } + let c4_attackstep = graph.asset(c4).attack_steps["attackstep"]; + assert_eq!(graph.step(c4_attackstep).inherits, Some(c3_attackstep)); + assert!(graph.step(c4_attackstep).own_children.is_empty()); + + let fc_attackstep = graph.asset(fc).attack_steps["attackstep"]; + assert_eq!(graph.step(fc_attackstep).inherits, Some(c4_attackstep)); + let fc_target1 = graph.asset(fc).attack_steps["target1"]; + let fc_target2 = graph.asset(fc).attack_steps["target2"]; + let fc_target3 = graph.asset(fc).attack_steps["target3"]; + let fc_target4 = graph.asset(fc).attack_steps["target4"]; + for target in [fc_target1, fc_target2, fc_target3, fc_target4] { + assert!(graph.step(fc_attackstep).own_children.contains_key(&target)); + } +} + +#[test] +fn probability_distributions() { + let spec = compile_file(fixtures_dir().join("prob_dists.mal")).expect("compile"); + let graph = maltoolbox_language::generate_graph(spec).expect("build graph"); + let dir = tmp_dir("prob-dists"); + save_language_graph_to_file(&graph, dir.join("prob_dists_lg.yml")).expect("save"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn attack_step_types_are_valid() { + use maltoolbox_language::graph::attack_step::AttackStepType; + + let graph = + from_mar_archive(fixtures_dir().join("org.mal-lang.coreLang-1.0.0.mar")).expect("load"); + for &asset_id in &graph.asset_order { + for &step_id in graph.asset(asset_id).attack_steps.values() { + let step_type = graph.step(step_id).step_type; + assert!(matches!( + step_type, + AttackStepType::Or + | AttackStepType::And + | AttackStepType::Defense + | AttackStepType::Exist + | AttackStepType::NotExist + )); + } + } +} + +#[test] +fn attack_graph_node_causal_mode_inheritance() { + use maltoolbox_language::graph::attack_step::CausalMode; + + let dir = tmp_dir("causal-mode"); + let lang_path = dir.join("lang.mal"); + std::fs::write( + &lang_path, + r#" + #id: "test-actions-effects" + #version: "0.0.0" + + category Test{ + asset AssetA { + | action attackstep1 + -> attackstep2 + + | action attackstep2 + } + + asset AssetB extends AssetA { + | effect attackstep2 + } + } + "#, + ) + .unwrap(); + + let spec = compile_file(&lang_path).expect("compile"); + let graph = maltoolbox_language::generate_graph(spec).expect("build graph"); + + let asset_a = graph.asset_id("AssetA").unwrap(); + let asset_b = graph.asset_id("AssetB").unwrap(); + let a_step1 = graph.asset(asset_a).attack_steps["attackstep1"]; + let a_step2 = graph.asset(asset_a).attack_steps["attackstep2"]; + let b_step1 = graph.asset(asset_b).attack_steps["attackstep1"]; + let b_step2 = graph.asset(asset_b).attack_steps["attackstep2"]; + + assert_eq!(graph.step(a_step1).causal_mode, Some(CausalMode::Action)); + assert_eq!(graph.step(a_step2).causal_mode, Some(CausalMode::Action)); + assert_eq!(graph.step(b_step1).causal_mode, Some(CausalMode::Action)); // inherited + assert_eq!(graph.step(b_step2).causal_mode, Some(CausalMode::Effect)); // overridden + + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/core/maltoolbox-language/tests/test_semantic.rs b/core/maltoolbox-language/tests/test_semantic.rs new file mode 100644 index 00000000..f80eb559 --- /dev/null +++ b/core/maltoolbox-language/tests/test_semantic.rs @@ -0,0 +1,377 @@ +//! Tests for the semantic analyzer (port of `mal_analyzer.py`, +//! `core/maltoolbox-language/src/compiler/semantic.rs`). +//! +//! Every negative case below was checked against the real Python +//! mal-toolbox oracle (`MalCompiler().compile(...)`) before being ported: +//! each snippet does raise a `malAnalyzerException` (or, for +//! `distributions`, a `DistributionsException`) there too, with an +//! equivalent error condition - exact diagnostic text isn't part of the +//! wire-compatibility contract this project maintains, so only pass/fail +//! is asserted here, not message content. +//! +//! One deliberate divergence, also oracle-confirmed: a two-file mutual +//! `include` cycle does *not* raise in Python, because `mal_compiler.py`'s +//! own `visited_files` dedup short-circuits the repeat include before the +//! analyzer's cycle-tracking logic ever runs - see the comment on +//! `compiler::mod::compile_inner`. Ported as observed, so no test asserts +//! an error for that case. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use maltoolbox_language::compile_file; + +static COUNTER: AtomicU64 = AtomicU64::new(0); + +fn tmp_dir(tag: &str) -> PathBuf { + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "maltoolbox-semantic-test-{tag}-{}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn compile_src(tag: &str, src: &str) -> Result { + let dir = tmp_dir(tag); + let path = dir.join("test.mal"); + std::fs::write(&path, src).unwrap(); + let result = compile_file(&path).map_err(|e| e.to_string()); + std::fs::remove_dir_all(&dir).ok(); + result +} + +fn assert_rejected(tag: &str, src: &str) { + let result = compile_src(tag, src); + assert!( + result.is_err(), + "expected '{tag}' to be rejected, got {result:?}" + ); +} + +const HEADER: &str = "#id: \"test-lang\"\n#version: \"0.0.0\"\n\n"; + +#[test] +fn missing_id_define_rejected() { + assert_rejected( + "missing_id", + "#version: \"0.0.0\"\n\ncategory C { asset A { | s1 } }", + ); +} + +#[test] +fn missing_version_define_rejected() { + assert_rejected( + "missing_version", + "#id: \"x\"\n\ncategory C { asset A { | s1 } }", + ); +} + +#[test] +fn malformed_version_rejected() { + assert_rejected( + "bad_version", + "#id: \"x\"\n#version: \"abc\"\n\ncategory C { asset A { | s1 } }", + ); +} + +#[test] +fn extends_undefined_asset_rejected() { + assert_rejected( + "extends_undefined", + &format!("{HEADER}category C {{ asset A extends B {{ | s1 }} }}"), + ); +} + +#[test] +fn circular_extends_rejected() { + assert_rejected( + "circular_extends", + &format!( + "{HEADER}category C {{ asset A extends B {{ | s1 }} asset B extends A {{ | s2 }} }}" + ), + ); +} + +#[test] +fn duplicate_asset_name_rejected() { + assert_rejected( + "dup_asset", + &format!("{HEADER}category C {{ asset A {{ | s1 }} asset A {{ | s2 }} }}"), + ); +} + +#[test] +fn duplicate_meta_key_rejected() { + assert_rejected( + "dup_meta", + &format!("{HEADER}category C {{ asset A meta info: \"x\" meta info: \"y\" {{ | s1 }} }}"), + ); +} + +#[test] +fn duplicate_custom_define_rejected() { + assert_rejected( + "dup_define", + "#id: \"x\"\n#version: \"0.0.0\"\n#author: \"a\"\n#author: \"b\"\n\ncategory C { asset A { | s1 } }", + ); +} + +#[test] +fn duplicate_attack_step_name_rejected() { + assert_rejected( + "dup_step", + &format!("{HEADER}category C {{ asset A {{ | s1 | s1 }} }}"), + ); +} + +#[test] +fn duplicate_variable_name_rejected() { + assert_rejected( + "dup_var", + &format!("{HEADER}category C {{ asset A {{ let x = (A) | s1 let x = (A) }} }}"), + ); +} + +#[test] +fn variable_redefinition_across_hierarchy_rejected() { + assert_rejected( + "var_redef_hierarchy", + &format!( + "{HEADER}category C {{ \ + asset Z {{ | zs }} \ + asset A {{ let x = (zfield) | s1 }} \ + asset B extends A {{ let x = (zfield) | s2 }} \ + }}\nassociations {{ A [afield] 1 <-- R1 --> 1 [zfield] Z }}" + ), + ); +} + +#[test] +fn association_to_undefined_asset_rejected() { + assert_rejected( + "assoc_undefined_asset", + &format!( + "{HEADER}category C {{ asset A {{ | s1 }} }}\nassociations {{ A [left] 1 <-- R --> 1 [right] B }}" + ), + ); +} + +#[test] +fn field_colliding_with_attack_step_rejected() { + assert_rejected( + "field_collides_step", + &format!( + "{HEADER}category C {{ asset A {{ | right }} asset B {{ | s1 }} }}\nassociations {{ A [left] 1 <-- R --> 1 [right] B }}" + ), + ); +} + +#[test] +fn append_reaches_without_base_rejected() { + assert_rejected( + "append_without_base", + &format!("{HEADER}category C {{ asset A {{ | s1 +> s2 }} }}"), + ); +} + +#[test] +fn attack_step_override_type_mismatch_rejected() { + assert_rejected( + "override_type_mismatch", + &format!("{HEADER}category C {{ asset A {{ | s1 }} asset B extends A {{ & s1 }} }}"), + ); +} + +/// Port of `test_compiler_non_existing_step` (`tests/language/ +/// test_compiler.py`), previously un-ported because it depends on this +/// analyzer. +#[test] +fn reaches_to_nonexisting_step_rejected() { + assert_rejected( + "reaches_nonexisting_step", + &format!("{HEADER}category C {{ asset A {{ | s1 -> nonExisting }} }}"), + ); +} + +#[test] +fn cia_on_defense_step_rejected() { + assert_rejected( + "cia_on_defense", + &format!("{HEADER}category C {{ asset A {{ # s1 {{C}} }} }}"), + ); +} + +#[test] +fn defense_with_disallowed_ttc_rejected() { + assert_rejected( + "defense_bad_ttc", + &format!("{HEADER}category C {{ asset A {{ # s1 [Exponential(1.0)] }} }}"), + ); +} + +#[test] +fn enabled_distribution_on_and_step_rejected() { + assert_rejected( + "enabled_on_and", + &format!("{HEADER}category C {{ asset A {{ & s1 [Enabled] }} }}"), + ); +} + +#[test] +fn bernoulli_in_subtraction_rejected() { + assert_rejected( + "bernoulli_in_subtraction", + &format!("{HEADER}category C {{ asset A {{ & s1 [1.0 - Bernoulli(0.5)] }} }}"), + ); +} + +#[test] +fn invalid_distribution_parameters_rejected() { + // Bernoulli's probability parameter must be in [0, 1] - exercises the + // ported `distributions::validate` arity/range checks, not just the + // analyzer's structural checks. + assert_rejected( + "bad_bernoulli_param", + &format!("{HEADER}category C {{ asset A {{ & s1 [Bernoulli(2.0)] }} }}"), + ); +} + +#[test] +fn requires_on_non_exist_step_rejected() { + assert_rejected( + "requires_on_and", + &format!("{HEADER}category C {{ asset A {{ | right\n & s1 <- right }} }}"), + ); +} + +#[test] +fn exist_step_without_requires_rejected() { + assert_rejected( + "exist_without_requires", + &format!("{HEADER}category C {{ asset A {{ E s1 }} }}"), + ); +} + +#[test] +fn variable_cycle_rejected() { + assert_rejected( + "var_cycle", + &format!("{HEADER}category C {{ asset A {{ let x = (y()) let y = (x()) | s1 }} }}"), + ); +} + +#[test] +fn variable_not_pointing_to_asset_rejected() { + assert_rejected( + "var_not_asset", + &format!("{HEADER}category C {{ asset A {{ let x = (nonexistentfield) | s1 }} }}"), + ); +} + +/// Intentional divergence from the Python oracle (see PORTING_NOTES.md +/// §4): `mal_compiler.py`'s own `visited_files` dedup intercepts a +/// repeated include *before* the analyzer's `_include_stack` cycle check +/// can ever see it, so a simple two-file mutual include silently +/// compiles in the real implementation. This Rust port implements real +/// cycle detection instead and rejects it. +#[test] +fn mutual_include_raises() { + let dir = tmp_dir("include_cycle"); + let a_path = dir.join("cyc_a.mal"); + let b_path = dir.join("cyc_b.mal"); + std::fs::write( + &a_path, + format!("{HEADER}include \"cyc_b.mal\"\ncategory C {{ asset A {{ | s1 }} }}"), + ) + .unwrap(); + std::fs::write( + &b_path, + "include \"cyc_a.mal\"\ncategory D { asset B { | s2 } }", + ) + .unwrap(); + + let result = compile_file(&a_path); + assert!( + result.is_err(), + "expected mutual include to be rejected, got {result:?}" + ); + let message = result.unwrap_err().to_string(); + assert!( + message.contains("cycle"), + "expected cycle error, got {message:?}" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +/// Non-cyclic repeated includes (a "diamond": both B and C include A) +/// must still compile - only an actual cycle through the active include +/// stack should be rejected. +#[test] +fn diamond_include_does_not_error() { + let dir = tmp_dir("include_diamond"); + let root_path = dir.join("diamond_root.mal"); + let b_path = dir.join("diamond_b.mal"); + let c_path = dir.join("diamond_c.mal"); + let shared_path = dir.join("diamond_shared.mal"); + std::fs::write( + &root_path, + format!( + "{HEADER}include \"diamond_b.mal\"\ninclude \"diamond_c.mal\"\ncategory C {{ asset Root {{ | s1 }} }}" + ), + ) + .unwrap(); + std::fs::write( + &b_path, + "include \"diamond_shared.mal\"\ncategory D { asset B { | s2 } }", + ) + .unwrap(); + std::fs::write( + &c_path, + "include \"diamond_shared.mal\"\ncategory E { asset C { | s3 } }", + ) + .unwrap(); + std::fs::write(&shared_path, "category F { asset Shared { | s4 } }").unwrap(); + + let result = compile_file(&root_path); + assert!( + result.is_ok(), + "expected diamond include to compile, got {result:?}" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +fn fixtures_dir() -> &'static Path { + Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")) +} + +/// Every pre-existing valid fixture must still compile cleanly with the +/// analyzer wired in - a regression guard distinct from `compiler_golden`/ +/// `graph_golden` (which diff output *shape*, not merely success). +#[test] +fn all_fixtures_still_compile_valid() { + for name in [ + "actions_effects_lang.mal", + "assocChainLang.mal", + "association_lang.mal", + "attackstep_inherit.mal", + "attackstep_override.mal", + "detector_lang.mal", + "inherited_vars.mal", + "interleaved_vars.mal", + "multiplicity_lang.mal", + "prob_dists.mal", + "set_ops_adv.mal", + "set_ops_collect_left.mal", + "set_ops.mal", + "shared_assoc_sibling.mal", + "subtype_attack_step.mal", + "transitive_advanced.mal", + "transitive.mal", + "wiperLang.mal", + ] { + let path = fixtures_dir().join(name); + compile_file(&path).unwrap_or_else(|e| panic!("{name} should still compile: {e}")); + } +} diff --git a/core/maltoolbox-model/Cargo.toml b/core/maltoolbox-model/Cargo.toml new file mode 100644 index 00000000..5c901b46 --- /dev/null +++ b/core/maltoolbox-model/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "maltoolbox-model" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[dependencies] +maltoolbox-io = { workspace = true } +maltoolbox-language = { workspace = true } +rustc-hash = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } diff --git a/core/maltoolbox-model/src/file.rs b/core/maltoolbox-model/src/file.rs new file mode 100644 index 00000000..322f4fca --- /dev/null +++ b/core/maltoolbox-model/src/file.rs @@ -0,0 +1,123 @@ +//! Port of `Model._from_dict`/`Model.load_from_file`/`Model.save_to_file` +//! (the file-I/O parts of `maltoolbox/model.py`). + +use std::collections::HashMap; +use std::path::Path; +use std::rc::Rc; + +use maltoolbox_language::graph::LanguageGraph; +use serde_json::Value; + +use crate::model::{Model, ModelError}; + +#[derive(Debug, thiserror::Error)] +pub enum LoadError { + #[error("{0}")] + FileUtil(#[from] maltoolbox_io::FileUtilError), + #[error("{0}")] + Model(#[from] ModelError), + #[error("Could not load model. It might be of an older version. Try to upgrade it with 'maltoolbox upgrade-model': {0}")] + Malformed(String), +} + +pub fn save_to_file( + model: &Model, + path: impl AsRef, +) -> Result<(), maltoolbox_io::FileUtilError> { + maltoolbox_io::save_dict_to_file(path, &model.to_dict()) +} + +pub fn load_from_file( + path: impl AsRef, + lang_graph: Rc, +) -> Result { + let dict = maltoolbox_io::load_dict_from_file(path)?; + from_dict(&dict, lang_graph).map_err(|e| LoadError::Malformed(e.to_string())) +} + +pub fn from_dict(serialized: &Value, lang_graph: Rc) -> Result { + let metadata = &serialized["metadata"]; + let name = metadata["name"] + .as_str() + .ok_or_else(|| LoadError::Malformed("metadata.name missing".into()))?; + let maltoolbox_version = metadata["MAL-Toolbox Version"] + .as_str() + .unwrap_or(crate::model::MALTOOLBOX_VERSION) + .to_string(); + + let mut model = Model::new(name, lang_graph); + model.maltoolbox_version = maltoolbox_version; + + let assets = serialized["assets"] + .as_object() + .ok_or_else(|| LoadError::Malformed("assets missing".into()))?; + + for (asset_id, asset_value) in assets { + let id: i64 = asset_id + .parse() + .map_err(|_| LoadError::Malformed(format!("invalid asset id \"{asset_id}\"")))?; + + // An asset may be given as a bare type-name string instead of an object. + let (asset_type, name, defenses_raw, extras) = match asset_value { + Value::Object(obj) => ( + obj["type"].as_str().unwrap_or_default().to_string(), + obj["name"].as_str().unwrap_or_default().to_string(), + obj.get("defenses").cloned(), + obj.get("extras").and_then(Value::as_object).cloned(), + ), + Value::String(s) => (s.clone(), format!("{s}:{asset_id}"), None, None), + _ => { + return Err(LoadError::Malformed(format!( + "asset {asset_id} must be an object or a type-name string" + ))) + } + }; + + // Defense values may be JSON numbers or numeric strings (older + // model versions serialize them as strings); accept either. + let defenses: HashMap = defenses_raw + .and_then(|d| d.as_object().cloned()) + .into_iter() + .flatten() + .filter_map(|(k, v)| { + v.as_f64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + .map(|f| (k, f)) + }) + .collect(); + + model + .add_asset( + &asset_type, + Some(name), + Some(id), + Some(defenses), + extras, + true, + ) + .map_err(LoadError::Model)?; + } + + for (asset_id, asset_value) in assets { + let id: i64 = asset_id.parse().expect("validated above"); + let Some(associated) = asset_value + .get("associated_assets") + .and_then(Value::as_object) + else { + continue; + }; + for (fieldname, assoc_assets) in associated { + let ids: std::collections::HashSet = assoc_assets + .as_object() + .into_iter() + .flatten() + .filter_map(|(k, _)| k.parse().ok()) + .collect(); + model + .add_associated_assets(id, fieldname, ids) + .map_err(LoadError::Model)?; + } + } + + Ok(model) +} diff --git a/core/maltoolbox-model/src/lib.rs b/core/maltoolbox-model/src/lib.rs new file mode 100644 index 00000000..7d416ea6 --- /dev/null +++ b/core/maltoolbox-model/src/lib.rs @@ -0,0 +1,9 @@ +//! Port of mal-toolbox's `maltoolbox/model.py`: the instance model +//! (assets + associated-asset links) built against a compiled +//! [`maltoolbox_language::graph::LanguageGraph`]. + +pub mod file; +pub mod model; + +pub use file::{from_dict, load_from_file, save_to_file, LoadError}; +pub use model::{AssetSnapshot, Model, ModelAsset, ModelError, MALTOOLBOX_VERSION}; diff --git a/core/maltoolbox-model/src/model.rs b/core/maltoolbox-model/src/model.rs new file mode 100644 index 00000000..3c650b5f --- /dev/null +++ b/core/maltoolbox-model/src/model.rs @@ -0,0 +1,517 @@ +//! Port of `maltoolbox/model.py`. +//! +//! Assets are keyed by their stable, user-facing integer id in a plain +//! `FxHashMap`, rather than the generational-key arena used by the +//! language/attack graphs - a `Model` has no partial-regeneration churn +//! that would risk a stale reference aliasing a reused slot, so that +//! extra machinery isn't needed here. See `PORTING_NOTES.md` for the +//! full comparison. + +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; + +use rustc_hash::FxHashMap; +use serde_json::{json, Map, Value}; + +use maltoolbox_language::graph::LanguageGraph; + +pub const MALTOOLBOX_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Debug, thiserror::Error)] +pub enum ModelError { + #[error("Asset index {0} already in use.")] + DuplicateAssetId(i64), + #[error( + "Asset type \"{asset_type}\" does not exist in language, must be one of:\n -{available}" + )] + UnknownAssetType { + asset_type: String, + available: String, + }, + #[error("Asset name {0} is a duplicate and we do not allow duplicates.")] + DuplicateAssetName(String), + #[error("Asset \"{name}\"({id}) is not part of model \"{model}\".")] + AssetNotFound { + name: String, + id: i64, + model: String, + }, + #[error("Fieldname '{fieldname}' is not an accepted association fieldname from asset type {asset_type}. Did you mean one of {accepted}?")] + UnknownFieldname { + fieldname: String, + asset_type: String, + accepted: String, + }, + #[error("Asset '{asset_name}' of type '{asset_type}' can not be added to association '{owner_name}.{fieldname}'. Expected type of '{fieldname}' is {expected_type}.")] + WrongAssociatedAssetType { + asset_name: String, + asset_type: String, + owner_name: String, + fieldname: String, + expected_type: String, + }, + #[error("You can have maximum {0} assets for association field {1}")] + TooManyAssetsInField(i64, String), + #[error("Association fieldname \"{fieldname}\" does not exist from <{from_type}> to <{to_type}>, must be one of:\n -{possible}")] + UnknownAssociation { + fieldname: String, + from_type: String, + to_type: String, + possible: String, + }, + #[error("Asset with id {0} not found in model.")] + UnknownAssetId(i64), + #[error( + "Asset '{asset_name}' is not associated via fieldname '{fieldname}' on '{owner_name}'." + )] + NotAssociated { + owner_name: String, + fieldname: String, + asset_name: String, + }, + #[error("{0}")] + Language(#[from] maltoolbox_language::graph::GraphError), + #[error("{0}")] + Malformed(String), +} + +/// Snapshot of an asset's data at the moment it is removed from the +/// model, letting callers (e.g. `AttackGraph::partially_regenerate_graph`) +/// resolve a removed asset's language type/name without needing +/// `Model::get_asset_by_id` to keep working for its id. Returned by +/// [`Model::remove_asset`]. +/// +/// `final_state` is captured *after* the associated-assets cleanup loop +/// in [`Model::remove_asset`] has run, so its `associated_assets` field +/// reflects the post-cleanup (typically empty) state, not the +/// pre-removal one. +#[derive(Debug, Clone)] +pub struct AssetSnapshot { + pub name: String, + pub lg_asset: maltoolbox_language::graph::AssetId, + pub final_state: ModelAsset, +} + +#[derive(Debug, Clone)] +pub struct ModelAsset { + pub name: String, + pub id: i64, + pub lg_asset: maltoolbox_language::graph::AssetId, + pub asset_type: String, + pub defenses: HashMap, + pub extras: Map, + pub associated_assets: HashMap>, +} + +impl ModelAsset { + pub fn to_dict(&self) -> Value { + let mut dict = Map::new(); + dict.insert("name".into(), json!(self.name)); + dict.insert("type".into(), json!(self.asset_type)); + + if !self.defenses.is_empty() { + dict.insert("defenses".into(), json!(self.defenses)); + } + + // Filled in by `Model::to_dict`, which has the id->name mapping needed. + dict.insert("associated_assets".into(), Value::Object(Map::new())); + + if !self.extras.is_empty() { + dict.insert("extras".into(), Value::Object(self.extras.clone())); + } + + Value::Object(dict) + } +} + +pub struct Model { + pub name: String, + pub maltoolbox_version: String, + pub next_id: i64, + pub assets: FxHashMap, + pub name_to_asset_id: FxHashMap, + /// Insertion order of assets. Attack graph generation iterates + /// assets in this order to assign node ids deterministically, so it + /// must match Python dict iteration order rather than `HashMap`'s + /// arbitrary order. + pub asset_order: Vec, + pub lang_graph: Rc, +} + +impl Model { + pub fn new(name: impl Into, lang_graph: Rc) -> Self { + Model { + name: name.into(), + maltoolbox_version: MALTOOLBOX_VERSION.to_string(), + next_id: 0, + assets: FxHashMap::default(), + name_to_asset_id: FxHashMap::default(), + asset_order: Vec::new(), + lang_graph, + } + } + + #[allow(clippy::too_many_arguments)] + pub fn add_asset( + &mut self, + asset_type: &str, + name: Option, + asset_id: Option, + defenses: Option>, + extras: Option>, + allow_duplicate_names: bool, + ) -> Result { + let asset_id = asset_id.unwrap_or(self.next_id); + if self.assets.contains_key(&asset_id) { + return Err(ModelError::DuplicateAssetId(asset_id)); + } + self.next_id = self.next_id.max(asset_id + 1); + + let name = match name { + None => format!("{asset_type}:{asset_id}"), + Some(n) if self.name_to_asset_id.contains_key(&n) => { + if allow_duplicate_names { + format!("{n}:{asset_id}") + } else { + return Err(ModelError::DuplicateAssetName(n)); + } + } + Some(n) => n, + }; + + let lg_asset = + self.lang_graph + .asset_id(asset_type) + .ok_or_else(|| ModelError::UnknownAssetType { + asset_type: asset_type.to_string(), + available: self + .lang_graph + .asset_order + .iter() + .map(|id| self.lang_graph.asset(*id).name.as_str()) + .collect::>() + .join("\n -"), + })?; + + let asset = ModelAsset { + name: name.clone(), + id: asset_id, + lg_asset, + asset_type: asset_type.to_string(), + defenses: defenses.unwrap_or_default(), + extras: extras.unwrap_or_default(), + associated_assets: HashMap::new(), + }; + + self.assets.insert(asset_id, asset); + self.name_to_asset_id.insert(name, asset_id); + self.asset_order.push(asset_id); + + Ok(asset_id) + } + + pub fn remove_asset(&mut self, asset_id: i64) -> Result { + let asset = self + .assets + .get(&asset_id) + .ok_or_else(|| ModelError::AssetNotFound { + name: String::new(), + id: asset_id, + model: self.name.clone(), + })?; + let name = asset.name.clone(); + let lg_asset = asset.lg_asset; + + let associated_fieldnames: Vec<(String, HashSet)> = asset + .associated_assets + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + for (fieldname, assoc_assets) in associated_fieldnames { + self.remove_associated_assets(asset_id, &fieldname, &assoc_assets)?; + } + + // Captured after the cleanup loop above, so `associated_assets` is already emptied. + let final_state = self + .assets + .get(&asset_id) + .expect("asset confirmed present above, not removed by cleanup loop") + .clone(); + let snapshot = AssetSnapshot { + name, + lg_asset, + final_state, + }; + + self.assets.remove(&asset_id); + self.name_to_asset_id.remove(&snapshot.name); + self.asset_order.retain(|&id| id != asset_id); + Ok(snapshot) + } + + pub fn get_asset_by_id(&self, asset_id: i64) -> Option<&ModelAsset> { + self.assets.get(&asset_id) + } + + pub fn get_asset_by_name(&self, name: &str) -> Option<&ModelAsset> { + self.name_to_asset_id + .get(name) + .and_then(|id| self.assets.get(id)) + } + + pub fn associations_with( + &self, + asset_id: i64, + other_id: i64, + ) -> HashSet> { + let Some(asset) = self.assets.get(&asset_id) else { + return HashSet::new(); + }; + let mut result = HashSet::new(); + for assoc in self.lang_graph.associations(asset.lg_asset).values() { + let to_left = asset + .associated_assets + .get(&assoc.left_field.fieldname) + .map(|s| s.contains(&other_id)) + .unwrap_or(false); + let to_right = asset + .associated_assets + .get(&assoc.right_field.fieldname) + .map(|s| s.contains(&other_id)) + .unwrap_or(false); + if to_left || to_right { + result.insert(assoc.clone()); + } + } + result + } + + pub fn has_association_with(&self, asset_id: i64, other_id: i64, assoc_name: &str) -> bool { + let Some(asset) = self.assets.get(&asset_id) else { + return false; + }; + for (fieldname, assoc_assets) in &asset.associated_assets { + let Some(assoc) = self + .lang_graph + .associations(asset.lg_asset) + .get(fieldname) + .cloned() + else { + continue; + }; + if assoc.name == assoc_name && assoc_assets.contains(&other_id) { + return true; + } + } + false + } + + pub fn validate_associated_assets( + &self, + asset_id: i64, + fieldname: &str, + assets_to_add: &HashSet, + ) -> Result<(), ModelError> { + let asset = self.assets.get(&asset_id).expect("asset must exist"); + let associations = self.lang_graph.associations(asset.lg_asset); + + let Some(lg_assoc) = associations.get(fieldname) else { + return Err(ModelError::UnknownFieldname { + fieldname: fieldname.to_string(), + asset_type: self.lang_graph.asset(asset.lg_asset).name.clone(), + accepted: associations.keys().cloned().collect::>().join(", "), + }); + }; + let assoc_field = lg_assoc.get_field(fieldname); + + for &other_id in assets_to_add { + let other = self.assets.get(&other_id).expect("asset must exist"); + if !self + .lang_graph + .is_subasset_of(other.lg_asset, assoc_field.asset) + { + return Err(ModelError::WrongAssociatedAssetType { + asset_name: other.name.clone(), + asset_type: other.asset_type.clone(), + owner_name: asset.name.clone(), + fieldname: fieldname.to_string(), + expected_type: self.lang_graph.asset(assoc_field.asset).name.clone(), + }); + } + } + + let before = asset + .associated_assets + .get(fieldname) + .cloned() + .unwrap_or_default(); + let after_len = before.union(assets_to_add).count(); + if let Some(max) = assoc_field.maximum { + if after_len as i64 > max { + return Err(ModelError::TooManyAssetsInField(max, fieldname.to_string())); + } + } + + Ok(()) + } + + pub fn add_associated_assets( + &mut self, + asset_id: i64, + fieldname: &str, + assets: HashSet, + ) -> Result<(), ModelError> { + let asset = self.assets.get(&asset_id).expect("asset must exist"); + let associations = self.lang_graph.associations(asset.lg_asset); + + let Some(lg_assoc) = associations.get(fieldname).cloned() else { + let (to_asset_type, possible): (String, Vec) = + if let Some(&first_other) = assets.iter().next() { + let other_lg_asset = self.assets[&first_other].lg_asset; + let to_name = self.lang_graph.asset(other_lg_asset).name.clone(); + let possible = self + .lang_graph + .associations_to(asset.lg_asset, other_lg_asset) + .keys() + .cloned() + .collect(); + (to_name, possible) + } else { + ("Any".to_string(), associations.keys().cloned().collect()) + }; + return Err(ModelError::UnknownAssociation { + fieldname: fieldname.to_string(), + from_type: self.lang_graph.asset(asset.lg_asset).name.clone(), + to_type: to_asset_type, + possible: possible.join("\n -"), + }); + }; + let other_fieldname = lg_assoc.get_opposite_fieldname(fieldname)?; + + self.validate_associated_assets(asset_id, fieldname, &assets)?; + for &other_id in &assets { + self.validate_associated_assets( + other_id, + &other_fieldname, + &HashSet::from([asset_id]), + )?; + } + + self.assets + .get_mut(&asset_id) + .unwrap() + .associated_assets + .entry(fieldname.to_string()) + .or_default() + .extend(&assets); + + for &other_id in &assets { + self.assets + .get_mut(&other_id) + .unwrap() + .associated_assets + .entry(other_fieldname.clone()) + .or_default() + .insert(asset_id); + } + + Ok(()) + } + + pub fn remove_associated_assets( + &mut self, + asset_id: i64, + fieldname: &str, + assets: &HashSet, + ) -> Result<(), ModelError> { + let asset = self + .assets + .get(&asset_id) + .ok_or(ModelError::UnknownAssetId(asset_id))?; + let lg_assoc = self + .lang_graph + .associations(asset.lg_asset) + .get(fieldname) + .cloned() + .ok_or_else(|| ModelError::UnknownFieldname { + fieldname: fieldname.to_string(), + asset_type: self.lang_graph.asset(asset.lg_asset).name.clone(), + accepted: self + .lang_graph + .associations(asset.lg_asset) + .keys() + .cloned() + .collect::>() + .join(", "), + })?; + let owner_name = asset.name.clone(); + let other_fieldname = lg_assoc.get_opposite_fieldname(fieldname)?; + + for &other_id in assets { + let other = self + .assets + .get_mut(&other_id) + .ok_or(ModelError::UnknownAssetId(other_id))?; + let is_associated = other + .associated_assets + .get(&other_fieldname) + .map(|set| set.contains(&asset_id)) + .unwrap_or(false); + if !is_associated { + return Err(ModelError::NotAssociated { + owner_name: other.name.clone(), + fieldname: other_fieldname, + asset_name: owner_name, + }); + } + + let set = other.associated_assets.get_mut(&other_fieldname).unwrap(); + set.remove(&asset_id); + if set.is_empty() { + other.associated_assets.remove(&other_fieldname); + } + } + + let this = self.assets.get_mut(&asset_id).unwrap(); + if let Some(set) = this.associated_assets.get_mut(fieldname) { + for id in assets { + set.remove(id); + } + if set.is_empty() { + this.associated_assets.remove(fieldname); + } + } + + Ok(()) + } + + pub fn to_dict(&self) -> Value { + let mut assets = Map::new(); + for (id, asset) in &self.assets { + let mut dict = asset.to_dict(); + let mut associated_assets = Map::new(); + for (fieldname, ids) in &asset.associated_assets { + let mut named = Map::new(); + for other_id in ids { + let other_name = self.assets[other_id].name.clone(); + named.insert(other_id.to_string(), json!(other_name)); + } + associated_assets.insert(fieldname.clone(), Value::Object(named)); + } + dict["associated_assets"] = Value::Object(associated_assets); + assets.insert(id.to_string(), dict); + } + + json!({ + "metadata": { + "name": self.name, + "langVersion": self.lang_graph.metadata.version, + "langID": self.lang_graph.metadata.id, + "malVersion": "0.1.0-SNAPSHOT", + "MAL-Toolbox Version": self.maltoolbox_version, + "info": "Created by the mal-toolbox model python module.", + }, + "assets": assets, + }) + } +} diff --git a/core/maltoolbox-model/tests/golden/wiper_model.json b/core/maltoolbox-model/tests/golden/wiper_model.json new file mode 100644 index 00000000..8950aad4 --- /dev/null +++ b/core/maltoolbox-model/tests/golden/wiper_model.json @@ -0,0 +1,60 @@ +{ + "assets": { + "0": { + "associated_assets": { + "hosts": { + "1": "device1" + } + }, + "name": "internet1", + "type": "Internet" + }, + "1": { + "associated_assets": { + "data": { + "2": "data1" + }, + "inet": { + "0": "internet1" + }, + "malware": { + "3": "wiper1" + } + }, + "defenses": { + "someDefense": 0.5 + }, + "extras": { + "note": "test" + }, + "name": "device1", + "type": "Device" + }, + "2": { + "associated_assets": { + "node": { + "1": "device1" + } + }, + "name": "data1", + "type": "Data" + }, + "3": { + "associated_assets": { + "victim": { + "1": "device1" + } + }, + "name": "wiper1", + "type": "Wiper" + } + }, + "metadata": { + "MAL-Toolbox Version": "2.11.0", + "info": "Created by the mal-toolbox model python module.", + "langID": "org.mal-lang.wiperLang", + "langVersion": "1.0.0", + "malVersion": "0.1.0-SNAPSHOT", + "name": "Test Model" + } +} \ No newline at end of file diff --git a/core/maltoolbox-model/tests/model_file_roundtrip.rs b/core/maltoolbox-model/tests/model_file_roundtrip.rs new file mode 100644 index 00000000..8c78b1c9 --- /dev/null +++ b/core/maltoolbox-model/tests/model_file_roundtrip.rs @@ -0,0 +1,87 @@ +//! Self-consistency round-trip for Model::to_dict / from_dict / +//! save_to_file / load_from_file, over the same hand-built wiperLang +//! model used by `model_golden.rs`. + +use std::collections::HashMap; +use std::rc::Rc; + +use maltoolbox_language::{compile_file, generate_graph}; +use maltoolbox_model::Model; +use serde_json::Value; + +fn fixture_path(name: &str) -> std::path::PathBuf { + std::path::Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../maltoolbox-language/tests/fixtures" + )) + .join(name) +} + +fn build_model() -> Model { + let spec = compile_file(fixture_path("wiperLang.mal")).expect("compile"); + let lang_graph = Rc::new(generate_graph(spec).expect("build language graph")); + + let mut model = Model::new("Test Model", lang_graph); + let internet = model + .add_asset("Internet", Some("internet1".into()), None, None, None, true) + .unwrap(); + let device = model + .add_asset( + "Device", + Some("device1".into()), + None, + Some(HashMap::from([("someDefense".to_string(), 0.5)])), + Some(serde_json::Map::from_iter([( + "note".to_string(), + Value::String("test".into()), + )])), + true, + ) + .unwrap(); + let data = model + .add_asset("Data", Some("data1".into()), None, None, None, true) + .unwrap(); + let wiper = model + .add_asset("Wiper", Some("wiper1".into()), None, None, None, true) + .unwrap(); + + model + .add_associated_assets(internet, "hosts", std::collections::HashSet::from([device])) + .unwrap(); + model + .add_associated_assets(device, "data", std::collections::HashSet::from([data])) + .unwrap(); + model + .add_associated_assets(device, "malware", std::collections::HashSet::from([wiper])) + .unwrap(); + + model +} + +#[test] +fn round_trips_through_dict() { + let model = build_model(); + let dict = model.to_dict(); + + let reloaded = maltoolbox_model::from_dict(&dict, model.lang_graph.clone()).expect("from_dict"); + let reloaded_dict = reloaded.to_dict(); + + assert_eq!(dict, reloaded_dict); +} + +#[test] +fn round_trips_through_file() { + let model = build_model(); + + let dir = + std::env::temp_dir().join(format!("maltoolbox-model-roundtrip-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("model.json"); + + maltoolbox_model::save_to_file(&model, &path).expect("save"); + let reloaded = maltoolbox_model::load_from_file(&path, model.lang_graph.clone()).expect("load"); + + assert_eq!(model.to_dict(), reloaded.to_dict()); + + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/core/maltoolbox-model/tests/model_golden.rs b/core/maltoolbox-model/tests/model_golden.rs new file mode 100644 index 00000000..db92ce39 --- /dev/null +++ b/core/maltoolbox-model/tests/model_golden.rs @@ -0,0 +1,84 @@ +//! Diffs `Model::to_dict()` against golden JSON produced by the real +//! Python mal-toolbox, built from the same wiperLang.mal fixture used by +//! maltoolbox-language's golden tests. +//! +//! The "MAL-Toolbox Version" metadata field is masked before comparison: +//! it's informational provenance (which implementation produced the +//! file), not part of the wire schema, so this Rust port legitimately +//! reports its own crate version there instead of claiming to be a +//! specific Python mal-toolbox release. + +use std::collections::HashMap; +use std::rc::Rc; + +use maltoolbox_language::{compile_file, generate_graph}; +use maltoolbox_model::Model; +use serde_json::Value; + +fn fixture_path(name: &str) -> std::path::PathBuf { + std::path::Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../maltoolbox-language/tests/fixtures" + )) + .join(name) +} + +fn mask_version(mut v: Value) -> Value { + v["metadata"]["MAL-Toolbox Version"] = Value::String("".into()); + v +} + +#[test] +fn matches_python_oracle_for_wiper_model() { + let spec = compile_file(fixture_path("wiperLang.mal")).expect("compile"); + let lang_graph = Rc::new(generate_graph(spec).expect("build language graph")); + + let mut model = Model::new("Test Model", lang_graph); + + let internet = model + .add_asset("Internet", Some("internet1".into()), None, None, None, true) + .unwrap(); + let device = model + .add_asset( + "Device", + Some("device1".into()), + None, + Some(HashMap::from([("someDefense".to_string(), 0.5)])), + Some(serde_json::Map::from_iter([( + "note".to_string(), + Value::String("test".into()), + )])), + true, + ) + .unwrap(); + let data = model + .add_asset("Data", Some("data1".into()), None, None, None, true) + .unwrap(); + let wiper = model + .add_asset("Wiper", Some("wiper1".into()), None, None, None, true) + .unwrap(); + + model + .add_associated_assets(internet, "hosts", std::collections::HashSet::from([device])) + .unwrap(); + model + .add_associated_assets(device, "data", std::collections::HashSet::from([data])) + .unwrap(); + model + .add_associated_assets(device, "malware", std::collections::HashSet::from([wiper])) + .unwrap(); + + let actual = mask_version(model.to_dict()); + + let golden_path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/golden/wiper_model.json"); + let expected: Value = + mask_version(serde_json::from_str(&std::fs::read_to_string(golden_path).unwrap()).unwrap()); + + assert_eq!( + actual, + expected, + "\n--- expected ---\n{}\n--- actual ---\n{}", + serde_json::to_string_pretty(&expected).unwrap(), + serde_json::to_string_pretty(&actual).unwrap(), + ); +} diff --git a/core/maltoolbox-model/tests/test_model.rs b/core/maltoolbox-model/tests/test_model.rs new file mode 100644 index 00000000..27839cb2 --- /dev/null +++ b/core/maltoolbox-model/tests/test_model.rs @@ -0,0 +1,629 @@ +//! Port of mal-toolbox's `tests/test_model.py`, over coreLang (loaded +//! from the same `.mar` fixture used by maltoolbox-language's tests), +//! mirroring `conftest.py`'s `corelang_lang_graph`/`model` fixtures. + +use std::collections::HashSet; +use std::rc::Rc; + +use maltoolbox_language::from_mar_archive; +use maltoolbox_model::{Model, ModelError}; + +fn corelang_model() -> Model { + let path = std::path::Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../maltoolbox-language/tests/fixtures/org.mal-lang.coreLang-1.0.0.mar" + )); + let lang_graph = Rc::new(from_mar_archive(path).expect("load corelang")); + Model::new("Test Model", lang_graph) +} + +#[test] +fn add_asset() { + let mut model = corelang_model(); + let before = model.assets.len(); + + let asset1 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + + assert_eq!(before + 1, model.assets.len()); + assert!(model.assets.contains_key(&asset1)); +} + +#[test] +fn add_asset_with_id_set() { + let mut model = corelang_model(); + let asset_id = model.next_id + 10; + + let asset1 = model + .add_asset("Application", None, Some(asset_id), None, None, true) + .unwrap(); + assert_eq!(asset1, asset_id); + assert_eq!(model.next_id, asset_id + 1); + + let err = model + .add_asset("Application", None, Some(asset_id), None, None, true) + .unwrap_err(); + assert!(matches!(err, ModelError::DuplicateAssetId(id) if id == asset_id)); +} + +#[test] +fn add_asset_duplicate_name() { + let mut model = corelang_model(); + let asset_name = "MyProgram"; + + let asset1 = model + .add_asset( + "Application", + Some(asset_name.to_string()), + None, + None, + None, + true, + ) + .unwrap(); + assert_eq!(model.assets.len(), 1); + assert_eq!(model.get_asset_by_id(asset1).unwrap().name, asset_name); + + let asset2 = model + .add_asset( + "Application", + Some(asset_name.to_string()), + None, + None, + None, + true, + ) + .unwrap(); + assert_eq!(model.assets.len(), 2); + assert_eq!( + model.get_asset_by_id(asset2).unwrap().name, + format!("{asset_name}:{asset2}") + ); + + let err = model + .add_asset( + "Application", + Some(asset_name.to_string()), + None, + None, + None, + false, + ) + .unwrap_err(); + assert!(matches!(err, ModelError::DuplicateAssetName(_))); + assert_eq!(model.assets.len(), 2); +} + +#[test] +fn remove_asset_with_association() { + let mut model = corelang_model(); + let asset1 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + let asset2 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + model + .add_associated_assets(asset1, "hostApp", HashSet::from([asset2])) + .unwrap(); + + assert_eq!( + model + .get_asset_by_id(asset1) + .unwrap() + .associated_assets + .get("hostApp"), + Some(&HashSet::from([asset2])) + ); + assert_eq!( + model + .get_asset_by_id(asset2) + .unwrap() + .associated_assets + .get("appExecutedApps"), + Some(&HashSet::from([asset1])) + ); + let count_before = model.assets.len(); + + model.remove_asset(asset1).unwrap(); + + assert!(model + .get_asset_by_id(asset2) + .unwrap() + .associated_assets + .is_empty()); + assert!(!model.assets.contains_key(&asset1)); + assert!(model.assets.contains_key(&asset2)); + assert_eq!(model.assets.len(), count_before - 1); +} + +#[test] +fn remove_nonexisting_asset() { + let mut model = corelang_model(); + let err = model.remove_asset(999_999).unwrap_err(); + assert!(matches!(err, ModelError::AssetNotFound { .. })); +} + +#[test] +fn add_associated_asset() { + let mut model = corelang_model(); + let asset1 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + let asset2 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + + assert!(!model + .get_asset_by_id(asset1) + .unwrap() + .associated_assets + .contains_key("appExecutedApps")); + assert!(!model + .get_asset_by_id(asset2) + .unwrap() + .associated_assets + .contains_key("hostApp")); + + model + .add_associated_assets(asset1, "appExecutedApps", HashSet::from([asset2])) + .unwrap(); + + assert!(model + .get_asset_by_id(asset1) + .unwrap() + .associated_assets + .get("appExecutedApps") + .unwrap() + .contains(&asset2)); + assert!(model + .get_asset_by_id(asset2) + .unwrap() + .associated_assets + .get("hostApp") + .unwrap() + .contains(&asset1)); + + let associations_in_common = model.associations_with(asset1, asset2); + assert!(!associations_in_common.is_empty()); + for assoc in &associations_in_common { + assert!(model.has_association_with(asset1, asset2, &assoc.name)); + } +} + +#[test] +fn add_appexecution_association_two_assets() { + // CoreLang specifies that AppExecution only can have one 'left' asset + // (hostApp has max multiplicity 1). + let mut model = corelang_model(); + let asset1 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + let asset2 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + + let err = model + .add_associated_assets(asset1, "hostApp", HashSet::from([asset1, asset2])) + .unwrap_err(); + assert!(matches!(err, ModelError::TooManyAssetsInField(..))); +} + +#[test] +fn add_association_wrong_type() { + // CoreLang specifies that hostApp must be an Application. + let mut model = corelang_model(); + let asset1 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + let data = model + .add_asset("Data", None, None, None, None, true) + .unwrap(); + + let err = model + .add_associated_assets(asset1, "hostApp", HashSet::from([data])) + .unwrap_err(); + assert!(matches!(err, ModelError::WrongAssociatedAssetType { .. })); +} + +#[test] +fn add_association_nonexisting_fieldname() { + let mut model = corelang_model(); + let asset1 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + let data = model + .add_asset("Data", None, None, None, None, true) + .unwrap(); + + let err = model + .add_associated_assets(asset1, "unknownFieldName", HashSet::from([data])) + .unwrap_err(); + assert!(matches!(err, ModelError::UnknownAssociation { .. })); +} + +#[test] +fn add_association_duplicate() { + let mut model = corelang_model(); + let d1 = model + .add_asset("Data", None, None, None, None, true) + .unwrap(); + let d2 = model + .add_asset("Data", None, None, None, None, true) + .unwrap(); + let d3 = model + .add_asset("Data", None, None, None, None, true) + .unwrap(); + + model + .add_associated_assets(d3, "containingData", HashSet::from([d1, d2])) + .unwrap(); + model + .add_associated_assets(d3, "containingData", HashSet::from([d2])) + .unwrap(); + model + .add_associated_assets(d2, "containedData", HashSet::from([d3])) + .unwrap(); + + assert_eq!( + model.get_asset_by_id(d1).unwrap().associated_assets["containedData"], + HashSet::from([d3]) + ); + assert_eq!( + model.get_asset_by_id(d2).unwrap().associated_assets["containedData"], + HashSet::from([d3]) + ); + assert_eq!( + model.get_asset_by_id(d3).unwrap().associated_assets["containingData"], + HashSet::from([d1, d2]) + ); +} + +#[test] +fn remove_associated_asset() { + let mut model = corelang_model(); + let asset1 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + let asset2 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + model + .add_associated_assets(asset1, "appExecutedApps", HashSet::from([asset2])) + .unwrap(); + + model + .remove_associated_assets(asset1, "appExecutedApps", &HashSet::from([asset2])) + .unwrap(); + + assert!(!model + .get_asset_by_id(asset1) + .unwrap() + .associated_assets + .contains_key("appExecutedApps")); + assert!(!model + .get_asset_by_id(asset2) + .unwrap() + .associated_assets + .contains_key("hostApp")); +} + +#[test] +fn remove_associated_asset_with_leftovers() { + let mut model = corelang_model(); + let asset1 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + let asset2 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + let asset3 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + model + .add_associated_assets(asset1, "appExecutedApps", HashSet::from([asset2, asset3])) + .unwrap(); + + model + .remove_associated_assets(asset1, "appExecutedApps", &HashSet::from([asset2])) + .unwrap(); + + let remaining = &model.get_asset_by_id(asset1).unwrap().associated_assets["appExecutedApps"]; + assert!(!remaining.contains(&asset2)); + assert!(remaining.contains(&asset3)); + assert!(!model + .get_asset_by_id(asset2) + .unwrap() + .associated_assets + .contains_key("hostApp")); + assert!(model.get_asset_by_id(asset3).unwrap().associated_assets["hostApp"].contains(&asset1)); +} + +#[test] +fn remove_nonexisting_associated_assets() { + let mut model = corelang_model(); + let asset1 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + let asset2 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + + let err = model + .remove_associated_assets(asset1, "appExecutedApps", &HashSet::from([asset2])) + .unwrap_err(); + assert!(matches!(err, ModelError::NotAssociated { .. })); +} + +#[test] +fn remove_asset_from_association_nonexisting_asset() { + let mut model = corelang_model(); + let asset1 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + let asset2 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + let asset3 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + let asset4 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + model + .add_associated_assets(asset1, "appExecutedApps", HashSet::from([asset2])) + .unwrap(); + + assert!(matches!( + model.remove_associated_assets(asset1, "appExecutedApps", &HashSet::from([asset3])), + Err(ModelError::NotAssociated { .. }) + )); + assert!(matches!( + model.remove_associated_assets(asset1, "appExecutedApps", &HashSet::from([asset4])), + Err(ModelError::NotAssociated { .. }) + )); +} + +#[test] +fn get_asset_by_id() { + let mut model = corelang_model(); + let asset1 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + let asset2 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + + assert_eq!(model.get_asset_by_id(asset1).unwrap().id, asset1); + assert_eq!(model.get_asset_by_id(asset2).unwrap().id, asset2); + assert!(model.get_asset_by_id(1337).is_none()); +} + +#[test] +fn get_asset_by_name() { + let mut model = corelang_model(); + let asset1 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + let asset2 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + let name1 = model.get_asset_by_id(asset1).unwrap().name.clone(); + let name2 = model.get_asset_by_id(asset2).unwrap().name.clone(); + + assert_eq!(model.get_asset_by_name(&name1).unwrap().id, asset1); + assert_eq!(model.get_asset_by_name(&name2).unwrap().id, asset2); + assert!(model.get_asset_by_name("Program 3").is_none()); +} + +#[test] +fn asset_to_dict() { + let mut model = corelang_model(); + let asset1 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + + let dict = model.get_asset_by_id(asset1).unwrap().to_dict(); + assert_eq!( + dict["name"], + model.get_asset_by_id(asset1).unwrap().name.as_str() + ); + assert_eq!(dict["type"], "Application"); + // Default values should not be saved. + assert!(dict.get("defenses").is_none()); +} + +#[test] +fn asset_with_nondefault_defense_to_dict() { + let mut model = corelang_model(); + let asset1 = model + .add_asset( + "Application", + None, + None, + Some(std::collections::HashMap::from([( + "notPresent".to_string(), + 1.0, + )])), + None, + true, + ) + .unwrap(); + + let dict = model.get_asset_by_id(asset1).unwrap().to_dict(); + assert_eq!(dict["defenses"]["notPresent"], 1.0); +} + +#[test] +fn serialize() { + let mut model = corelang_model(); + let asset1 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + let asset2 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + let _asset3 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + model + .add_associated_assets(asset1, "appExecutedApps", HashSet::from([asset2])) + .unwrap(); + + let dict = model.to_dict(); + + assert_eq!(dict["metadata"]["name"], model.name.as_str()); + assert_eq!( + dict["metadata"]["langVersion"], + model.lang_graph.metadata.version.as_str() + ); + assert_eq!( + dict["metadata"]["langID"], + model.lang_graph.metadata.id.as_str() + ); +} + +#[test] +fn save_and_load_model_from_scratch() { + let mut model = corelang_model(); + let asset1 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + model + .assets + .get_mut(&asset1) + .unwrap() + .extras + .insert("testing".to_string(), "testing".into()); + let asset2 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + let asset3 = model + .add_asset("Application", None, None, None, None, true) + .unwrap(); + model + .add_associated_assets(asset1, "appExecutedApps", HashSet::from([asset2, asset3])) + .unwrap(); + + let dir = std::env::temp_dir().join(format!("maltoolbox-model-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + for ext in ["yml", "yaml", "json"] { + let path = dir.join(format!("test.{ext}")); + maltoolbox_model::save_to_file(&model, &path).unwrap(); + let new_model = maltoolbox_model::load_from_file(&path, model.lang_graph.clone()).unwrap(); + assert_eq!(new_model.to_dict(), model.to_dict()); + } + + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn dynamic_model() { + let lang_path = std::path::Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../maltoolbox-language/tests/fixtures/wiperLang.mal" + )); + let lang = Rc::new(maltoolbox_language::from_mal_spec(lang_path).expect("load wiperLang")); + let mut model = Model::new("Example Model", lang.clone()); + + let c2server = model + .add_asset( + "C2Server", + Some("C2Server".into()), + Some(2), + None, + None, + true, + ) + .unwrap(); + let infected_device = model + .add_asset( + "Device", + Some("InfectedDevice".into()), + Some(3), + None, + None, + true, + ) + .unwrap(); + model + .add_associated_assets(c2server, "receiveFrom", HashSet::from([infected_device])) + .unwrap(); + + let data = model + .add_asset( + "Data", + Some("InfectedData".into()), + Some(4), + None, + None, + true, + ) + .unwrap(); + model + .add_associated_assets(infected_device, "data", HashSet::from([data])) + .unwrap(); + + let malware = model + .add_asset("Wiper", Some("Wiper".into()), Some(5), None, None, true) + .unwrap(); + model + .add_associated_assets(malware, "victim", HashSet::from([infected_device])) + .unwrap(); + + let vulnerable_device = model + .add_asset( + "Device", + Some("VulnerableDevice".into()), + Some(6), + None, + None, + true, + ) + .unwrap(); + model + .add_associated_assets( + vulnerable_device, + "receiveFrom", + HashSet::from([infected_device]), + ) + .unwrap(); + model + .add_associated_assets( + vulnerable_device, + "sendTo", + HashSet::from([infected_device]), + ) + .unwrap(); + + let internet = model + .add_asset( + "Internet", + Some("Internet".into()), + Some(1), + None, + None, + true, + ) + .unwrap(); + model + .add_associated_assets( + internet, + "hosts", + HashSet::from([c2server, infected_device, vulnerable_device]), + ) + .unwrap(); + + let dir = + std::env::temp_dir().join(format!("maltoolbox-model-dyn-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("test.yml"); + + maltoolbox_model::save_to_file(&model, &path).unwrap(); + let loaded_model = maltoolbox_model::load_from_file(&path, lang).unwrap(); + + assert_eq!(loaded_model.to_dict(), model.to_dict()); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/core/maltoolbox-patternfinder/Cargo.toml b/core/maltoolbox-patternfinder/Cargo.toml new file mode 100644 index 00000000..69d96e62 --- /dev/null +++ b/core/maltoolbox-patternfinder/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "maltoolbox-patternfinder" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[dependencies] +maltoolbox-attackgraph = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +maltoolbox-language = { workspace = true } +maltoolbox-model = { workspace = true } diff --git a/core/maltoolbox-patternfinder/src/lib.rs b/core/maltoolbox-patternfinder/src/lib.rs new file mode 100644 index 00000000..c0e012a2 --- /dev/null +++ b/core/maltoolbox-patternfinder/src/lib.rs @@ -0,0 +1,155 @@ +//! Port of mal-toolbox's `maltoolbox/patternfinder/attackgraph_patterns.py`: +//! finds sequences of attack graph nodes matching a list of conditions, +//! following `children` edges, with support for optional/repeatable +//! conditions. + +use std::collections::HashSet; + +use maltoolbox_attackgraph::{AttackGraph, AttackGraphNode, AttackGraphNodeId}; + +/// A condition that has to be true for a node to match. +/// +/// Doesn't derive `PartialEq`/`Hash` since `matches` is a closure with +/// no meaningful value-equality. +pub struct SearchCondition { + pub matches: Box bool>, + pub greedy: bool, + pub min_repeated: usize, + pub max_repeated: usize, +} + +impl SearchCondition { + /// Matches any node unconditionally. + pub fn any() -> Self { + SearchCondition { + matches: Box::new(|_| true), + greedy: false, + min_repeated: 1, + max_repeated: 1, + } + } + + pub fn new(matches: impl Fn(&AttackGraphNode) -> bool + 'static) -> Self { + SearchCondition { + matches: Box::new(matches), + greedy: false, + min_repeated: 1, + max_repeated: 1, + } + } + + pub fn repeated(mut self, min_repeated: usize, max_repeated: usize) -> Self { + self.min_repeated = min_repeated; + self.max_repeated = max_repeated; + self + } + + pub fn greedy(mut self, greedy: bool) -> Self { + self.greedy = greedy; + self + } + + /// True if this condition can be matched again (another repetition). + pub fn can_match_again(&self, num_matches: usize) -> bool { + num_matches < self.max_repeated + } + + /// True if this condition must match again to be fulfilled. + pub fn must_match_again(&self, num_matches: usize) -> bool { + num_matches < self.min_repeated + } +} + +/// A pattern consists of conditions; the conditions are used to find all +/// matching sequences of nodes in an [`AttackGraph`]. +pub struct SearchPattern { + pub conditions: Vec, +} + +impl SearchPattern { + pub fn new(conditions: Vec) -> Self { + assert!( + !conditions.is_empty(), + "a search pattern needs at least one condition" + ); + SearchPattern { conditions } + } + + /// Search `graph` for this pattern and return the matching paths of + /// node ids. + pub fn find_matches(&self, graph: &AttackGraph) -> Vec> { + let first = &self.conditions[0]; + let mut matching_paths: HashSet> = HashSet::new(); + + for node_key in graph.nodes.keys() { + if (first.matches)(&graph.nodes[node_key]) { + find_matches_recursively( + graph, + node_key, + &self.conditions, + &[], + &mut matching_paths, + 0, + ); + } + } + + matching_paths.into_iter().collect() + } +} + +/// Find all paths of nodes that match `conditions`, recursively, +/// following children edges. +fn find_matches_recursively( + graph: &AttackGraph, + node: AttackGraphNodeId, + conditions: &[SearchCondition], + current_path: &[AttackGraphNodeId], + matching_paths: &mut HashSet>, + condition_match_count: usize, +) { + if current_path.contains(&node) { + // Avoid infinite loops through cycles. + return; + } + + let (curr_cond, next_conds) = conditions + .split_first() + .expect("condition list must not be empty"); + + if !next_conds.is_empty() && !curr_cond.must_match_again(condition_match_count) { + // Try the next condition for the current node if there are more + // and the current condition is already fulfilled. + find_matches_recursively(graph, node, next_conds, current_path, matching_paths, 0); + } + + if (curr_cond.matches)(&graph.nodes[node]) { + let mut path = current_path.to_vec(); + path.push(node); + let match_count = condition_match_count + 1; + + if !next_conds.is_empty() { + // If there are more conditions, try the next one for all children. + for &child in &graph.nodes[node].children { + find_matches_recursively(graph, child, next_conds, &path, matching_paths, 0); + } + } + if curr_cond.can_match_again(match_count) { + // If we can match the current condition again, try for all children. + for &child in &graph.nodes[node].children { + find_matches_recursively( + graph, + child, + conditions, + &path, + matching_paths, + match_count, + ); + } + } + if next_conds.is_empty() { + // Matched a full unique search pattern. + matching_paths.insert(path); + } + } +} diff --git a/core/maltoolbox-patternfinder/tests/pattern_golden.rs b/core/maltoolbox-patternfinder/tests/pattern_golden.rs new file mode 100644 index 00000000..862604a1 --- /dev/null +++ b/core/maltoolbox-patternfinder/tests/pattern_golden.rs @@ -0,0 +1,134 @@ +//! Diffs pattern-matching results against output produced by the real +//! Python mal-toolbox's `SearchPattern.find_matches`, over the same +//! hand-built wiperLang model used by the attackgraph golden tests. + +use std::collections::HashSet; +use std::rc::Rc; + +use maltoolbox_attackgraph::AttackGraph; +use maltoolbox_language::{compile_file, generate_graph}; +use maltoolbox_model::Model; +use maltoolbox_patternfinder::{SearchCondition, SearchPattern}; + +fn fixture_path(name: &str) -> std::path::PathBuf { + std::path::Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../maltoolbox-language/tests/fixtures" + )) + .join(name) +} + +fn build_graph() -> (Model, AttackGraph) { + let spec = compile_file(fixture_path("wiperLang.mal")).expect("compile"); + let lang_graph = Rc::new(generate_graph(spec).expect("build language graph")); + + let mut model = Model::new("Test Model", lang_graph); + let internet = model + .add_asset("Internet", Some("internet1".into()), None, None, None, true) + .unwrap(); + let device = model + .add_asset("Device", Some("device1".into()), None, None, None, true) + .unwrap(); + let data = model + .add_asset("Data", Some("data1".into()), None, None, None, true) + .unwrap(); + let wiper1 = model + .add_asset("Wiper", Some("wiper1".into()), None, None, None, true) + .unwrap(); + let wiper2 = model + .add_asset("Wiper", Some("wiper2".into()), None, None, None, true) + .unwrap(); + + model + .add_associated_assets(internet, "hosts", HashSet::from([device])) + .unwrap(); + model + .add_associated_assets(device, "data", HashSet::from([data])) + .unwrap(); + model + .add_associated_assets(device, "malware", HashSet::from([wiper1, wiper2])) + .unwrap(); + + let attack_graph = AttackGraph::from_model(&model).expect("build attack graph"); + (model, attack_graph) +} + +fn path_full_names( + graph: &AttackGraph, + model: &Model, + path: &[maltoolbox_attackgraph::AttackGraphNodeId], +) -> Vec { + path.iter() + .map(|&id| graph.full_name_of(id, Some(model))) + .collect() +} + +#[test] +fn matches_python_oracle_simple_pattern() { + let (model, graph) = build_graph(); + + let pattern = SearchPattern::new(vec![ + SearchCondition::new(|n| n.name == "infect"), + SearchCondition::new(|n| n.name == "activate"), + SearchCondition::new(|n| n.name == "exfiltrate"), + ]); + + let mut actual: Vec> = pattern + .find_matches(&graph) + .iter() + .map(|path| path_full_names(&graph, &model, path)) + .collect(); + actual.sort(); + + let expected: Vec> = vec![ + vec![ + "device1:infect".into(), + "wiper1:activate".into(), + "wiper1:exfiltrate".into(), + ], + vec![ + "device1:infect".into(), + "wiper2:activate".into(), + "wiper2:exfiltrate".into(), + ], + ]; + + assert_eq!(actual, expected); +} + +#[test] +fn matches_python_oracle_repeated_condition_pattern() { + let (model, graph) = build_graph(); + + let pattern = SearchPattern::new(vec![ + SearchCondition::new(|_| true).repeated(0, 1_000_000), + SearchCondition::new(|n| n.name == "trigger"), + ]); + + let mut actual: Vec> = pattern + .find_matches(&graph) + .iter() + .map(|path| path_full_names(&graph, &model, path)) + .collect(); + actual.sort(); + + let mut expected: Vec> = vec![ + vec![ + "device1:infect".into(), + "wiper1:activate".into(), + "wiper1:trigger".into(), + ], + vec![ + "device1:infect".into(), + "wiper2:activate".into(), + "wiper2:trigger".into(), + ], + vec!["wiper1:activate".into(), "wiper1:trigger".into()], + vec!["wiper1:trigger".into()], + vec!["wiper2:activate".into(), "wiper2:trigger".into()], + vec!["wiper2:trigger".into()], + ]; + expected.sort(); + + assert_eq!(actual, expected); +} diff --git a/core/maltoolbox-patternfinder/tests/test_attackgraph_patterns.rs b/core/maltoolbox-patternfinder/tests/test_attackgraph_patterns.rs new file mode 100644 index 00000000..3ea95ea8 --- /dev/null +++ b/core/maltoolbox-patternfinder/tests/test_attackgraph_patterns.rs @@ -0,0 +1,196 @@ +//! Port of `tests/patternfinder/test_attackgraph_patterns.py`. + +use std::collections::HashSet; +use std::rc::Rc; + +use maltoolbox_attackgraph::{AttackGraph, AttackGraphNodeId}; +use maltoolbox_language::graph::LanguageGraph; +use maltoolbox_model::Model; +use maltoolbox_patternfinder::{SearchCondition, SearchPattern}; + +fn fixtures_dir() -> &'static std::path::Path { + std::path::Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../maltoolbox-language/tests/fixtures" + )) +} + +/// A minimal stand-in for Python's `dummy_lang_graph` fixture: one asset +/// with one `and`-type attack step, just enough to construct bare +/// `AttackGraphNode`s from, mirroring how the Python tests build a graph +/// by hand without a `Model`. +fn dummy_lang_graph() -> Rc { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "maltoolbox-pattern-test-{}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("dummy.mal"); + std::fs::write( + &path, + r#" + #id: "dummy" + #version: "0.0.0" + category C { + asset DummyAsset { + & DummyAndAttackStep + } + } + "#, + ) + .unwrap(); + let graph = Rc::new(maltoolbox_language::from_mal_spec(&path).expect("compile dummy lang")); + std::fs::remove_dir_all(&dir).ok(); + graph +} + +/// Build an `AttackGraph` with no model, manually inserting bare nodes +/// for `DummyAsset:DummyAndAttackStep` with explicit ids, mirroring +/// `AttackGraphNode(id, dummy_and_step, dummy_asset)` plus manual +/// `.parents`/`.children` assignment in the Python tests. +fn bare_graph(lang_graph: Rc, ids: &[i64]) -> (AttackGraph, Vec) { + let dummy_asset = lang_graph.asset_id("DummyAsset").expect("DummyAsset"); + let step_id = lang_graph.asset(dummy_asset).attack_steps["DummyAndAttackStep"]; + + let mut graph = AttackGraph::empty(lang_graph); + let mut keys = Vec::new(); + for &id in ids { + let key = graph + .add_node(step_id, None, Some(id), None, None, None, None) + .expect("add_node"); + keys.push(key); + } + (graph, keys) +} + +fn link(graph: &mut AttackGraph, parent: AttackGraphNodeId, children: &[AttackGraphNodeId]) { + for &child in children { + graph.nodes[parent].children.insert(child); + graph.nodes[child].parents.insert(parent); + } +} + +#[test] +fn find_pattern_example_graph() { + let lang_graph = Rc::new( + maltoolbox_language::from_mar_archive( + fixtures_dir().join("org.mal-lang.coreLang-1.0.0.mar"), + ) + .expect("load corelang"), + ); + let mut model = Model::new("Test Model", lang_graph); + let app1 = model + .add_asset( + "Application", + Some("Application 1".into()), + None, + None, + None, + true, + ) + .unwrap(); + let app2 = model + .add_asset( + "Application", + Some("Application 2".into()), + None, + None, + None, + true, + ) + .unwrap(); + model + .add_associated_assets(app1, "appExecutedApps", HashSet::from([app2])) + .unwrap(); + + let attack_graph = AttackGraph::from_model(&model).expect("build attack graph"); + + let pattern = SearchPattern::new(vec![ + SearchCondition::new(|n| n.name == "attemptRead"), + SearchCondition::new(|n| n.name == "successfulRead"), + SearchCondition::new(|n| n.name == "read"), + ]); + + let paths = pattern.find_matches(&attack_graph); + for path in paths { + let names: Vec<&str> = path + .iter() + .map(|&id| attack_graph.nodes[id].name.as_str()) + .collect(); + assert_eq!(names, vec!["attemptRead", "successfulRead", "read"]); + } +} + +#[test] +fn find_multiple() { + let lang_graph = dummy_lang_graph(); + let (mut graph, ids) = bare_graph(lang_graph, &[1, 2, 3, 4, 5, 6, 7]); + let (n1, n2, n3, n4, n5, n6, n7) = (ids[0], ids[1], ids[2], ids[3], ids[4], ids[5], ids[6]); + + link(&mut graph, n1, &[n2, n3]); + link(&mut graph, n2, &[n4]); + link(&mut graph, n3, &[n5, n6]); + link(&mut graph, n4, &[n7]); + + // Search pattern from Node1 to either Node6 or Node7, through any + // number of intermediate nodes. + let pattern = SearchPattern::new(vec![ + SearchCondition::new(|n| n.id == 1), + SearchCondition::any().repeated(1, 1_000_000), + SearchCondition::new(|n| n.id == 6 || n.id == 7), + ]); + let paths = pattern.find_matches(&graph); + + assert_eq!(paths.len(), 2); + assert!(paths.contains(&vec![n1, n2, n4, n7])); + assert!(paths.contains(&vec![n1, n3, n6])); +} + +#[test] +fn find_multiple_same_subpath() { + let lang_graph = dummy_lang_graph(); + let (mut graph, ids) = bare_graph(lang_graph, &[1, 2, 3, 4, 5]); + let (n1, n2, n3, n4, n5) = (ids[0], ids[1], ids[2], ids[3], ids[4]); + + link(&mut graph, n1, &[n2, n3]); + link(&mut graph, n2, &[n4]); + link(&mut graph, n3, &[n5]); + + // Search pattern from Node1 to any node, through zero or more + // intermediate nodes. + let pattern = SearchPattern::new(vec![ + SearchCondition::new(|n| n.id == 1), + SearchCondition::new(|_| true).repeated(0, 1_000_000), + SearchCondition::new(|n| matches!(n.id, 2..=5)), + ]); + let paths = pattern.find_matches(&graph); + + assert!(paths.contains(&vec![n1, n2, n4])); + assert!(paths.contains(&vec![n1, n3, n5])); + assert!(paths.contains(&vec![n1, n2])); + assert!(paths.contains(&vec![n1, n3])); +} + +#[test] +fn two_same_start_end_node() { + let lang_graph = dummy_lang_graph(); + let (mut graph, ids) = bare_graph(lang_graph, &[1, 2, 3, 4]); + let (n1, n2, n3, n4) = (ids[0], ids[1], ids[2], ids[3]); + + link(&mut graph, n1, &[n2, n3]); + link(&mut graph, n2, &[n4]); + link(&mut graph, n3, &[n4]); + + let pattern = SearchPattern::new(vec![ + SearchCondition::new(|n| n.id == 1), + SearchCondition::any(), + SearchCondition::new(|n| n.id == 4), + ]); + let paths = pattern.find_matches(&graph); + + assert!(paths.contains(&vec![n1, n2, n4])); + assert!(paths.contains(&vec![n1, n3, n4])); +} diff --git a/maltoolbox/attackgraph/attackgraph.py b/maltoolbox/attackgraph/attackgraph.py deleted file mode 100644 index 2c833480..00000000 --- a/maltoolbox/attackgraph/attackgraph.py +++ /dev/null @@ -1,419 +0,0 @@ -"""MAL-Toolbox Attack Graph Module""" - -from __future__ import annotations - -import copy -import logging -from typing import TYPE_CHECKING - -from maltoolbox.attackgraph.detector import Detector -from maltoolbox.attackgraph.generate import ( - _create_detectors, - generate_graph, - link_node_children, -) -from maltoolbox.attackgraph.node_getters import get_node_by_full_name -from maltoolbox.attackgraph.partially_generate import ( - assoc_affected_nodes, - correct_node_children_on_modified_assoc, - create_nodes_from_assets, - nodes_to_be_removed, - switch_fieldname, -) -from maltoolbox.language.languagegraph import disaggregate_attack_step_full_name - -from ..file_utils import ( - load_dict_from_json_file, - load_dict_from_yaml_file, - save_dict_to_file, -) -from ..language import LanguageGraph, LanguageGraphAttackStep -from ..model import Model -from .node import AttackGraphNode - -if TYPE_CHECKING: - from ..model import ModelAsset - -logger = logging.getLogger(__name__) - - -def attack_graph_from_dict( - serialized_object: dict, lang_graph: LanguageGraph, model: Model | None -): - attack_graph = AttackGraph(lang_graph) - attack_graph.model = model - serialized_attack_steps: dict[str, dict] = serialized_object['attack_steps'] - - # Create all of the nodes in the imported attack graph. - for node_full_name, node_dict in serialized_attack_steps.items(): - # Recreate asset links if model is available. - node_asset = None - if model and 'asset' in node_dict: - node_asset = model.get_asset_by_name(node_dict['asset']) - if node_asset is None: - msg = ( - 'Failed to find asset with name "%s"' - ' when loading from attack graph dict' - ) - logger.error(msg, node_dict['asset']) - raise LookupError(msg % node_dict['asset']) - - lg_asset_name, lg_attack_step_name = disaggregate_attack_step_full_name( - node_dict['lang_graph_attack_step'] - ) - lg_attack_step = lang_graph.assets[lg_asset_name].attack_steps[ - lg_attack_step_name - ] - ag_node = attack_graph.add_node( - lg_attack_step=lg_attack_step, - node_id=node_dict['id'], - model_asset=node_asset, - ttc_dist=node_dict['ttc'], - existence_status=( - bool(node_dict['existence_status']) - if 'existence_status' in node_dict - else None - ), - # Give explicit full name if model is missing, otherwise - # it will generate automatically in node.full_name - full_name=node_full_name if not model else None, - ) - ag_node.tags = list(node_dict.get('tags', [])) - ag_node.extras = node_dict.get('extras', {}) - - if node_asset: - # Add AttackGraphNode to attack_step_nodes of asset - if hasattr(node_asset, 'attack_step_nodes'): - node_attack_steps = list(node_asset.attack_step_nodes) - node_attack_steps.append(ag_node) - node_asset.attack_step_nodes = node_attack_steps - else: - node_asset.attack_step_nodes = [ag_node] - - # Re-establish links between nodes. - for node_dict in serialized_attack_steps.values(): - _ag_node = attack_graph.nodes[node_dict['id']] - if not isinstance(_ag_node, AttackGraphNode): - msg = 'Failed to find node with id %s when loading attack graph from dict' - logger.error(msg, node_dict['id']) - raise LookupError(msg % node_dict['id']) # noqa: TRY004 - for child_id in node_dict['children']: - child = attack_graph.nodes[int(child_id)] - if child is None: - msg = ( - 'Failed to find child node with id %s' - ' when loading from attack graph from dict' - ) - logger.error(msg, child_id) - raise LookupError(msg % child_id) - _ag_node.children.add(child) - - for parent_id in node_dict['parents']: - parent = attack_graph.nodes[int(parent_id)] - if parent is None: - msg = ( - 'Failed to find parent node with id %s ' - 'when loading from attack graph from dict' - ) - logger.error(msg, parent_id) - raise LookupError(msg % parent_id) - _ag_node.parents.add(parent) - - return attack_graph - - -def attack_graph_from_file( - filename: str, lang_graph: LanguageGraph, model: Model | None -): - if model is not None: - logger.debug( - 'Load attack graph from file "%s" with model "%s".', filename, model.name - ) - else: - logger.debug('Load attack graph from file "%s" without model.', filename) - serialized_attack_graph = None - if filename.endswith(('.yml', '.yaml')): - serialized_attack_graph = load_dict_from_yaml_file(filename) - elif filename.endswith('.json'): - serialized_attack_graph = load_dict_from_json_file(filename) - else: - raise ValueError('Unknown file extension, expected json/yml/yaml') - return attack_graph_from_dict(serialized_attack_graph, lang_graph, model) - - -class AttackGraph: - """Graph representation of attack and defense steps""" - - def __init__(self, lang_graph: LanguageGraph, model: Model | None = None): - self.nodes: dict[int, AttackGraphNode] = {} - self.attack_steps: list[AttackGraphNode] = [] - self.defense_steps: list[AttackGraphNode] = [] - self.detectors: list[Detector] = [] - self.model = model - self.lang_graph = lang_graph - self.next_node_id = 0 - self.full_name_to_node: dict[str, AttackGraphNode] = {} - - if self.model is not None: - ( - self.nodes, - self.attack_steps, - self.defense_steps, - self.full_name_to_node, - self.detectors, - ) = generate_graph(self.model) - self.next_node_id = max((self.nodes or {}).keys(), default=-1) + 1 - - def __repr__(self) -> str: - return ( - f'AttackGraph(Number of nodes: {len(self.nodes)}, ' - f'model: {self.model}, language: {self.lang_graph}' - ) - - def _to_dict(self) -> dict: - """Convert AttackGraph to dict""" - serialized_attack_steps = {} - for ag_node in self.nodes.values(): - serialized_attack_steps[ag_node.full_name] = ag_node.to_dict() - return {'attack_steps': serialized_attack_steps} - - def __deepcopy__(self, memo): - """Custom deepcopy implementation for attack graph""" - # Check if the object is already in the memo dictionary - if id(self) in memo: - return memo[id(self)] - - copied_attackgraph = AttackGraph(self.lang_graph) - copied_attackgraph.model = self.model - copied_attackgraph.nodes = {} - - # Deep copy nodes - for node_id, node in self.nodes.items(): - copied_node = copy.deepcopy(node, memo) - copied_attackgraph.nodes[node_id] = copied_node - - # Re-link node references - for node in self.nodes.values(): - if node.parents: - memo[id(node)].parents = copy.deepcopy(node.parents, memo) - if node.children: - memo[id(node)].children = copy.deepcopy(node.children, memo) - - # Copy lookup dicts - copied_attackgraph.full_name_to_node = copy.deepcopy( - self.full_name_to_node, memo - ) - - # Copy counters - copied_attackgraph.next_node_id = self.next_node_id - return copied_attackgraph - - def save_to_file(self, filename: str) -> None: - """Save to json/yml depending on extension""" - logger.debug('Save attack graph to file "%s".', filename) - return save_dict_to_file(filename, self._to_dict()) - - @classmethod - def load_from_file( - cls, filename: str, lang_graph: LanguageGraph, model: Model | None = None - ) -> AttackGraph: - """Create from json or yaml file depending on file extension""" - return attack_graph_from_file(filename, lang_graph, model) - - def get_node_by_full_name(self, full_name: str) -> AttackGraphNode: - """Return the attack node that matches the full name provided. - - Arguments: - --------- - full_name - the full name of the attack graph node we are looking - for - - Return: - ------ - The attack step node that matches the given full name. - - """ - return get_node_by_full_name(self.full_name_to_node, full_name) - - def regenerate_graph(self) -> None: - """Regenerate the attack graph based on the original model instance and - the MAL language specification provided at initialization. - """ - assert self.model, 'Model required to generate graph' - ( - self.nodes, - self.attack_steps, - self.defense_steps, - self.full_name_to_node, - self.detectors, - ) = generate_graph(self.model) - self.next_node_id = max(self.nodes.keys(), default=-1) + 1 - - def partially_regenerate_graph( - self, - new_assets: set[ModelAsset] | None = None, - new_associations: set[tuple[ModelAsset, str, ModelAsset]] | None = None, - removed_assets: set[ModelAsset] | None = None, - removed_associations: set[tuple[ModelAsset, str, ModelAsset]] | None = None, - ) -> set[AttackGraphNode]: - """Partially regenerate the attack graph based on changes to the model. - - Arguments: - --------- - new_assets - a set of new assets that have been added to the model - new_associations - a set of new associations that have been added to the model - removed_assets - a set of assets that have been removed from the model - removed_associations - a set of associations that have been removed from the model - - Return: - ------ - The newly created nodes for the attack graph. - """ - - if new_assets is None: - new_assets = set() - if new_associations is None: - new_associations = set() - if removed_assets is None: - removed_assets = set() - if removed_associations is None: - removed_associations = set() - - - assert self.model, 'Model required to generate graph' - - ( - id_to_created_nodes, - created_attack_steps, - created_defense_steps, - full_name_to_created_nodes, - ) = create_nodes_from_assets(new_assets, self.next_node_id, self.model) - - self.nodes.update(id_to_created_nodes) - self.attack_steps.extend(created_attack_steps) - self.defense_steps.extend(created_defense_steps) - self.full_name_to_node.update(full_name_to_created_nodes) - self.next_node_id += len(id_to_created_nodes) - - # Do full linking of children for steps of newly created assets - for ag_node in full_name_to_created_nodes.values(): - link_node_children(self.model, ag_node, self.full_name_to_node) - - removed_assoc_dict: dict[ModelAsset, dict[str, set[ModelAsset]]] = {} - for left_asset, fieldname, right_asset in removed_associations: - removed_assoc_dict.setdefault(left_asset, {}).setdefault(fieldname, set()).add(right_asset) - removed_assoc_dict.setdefault(right_asset, {}).setdefault( - switch_fieldname(left_asset, fieldname), set() - ).add(left_asset) - - new_assoc_dict: dict[ModelAsset, dict[str, set[ModelAsset]]] = {} - for left_asset, fieldname, right_asset in new_associations: - new_assoc_dict.setdefault(left_asset, {}).setdefault(fieldname, set()).add(right_asset) - new_assoc_dict.setdefault(right_asset, {}).setdefault( - switch_fieldname(left_asset, fieldname), set() - ).add(left_asset) - # Correct links referencing modified associations - affected_assoc_dict: dict[ModelAsset, dict[str, set[ModelAsset]]] = {} - for assoc_dict in (new_assoc_dict, removed_assoc_dict): - for asset, fields in assoc_dict.items(): - dest_fields = affected_assoc_dict.setdefault(asset, {}) - for fieldname, assets in fields.items(): - dest_fields.setdefault(fieldname, set()).update(assets) - nodes_of_modified_assoc = assoc_affected_nodes(self.model, affected_assoc_dict, self.full_name_to_node) - for ag_node in nodes_of_modified_assoc: - assert ag_node.model_asset is not None, "Attack graph must have access to the model to do partial regeneration." - correct_node_children_on_modified_assoc( - self.model, - ag_node, - self.full_name_to_node, - ) - - new_detectors = _create_detectors(full_name_to_created_nodes, self.model) - self.detectors.extend(new_detectors) - - removal_candidates = nodes_to_be_removed(removed_assets, self.full_name_to_node) - for removal_candidate in removal_candidates: - self.remove_node(removal_candidate) - - return set(created_attack_steps + created_defense_steps) - - def add_node( - self, - lg_attack_step: LanguageGraphAttackStep, - node_id: int | None = None, - model_asset: ModelAsset | None = None, - ttc_dist: dict | None = None, - existence_status: bool | None = None, - full_name: str | None = None, - ) -> AttackGraphNode: - """Create and add a node to the graph - Arguments: - lg_attack_step - the language graph attack step that corresponds - to the attack graph node to create - node_id - id to assign to the newly created node, usually - provided only when loading an existing attack - graph from a file. If not provided the id will - be set to the next highest id available. - model_asset - the model asset that corresponds to the attack - step node. While optional it is highly - recommended that this be provided. It should - only be ommitted if the model which was used to - generate the attack graph is not available when - loading an attack graph from a file. - ttc_dist - the ttc distribution to assign to the node. This - is relevant for when we want to override the ttc - distribution as it is defined in the language. - Frequently used for defenses. - existence_status - the existence status of the node. Only, relevant - for exist and notExist type nodes. - - Return: - ------ - The newly created attack step node. - - """ - - node_id = node_id if node_id is not None else self.next_node_id - if node_id in self.nodes: - raise ValueError(f'Node index {node_id} already in use.') - self.next_node_id = node_id + 1 - - logger.debug( - 'Create and add to attackgraph node of type "%s" with id:%d.\n', - lg_attack_step.full_name, - node_id, - ) - - node = AttackGraphNode( - node_id=node_id, - lg_attack_step=lg_attack_step, - model_asset=model_asset, - ttc_dist=ttc_dist, - existence_status=existence_status, - full_name=full_name, - ) - - # Add to different lists depending on types - # Useful but not vital for functionality - if node.type in ('or', 'and'): - self.attack_steps.append(node) - if node.type == 'defense': - self.defense_steps.append(node) - - self.nodes[node_id] = node - self.full_name_to_node[node.full_name] = node - - return node - - def remove_node(self, node: AttackGraphNode) -> None: - """Remove node from attack graph - Arguments: - node - the node we wish to remove from the attack graph - """ - logger.debug('Remove node "%s"(%d).', node.full_name, node.id) - for child in node.children: - child.parents.remove(node) - for parent in node.parents: - parent.children.remove(node) - del self.nodes[node.id] - del self.full_name_to_node[node.full_name] diff --git a/maltoolbox/attackgraph/detector.py b/maltoolbox/attackgraph/detector.py deleted file mode 100644 index 400b3fe4..00000000 --- a/maltoolbox/attackgraph/detector.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from maltoolbox.attackgraph.node import AttackGraphNode - - -@dataclass(frozen=True, eq=True) -class Detector: - name: str | None - node: AttackGraphNode - potential_context: dict[str, set[AttackGraphNode]] - tprate: float | None = None - fprate: float | None = None - - def to_dict(self) -> dict: - return { - 'name': self.name, - 'node': self.node, - 'potential_context': self.potential_context, - 'tprate': self.tprate, - } diff --git a/maltoolbox/attackgraph/generate.py b/maltoolbox/attackgraph/generate.py deleted file mode 100644 index aa7e5ab6..00000000 --- a/maltoolbox/attackgraph/generate.py +++ /dev/null @@ -1,346 +0,0 @@ -"""Graph generation functions""" - -from __future__ import annotations - -import json -import logging -from typing import TYPE_CHECKING - -from maltoolbox.attackgraph.detector import Detector -from maltoolbox.attackgraph.node_getters import get_node_by_full_name -from maltoolbox.attackgraph.ttcs import get_ttc_dist -from maltoolbox.language.language_graph_detector import LanguageGraphDetector - -from ..exceptions import ( - AttackGraphException, - AttackGraphStepExpressionError, - LanguageGraphException, -) -from ..language import ExpressionsChain, LanguageGraphAttackStep -from ..model import Model -from .node import AttackGraphNode - -if TYPE_CHECKING: - from typing import Any - - from ..model import ModelAsset - -logger = logging.getLogger(__name__) - - -def link_node_children( - model: Model, - ag_node: AttackGraphNode, - full_name_to_node: dict[str, AttackGraphNode], -) -> None: - """Link one node to its children.""" - if not ag_node.model_asset: - raise AttackGraphException('Attack graph node is missing asset link') - - lg_asset = model.lang_graph.assets[ag_node.model_asset.type] - lg_attack_step: LanguageGraphAttackStep | None = lg_asset.attack_steps[ag_node.name] - while lg_attack_step: - for child_type, expr_chains in lg_attack_step.children.items(): - for expr_chain in expr_chains: - link_from_expr_chain( - model, ag_node, child_type, expr_chain, full_name_to_node - ) - if lg_attack_step.overrides: - break - lg_attack_step = lg_attack_step.inherits - - -def link_from_expr_chain( - model: Model, - ag_node: AttackGraphNode, - child_type: LanguageGraphAttackStep, - expr_chain: ExpressionsChain | None, - full_name_to_node: dict[str, AttackGraphNode], -) -> None: - """Link a node to targets from a specific expression chain.""" - if not ag_node.model_asset: - raise AttackGraphException('Need model asset connection to generate graph') - - target_assets = follow_expr_chain(model, {ag_node.model_asset}, expr_chain) - for target_asset in target_assets: - if not target_asset: - continue - target_node = get_node_by_full_name( - full_name_to_node, f'{target_asset.name}:{child_type.name}' - ) - if not target_node: - raise AttackGraphStepExpressionError( - f'Failed to find target node "{target_asset.name}:{child_type.name}" ' - f'for "{ag_node.full_name}"({ag_node.id})' - ) - logger.debug( - 'Linking attack step "%s"(%d) to attack step "%s"(%d)', - ag_node.full_name, - ag_node.id, - target_node.full_name, - target_node.id, - ) - ag_node.children.add(target_node) - target_node.parents.add(ag_node) - - -def follow_field_expr_chain( - target_assets: set[ModelAsset], expr_chain: ExpressionsChain -): - # Change the target assets from the current ones to the - # associated assets given the specified field name. - if not expr_chain.fieldname: - raise LanguageGraphException( - '"field" step expression chain is missing fieldname.' - ) - new_target_assets: set[ModelAsset] = set() - new_target_assets.update( - *( - asset.associated_assets.get(expr_chain.fieldname, set()) - for asset in target_assets - ) - ) - return new_target_assets - - -def follow_transitive_expr_chain( - model: Model, target_assets: set[ModelAsset], expr_chain: ExpressionsChain -): - if not expr_chain.sub_link: - raise LanguageGraphException( - '"transitive" step expression chain is missing sub link.' - ) - - new_assets = target_assets - while new_assets := follow_expr_chain(model, new_assets, expr_chain.sub_link): - new_assets = new_assets.difference(target_assets) - if not new_assets: - break - target_assets.update(new_assets) - return target_assets - - -def follow_subtype_expr_chain( - model: Model, target_assets: set[ModelAsset], expr_chain: ExpressionsChain -): - if not expr_chain.sub_link: - raise LanguageGraphException( - '"subType" step expression chain is missing sub link.' - ) - new_target_assets = set() - new_target_assets.update( - follow_expr_chain(model, target_assets, expr_chain.sub_link) - ) - selected_new_target_assets = set() - for asset in new_target_assets: - lang_graph_asset = model.lang_graph.assets[asset.type] - if not lang_graph_asset: - raise LookupError( - f'Failed to find asset "{asset.type}" in the language graph.' - ) - lang_graph_subtype_asset = expr_chain.subtype - if not lang_graph_subtype_asset: - raise LookupError( - 'Failed to find asset "{expr_chain.subtype}" in the language graph.' - ) - if lang_graph_asset.is_subasset_of(lang_graph_subtype_asset): - selected_new_target_assets.add(asset) - - return selected_new_target_assets - - -def follow_union_intersection_difference_expr_chain( - model: Model, target_assets: set[ModelAsset], expr_chain: ExpressionsChain -) -> set[Any]: - # The set operators are used to combine the left hand and - # right hand targets accordingly. - if not expr_chain.left_link: - raise LanguageGraphException( - '"%s" step expression chain is missing the left link.', expr_chain.type - ) - if not expr_chain.right_link: - raise LanguageGraphException( - '"%s" step expression chain is missing the right link.', expr_chain.type - ) - lh_targets = follow_expr_chain(model, target_assets, expr_chain.left_link) - rh_targets = follow_expr_chain(model, target_assets, expr_chain.right_link) - - if expr_chain.type == 'union': - # Once the assets become hashable set operations should be - # used instead. - return lh_targets.union(rh_targets) - - if expr_chain.type == 'intersection': - return lh_targets.intersection(rh_targets) - - if expr_chain.type == 'difference': - return lh_targets.difference(rh_targets) - - raise ValueError('Expr chain must be of type union, intersectin or difference') - - -def follow_collect_expr_chain( - model: Model, target_assets: set[ModelAsset], expr_chain: ExpressionsChain -) -> set[Any]: - if not expr_chain.left_link: - raise LanguageGraphException( - '"collect" step expression chain missing the left link.' - ) - if not expr_chain.right_link: - raise LanguageGraphException( - '"collect" step expression chain missing the right link.' - ) - lh_targets = follow_expr_chain(model, target_assets, expr_chain.left_link) - rh_targets = set() - for lh_target in lh_targets: - rh_targets |= follow_expr_chain(model, {lh_target}, expr_chain.right_link) - return rh_targets - - -def follow_expr_chain( - model: Model, target_assets: set[ModelAsset], expr_chain: ExpressionsChain | None -): - if expr_chain is None: - # There is no expressions chain link left to follow return the - # current target assets - return set(target_assets) - - if logger.isEnabledFor(logging.DEBUG): - # Avoid running json.dumps when not in debug - logger.debug( - 'Following Expressions Chain:\n%s', - json.dumps(expr_chain.to_dict(), indent=2), - ) - - match expr_chain.type: - case 'union' | 'intersection' | 'difference': - return follow_union_intersection_difference_expr_chain( - model, target_assets, expr_chain - ) - - case 'field': - return follow_field_expr_chain(target_assets, expr_chain) - - case 'transitive': - return follow_transitive_expr_chain(model, target_assets, expr_chain) - - case 'subType': - return follow_subtype_expr_chain(model, target_assets, expr_chain) - - case 'collect': - return follow_collect_expr_chain(model, target_assets, expr_chain) - - case _: - msg = 'Unknown attack expressions chain type: %s' - logger.error(msg, expr_chain.type) - raise AttackGraphStepExpressionError(msg % expr_chain.type) - - -def link_nodes_by_language(model: Model, full_name_to_node: dict[str, AttackGraphNode]): - for ag_node in full_name_to_node.values(): - link_node_children(model, ag_node, full_name_to_node) - - -def create_nodes_from_model(model: Model): - id_to_node = {} - full_name_to_node = {} - attack_steps = [] - defense_steps = [] - - node_id = 0 - for asset in model.assets.values(): - asset.attack_step_nodes = [] # TODO: deprecate this - for lg_attack_step in asset.lg_asset.attack_steps.values(): - node = AttackGraphNode( - node_id=node_id, - lg_attack_step=lg_attack_step, - model_asset=asset, - ttc_dist=get_ttc_dist(asset, lg_attack_step), - existence_status=(get_existance_status(model, asset, lg_attack_step)), - ) - asset.attack_step_nodes.append(node) # TODO: deprecate this - id_to_node[node.id] = node - full_name_to_node[node.full_name] = node - - if node.type in ('or', 'and'): - attack_steps.append(node) - elif node.type == 'defense': - defense_steps.append(node) - - node_id += 1 - - return id_to_node, attack_steps, defense_steps, full_name_to_node - - -def _get_potential_context( - model: Model, - asset: ModelAsset, - nodes: dict[str, AttackGraphNode], - lg_detector: LanguageGraphDetector, -) -> dict[str, set[AttackGraphNode]]: - - context: dict[str, set[AttackGraphNode]] = {} - for context_label, context_item in lg_detector.context.items(): - target_assets = follow_expr_chain(model, {asset}, context_item.expr) - for target_asset in target_assets: - target_node = get_node_by_full_name( - nodes, f'{target_asset.name}:{context_item.attack_step_name}' - ) - if not target_node: - raise AttackGraphException( - f'Failed to find target node for context item "{context_label}" ' - f'with asset "{target_asset.name}" and attack step "{context_item.attack_step_name}"' - ) - context.setdefault(context_label, set()).add(target_node) - return context - - -def _create_detectors( - nodes: dict[str, AttackGraphNode], model: Model -) -> list[Detector]: - detectors: list[Detector] = [] - for node in nodes.values(): - node_detectors = {} - for det_label, lg_detector in node.lg_attack_step.detectors.items(): - assert node.model_asset, 'Attack graph node is missing asset link' - node_detectors[det_label] = Detector( - name=det_label, - node=node, - potential_context=_get_potential_context( - model, node.model_asset, nodes, lg_detector - ), - tprate=lg_detector.tprate, - fprate=lg_detector.fprate, - ) - node.detectors = node_detectors - detectors.extend(node_detectors.values()) - return detectors - - -def generate_graph(model: Model): - id_to_node, attack_steps, defense_steps, full_name_to_node = ( - create_nodes_from_model(model) - ) - link_nodes_by_language(model, full_name_to_node) - detectors = _create_detectors(full_name_to_node, model) - return id_to_node, attack_steps, defense_steps, full_name_to_node, detectors - - -def get_existance_status( - model: Model, asset: ModelAsset, lg_attack_step: LanguageGraphAttackStep -): - - if lg_attack_step.type not in ('exist', 'notExist'): - # No existence status for other type of steps - return None - - existence_status = False - for requirement in lg_attack_step.requires: - target_assets = follow_expr_chain(model, {asset}, requirement) - # If the step expression resolution yielded - # the target assets then the required assets - # exist in the model. - if target_assets: - existence_status = True - break - return existence_status diff --git a/maltoolbox/attackgraph/node.py b/maltoolbox/attackgraph/node.py deleted file mode 100644 index ddc64695..00000000 --- a/maltoolbox/attackgraph/node.py +++ /dev/null @@ -1,127 +0,0 @@ -"""MAL-Toolbox Attack Graph Node""" - -from __future__ import annotations - -import copy -from functools import cached_property -from typing import TYPE_CHECKING - -from maltoolbox.attackgraph.detector import Detector - -if TYPE_CHECKING: - from ..language import LanguageGraphAttackStep - from ..model import ModelAsset - - -class AttackGraphNode: - """Node part of AttackGraph""" - - def __init__( - self, - node_id: int, - lg_attack_step: LanguageGraphAttackStep, - model_asset: ModelAsset | None = None, - ttc_dist: dict | None = None, - existence_status: bool | None = None, - full_name: str | None = None, - ): - self.lg_attack_step = lg_attack_step - self.name = lg_attack_step.name - self.type = lg_attack_step.type - self.causal_mode = self.lg_attack_step.causal_mode - self.ttc = ttc_dist if ttc_dist is not None else lg_attack_step.ttc - self.tags = lg_attack_step.tags - self.additive_model_effects = lg_attack_step.additive_model_effects if len(lg_attack_step.additive_model_effects) > 0 else None - self.subtractive_model_effects = lg_attack_step.subtractive_model_effects if len(lg_attack_step.subtractive_model_effects) > 0 else None - - self._full_name = full_name - self.id = node_id - self.model_asset = model_asset - self.existence_status = existence_status - self.children: set[AttackGraphNode] = set() - self.parents: set[AttackGraphNode] = set() - self.extras: dict = {} - self.detectors: dict[str, Detector] = {} - - def to_dict(self) -> dict: - """Convert node to dictionary""" - node_dict: dict = { - 'id': self.id, - 'type': self.type, - 'lang_graph_attack_step': self.lg_attack_step.full_name, - 'name': self.name, - 'ttc': self.ttc, - 'children': {child.id: child.full_name for child in self.children}, - 'parents': {parent.id: parent.full_name for parent in self.parents}, - } - - for detector in self.detectors.values(): - node_dict.setdefault('detectors', {})[detector.name] = detector.to_dict() - if self.model_asset is not None: - node_dict['asset'] = str(self.model_asset.name) - if self.existence_status is not None: - node_dict['existence_status'] = self.existence_status - if self.tags: - node_dict['tags'] = list(self.tags) - if self.extras: - node_dict['extras'] = self.extras - - return node_dict - - def __repr__(self) -> str: - return ( - f'AttackGraphNode(name: "{self.full_name}", id: {self.id}, ' - f'type: {self.type})' - ) - - def __deepcopy__(self, memo) -> AttackGraphNode: - """Deep copy an attackgraph node - - The deepcopy will copy over node specific information, such as type, - name, etc., but it will not copy attack graph relations such as - parents, children, or attackers it is compromised by. These references - should be recreated when deepcopying the attack graph itself. - - """ - # Check if the object is already in the memo dictionary - if id(self) in memo: - return memo[id(self)] - - copied_node = AttackGraphNode( - node_id=self.id, - model_asset=self.model_asset, - lg_attack_step=self.lg_attack_step, - ) - - copied_node.tags = copy.deepcopy(self.tags, memo) - copied_node.extras = copy.deepcopy(self.extras, memo) - copied_node.ttc = copy.deepcopy(self.ttc, memo) - - copied_node.existence_status = self.existence_status - - # Remember that self was already copied - memo[id(self)] = copied_node - - return copied_node - - @property - def full_name(self) -> str: - """Return the full name of the attack step. This is normally a - combination of the asset name to which the attack step - belongs and attack step name itself, but can also be - explicitly set or a combination of the step id and step name. - """ - if self._full_name: - # Explicitly set - return self._full_name - if self.model_asset: - # Inherited from model asset - full_name = self.model_asset.name + ':' + self.name - else: - # Fallback: use ID - full_name = str(self.id) + ':' + self.name - return full_name - - @cached_property - def info(self) -> dict[str, str]: - return self.lg_attack_step.info diff --git a/maltoolbox/attackgraph/node_getters.py b/maltoolbox/attackgraph/node_getters.py deleted file mode 100644 index 813bb79c..00000000 --- a/maltoolbox/attackgraph/node_getters.py +++ /dev/null @@ -1,38 +0,0 @@ -"""MAL-Toolbox Attack Graph Module""" - -from __future__ import annotations - -import logging - -from ..str_utils import levenshtein_distance -from .node import AttackGraphNode - -logger = logging.getLogger(__name__) - - -def get_similar_full_names(full_name_to_node: dict[str, AttackGraphNode], q: str): - shortest_dist = 100 - similar_names = [] - - for full_name in full_name_to_node: - dist = levenshtein_distance(q, full_name) - if dist == shortest_dist: - similar_names.append(full_name) - elif dist < shortest_dist: - similar_names = [full_name] - shortest_dist = dist - - return similar_names - - -def get_node_by_full_name( - full_name_to_node: dict[str, AttackGraphNode], full_name: str -): - logger.debug('Looking up node with full name "%s"', full_name) - if full_name not in full_name_to_node: - similar_names = get_similar_full_names(full_name_to_node, full_name) - raise LookupError( - f'Could not find node with name "{full_name}". ' - f'Did you mean: {", ".join(similar_names)}?' - ) - return full_name_to_node[full_name] diff --git a/maltoolbox/attackgraph/partially_generate.py b/maltoolbox/attackgraph/partially_generate.py deleted file mode 100644 index 987f2c6d..00000000 --- a/maltoolbox/attackgraph/partially_generate.py +++ /dev/null @@ -1,303 +0,0 @@ -"""Graph generation functions to update the attack graph when new assets are added to the model.""" - -import logging - -from maltoolbox.attackgraph.generate import ( - follow_expr_chain, - get_existance_status, -) -from maltoolbox.attackgraph.ttcs import get_ttc_dist -from maltoolbox.language import LanguageGraphAttackStep -from maltoolbox.language.expression_chain import ( - ExpressionsChain, - ExprType, - chain_fieldnames, -) - -from ..exceptions import ( - AttackGraphException, -) -from ..model import Model, ModelAsset -from .node import AttackGraphNode - -logger = logging.getLogger(__name__) - -def create_nodes_from_assets( - assets: set[ModelAsset], starting_id: int, model: Model -) -> tuple[dict, list, list, dict]: - id_to_node = {} - full_name_to_node = {} - attack_steps = [] - defense_steps = [] - - node_id = starting_id - for asset in assets: - asset.attack_step_nodes = [] # TODO: deprecate this - for lg_attack_step in asset.lg_asset.attack_steps.values(): - node = AttackGraphNode( - node_id=node_id, - lg_attack_step=lg_attack_step, - model_asset=asset, - ttc_dist=get_ttc_dist(asset, lg_attack_step), - existence_status=(get_existance_status(model, asset, lg_attack_step)), - ) - asset.attack_step_nodes.append(node) # TODO: deprecate this - id_to_node[node.id] = node - full_name_to_node[node.full_name] = node - - if node.type in ('or', 'and'): - attack_steps.append(node) - elif node.type == 'defense': - defense_steps.append(node) - - node_id += 1 - - return id_to_node, attack_steps, defense_steps, full_name_to_node - -def switch_fieldname(asset: ModelAsset, fieldname: str) -> str: - """Given an asset and a fieldname, return the other fieldname in the association.""" - assoc_def = asset.lg_asset.associations.get(fieldname) - if not assoc_def: - raise AttackGraphException( - f'Fieldname {fieldname} not found in associations of asset {asset.name}' - ) - if fieldname == assoc_def.left_field.fieldname: - return assoc_def.right_field.fieldname - elif fieldname == assoc_def.right_field.fieldname: - return assoc_def.left_field.fieldname - else: - raise AttackGraphException( - f'Fieldname {fieldname} not found in association {assoc_def.name}' - ) - -def correct_node_children_on_modified_assoc( - model: Model, - affected_node: AttackGraphNode, - full_name_to_node: dict[str, AttackGraphNode], -) -> None: - """Recompute ag_node's children from the model's current associations, - since the expression chain's actual target may be several hops past - the association that changed.""" - if not affected_node.model_asset: - raise AttackGraphException('Attack graph node is missing asset link') - model_asset = affected_node.model_asset - - lg_asset = model.lang_graph.assets[model_asset.type] - lg_attack_step: LanguageGraphAttackStep | None = lg_asset.attack_steps[affected_node.name] - - correct_children: set[AttackGraphNode] = set() - while lg_attack_step: - for child_type, expr_chains in lg_attack_step.children.items(): - for expr_chain in expr_chains: - for target_asset in follow_expr_chain(model, {model_asset}, expr_chain): - target_node = full_name_to_node.get(f'{target_asset.name}:{child_type.name}') - if target_node is None: - raise AttackGraphException( - f'Failed to find target node "{target_asset.name}:{child_type.name}" ' - f'for "{affected_node.full_name}"({affected_node.id})' - ) - correct_children.add(target_node) - if lg_attack_step.overrides: - break - lg_attack_step = lg_attack_step.inherits - - for target_node in correct_children - affected_node.children: - logger.debug( - 'Linking attack step "%s"(%d) to attack step "%s"(%d)', - affected_node.full_name, affected_node.id, target_node.full_name, target_node.id, - ) - affected_node.children.add(target_node) - target_node.parents.add(affected_node) - - for target_node in affected_node.children - correct_children: - logger.debug( - 'Unlinking attack step "%s"(%d) from attack step "%s"(%d)', - affected_node.full_name, affected_node.id, target_node.full_name, target_node.id, - ) - affected_node.children.discard(target_node) - target_node.parents.discard(affected_node) - -def nodes_to_be_removed( - removed_assets: set[ModelAsset], full_name_to_node: dict[str, AttackGraphNode] -) -> set[AttackGraphNode]: - """Remove nodes from the attack graph that correspond to the given assets.""" - removal_candidates = set() - for asset in removed_assets: - for lg_attack_step in asset.lg_asset.attack_steps.values(): - node_full_name = f'{asset.name}:{lg_attack_step.name}' - node = full_name_to_node.get(node_full_name) - if not node: - raise AttackGraphException( - f'Failed to find {lg_attack_step.full_name} for removed asset {asset.name}.' - ) - removal_candidates.add(node) - return removal_candidates - -def assoc_affected_expr_chain( - model: Model, - instigating_assets: set[ModelAsset], - affected_assoc_dict: dict[ModelAsset, dict[str, set[ModelAsset]]], - expr_chain: ExpressionsChain | None, - modified_fieldnames: frozenset[str] | None = None, -) -> bool: - """Check whether evaluating expr_chain starting from instigating_assets - passes through any association recorded in affected_assoc_dict.""" - if expr_chain is None or not instigating_assets: - return False - if modified_fieldnames is None: - modified_fieldnames = frozenset( - fieldname for fields in affected_assoc_dict.values() for fieldname in fields - ) - if not (expr_chain.fieldnames & modified_fieldnames): - return False - if expr_chain.type == ExprType.FIELD: - assert expr_chain.fieldname is not None, "Fieldname should not be None for FIELD type" - return any( - expr_chain.fieldname in affected_assoc_dict.get(instigating_asset, {}) - for instigating_asset in instigating_assets - ) - elif expr_chain.type in (ExprType.UNION, ExprType.INTERSECTION, ExprType.DIFFERENCE): - # Both sides are evaluated starting from the same assets. - return ( - assoc_affected_expr_chain(model, instigating_assets, affected_assoc_dict, expr_chain.left_link, modified_fieldnames) - or assoc_affected_expr_chain(model, instigating_assets, affected_assoc_dict, expr_chain.right_link, modified_fieldnames) - ) - elif expr_chain.type == ExprType.COLLECT: - # The right hand side is evaluated starting from the assets reached - # by the left hand side, not from the original instigating assets. - if ( - chain_fieldnames(expr_chain.left_link) & modified_fieldnames - and assoc_affected_expr_chain(model, instigating_assets, affected_assoc_dict, expr_chain.left_link, modified_fieldnames) - ): - return True - if not (chain_fieldnames(expr_chain.right_link) & modified_fieldnames): - return False - next_assets = follow_expr_chain(model, instigating_assets, expr_chain.left_link) - return assoc_affected_expr_chain(model, next_assets, affected_assoc_dict, expr_chain.right_link, modified_fieldnames) - elif expr_chain.type == ExprType.SUBTYPE: - return assoc_affected_expr_chain(model, instigating_assets, affected_assoc_dict, expr_chain.sub_link, modified_fieldnames) - elif expr_chain.type == ExprType.TRANSITIVE: - assert expr_chain.sub_link is not None, "Sub link should not be None for TRANSITIVE type" - # Check every depth of the transitive closure, since the modified - # association may be several hops away from instigating_assets. - frontier = set(instigating_assets) - visited: set[ModelAsset] = set() - while frontier: - if assoc_affected_expr_chain(model, frontier, affected_assoc_dict, expr_chain.sub_link, modified_fieldnames): - return True - visited |= frontier - frontier = follow_expr_chain(model, frontier, expr_chain.sub_link) - visited - return False - else: - raise ValueError(f"Unknown expression chain type: {expr_chain.type}") - -def assoc_left_assets( - model: Model, - affected_assoc_dict: dict[ModelAsset, dict[str, set[ModelAsset]]], - expr_chain: ExpressionsChain | None, - modified_fieldnames: frozenset[str], -) -> set[ModelAsset]: - """Return every left asset from modified associations, - based on if the expression chain reaches the association. - - Only valid for additive chains, whose results union across assets. - - Arguments: - --------- - model - the model the assets belong to - affected_assoc_dict - per-asset, per-fieldname sets of associated - assets that were modified (added or removed) - expr_chain - the expressions chain to walk backward from - modified_fieldnames - every fieldname appearing anywhere in - affected_assoc_dict, used to skip sub-chains - that can't have been affected - - Return: - ------ - The set of left assets, from which the expression chain reaches a modified association. - - """ - if expr_chain is None or not (expr_chain.fieldnames & modified_fieldnames): - return set() - - if expr_chain.type == ExprType.FIELD: - assert expr_chain.fieldname is not None, "Fieldname should not be None for FIELD type" - # Fieldname strings aren't unique across associations, so also match - # on the association to avoid an unrelated field of the same name. - return { - asset for asset, fields in affected_assoc_dict.items() - if expr_chain.fieldname in fields - and asset.lg_asset.associations.get(expr_chain.fieldname) == expr_chain.association - } - elif expr_chain.type in (ExprType.UNION, ExprType.INTERSECTION, ExprType.DIFFERENCE): - return ( - assoc_left_assets(model, affected_assoc_dict, expr_chain.left_link, modified_fieldnames) - | assoc_left_assets(model, affected_assoc_dict, expr_chain.right_link, modified_fieldnames) - ) - elif expr_chain.type == ExprType.COLLECT: - roots: set[ModelAsset] = set() - if chain_fieldnames(expr_chain.left_link) & modified_fieldnames: - roots |= assoc_left_assets(model, affected_assoc_dict, expr_chain.left_link, modified_fieldnames) - if chain_fieldnames(expr_chain.right_link) & modified_fieldnames: - right_roots = assoc_left_assets(model, affected_assoc_dict, expr_chain.right_link, modified_fieldnames) - if right_roots: - reverse_left = model.lang_graph.reverse_expr_chain(expr_chain.left_link, None) - roots |= follow_expr_chain(model, set(right_roots), reverse_left) - return roots - elif expr_chain.type == ExprType.SUBTYPE: - return assoc_left_assets(model, affected_assoc_dict, expr_chain.sub_link, modified_fieldnames) - elif expr_chain.type == ExprType.TRANSITIVE: - assert expr_chain.sub_link is not None, "Sub link should not be None for TRANSITIVE type" - seed_roots = assoc_left_assets(model, affected_assoc_dict, expr_chain.sub_link, modified_fieldnames) - reverse_sub = model.lang_graph.reverse_expr_chain(expr_chain.sub_link, None) - roots = set(seed_roots) - frontier = set(seed_roots) - while frontier: - frontier = follow_expr_chain(model, frontier, reverse_sub) - roots - roots |= frontier - return roots - else: - raise ValueError(f"Unknown expression chain type: {expr_chain.type}") - -def assoc_affected_nodes(model: Model, affected_assoc_dict: dict[ModelAsset, dict[str, set[ModelAsset]]], full_name_to_node: dict[str, AttackGraphNode]) -> set[AttackGraphNode]: - """Return every attack graph node whose children are reached via an - association that was added or removed.""" - modified_fieldnames = frozenset( - fieldname for fields in affected_assoc_dict.values() for fieldname in fields - ) - - candidate_steps: set[tuple[str, str]] = set() - for fieldname in modified_fieldnames: - candidate_steps |= model.lang_graph.fieldname_to_candidate_steps.get(fieldname, set()) - - # Built lazily, only for non-additive chains. - assets_by_type: dict[str, list[ModelAsset]] | None = None - - ret_nodes = set() - for asset_type, step_name in candidate_steps: - lg_step = model.lang_graph.assets[asset_type].attack_steps[step_name] - affected_assets: set[ModelAsset] = set() - for expr_chains in lg_step.children.values(): - for expr_chain in expr_chains: - if expr_chain is None: - continue - if expr_chain.is_additive: - affected_assets |= assoc_left_assets( - model, affected_assoc_dict, expr_chain, modified_fieldnames - ) - continue - if assets_by_type is None: - assets_by_type = {} - for asset in model.assets.values(): - assets_by_type.setdefault(asset.type, []).append(asset) - for asset in assets_by_type.get(asset_type, []): - if assoc_affected_expr_chain(model, {asset}, affected_assoc_dict, expr_chain, modified_fieldnames): - affected_assets.add(asset) - for asset in affected_assets: - # A shared association can be inherited by sibling types that - # don't all define step_name themselves (e.g. two subtypes of - # the same abstract asset), so re-check before the lookup. - if step_name not in asset.lg_asset.attack_steps: - continue - ret_nodes.add(full_name_to_node[f'{asset.name}:{step_name}']) - return ret_nodes \ No newline at end of file diff --git a/maltoolbox/attackgraph/ttcs.py b/maltoolbox/attackgraph/ttcs.py deleted file mode 100644 index 804bb2fc..00000000 --- a/maltoolbox/attackgraph/ttcs.py +++ /dev/null @@ -1,28 +0,0 @@ -import copy -import logging - -from maltoolbox.language.language_graph_attack_step import LanguageGraphAttackStep -from maltoolbox.model import ModelAsset - -logger = logging.getLogger(__name__) - - -def get_ttc_dist(asset: ModelAsset, attack_step: LanguageGraphAttackStep): - """Get step ttc distribution based on language - and possibly overriding defense status - """ - ttc_dist = copy.deepcopy(attack_step.ttc) - if (attack_step.type == 'defense') and (attack_step.name in asset.defenses): - # If defense status was set in model, set ttc accordingly - defense_value = float(asset.defenses[attack_step.name]) - ttc_dist = { - 'arguments': [defense_value], - 'name': 'Bernoulli', - 'type': 'function', - } - logger.debug( - 'Setting defense "%s" to "%s".', - asset.name + ':' + attack_step.name, - defense_value, - ) - return ttc_dist diff --git a/maltoolbox/exceptions.py b/maltoolbox/exceptions.py deleted file mode 100644 index 444144b1..00000000 --- a/maltoolbox/exceptions.py +++ /dev/null @@ -1,42 +0,0 @@ -class MalToolboxException(Exception): - """Base exception for all other maltoolbox exceptions to inherit from.""" - - -class LanguageGraphException(MalToolboxException): - """Base exception for all language-graph related exceptions.""" - - -class LanguageGraphSuperAssetNotFoundError(LanguageGraphException): - """Asset's super asset not found in language graph during attack graph construction.""" - - -class LanguageGraphAssociationError(LanguageGraphException): - """Error in building an association. - - For example, right or left-hand side asset of association missing in - language graph. - """ - - -class LanguageGraphStepExpressionError(LanguageGraphException): - """A target asset cannot be linked with for a step expression.""" - - -class AttackGraphException(MalToolboxException): - """Base exception for all attack-graph related exceptions.""" - - -class AttackGraphStepExpressionError(AttackGraphException): - """A target attack step cannot be linked with for a step expression.""" - - -class ModelException(MalToolboxException): - """Base Exception for all Model related exceptions""" - - -class ModelAssociationException(ModelException): - """Exception related to associations in Model""" - - -class DuplicateModelAssociationError(ModelException): - """Associations should be unique as part of Model""" diff --git a/maltoolbox/language/__init__.py b/maltoolbox/language/__init__.py deleted file mode 100644 index 0d53ea2e..00000000 --- a/maltoolbox/language/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Contains tools to process MAL languages""" - -from .language_graph_detector import LanguageGraphDetector -from .languagegraph import ( - ExpressionsChain, - LanguageGraph, - LanguageGraphAsset, - LanguageGraphAssociation, - LanguageGraphAttackStep, - disaggregate_attack_step_full_name, -) - -__all__ = [ - 'ExpressionsChain', - 'LanguageGraph', - 'LanguageGraphAsset', - 'LanguageGraphAssociation', - 'LanguageGraphAttackStep', - 'LanguageGraphDetector', - 'disaggregate_attack_step_full_name', -] diff --git a/maltoolbox/language/assoc_traversal_processor.py b/maltoolbox/language/assoc_traversal_processor.py deleted file mode 100644 index 6612de14..00000000 --- a/maltoolbox/language/assoc_traversal_processor.py +++ /dev/null @@ -1,196 +0,0 @@ -import logging - -from maltoolbox.language.compiler.mal_analyzer import malAnalyzerException -from maltoolbox.language.language_graph_asset import LanguageGraphAsset -from maltoolbox.language.language_graph_attack_step import LanguageGraphAttackStep -from maltoolbox.language.language_graph_model_effect import ( - AssocSet, - AssocTraversal, - AssocTraversalChain, - GlobAssocTraversal, - ModelEffectType, - SetOperation, -) - -logger = logging.getLogger(__name__) - - -def _assoc_traversal( - step: LanguageGraphAttackStep, - instigating_assets: set[LanguageGraphAsset], - assoc_traversal: AssocTraversal, -) -> set[LanguageGraphAsset]: - next_assets: set[LanguageGraphAsset] = set() - for asset in instigating_assets: - if assoc_traversal.field_name == 'self': - next_assets.add(step.asset) - continue - try: - assoc = asset.associations[assoc_traversal.field_name] - next_assets.add(assoc.get_field(assoc_traversal.field_name).asset) - except KeyError: - raise malAnalyzerException( - f'Asset {asset.name} does not have an association' - f' for field {assoc_traversal.field_name}.' - ) - return next_assets - - -def _glob_assoc_traversal( - step: LanguageGraphAttackStep, - instigating_assets: set[LanguageGraphAsset], - glob_assoc_traversal: GlobAssocTraversal, -) -> set[LanguageGraphAsset]: - next_assets = _traverse_association_chain( - step, instigating_assets, glob_assoc_traversal.pattern - ) - while True: - new_assets = _traverse_association_chain( - step, instigating_assets, glob_assoc_traversal.pattern - ) - if len(new_assets.difference(next_assets)) == 0: - break - next_assets = new_assets - return next_assets - - -def _assoc_set_traversal( - step: LanguageGraphAttackStep, - starting_assets: set[LanguageGraphAsset], - assoc_set: AssocSet, -) -> set[LanguageGraphAsset]: - candidate_assets = set() - left = _traverse_association_chain(step, starting_assets, assoc_set.left) - right = _traverse_association_chain(step, starting_assets, assoc_set.right) - if assoc_set.set_op == SetOperation.UNION: - candidate_assets = left | right - elif assoc_set.set_op == SetOperation.DIFFERENCE: - candidate_assets = left - right - elif assoc_set.set_op == SetOperation.INTERSECTION: - candidate_assets = left & right - else: - raise ValueError( - f'Unknown set operation {assoc_set.set_op} in association set traversal.' - ) - return candidate_assets - - -def _traverse_association_chain( - step: LanguageGraphAttackStep, - instigating_assets: set[LanguageGraphAsset], - assoc_traversals: AssocTraversalChain, -) -> set[LanguageGraphAsset]: - """Traverse the association chain starting from the given asset.""" - current_assets = instigating_assets - for assoc_traversal in assoc_traversals: - if isinstance(assoc_traversal, AssocTraversal): - current_assets = _assoc_traversal(step, current_assets, assoc_traversal) - elif isinstance(assoc_traversal, GlobAssocTraversal): - current_assets = _glob_assoc_traversal( - step, current_assets, assoc_traversal - ) - elif isinstance(assoc_traversal, AssocSet): - current_assets = _assoc_set_traversal(step, current_assets, assoc_traversal) - else: - raise TypeError( - f'Unknown association traversal type: {type(assoc_traversal)}' - ) - return current_assets - - -def _resolve_terminal_traversal( - step: LanguageGraphAttackStep, - instigating_assets: set[LanguageGraphAsset], - assoc_traversals: AssocTraversalChain, -) -> set[tuple[LanguageGraphAsset, str, LanguageGraphAsset]]: - current_assets = _traverse_association_chain( - step, instigating_assets, assoc_traversals[:-1] - ) - last = assoc_traversals[-1] - if isinstance(last, AssocTraversal): - terminal_resolves: set[tuple[LanguageGraphAsset, str, LanguageGraphAsset]] = ( - set() - ) - for asset in current_assets: - if last.field_name == 'self': - terminal_resolves.add((asset, last.field_name, step.asset)) - continue - try: - if last.asset_filter is not None: - candidate_asset = last.asset_filter - else: - candidate_asset = ( - asset.associations[last.field_name].get_field(last.field_name).asset - ) - terminal_resolves.add((asset, last.field_name, candidate_asset)) - except KeyError: - raise malAnalyzerException( - f'Asset {asset.name} does not have an association' - f' for field {last.field_name}.' - ) - return terminal_resolves - elif isinstance(last, GlobAssocTraversal): - # `*` is a repeated application of the pattern, so the termination - # is whatever the pattern itself terminates in. - return _resolve_terminal_traversal(step, current_assets, last.pattern) - elif isinstance(last, AssocSet): - left = _resolve_terminal_traversal(step, current_assets, last.left) - right = _resolve_terminal_traversal(step, current_assets, last.right) - if last.set_op == SetOperation.UNION: - return left | right - elif last.set_op == SetOperation.DIFFERENCE: - return left - right - elif last.set_op == SetOperation.INTERSECTION: - return left & right - else: - raise ValueError( - f'Unknown set operation {last.set_op} in association set traversal.' - ) - else: - raise TypeError(f'Unknown association traversal type: {type(last)}') - - -def validate_model_effects(assets: dict[str, LanguageGraphAsset]) -> None: - for asset in assets.values(): - for step in asset.attack_steps.values(): - for model_effect in ( - step.additive_model_effects + step.subtractive_model_effects - ): - base_resolves = _resolve_terminal_traversal( - step, {asset}, model_effect.base - ) - for dyn_target in model_effect.targets: - is_edge_addition = ( - model_effect.model_effect_type == ModelEffectType.ADDITIVE - and dyn_target.assoc_op - ) - if is_edge_addition: - dyn_target_resolves = _resolve_terminal_traversal( - step, {asset}, dyn_target.assoc_traversal - ) - for ( - anchor_asset, - field_name, - terminating_asset, - ) in dyn_target_resolves: - for ( - base_asset, - base_field_name, - base_terminating_asset, - ) in base_resolves: - if not base_terminating_asset.is_subasset_of( - terminating_asset - ): - raise malAnalyzerException( - 'Invalid model effect for edge addition. ' - f'Base terminates in field {base_field_name} with type {base_terminating_asset.name}, ' - f'which is not a subasset of the target asset {terminating_asset.name} ' - f'that terminates the dynamic target with index {model_effect.targets.index(dyn_target)},' - f' in {step.full_name}.' - ) - else: - dyn_target_resolves = _resolve_terminal_traversal( - step, - {base_resolve[2] for base_resolve in base_resolves}, - dyn_target.assoc_traversal, - ) diff --git a/maltoolbox/language/compiler/exceptions.py b/maltoolbox/language/compiler/exceptions.py deleted file mode 100644 index 08adfd59..00000000 --- a/maltoolbox/language/compiler/exceptions.py +++ /dev/null @@ -1,27 +0,0 @@ -class MalCompilerError(Exception): - """Base exception for MalCompiler errors.""" - - -class MalSyntaxError(MalCompilerError): - """Raised when syntax error is encountered during compilation.""" - - def __init__(self, message, line=None, column=None): - self.line = line - self.column = column - super().__init__(message) - - -class MalParseError(MalCompilerError): - """Raised when parsing fails.""" - - -class MalTypeError(MalCompilerError): - """Raised when type checking fails.""" - - -class MalNameError(MalCompilerError): - """Raised when an undefined name is referenced.""" - - -class MalCompilationError(MalCompilerError): - """Raised when code generation fails.""" diff --git a/maltoolbox/language/language_graph_asset.py b/maltoolbox/language/language_graph_asset.py deleted file mode 100644 index 99389451..00000000 --- a/maltoolbox/language/language_graph_asset.py +++ /dev/null @@ -1,162 +0,0 @@ -"""LanguageGraphAsset functionality -- Represents an asset (type) defined in a MAL language -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from functools import cached_property -from typing import TYPE_CHECKING - -from maltoolbox.language.language_graph_attack_step import LanguageGraphAttackStep - -if TYPE_CHECKING: - from maltoolbox.language.expression_chain import ExpressionsChain - from maltoolbox.language.language_graph_assoc import LanguageGraphAssociation - - -@dataclass -class LanguageGraphAsset: - """An asset type as defined in the MAL language""" - - name: str - own_associations: dict[str, LanguageGraphAssociation] = field(default_factory=dict) - attack_steps: dict[str, LanguageGraphAttackStep] = field(default_factory=dict) - info: dict = field(default_factory=dict) - own_super_asset: LanguageGraphAsset | None = None - own_sub_assets: list[LanguageGraphAsset] = field(default_factory=list) - own_variables: dict = field(default_factory=dict) - is_abstract: bool | None = None - - def to_dict(self) -> dict: - """Convert LanguageGraphAsset to dictionary""" - return { - 'name': self.name, - 'associations': {k: v.to_dict() for k, v in self.own_associations.items()}, - 'attack_steps': {s.name: s.to_dict() for s in self.attack_steps.values()}, - 'info': self.info, - 'super_asset': self.own_super_asset.name if self.own_super_asset else '', - 'sub_assets': [a.name for a in self.own_sub_assets], - 'variables': { - name: (asset.name, expr.to_dict()) - for name, (asset, expr) in self.own_variables.items() - }, - 'is_abstract': self.is_abstract, - } - - def __repr__(self) -> str: - return f'LanguageGraphAsset(name: "{self.name}")' - - def __hash__(self): - return id(self) - - def is_subasset_of(self, target_asset: LanguageGraphAsset) -> bool: - """Check if an asset extends the target asset through inheritance. - - Arguments: - --------- - target_asset - the target asset we wish to evaluate if this asset - extends - - Return: - ------ - True if this asset extends the target_asset via inheritance. - False otherwise. - - """ - current_asset: LanguageGraphAsset | None = self - while current_asset: - if current_asset == target_asset: - return True - current_asset = current_asset.own_super_asset - return False - - @cached_property - def sub_assets(self) -> set[LanguageGraphAsset]: - """Return a list of all of the assets that directly or indirectly extend - this asset. - - Return: - ------ - A list of all of the assets that extend this asset plus itself. - - """ - subassets: list[LanguageGraphAsset] = [] - for subasset in self.own_sub_assets: - subassets.extend(subasset.sub_assets) - - subassets.extend(self.own_sub_assets) - subassets.append(self) - - return set(subassets) - - @cached_property - def super_assets(self) -> list[LanguageGraphAsset]: - """Return a list of all of the assets that this asset directly or - indirectly extends. - - Return: - ------ - A list of all of the assets that this asset extends plus itself. - - """ - current_asset: LanguageGraphAsset | None = self - superassets = [] - while current_asset: - superassets.append(current_asset) - current_asset = current_asset.own_super_asset - return superassets - - def associations_to( - self, asset_type: LanguageGraphAsset - ) -> dict[str, LanguageGraphAssociation]: - """Return dict of association types that go from self - to given `asset_type` - """ - associations_to_asset_type = {} - for fieldname, association in self.associations.items(): - if association in asset_type.associations.values(): - associations_to_asset_type[fieldname] = association - return associations_to_asset_type - - @cached_property - def associations(self) -> dict[str, LanguageGraphAssociation]: - """Return a list of all of the associations that belong to this asset - directly or indirectly via inheritance. - - Return: - ------ - A list of all of the associations that apply to this asset, either - directly or via inheritance. - - """ - associations = dict(self.own_associations) - if self.own_super_asset: - associations |= self.own_super_asset.associations - return associations - - @property - def variables( - self, - ) -> dict[str, tuple[LanguageGraphAsset, ExpressionsChain | None]]: - """Return a list of all of the variables that belong to this asset - directly or indirectly via inheritance. - - Return: - ------ - A list of all of the variables that apply to this asset, either - directly or via inheritance. - - """ - all_vars = dict(self.own_variables) - if self.own_super_asset: - all_vars |= self.own_super_asset.variables - return all_vars - - def get_all_common_superassets(self, other: LanguageGraphAsset) -> set[str]: - """Return a set of all common ancestors between this asset - and the other asset given as parameter - """ - self_superassets = {asset.name for asset in self.super_assets} - other_superassets = {asset.name for asset in other.super_assets} - return self_superassets.intersection(other_superassets) diff --git a/maltoolbox/language/language_graph_assoc.py b/maltoolbox/language/language_graph_assoc.py deleted file mode 100644 index 3fb096b2..00000000 --- a/maltoolbox/language/language_graph_assoc.py +++ /dev/null @@ -1,138 +0,0 @@ -"""LanguageGraphAssoc functionality -- Represents an association (type) defined in a MAL language -""" - -from __future__ import annotations - -import logging -from dataclasses import dataclass, field -from typing import Any - -from maltoolbox.exceptions import LanguageGraphAssociationError -from maltoolbox.language.language_graph_asset import LanguageGraphAsset - -logger = logging.getLogger(__name__) - - -def link_association_to_assets( - assoc: LanguageGraphAssociation, - left_asset: LanguageGraphAsset, - right_asset: LanguageGraphAsset, -) -> None: - left_asset.own_associations[assoc.right_field.fieldname] = assoc - right_asset.own_associations[assoc.left_field.fieldname] = assoc - - -@dataclass(frozen=True, eq=True) -class LanguageGraphAssociationField: - """A field in an association""" - - asset: LanguageGraphAsset - fieldname: str - minimum: int - maximum: int - - -@dataclass(frozen=True, eq=True) -class LanguageGraphAssociation: - """An association type between asset types as defined in the MAL language""" - - name: str - left_field: LanguageGraphAssociationField - right_field: LanguageGraphAssociationField - info: dict = field(default_factory=dict, compare=False) - - def to_dict(self) -> dict: - """Convert LanguageGraphAssociation to dictionary""" - assoc_dict = { - 'name': self.name, - 'info': self.info, - 'left': { - 'asset': self.left_field.asset.name, - 'fieldname': self.left_field.fieldname, - 'min': self.left_field.minimum, - 'max': self.left_field.maximum, - }, - 'right': { - 'asset': self.right_field.asset.name, - 'fieldname': self.right_field.fieldname, - 'min': self.right_field.minimum, - 'max': self.right_field.maximum, - }, - } - - return assoc_dict - - def __repr__(self) -> str: - return ( - f'LanguageGraphAssociation(name: "{self.name}", ' - f'left_field: {self.left_field}, ' - f'right_field: {self.right_field})' - ) - - @property - def full_name(self) -> str: - """Return the full name of the association. This is a combination of the - association name, left field name, left asset type, right field name, - and right asset type. - """ - full_name = ( - f'{self.name}_{self.left_field.fieldname}_{self.right_field.fieldname}' - ) - return full_name - - def get_field(self, fieldname: str) -> LanguageGraphAssociationField: - """Return the field that matches the `fieldname` given as parameter.""" - if self.right_field.fieldname == fieldname: - return self.right_field - return self.left_field - - def contains_fieldname(self, fieldname: str) -> bool: - """Check if the association contains the field name given as a parameter. - - Arguments: - --------- - fieldname - the field name to look for - Return True if either of the two field names matches. - False, otherwise. - - """ - return (self.left_field.fieldname == fieldname) or ( - self.right_field.fieldname == fieldname - ) - - def contains_asset(self, asset: Any) -> bool: - """Check if the association matches the asset given as a parameter. A - match can either be an explicit one or if the asset given subassets - either of the two assets that are part of the association. - - Arguments: - --------- - asset - the asset to look for - Return True if either of the two asset matches. - False, otherwise. - - """ - return bool(asset.is_subasset_of(self.left_field.asset)) or bool( - asset.is_subasset_of(self.right_field.asset) - ) - - def get_opposite_fieldname(self, fieldname: str) -> str: - """Return the opposite field name if the association contains the field - name given as a parameter. - - Arguments: - --------- - fieldname - the field name to look for - Return the other field name if the parameter matched either of the - two. None, otherwise. - - """ - if self.left_field.fieldname == fieldname: - return self.right_field.fieldname - if self.right_field.fieldname == fieldname: - return self.left_field.fieldname - - msg = 'Requested fieldname "%s" from association %s which did not contain it!' - logger.error(msg, fieldname, self.name) - raise LanguageGraphAssociationError(msg % (fieldname, self.name)) diff --git a/maltoolbox/language/language_graph_attack_step.py b/maltoolbox/language/language_graph_attack_step.py deleted file mode 100644 index 84d7d427..00000000 --- a/maltoolbox/language/language_graph_attack_step.py +++ /dev/null @@ -1,133 +0,0 @@ -"""LanguageGraphAttackStep functionality -- Represents a step (type) defined in a MAL language -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from functools import cached_property -from typing import TYPE_CHECKING, Any, Literal - -if TYPE_CHECKING: - from maltoolbox.language.expression_chain import ExpressionsChain - from maltoolbox.language.language_graph_asset import LanguageGraphAsset - from maltoolbox.language.language_graph_detector import LanguageGraphDetector - from maltoolbox.language.language_graph_model_effect import LanguageGraphModelEffect - -@dataclass -class LanguageGraphAttackStep: - """An attack step belonging to an asset type in the MAL language.""" - - name: str - type: Literal['or', 'and', 'defense', 'exist', 'notExist'] - asset: LanguageGraphAsset - causal_mode: Literal['action', 'effect'] | None = None - ttc: dict | None = field(default_factory=dict) - overrides: bool = False - - own_children: dict[LanguageGraphAttackStep, list[ExpressionsChain | None]] = field( - default_factory=dict - ) - own_parents: dict[LanguageGraphAttackStep, list[ExpressionsChain | None]] = field( - default_factory=dict - ) - own_additive_model_effects: list[LanguageGraphModelEffect] = field(default_factory=list) - own_subtractive_model_effects: list[LanguageGraphModelEffect] = field(default_factory=list) - info: dict = field(default_factory=dict) - inherits: LanguageGraphAttackStep | None = None - own_requires: list[ExpressionsChain] = field(default_factory=list) - tags: list = field(default_factory=list) - detectors: dict[str, LanguageGraphDetector] = field(default_factory=dict) - - def __hash__(self): - return id(self) - - @property - def children(self) -> dict[LanguageGraphAttackStep, list[ExpressionsChain | None]]: - """Return own and inherited children.""" - all_children = dict(self.own_children) - if self.overrides or not self.inherits: - return all_children - - for child, chains in self.inherits.children.items(): - if child in all_children: - all_children[child] += [ - c for c in chains if c not in all_children[child] - ] - else: - all_children[child] = list(chains) - return all_children - - @property - def parents(self) -> None: - raise NotImplementedError("Fetching parents is not supported.") - - @property - def additive_model_effects(self) -> list[LanguageGraphModelEffect]: - """Return own and inherited additive model effects.""" - all_effects = list(self.own_additive_model_effects) - # TODO: Figure out if self.overrides is correct here or only for "static" steps - if self.overrides or not self.inherits: - return all_effects - - all_effects += self.inherits.additive_model_effects - return all_effects - - @property - def subtractive_model_effects(self) -> list[LanguageGraphModelEffect]: - """Return own and inherited subtractive model effects.""" - all_effects = list(self.own_subtractive_model_effects) - if self.overrides or not self.inherits: - return all_effects - - all_effects += self.inherits.subtractive_model_effects - return all_effects - - @property - def full_name(self) -> str: - """Return a composite name: asset_name:attack_step_name.""" - return f'{self.asset.name}:{self.name}' - - def to_dict(self) -> dict: - """Serialize the attack step to a dictionary.""" - node_dict: dict[Any, Any] = { - 'name': self.name, - 'type': self.type, - 'asset': self.asset.name, - 'ttc': self.ttc, - 'own_children': {}, - 'own_parents': {}, - 'info': self.info, - 'overrides': self.overrides, - 'inherits': self.inherits.full_name if self.inherits else None, - 'tags': list(self.tags), - 'detectors': { - label: detector.to_dict() for label, detector in self.detectors.items() - }, - } - - # Children - for child, chains in self.own_children.items(): - node_dict['own_children'][child.full_name] = [ - chain.to_dict() if chain else None for chain in chains - ] - - # Parents - for parent, chains in self.own_parents.items(): - node_dict['own_parents'][parent.full_name] = [ - chain.to_dict() if chain else None for chain in chains - ] - - # Requires - if self.own_requires: - node_dict['requires'] = [req.to_dict() for req in self.own_requires] - - return node_dict - - @cached_property - def requires(self) -> list[ExpressionsChain]: - """Return own and inherited requirements.""" - reqs = list(self.own_requires) - if self.inherits: - reqs.extend(self.inherits.requires) - return reqs diff --git a/maltoolbox/language/language_graph_builder.py b/maltoolbox/language/language_graph_builder.py deleted file mode 100644 index 91ff3032..00000000 --- a/maltoolbox/language/language_graph_builder.py +++ /dev/null @@ -1,382 +0,0 @@ -import json -import logging -from typing import Any - -from maltoolbox.exceptions import ( - LanguageGraphAssociationError, - LanguageGraphException, - LanguageGraphStepExpressionError, - LanguageGraphSuperAssetNotFoundError, -) -from maltoolbox.language.assoc_traversal_processor import validate_model_effects -from maltoolbox.language.language_graph_asset import LanguageGraphAsset -from maltoolbox.language.language_graph_assoc import ( - LanguageGraphAssociation, - LanguageGraphAssociationField, - link_association_to_assets, -) -from maltoolbox.language.language_graph_attack_step import LanguageGraphAttackStep -from maltoolbox.language.language_graph_detector import ( - LanguageGraphContextItem, - LanguageGraphDetector, -) -from maltoolbox.language.language_graph_lookup import ( - get_attacks_for_asset_type, - get_variables_for_asset_type, -) -from maltoolbox.language.language_graph_model_effect import build_model_effect -from maltoolbox.language.step_expression_processor import ( - process_step_expression, - resolve_variable, - reverse_expr_chain, -) - -logger = logging.getLogger(__name__) - - -def generate_graph(lang_spec) -> dict[str, LanguageGraphAsset]: - """Generate language graph starting from the MAL language specification - given in the constructor.""" - # Generate all of the asset nodes of the language graph. - assets = create_lg_assets(lang_spec) - - # Link assets to each other - link_assets(lang_spec, assets) - - # Add and link associations to assets - create_associations_for_assets(lang_spec, assets) - - # Set the variables for each asset - set_variables_for_assets(assets, lang_spec) - - # Add attack steps to the assets - generate_attack_steps(assets, lang_spec) - - return assets - - -def create_lg_assets(lang_spec: dict[str, Any]) -> dict[str, LanguageGraphAsset]: - """Create the LanguageGraphAsset nodes for the language graph based on the language specification.""" - assets = {} - for asset_dict in lang_spec['assets']: - logger.debug( - 'Create asset language graph nodes for asset %s', asset_dict['name'] - ) - asset_node = LanguageGraphAsset( - name=asset_dict['name'], - own_associations={}, - attack_steps={}, - info=asset_dict['meta'], - own_super_asset=None, - own_sub_assets=[], - own_variables={}, - is_abstract=asset_dict['isAbstract'], - ) - assets[asset_dict['name']] = asset_node - return assets - - -def link_assets( - lang_spec: dict[str, Any], assets: dict[str, LanguageGraphAsset] -) -> None: - """Link assets based on inheritance and associations.""" - for asset_dict in lang_spec['assets']: - asset = assets[asset_dict['name']] - if asset_dict['superAsset']: - super_asset = assets[asset_dict['superAsset']] - if not super_asset: - msg = 'Failed to find super asset "%s" for asset "%s"!' - logger.error(msg, asset_dict['superAsset'], asset_dict['name']) - raise LanguageGraphSuperAssetNotFoundError( - msg % (asset_dict['superAsset'], asset_dict['name']) - ) - - super_asset.own_sub_assets.append(asset) - asset.own_super_asset = super_asset - - -def set_variables_for_assets(assets: dict[str, LanguageGraphAsset], lang_spec) -> None: - """Set the variables for each asset based on the language specification. - - Arguments: - --------- - assets - a dictionary of LanguageGraphAsset objects - indexed by their names - - """ - for asset in assets.values(): - logger.debug('Set variables for asset %s', asset.name) - variables = get_variables_for_asset_type(asset.name, lang_spec) - for variable in variables: - asset.own_variables[variable['name']] = resolve_variable( - assets, asset, variable['name'], lang_spec - ) - - -def _create_detector( - assets, target_asset, step_dict: dict, lang_spec: dict -) -> dict[str, LanguageGraphDetector]: - detectors = {} - for det in step_dict.get('detectors', {}).values(): - detector_context = {} - for context_label, context in det.get('context', {}).items(): - asset_type, expr_chain, attack_step_name = process_step_expression( - assets, - target_asset=target_asset, - expr_chain=None, - step_expression=context, - lang_spec=lang_spec, - ) - detector_context[context_label] = LanguageGraphContextItem( - label=context_label, - asset_type=asset_type, - attack_step_name=attack_step_name, - expr=expr_chain, - ) - detectors[det['name']] = LanguageGraphDetector( - context=detector_context, - name=det.get('name'), - type=det.get('type'), - tprate=det.get('tprate'), - fprate=det.get('fprate'), - ) - return detectors - - -def _create_lg_attack_step_nodes( - assets: dict[str, LanguageGraphAsset], lang_spec: dict -) -> dict[str, dict]: - - attack_step_dicts = {} - for asset in assets.values(): - logger.debug( - 'Create attack steps language graph nodes for asset %s', asset.name - ) - for step_dict in get_attacks_for_asset_type(asset.name, lang_spec).values(): - logger.debug( - 'Create attack step language graph nodes for %s', step_dict['name'] - ) - node = LanguageGraphAttackStep( - name=step_dict['name'], - type=step_dict['type'], - asset=asset, - causal_mode=step_dict.get('causal_mode'), - ttc=step_dict['ttc'], - overrides=( - step_dict['reaches']['overrides'] if step_dict['reaches'] else False - ), - own_children={}, - own_parents={}, - info=step_dict['meta'], - tags=list(step_dict['tags']), - detectors=_create_detector(assets, asset, step_dict, lang_spec), - ) - attack_step_dicts[node.full_name] = step_dict - asset.attack_steps[node.name] = node - - return attack_step_dicts - - -def _inherit_attack_steps(assets: dict[str, LanguageGraphAsset]) -> None: - pending = list(assets.values()) - while pending: - asset = pending.pop(0) - super_asset = asset.own_super_asset - if super_asset in pending: - # Super asset still needs processing, defer this asset - pending.append(asset) - continue - if not super_asset: - continue - for super_step in super_asset.attack_steps.values(): - current_step = asset.attack_steps.get(super_step.name) - if not current_step: - node = LanguageGraphAttackStep( - name=super_step.name, - type=super_step.type, - asset=asset, - causal_mode=super_step.causal_mode, - ttc=super_step.ttc, - overrides=False, - own_children={}, - own_parents={}, - info=super_step.info, - tags=list(super_step.tags), - ) - node.inherits = super_step - asset.attack_steps[super_step.name] = node - elif current_step.overrides: - continue - else: - current_step.inherits = super_step - current_step.tags += super_step.tags - current_step.info |= super_step.info - - -def _connect_attack_steps( - assets: dict[str, LanguageGraphAsset], - lang_spec: dict[str, Any], - attack_step_dicts: dict[str, dict], -) -> None: - """Connect attack steps based on the 'reaches', 'append_reaches', 'remove_reaches' and 'requires' expressions in the language specification.""" - - for asset in assets.values(): - for step in asset.attack_steps.values(): - logger.debug('Determining children for attack step %s', step.name) - - if step.full_name not in attack_step_dicts: - # Skip steps that are not in the lang spec (inherited steps) - continue - - attack_step_dict = attack_step_dicts[step.full_name] - - step_reaches = attack_step_dict.get('reaches') or {} - - for expr in step_reaches.get('stepExpressions', []): - tgt_asset, chain, tgt_name = process_step_expression( - assets, step.asset, None, expr, lang_spec - ) - if not tgt_asset: - raise LanguageGraphStepExpressionError( - f'Failed to find target asset for:\n{json.dumps(expr, indent=2)}' - ) - if tgt_name not in tgt_asset.attack_steps: - raise LanguageGraphStepExpressionError( - f'Failed to find target attack step {tgt_name} on {tgt_asset.name}:\n{json.dumps(expr, indent=2)}' - ) - - tgt = tgt_asset.attack_steps[tgt_name] - step.own_children.setdefault(tgt, []).append(chain) - tgt.own_parents.setdefault(step, []).append( - reverse_expr_chain(chain, None) - ) - - if step.type in ('exist', 'notExist'): - reqs = attack_step_dict.get('requires', {}).get('stepExpressions', []) - if not reqs: - raise LanguageGraphStepExpressionError( - f'Missing requirements for "{step.name}" of type "{step.type}":\n{json.dumps(attack_step_dict, indent=2)}' - ) - for expr in reqs: - _, chain, _ = process_step_expression( - assets, step.asset, None, expr, lang_spec - ) - if chain is None: - raise LanguageGraphException( - f'Failed to find existence step requirement for:\n{expr}' - ) - step.own_requires.append(chain) - -def _connect_model_effects( - assets: dict[str, LanguageGraphAsset], - lang_spec: dict[str, Any], - attack_step_dicts: dict[str, dict] -) -> None: - """Connect model effects to attack steps""" - - for asset in assets.values(): - for step in asset.attack_steps.values(): - logger.debug('Determining children for attack step %s', step.name) - - if step.full_name not in attack_step_dicts: - # Skip steps that are not in the lang spec (inherited steps) - continue - - attack_step_dict = attack_step_dicts[step.full_name] - - append_reaches = attack_step_dict.get('append_reaches') or {} - for expr in append_reaches.get('stepExpressions', []): - model_effect = build_model_effect(assets, step.asset, expr, lang_spec, is_additive=True) - step.own_additive_model_effects.append(model_effect) - - remove_reaches = attack_step_dict.get('remove_reaches') or {} - for expr in remove_reaches.get('stepExpressions', []): - model_effect = build_model_effect(assets, step.asset, expr, lang_spec, is_additive=False) - step.own_subtractive_model_effects.append(model_effect) - - -def generate_attack_steps( - assets: dict[str, LanguageGraphAsset], lang_spec: dict -) -> None: - """ - Generate attack steps for all assets and link them according to the - language specification. - - This method performs three phases: - - 1. Create attack step nodes for each asset, including detectors. - 2. Inherit attack steps from super-assets, respecting overrides. - 3. Link attack steps via 'reaches' and evaluate 'exist'/'notExist' - requirements. - 4. Link model effects via 'append_reaches' and 'remove_reaches'. - - Args: - assets (dict): Mapping of asset names to asset objects. - - Raises: - LanguageGraphStepExpressionError: If a step expression cannot be - resolved to a target asset or attack step. - LanguageGraphException: If an existence requirement cannot be - resolved. - """ - - attack_step_dicts = _create_lg_attack_step_nodes(assets, lang_spec) - _inherit_attack_steps(assets) - _connect_attack_steps(assets, lang_spec, attack_step_dicts) - _connect_model_effects(assets, lang_spec, attack_step_dicts) - validate_model_effects(assets) - -def create_associations_for_assets( - lang_spec: dict[str, Any], assets: dict[str, LanguageGraphAsset] -) -> None: - """Link associations to assets based on the language specification. - - Arguments: - --------- - lang_spec - the language specification dictionary - assets - a dictionary of LanguageGraphAsset objects - indexed by their names - - """ - for association_dict in lang_spec['associations']: - logger.debug( - 'Create association language graph nodes for association %s', - association_dict['name'], - ) - - left_asset_name = association_dict['leftAsset'] - right_asset_name = association_dict['rightAsset'] - - if left_asset_name not in assets: - raise LanguageGraphAssociationError( - f'Left asset "{left_asset_name}" for ' - f'association "{association_dict["name"]}" not found!' - ) - if right_asset_name not in assets: - raise LanguageGraphAssociationError( - f'Right asset "{right_asset_name}" for ' - f'association "{association_dict["name"]}" not found!' - ) - - left_asset = assets[left_asset_name] - right_asset = assets[right_asset_name] - - assoc_node = LanguageGraphAssociation( - name=association_dict['name'], - left_field=LanguageGraphAssociationField( - left_asset, - association_dict['leftField'], - association_dict['leftMultiplicity']['min'], - association_dict['leftMultiplicity']['max'], - ), - right_field=LanguageGraphAssociationField( - right_asset, - association_dict['rightField'], - association_dict['rightMultiplicity']['min'], - association_dict['rightMultiplicity']['max'], - ), - info=association_dict['meta'], - ) - - # Add the association to the left and right asset - link_association_to_assets(assoc_node, left_asset, right_asset) diff --git a/maltoolbox/language/language_graph_detector.py b/maltoolbox/language/language_graph_detector.py deleted file mode 100644 index 53fa0b83..00000000 --- a/maltoolbox/language/language_graph_detector.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Detector functionality -- A detector represent a logging rule on an attack step -- It includes a context and a name -""" - -from __future__ import annotations - -from dataclasses import dataclass - -from maltoolbox.language.expression_chain import ExpressionsChain -from maltoolbox.language.language_graph_asset import LanguageGraphAsset - - -@dataclass(frozen=True, eq=True) -class LanguageGraphDetector: - name: str | None - context: dict[str, LanguageGraphContextItem] - type: str | None - tprate: float = 1.0 - fprate: float = 0.0 - - def to_dict(self) -> dict: - """Convert Detector to dictionary""" - return { - 'name': self.name, - 'context': {k: v.to_dict() for k, v in self.context.items()}, - 'type': self.type, - 'tprate': self.tprate, - } - - -@dataclass(frozen=True, eq=True) -class LanguageGraphContextItem: - """Represents a single item in the context of a detector, which specifies - the label and attack step that the detector is associated with. - """ - - label: str - asset_type: LanguageGraphAsset - attack_step_name: str | None - expr: ExpressionsChain | None - - def to_dict(self) -> dict: - """Convert ContextItem to dictionary""" - return { - 'label': self.label, - 'asset_type': self.asset_type.name, - 'attack_step_name': self.attack_step_name, - 'expr': self.expr.to_dict() if self.expr else None, - } diff --git a/maltoolbox/language/language_graph_model_effect.py b/maltoolbox/language/language_graph_model_effect.py deleted file mode 100644 index 280d77af..00000000 --- a/maltoolbox/language/language_graph_model_effect.py +++ /dev/null @@ -1,183 +0,0 @@ -"""LanguageGraphAttackStep functionality -- Represents a step (type) defined in a MAL language -""" -from __future__ import annotations - -from copy import deepcopy -from dataclasses import dataclass -from enum import Enum -from typing import TYPE_CHECKING, Any, NamedTuple, TypeAlias - -from maltoolbox.language.expression_chain import ExprType -from maltoolbox.language.step_expression_processor import ( - process_assoc_op_step_expression, - process_step_expression, -) - -if TYPE_CHECKING: - from maltoolbox.language.expression_chain import ExpressionsChain - from maltoolbox.language.language_graph_asset import LanguageGraphAsset - -def _parse_quantity(quantity_dict: Any) -> int | tuple[int, int] | None: - assert isinstance(quantity_dict, dict), "Quantity must be a dictionary" - min, max = quantity_dict.get("min"), quantity_dict.get("max") - if min is None and max is None: - return None - elif min is not None and max is not None: - return (int(min), int(max)) - elif min is not None and max is None: - return int(min) - else: - raise ValueError(f"Invalid quantity: {quantity_dict}") - - -class AssocTraversal(NamedTuple): - """A reference to assets by role name, optionally filtered by asset type and quantity.""" - field_name: str - asset_filter: LanguageGraphAsset | None - quantity_filter: int | tuple[int, int] | None - -SELF_TRAVERSAL = AssocTraversal(field_name="self", asset_filter=None, quantity_filter=None) - -class GlobAssocTraversal(NamedTuple): - """A glob on the traversal chain.""" - pattern: AssocTraversalChain - quantity_filter: int | tuple[int, int] | None - -class SetOperation(Enum): - UNION = "UNION" - DIFFERENCE = "DIFFERENCE" - INTERSECTION = "INTERSECTION" - -class AssocSet(NamedTuple): - """A set operation between the results of two AssocTraversalChains""" - set_op: SetOperation - left: AssocTraversalChain - right: AssocTraversalChain - quantity_filter: int | tuple[int, int] | None - -AssocTraversalChain: TypeAlias = list[AssocTraversal | GlobAssocTraversal | AssocSet] - -class DynTarget(NamedTuple): - """A target in a dynamic sentence - - Attributes: - assoc_op: bool - Decides if we are adding/removing assets or connections to assets (associations) - assoc_traversal: AssocTraversalChain - The traversal chain to reach the target asset(s) - """ - assoc_op: bool # Decides if we are adding/removing assets or connections to assets - assoc_traversal: AssocTraversalChain - -class ModelEffectType(Enum): - """The type of effect on the model instance, i.e. addition or subtraction.""" - ADDITIVE = "ADDITIVE" - SUBTRACTIVE = "SUBTRACTIVE" - -@dataclass -class LanguageGraphModelEffect: - """An effect on the model instance triggered by compromising the attack step.""" - model_effect_type: ModelEffectType - base: AssocTraversalChain - targets: list[DynTarget] # Assumes ^ between targets - - -def build_model_effect( - assets: dict[str, LanguageGraphAsset], - target_asset: LanguageGraphAsset, - step_expression: dict, - lang_spec, - is_additive: bool - ) -> LanguageGraphModelEffect: - """Build a model effect from a step expression""" - - assert step_expression["type"] == "dyn_sentence", "Only dynamic sentences can be used to build model effects" - model_effect_type = ModelEffectType.ADDITIVE if is_additive else ModelEffectType.SUBTRACTIVE - - def parse_assoc_traversal(expr_chain: ExpressionsChain | None) -> AssocTraversalChain: - """Create a list of AssocTraversal from an expression chain""" - - if expr_chain is None: - return [] - elif expr_chain.type == ExprType.COLLECT: - left = parse_assoc_traversal(expr_chain.left_link) - right = parse_assoc_traversal(expr_chain.right_link) - return left + right - elif expr_chain.type == ExprType.FIELD: - assert expr_chain.fieldname is not None, "Fieldname must be set for FIELD expression" - return [AssocTraversal(field_name=expr_chain.fieldname, asset_filter=None, quantity_filter=None)] - elif expr_chain.type == ExprType.MULTIPLICITY: - ret = parse_assoc_traversal(expr_chain.sub_link) - last_traversal = ret[-1] - last_traversal = last_traversal._replace(quantity_filter=_parse_quantity(expr_chain.multiplicity)) - ret[-1] = last_traversal - return ret - elif expr_chain.type == ExprType.SUBTYPE: - ret = parse_assoc_traversal(expr_chain.sub_link) - if isinstance(ret[-1], AssocTraversal): - ret[-1] = ret[-1]._replace(asset_filter=expr_chain.subtype) - elif isinstance(ret[-1], GlobAssocTraversal) and isinstance(ret[-1].pattern[-1], AssocTraversal): - ret[-1].pattern[-1] = ret[-1].pattern[-1]._replace(asset_filter=expr_chain.subtype) - else: - raise ValueError("Unexpected traversal chain structure for SUBTYPE expression") - return ret - elif expr_chain.type == ExprType.TRANSITIVE: - pattern = parse_assoc_traversal(expr_chain.sub_link) - return [GlobAssocTraversal(pattern=pattern, quantity_filter=None)] - elif expr_chain.type == ExprType.UNION: - left = parse_assoc_traversal(expr_chain.left_link) - right = parse_assoc_traversal(expr_chain.right_link) - return [AssocSet(set_op=SetOperation.UNION, left=left, right=right, quantity_filter=None)] - elif expr_chain.type == ExprType.DIFFERENCE: - left = parse_assoc_traversal(expr_chain.left_link) - right = parse_assoc_traversal(expr_chain.right_link) - return [AssocSet(set_op=SetOperation.DIFFERENCE, left=left, right=right, quantity_filter=None)] - elif expr_chain.type == ExprType.INTERSECTION: - left = parse_assoc_traversal(expr_chain.left_link) - right = parse_assoc_traversal(expr_chain.right_link) - return [AssocSet(set_op=SetOperation.INTERSECTION, left=left, right=right, quantity_filter=None)] - - raise ValueError(f"Unexpected expression chain type: {expr_chain.type}") - - def build_assoc_traversals(expr_chain: ExpressionsChain | None) -> AssocTraversalChain: - """Build a list of lists of AssocTraversal from an expression chain, splitting on unions""" - - if expr_chain is None: - traversals: AssocTraversalChain = [deepcopy(SELF_TRAVERSAL)] - else: - traversals = parse_assoc_traversal(expr_chain) - return traversals - - base_expr = step_expression["base"] - target_exprs = step_expression["targets"] - - base_target_asset, base_expr_chain, base_step = process_step_expression(assets, target_asset, None, base_expr, lang_spec) - assert not base_step, "Base can not refer to an attack step in a dynamic sentence" - base = build_assoc_traversals(base_expr_chain) - - targets = [] - for target_expr in target_exprs: - is_assoc_op = (target_expr["type"] == "assoc_op") - process_function = process_assoc_op_step_expression if is_assoc_op else process_step_expression - # If we are doing link addition the instigating asset is the asset which defines the step, - # otherwise the instigating asset are the assets collected from the base expression. - # See for further details: https://github.com/mal-lang/mal-toolbox/pull/244#issuecomment-5190495429 - if is_assoc_op and model_effect_type == ModelEffectType.ADDITIVE: - _, target_expr_chain, target_step = process_function(assets, target_asset, None, target_expr, lang_spec) - else: - _, target_expr_chain, target_step = process_function(assets, base_target_asset, None, target_expr, lang_spec) - assert not target_step, "Targets can not refer to an attack step in a dynamic sentence" - - if target_expr_chain: - if target_expr_chain.type == ExprType.ASSOC_OP: - assoc_op = True - target_expr_chain = target_expr_chain.sub_link - else: - assoc_op = False - assoc_traversal = build_assoc_traversals(target_expr_chain) - targets.append(DynTarget(assoc_op=assoc_op, assoc_traversal=assoc_traversal)) - else: - assoc_op = False - self_traversal: AssocTraversalChain = [deepcopy(SELF_TRAVERSAL)] - targets.append(DynTarget(assoc_op=assoc_op, assoc_traversal=self_traversal)) - - return LanguageGraphModelEffect(model_effect_type=model_effect_type, base=base, targets=targets) \ No newline at end of file diff --git a/maltoolbox/language/languagegraph.py b/maltoolbox/language/languagegraph.py deleted file mode 100644 index 76c3156c..00000000 --- a/maltoolbox/language/languagegraph.py +++ /dev/null @@ -1,561 +0,0 @@ -"""MAL-Toolbox Language Graph functionality -- A graph representation of a MAL language -- Used when creating models and when generating attack graphs -""" - -from __future__ import annotations - -import json -import logging -import zipfile -from functools import cached_property -from typing import Any - -from maltoolbox.exceptions import ( - LanguageGraphAssociationError, - LanguageGraphException, - LanguageGraphSuperAssetNotFoundError, -) -from maltoolbox.file_utils import ( - download_git_repo, - load_dict_from_json_file, - load_dict_from_yaml_file, - save_dict_to_file, -) -from maltoolbox.language.compiler.mal_compiler import MalCompiler -from maltoolbox.language.expression_chain import ExpressionsChain, chain_fieldnames -from maltoolbox.language.language_graph_asset import LanguageGraphAsset -from maltoolbox.language.language_graph_assoc import ( - LanguageGraphAssociation, - LanguageGraphAssociationField, -) -from maltoolbox.language.language_graph_attack_step import LanguageGraphAttackStep -from maltoolbox.language.language_graph_builder import generate_graph -from maltoolbox.language.step_expression_processor import ( - StepResult, - process_attack_step_expression, - process_collect_step_expression, - process_field_step_expression, - process_set_operation_step_expression, - process_step_expression, - process_subType_step_expression, - process_transitive_step_expression, - process_variable_step_expression, - reverse_expr_chain, -) - -logger = logging.getLogger(__name__) - - -class LanguageGraph: - """Graph representation of a MAL language""" - - def __init__(self, lang_spec: dict | None = None): - - self.assets: dict[str, LanguageGraphAsset] = {} - self.lang_spec = lang_spec - - if self.lang_spec is not None: - self.metadata = { - 'version': self.lang_spec['defines']['version'], - 'id': self.lang_spec['defines']['id'], - } - self.assets = generate_graph(self.lang_spec) - - def __repr__(self) -> str: - """String representation of a LanguageGraph""" - return ( - f'LanguageGraph(id: "{self.metadata.get("id", "N/A")}", ' - f'version: "{self.metadata.get("version", "N/A")}")' - ) - - @classmethod - def from_mal_spec(cls, mal_spec_file: str) -> LanguageGraph: - """Create a LanguageGraph from a .mal file (a MAL spec). - - Arguments: - --------- - mal_spec_file - the path to the .mal file - - """ - return language_graph_from_mal_spec(mal_spec_file) - - @classmethod - def from_mar_archive(cls, mar_archive: str) -> LanguageGraph: - """Create a LanguageGraph from a ".mar" archive provided by malc - (https://github.com/mal-lang/malc). - - Arguments: - --------- - mar_archive - the path to a ".mar" archive - - """ - return language_graph_from_mar_archive(mar_archive) - - def to_mar_archive(self, mar_archive_path: str) -> None: - """Save the LanguageGraph lang_spec to a .mar archive - that can be loaded with `from_mar_archive`. - - The .mar is a zip file containing a single file "langspec.json" - containing the language specification in JSON format. - """ - with zipfile.ZipFile( - mar_archive_path, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9 - ) as archive: - langspec_json = json.dumps(self.lang_spec, indent=4) - archive.writestr('langspec.json', langspec_json) - - @property - def associations(self) -> set[LanguageGraphAssociation]: - """Return all associations in the language graph.""" - return get_language_graph_associations(self) - - @property - def attack_steps(self) -> set[LanguageGraphAttackStep]: - """Return all attack steps in the language graph.""" - return get_language_graph_attack_steps(self) - - @cached_property - def fieldname_to_candidate_steps(self) -> dict[str, set[tuple[str, str]]]: - """Map each association fieldname to the (asset_type, attack_step_name) - pairs whose children expression chains can traverse that field.""" - mapping: dict[str, set[tuple[str, str]]] = {} - for asset_type, lg_asset in self.assets.items(): - for step_name, lg_step in lg_asset.attack_steps.items(): - fieldnames: set[str] = set() - for expr_chains in lg_step.children.values(): - for expr_chain in expr_chains: - fieldnames |= chain_fieldnames(expr_chain) - for fieldname in fieldnames: - mapping.setdefault(fieldname, set()).add((asset_type, step_name)) - return mapping - - @staticmethod - def _link_association_to_assets( - assoc: LanguageGraphAssociation, - left_asset: LanguageGraphAsset, - right_asset: LanguageGraphAsset, - ): - left_asset.own_associations[assoc.right_field.fieldname] = assoc - right_asset.own_associations[assoc.left_field.fieldname] = assoc - - def save_to_file(self, filename: str) -> None: - """Save to json/yml depending on extension""" - - match filename.split('.')[-1]: - case 'json' | 'yaml' | 'yml': - save_dict_to_file(filename, language_graph_to_dict(self)) - case 'mar': - self.to_mar_archive(filename) - case _: - raise TypeError( - 'Unknown file extension, expected json/mal/mar/yml/yaml' - ) - - @classmethod - def load_from_file(cls, filename: str) -> LanguageGraph: - """Create LanguageGraph from mal, mar, yaml or json""" - return load_language_graph_from_file(filename) - - def save_language_specification_to_json(self, filename: str) -> None: - """Save a MAL language specification dictionary to a JSON file - - Arguments: - --------- - filename - the JSON filename where the language specification will be written - - """ - logger.info('Save language specification to %s', filename) - with open(filename, 'w', encoding='utf-8') as file: - json.dump(self.lang_spec, file, indent=4) - - def process_attack_step_expression( - self, target_asset: LanguageGraphAsset, step_expression: dict[str, Any] - ) -> StepResult: - """The attack step expression just adds the name of the attack - step. All other step expressions only modify the target - asset and parent associations chain. - """ - return process_attack_step_expression(target_asset, step_expression['name']) - - def process_set_operation_step_expression( - self, - target_asset: LanguageGraphAsset, - expr_chain: ExpressionsChain | None, - step_expression: dict[str, Any], - ) -> StepResult: - """The set operators are used to combine the left hand and right - hand targets accordingly. - """ - return process_set_operation_step_expression( - self.assets, target_asset, expr_chain, step_expression, self.lang_spec - ) - - def process_variable_step_expression( - self, - target_asset: LanguageGraphAsset, - step_expression: dict[str, Any], - ) -> StepResult: - - return process_variable_step_expression( - self.assets, target_asset, step_expression, self.lang_spec - ) - - def process_field_step_expression( - self, target_asset: LanguageGraphAsset, step_expression: dict[str, Any] - ) -> StepResult: - """Change the target asset from the current one to the associated - asset given the specified field name and add the parent - fieldname and association to the parent associations chain. - """ - return process_field_step_expression(target_asset, step_expression) - - def process_transitive_step_expression( - self, - target_asset: LanguageGraphAsset, - expr_chain: ExpressionsChain | None, - step_expression: dict[str, Any], - ) -> StepResult: - """Create a transitive tuple entry that applies to the next - component of the step expression. - """ - return process_transitive_step_expression( - self.assets, target_asset, expr_chain, step_expression, self.lang_spec - ) - - def process_subType_step_expression( - self, - target_asset: LanguageGraphAsset, - expr_chain: ExpressionsChain | None, - step_expression: dict[str, Any], - ) -> StepResult: - """Create a subType tuple entry that applies to the next - component of the step expression and changes the target - asset to the subasset. - """ - return process_subType_step_expression( - self.assets, target_asset, expr_chain, step_expression, self.lang_spec - ) - - def process_collect_step_expression( - self, - target_asset: LanguageGraphAsset, - expr_chain: ExpressionsChain | None, - step_expression: dict[str, Any], - ) -> tuple[LanguageGraphAsset, ExpressionsChain | None, str | None]: - """Apply the right hand step expression to left hand step - expression target asset and parent associations chain. - """ - return process_collect_step_expression( - self.assets, target_asset, expr_chain, step_expression, self.lang_spec - ) - - def process_step_expression( - self, - target_asset: LanguageGraphAsset, - expr_chain: ExpressionsChain | None, - step_expression: dict, - ) -> tuple[LanguageGraphAsset, ExpressionsChain | None, str | None]: - """Recursively process an attack step expression. - - Arguments: - --------- - target_asset - The asset type that this step expression should - apply to. Initially it will contain the asset - type to which the attack step belongs. - expr_chain - A expressions chain of linked of associations - and set operations from the attack step to its - parent attack step. - Note: This was done for the parent attack step - because it was easier to construct recursively - given the left-hand first expansion of the - current MAL language specification. - step_expression - A dictionary containing the step expression. - - Return: - ------ - A tuple triplet containing the target asset, the resulting parent - associations chain, and the name of the attack step. - - """ - return process_step_expression( - self.assets, target_asset, expr_chain, step_expression, self.lang_spec - ) - - def reverse_expr_chain( - self, - expr_chain: ExpressionsChain | None, - reverse_chain: ExpressionsChain | None, - ) -> ExpressionsChain | None: - """Recursively reverse the associations chain. From parent to child or - vice versa. - - Arguments: - --------- - expr_chain - A chain of nested tuples that specify the - associations and set operations chain from an - attack step to its connected attack step. - reverse_chain - A chain of nested tuples that represents the - current reversed associations chain. - - Return: - ------ - The resulting reversed associations chain. - - """ - return reverse_expr_chain(expr_chain, reverse_chain) - - def regenerate_graph(self) -> None: - """Regenerate language graph starting from the MAL language specification - given in the constructor. - """ - self.assets = generate_graph(self.lang_spec) - - def _to_dict(self) -> dict[str, Any]: - return language_graph_to_dict(self) - - -def disaggregate_attack_step_full_name(attack_step_full_name: str) -> list[str]: - """From an attack step full name, get (asset_name, attack_step_name)""" - return attack_step_full_name.split(':') - - -def language_graph_to_dict(graph: LanguageGraph) -> dict: - """Converts LanguageGraph into a dict""" - logger.debug('Serializing %s assets.', len(graph.assets.items())) - - serialized_graph = {'metadata': graph.metadata} - for asset in graph.assets.values(): - serialized_graph[asset.name] = asset.to_dict() - - return serialized_graph - - -def language_graph_from_dict(serialized_graph: dict) -> LanguageGraph: - """Rebuild a LanguageGraph instance from its serialized dict form.""" - logger.debug('Create language graph from dictionary.') - lang_graph = LanguageGraph() - lang_graph.metadata = serialized_graph.pop('metadata') - - # Create asset nodes - for asset in serialized_graph.values(): - logger.debug('Create asset %s', asset['name']) - lang_graph.assets[asset['name']] = LanguageGraphAsset( - name=asset['name'], - own_associations={}, - attack_steps={}, - info=asset['info'], - own_super_asset=None, - own_sub_assets=[], - own_variables={}, - is_abstract=asset['is_abstract'], - ) - - # Link inheritance - for asset in serialized_graph.values(): - asset_node = lang_graph.assets[asset['name']] - if super_name := asset['super_asset']: - try: - super_asset = lang_graph.assets[super_name] - except KeyError: - msg = f'Super asset "{super_name}" for "{asset["name"]}" not found' - logger.error(msg) - raise LanguageGraphSuperAssetNotFoundError(msg) - - super_asset.own_sub_assets.append(asset_node) - asset_node.own_super_asset = super_asset - - # Associations - for asset in serialized_graph.values(): - logger.debug('Create associations for asset %s', asset['name']) - a_node = lang_graph.assets[asset['name']] - for assoc in asset['associations'].values(): - try: - left = lang_graph.assets[assoc['left']['asset']] - right = lang_graph.assets[assoc['right']['asset']] - except KeyError as e: - side = 'Left' if 'left' in str(e) else 'Right' - msg = f'{side} asset for association "{assoc["name"]}" not found' - logger.error(msg) - raise LanguageGraphAssociationError(msg) - assoc_node = LanguageGraphAssociation( - name=assoc['name'], - left_field=LanguageGraphAssociationField( - left, - assoc['left']['fieldname'], - assoc['left']['min'], - assoc['left']['max'], - ), - right_field=LanguageGraphAssociationField( - right, - assoc['right']['fieldname'], - assoc['right']['min'], - assoc['right']['max'], - ), - info=assoc['info'], - ) - lang_graph._link_association_to_assets(assoc_node, left, right) - - # Variables - for asset in serialized_graph.values(): - a_node = lang_graph.assets[asset['name']] - for var, (target_name, expr_dict) in asset['variables'].items(): - target = lang_graph.assets[target_name] - a_node.own_variables[var] = ( - target, - ExpressionsChain._from_dict(expr_dict, lang_graph), - ) - - # Attack steps - for asset in serialized_graph.values(): - a_node = lang_graph.assets[asset['name']] - for step in asset['attack_steps'].values(): - a_node.attack_steps[step['name']] = LanguageGraphAttackStep( - name=step['name'], - type=step['type'], - asset=a_node, - causal_mode=step.get('causal_mode'), - ttc=step['ttc'], - overrides=step['overrides'], - own_children={}, - own_parents={}, - info=step['info'], - tags=list(step['tags']), - ) - - # Inheritance for attack steps - for asset in serialized_graph.values(): - a_node = lang_graph.assets[asset['name']] - for step in asset['attack_steps'].values(): - if not (inh := step.get('inherits')): - continue - a_step = a_node.attack_steps[step['name']] - a_name, s_name = disaggregate_attack_step_full_name(inh) - a_step.inherits = lang_graph.assets[a_name].attack_steps[s_name] - - # Expression chains and requirements - for asset in serialized_graph.values(): - a_node = lang_graph.assets[asset['name']] - for step in asset['attack_steps'].values(): - s_node = a_node.attack_steps[step['name']] - for tgt_name, exprs in step['own_children'].items(): - t_asset, t_step = disaggregate_attack_step_full_name(tgt_name) - t_node = lang_graph.assets[t_asset].attack_steps[t_step] - for expr in exprs: - chain = ExpressionsChain._from_dict(expr, lang_graph) - s_node.own_children.setdefault(t_node, []).append(chain) - for tgt_name, exprs in step['own_parents'].items(): - t_asset, t_step = disaggregate_attack_step_full_name(tgt_name) - t_node = lang_graph.assets[t_asset].attack_steps[t_step] - for expr in exprs: - chain = ExpressionsChain._from_dict(expr, lang_graph) - s_node.own_parents.setdefault(t_node, []).append(chain) - if step['type'] in ('exist', 'notExist') and (reqs := step.get('requires')): - s_node.own_requires = [ - chain - for expr in reqs - if (chain := ExpressionsChain._from_dict(expr, lang_graph)) - ] - - return lang_graph - - -def load_language_graph_from_file(filename: str) -> LanguageGraph: - """Create LanguageGraph from mal, mar, yaml, json or git url""" - lang_graph = None - if filename.endswith('.mal'): - lang_graph = language_graph_from_mal_spec(filename) - elif filename.endswith('.mar'): - lang_graph = language_graph_from_mar_archive(filename) - elif filename.endswith(('.yaml', '.yml')): - lang_graph = language_graph_from_dict(load_dict_from_yaml_file(filename)) - elif filename.endswith('.json'): - lang_graph = language_graph_from_dict(load_dict_from_json_file(filename)) - elif filename.endswith('.git'): - lang_graph = language_graph_from_git_url(filename) - else: - raise TypeError('Unknown file extension, expected json/mal/mar/yml/yaml') - if lang_graph: - return lang_graph - raise LanguageGraphException( - f'Failed to load language graph from file "{filename}".' - ) - - -def get_language_graph_associations(language_graph: LanguageGraph): - return { - assoc - for asset in language_graph.assets.values() - for assoc in asset.associations.values() - } - -def get_language_graph_attack_steps(language_graph: LanguageGraph) -> set[LanguageGraphAttackStep]: - return { - step - for asset in language_graph.assets.values() - for step in asset.attack_steps.values() - } - - -def language_graph_from_git_url(git_url: str) -> LanguageGraph: - """Create a LanguageGraph from a git url pointing to MAL lang. - - The git repository contains a src/main/mal/main.mal - which is the file to load as the MAL language specification. - The git repo will be cloned to a local directory named ./.langs. - - Arguments: - --------- - git_url - the git url pointing to the MAL language specification - - """ - logger.info('Loading language graph from git url %s', git_url) - - # Download the repository to a local directory - dir = download_git_repo(git_url) - mal_files = list(dir.rglob('*.mal')) - - if not mal_files: - raise FileNotFoundError( - 'Execution failed: No .mal files found in the cloned repository.' - ) - - if len(mal_files) == 1: - mal_file = mal_files[0] - else: - main_mal_files = [f for f in mal_files if f.name == 'main.mal'] - if main_mal_files: - mal_file = main_mal_files[0] - else: - raise ValueError( - 'Execution failed: .mal files found but no main.mal file in the repository.' - ) - - return language_graph_from_mal_spec(str(mal_file)) - - -def language_graph_from_mal_spec(mal_spec_file: str) -> LanguageGraph: - """Create a LanguageGraph from a .mal file (a MAL spec). - - Arguments: - --------- - mal_spec_file - the path to the .mal file - - """ - logger.info('Loading mal spec %s', mal_spec_file) - return LanguageGraph(MalCompiler().compile(mal_spec_file)) - - -def language_graph_from_mar_archive(mar_archive: str) -> LanguageGraph: - """Create a LanguageGraph from a ".mar" archive provided by malc - (https://github.com/mal-lang/malc). - - Arguments: - --------- - mar_archive - the path to a ".mar" archive - - """ - logger.info('Loading mar archive %s', mar_archive) - with zipfile.ZipFile(mar_archive, 'r') as archive: - langspec = archive.read('langspec.json') - return LanguageGraph(json.loads(langspec)) diff --git a/maltoolbox/language/step_expression_processor.py b/maltoolbox/language/step_expression_processor.py deleted file mode 100644 index a35ae727..00000000 --- a/maltoolbox/language/step_expression_processor.py +++ /dev/null @@ -1,476 +0,0 @@ -from __future__ import annotations - -import json -import logging -from typing import Any - -from maltoolbox.exceptions import ( - LanguageGraphAssociationError, - LanguageGraphException, -) -from maltoolbox.language.expression_chain import ExpressionsChain, ExprType -from maltoolbox.language.language_graph_asset import LanguageGraphAsset -from maltoolbox.language.language_graph_lookup import get_var_expr_for_asset - -logger = logging.getLogger(__name__) - -StepResult = tuple[ - LanguageGraphAsset, - ExpressionsChain | None, - str | None, -] - -def process_attack_step_expression( - target_asset: LanguageGraphAsset, step_expression: dict[str, Any] -) -> StepResult: - """The attack step expression just adds the name of the attack - step. All other step expressions only modify the target - asset and parent associations chain. - """ - return (target_asset, None, step_expression['name']) - - -def process_set_operation_step_expression( - assets: dict[str, LanguageGraphAsset], - target_asset: LanguageGraphAsset, - expr_chain: ExpressionsChain | None, - step_expression: dict[str, Any], - lang_spec, -) -> StepResult: - """The set operators are used to combine the left hand and right - hand targets accordingly. - """ - lh_target_asset, lh_expr_chain, _ = process_step_expression( - assets, target_asset, expr_chain, step_expression['lhs'], lang_spec - ) - rh_target_asset, rh_expr_chain, _ = process_step_expression( - assets, target_asset, expr_chain, step_expression['rhs'], lang_spec - ) - - if lh_target_asset is None: - raise ValueError(f'No lh target in step expression {step_expression}') - if rh_target_asset is None: - raise ValueError(f'No rh target in step expression {step_expression}') - - if not lh_target_asset.get_all_common_superassets(rh_target_asset): - raise ValueError( - 'Set operation attempted between targets that do not share ' - f'any common superassets: {lh_target_asset.name} ' - f'and {rh_target_asset.name}!' - ) - - new_expr_chain = ExpressionsChain( - type=ExprType(step_expression['type']), - left_link=lh_expr_chain, - right_link=rh_expr_chain, - ) - return (lh_target_asset, new_expr_chain, None) - - -def process_variable_step_expression( - assets: dict[str, LanguageGraphAsset], - target_asset: LanguageGraphAsset, - step_expression: dict[str, Any], - lang_spec, -) -> StepResult: - - var_name = step_expression['name'] - var_target_asset, var_expr_chain = resolve_variable( - assets, target_asset, var_name, lang_spec - ) - - if var_expr_chain is None: - raise LookupError( - f'Failed to find variable "{step_expression["name"]}" ' - f'for {target_asset.name}', - ) - - return (var_target_asset, var_expr_chain, None) - - -def process_field_step_expression( - target_asset: LanguageGraphAsset, step_expression: dict[str, Any] -) -> StepResult: - """Change the target asset from the current one to the associated - asset given the specified field name and add the parent - fieldname and association to the parent associations chain. - """ - fieldname = step_expression['name'] - - if target_asset is None: - raise ValueError(f'Missing target asset for field "{fieldname}"!') - - for association in target_asset.associations.values(): - if ( - association.left_field.fieldname == fieldname - and target_asset.is_subasset_of(association.right_field.asset) - ): - return ( - association.left_field.asset, - ExpressionsChain( - type=ExprType.FIELD, fieldname=fieldname, association=association - ), - None, - ) - - if ( - association.right_field.fieldname == fieldname - and target_asset.is_subasset_of(association.left_field.asset) - ): - return ( - association.right_field.asset, - ExpressionsChain( - type=ExprType.FIELD, fieldname=fieldname, association=association - ), - None, - ) - - if fieldname == 'self': - # TODO: See if this needs an expression chain of some kind - # Possibly implement another ExprType for self references - return ( - target_asset, - None, - None, - ) - raise LookupError( - f'Failed to find field {fieldname} on asset {target_asset.name}!', - ) - - -def process_transitive_step_expression( - assets: dict[str, LanguageGraphAsset], - target_asset: LanguageGraphAsset, - expr_chain: ExpressionsChain | None, - step_expression: dict[str, Any], - lang_spec, -) -> StepResult: - """Create a transitive tuple entry that applies to the next - component of the step expression. - """ - result_target_asset, result_expr_chain, _ = process_step_expression( - assets, target_asset, expr_chain, step_expression['stepExpression'], lang_spec - ) - new_expr_chain = ExpressionsChain( - type=ExprType.TRANSITIVE, sub_link=result_expr_chain - ) - return (result_target_asset, new_expr_chain, None) - - -def process_subType_step_expression( - assets: dict[str, LanguageGraphAsset], - target_asset: LanguageGraphAsset, - expr_chain: ExpressionsChain | None, - step_expression: dict[str, Any], - lang_spec, -) -> StepResult: - """Create a subType tuple entry that applies to the next - component of the step expression and changes the target - asset to the subasset. - """ - subtype_name = step_expression['subType'] - result_target_asset, result_expr_chain, _ = process_step_expression( - assets, target_asset, expr_chain, step_expression['stepExpression'], lang_spec - ) - - if subtype_name not in assets: - raise LanguageGraphException(f'Failed to find subtype {subtype_name}') - - subtype_asset = assets[subtype_name] - - if result_target_asset is None: - raise LookupError('Nonexisting asset for subtype') - - if not subtype_asset.is_subasset_of(result_target_asset): - raise ValueError( - f'Found subtype {subtype_name} which does not extend ' - f'{result_target_asset.name}, subtype cannot be resolved.' - ) - - new_expr_chain = ExpressionsChain( - type=ExprType.SUBTYPE, sub_link=result_expr_chain, subtype=subtype_asset - ) - return (subtype_asset, new_expr_chain, None) - -def process_multiplicity_step_expression( - assets: dict[str, LanguageGraphAsset], - target_asset: LanguageGraphAsset, - expr_chain: ExpressionsChain | None, - step_expression: dict[str, Any], - lang_spec, -) -> StepResult: - multiplicity = step_expression['multiplicity'] - result_target_asset, result_expr_chain, step_name = process_step_expression( - assets, target_asset, expr_chain, step_expression['stepExpression'], lang_spec - ) - - assert step_name is None, f"Step `{step_name}` cannot have a multiplicity qualifier." - new_expr_chain = ExpressionsChain( - type=ExprType.MULTIPLICITY, - sub_link=result_expr_chain, - multiplicity=multiplicity - ) - return (result_target_asset, new_expr_chain, None) - - - -def process_collect_step_expression( - assets: dict[str, LanguageGraphAsset], - target_asset: LanguageGraphAsset, - expr_chain: ExpressionsChain | None, - step_expression: dict[str, Any], - lang_spec, -) -> StepResult: - """Apply the right hand step expression to left hand step - expression target asset and parent associations chain. - """ - lh_target_asset, lh_expr_chain, _ = process_step_expression( - assets, target_asset, expr_chain, step_expression['lhs'], lang_spec - ) - - if lh_target_asset is None: - raise ValueError( - f'No left hand asset in collect expression {step_expression["lhs"]}' - ) - - rh_target_asset, rh_expr_chain, rh_attack_step_name = process_step_expression( - assets, lh_target_asset, None, step_expression['rhs'], lang_spec - ) - - new_expr_chain = lh_expr_chain - if rh_expr_chain: - new_expr_chain = ExpressionsChain( - type=ExprType.COLLECT, left_link=lh_expr_chain, right_link=rh_expr_chain - ) - - return (rh_target_asset, new_expr_chain, rh_attack_step_name) - - -def process_step_expression( - assets: dict[str, LanguageGraphAsset], - target_asset: LanguageGraphAsset, - expr_chain: ExpressionsChain | None, - step_expression: dict, - lang_spec, -) -> StepResult: - """Recursively process an attack step expression. - - Arguments: - --------- - target_asset - The asset type that this step expression should - apply to. Initially it will contain the asset - type to which the attack step belongs. - expr_chain - A expressions chain of linked of associations - and set operations from the attack step to its - parent attack step. - Note: This was done for the parent attack step - because it was easier to construct recursively - given the left-hand first expansion of the - current MAL language specification. - step_expression - A dictionary containing the step expression. - - Return: - ------ - A tuple triplet containing the target asset, the resulting parent - associations chain, and the name of the attack step. - - """ - if logger.isEnabledFor(logging.DEBUG): - # Avoid running json.dumps when not in debug - logger.debug( - 'Processing Step Expression:\n%s', json.dumps(step_expression, indent=2) - ) - - result: StepResult - - if step_expression['type'] == 'attackStep': - result = process_attack_step_expression(target_asset, step_expression) - elif step_expression['type'] in ['union', 'intersection', 'difference']: - result = process_set_operation_step_expression( - assets, target_asset, expr_chain, step_expression, lang_spec - ) - elif step_expression['type'] == 'variable': - result = process_variable_step_expression( - assets, target_asset, step_expression, lang_spec - ) - elif step_expression['type'] == 'field': - result = process_field_step_expression(target_asset, step_expression) - elif step_expression['type'] == 'transitive': - result = process_transitive_step_expression( - assets, target_asset, expr_chain, step_expression, lang_spec - ) - elif step_expression['type'] == 'subType': - result = process_subType_step_expression( - assets, target_asset, expr_chain, step_expression, lang_spec - ) - elif step_expression['type'] == 'multiplicity': - result = process_multiplicity_step_expression( - assets, target_asset, expr_chain, step_expression, lang_spec - ) - elif step_expression['type'] == 'collect': - result = process_collect_step_expression( - assets, target_asset, expr_chain, step_expression, lang_spec - ) - else: - raise LookupError(f'Unknown attack step type: "{step_expression["type"]}"') - return result - - -def reverse_expr_chain( - expr_chain: ExpressionsChain | None, reverse_chain: ExpressionsChain | None -) -> ExpressionsChain | None: - """Recursively reverse the associations chain. From parent to child or - vice versa. - - Arguments: - --------- - expr_chain - A chain of nested tuples that specify the - associations and set operations chain from an - attack step to its connected attack step. - reverse_chain - A chain of nested tuples that represents the - current reversed associations chain. - - Return: - ------ - The resulting reversed associations chain. - - """ - if not expr_chain: - return reverse_chain - if expr_chain.type.is_binary(): - left_reverse_chain = reverse_expr_chain(expr_chain.left_link, reverse_chain) - right_reverse_chain = reverse_expr_chain(expr_chain.right_link, reverse_chain) - if expr_chain.type == ExprType.COLLECT: - new_expr_chain = ExpressionsChain( - type=expr_chain.type, - left_link=right_reverse_chain, - right_link=left_reverse_chain, - ) - else: - new_expr_chain = ExpressionsChain( - type=expr_chain.type, - left_link=left_reverse_chain, - right_link=right_reverse_chain, - ) - - return new_expr_chain - - if expr_chain.type == ExprType.TRANSITIVE: - result_reverse_chain = reverse_expr_chain(expr_chain.sub_link, reverse_chain) - new_expr_chain = ExpressionsChain( - type=ExprType.TRANSITIVE, sub_link=result_reverse_chain - ) - return new_expr_chain - - if expr_chain.type == ExprType.FIELD: - association = expr_chain.association - - if not association: - raise LanguageGraphException('Missing association for expressions chain') - - if not expr_chain.fieldname: - raise LanguageGraphException('Missing field name for expressions chain') - - opposite_fieldname = association.get_opposite_fieldname(expr_chain.fieldname) - new_expr_chain = ExpressionsChain( - type=ExprType.FIELD, association=association, fieldname=opposite_fieldname - ) - return new_expr_chain - - if expr_chain.type == ExprType.SUBTYPE: - result_reverse_chain = reverse_expr_chain(expr_chain.sub_link, reverse_chain) - new_expr_chain = ExpressionsChain( - type=ExprType.SUBTYPE, - sub_link=result_reverse_chain, - subtype=expr_chain.subtype, - ) - return new_expr_chain - - else: - msg = 'Unknown assoc chain element "%s"' - logger.error(msg, expr_chain.type) - raise LanguageGraphAssociationError(msg % expr_chain.type) - - -def resolve_variable( - assets: dict[str, LanguageGraphAsset], - asset: LanguageGraphAsset, - var_name: str, - lang_spec, -) -> tuple[LanguageGraphAsset, ExpressionsChain | None]: - """Resolve a variable for a specific asset by variable name. - - Arguments: - --------- - asset - a language graph asset to which the variable belongs - var_name - a string representing the variable name - - Return: - ------ - A tuple containing the target asset and expressions chain required to - reach it. - - """ - if var_name not in asset.variables: - var_expr = get_var_expr_for_asset(asset.name, var_name, lang_spec) - target_asset, expr_chain, _ = process_step_expression( - assets, asset, None, var_expr, lang_spec - ) - return (target_asset, expr_chain) - return asset.variables[var_name] - -# def proccess_dyn_sentence( -# assets: dict[str, LanguageGraphAsset], -# target_asset: LanguageGraphAsset, -# step_expression: dict[str, Any], -# lang_spec -# ) -> DynStepResult: -# base_asset, base_expr, base_step = process_step_expression(assets, target_asset, None, step_expression["base"], lang_spec) -# assert not base_step, "A base in a dynamic sentence can not end with an attack step" - -# assoc_op_or_not = lambda step_expr: process_assoc_op_step_expression( -# assets, target_asset, None, step_expr, lang_spec -# ) if step_expr["type"] == "assoc_op" else process_step_expression( -# assets, -# target_asset, -# None, -# step_expr, -# lang_spec -# ) - -# target_results = [assoc_op_or_not(target) for target in step_expression["targets"]] -# assert all(not target_result[2] for target_result in target_results) -# # for target in step_expression["targets"]: -# # base2target_asset, base2target_expr, base2target_step = assoc_op_or_not(target) -# # assert not base2target_step, "A target in a dynamic sentence can not end with an attack step" - -# return (base_asset, base_expr, target_results) - -def process_assoc_op_step_expression( - assets: dict[str, LanguageGraphAsset], - target_asset: LanguageGraphAsset, - expr_chain: ExpressionsChain | None, - step_expression: dict[str, Any], - lang_spec -) -> StepResult: - - result_target_asset, result_expr_chain, _result_step_name = ( - process_step_expression( - assets, - target_asset, - expr_chain, - step_expression['operand'], - lang_spec - ) - ) - assert not _result_step_name, "ASSOC_OP can not have an attack step as operand" - - new_expr_chain = ExpressionsChain( - type=ExprType.ASSOC_OP, - sub_link=result_expr_chain, - ) - return ( - result_target_asset, - new_expr_chain, - None - ) \ No newline at end of file diff --git a/maltoolbox/model.py b/maltoolbox/model.py deleted file mode 100644 index f77949da..00000000 --- a/maltoolbox/model.py +++ /dev/null @@ -1,480 +0,0 @@ -"""MAL-Toolbox Model Module""" - -from __future__ import annotations - -import json -import logging -import math -from typing import TYPE_CHECKING - -from . import __version__ -from .exceptions import ModelException -from .file_utils import ( - load_dict_from_json_file, - load_dict_from_yaml_file, - save_dict_to_file, -) -from .language import LanguageGraph - -if TYPE_CHECKING: - from typing import Any - - from .language import LanguageGraphAsset, LanguageGraphAssociation - -logger = logging.getLogger(__name__) - - -class Model: - """An implementation of a MAL language model containing assets""" - - next_id: int = 0 - - def __repr__(self) -> str: - return f'Model(name: "{self.name}", language: {self.lang_graph})' - - def __init__( - self, name: str, lang_graph: LanguageGraph, mt_version: str = __version__ - ): - - self.name = name - self.assets: dict[int, ModelAsset] = {} - self._name_to_asset: dict[str, ModelAsset] = {} # optimization - self.lang_graph = lang_graph - self.maltoolbox_version: str = mt_version - - def add_asset( - self, - asset_type: str, - name: str | None = None, - asset_id: int | None = None, - defenses: dict[str, float] | None = None, - extras: dict | None = None, - allow_duplicate_names: bool = True, - ) -> ModelAsset: - """Create an asset based on the provided parameters and add it to the - model. - - Arguments: - --------- - asset_type - string containing the asset type name - name - string containing the asset name. If not - provided the concatenated asset type and id - will be used as a name. - asset_id - id to assign to this asset, usually from an - instance model file. If not provided the id - will be set to the next highest id - available. - defeses - dictionary of defense values - extras - dictionary of extras - allow_duplicate_name - allow duplicate names to be used. If allowed - and a duplicate is encountered the name will - be appended with the id. - - Return: - ------ - The newly created asset. - - """ - # Set asset ID and check for duplicates - asset_id = asset_id if asset_id is not None else self.next_id - if asset_id in self.assets: - raise ValueError(f'Asset index {asset_id} already in use.') - - self.next_id = max(asset_id + 1, self.next_id) - - if not name: - name = asset_type + ':' + str(asset_id) - elif name in self._name_to_asset: - if allow_duplicate_names: - name = name + ':' + str(asset_id) - else: - raise ValueError( - f'Asset name {name} is a duplicate and we do not allow duplicates.' - ) - - if asset_type not in self.lang_graph.assets: - raise ValueError( - f'Asset type "{asset_type}" does not exist in language, ' - 'must be one of:\n -' + '\n -'.join(self.lang_graph.assets.keys()) - ) - lg_asset = self.lang_graph.assets[asset_type] - - asset = ModelAsset( - name=name, - asset_id=asset_id, - lg_asset=lg_asset, - defenses=defenses, - extras=extras, - ) - - logger.debug('Add "%s"(%d) to model "%s".', name, asset_id, self.name) - self.assets[asset_id] = asset - self._name_to_asset[name] = asset - - return asset - - def remove_asset(self, asset: ModelAsset) -> None: - """Remove an asset from the model. - - Arguments: - --------- - asset - the asset to remove - - """ - logger.debug( - 'Remove "%s"(%d) from model "%s".', asset.name, asset.id, self.name - ) - if asset.id not in self.assets: - raise LookupError( - f'Asset "{asset.name}"({asset.id}) is not part of model"{self.name}".' - ) - - # First remove all of the associated assets - # We can not remove from the dict while iterating over it - # so we first have to copy the keys and then remove those assets - associated_fieldnames = dict(asset.associated_assets) - for fieldname, assoc_assets in associated_fieldnames.items(): - asset.remove_associated_assets(fieldname, assoc_assets) - - del self.assets[asset.id] - del self._name_to_asset[asset.name] - - def get_asset_by_id(self, asset_id: int) -> ModelAsset | None: - """Find an asset in the model based on its id. - - Arguments: - --------- - asset_id - the id of the asset we are looking for - - Return: - ------ - An asset matching the id if it exists in the model. - - """ - logger.debug('Get asset with id %d from model "%s".', asset_id, self.name) - return self.assets.get(asset_id, None) - - def get_asset_by_name(self, asset_name: str) -> ModelAsset | None: - """Find an asset in the model based on its name. - - Arguments: - --------- - asset_name - the name of the asset we are looking for - - Return: - ------ - An asset matching the name if it exists in the model. - - """ - logger.debug('Get asset with name "%s" from model "%s".', asset_name, self.name) - return self._name_to_asset.get(asset_name, None) - - def _to_dict(self) -> dict: - """Backwards compatible""" - return self.to_dict() - - def to_dict(self) -> dict: - """Get dictionary representation of the model.""" - logger.debug('Translating model to dict.') - contents: dict[str, Any] = { - 'metadata': {}, - 'assets': {}, - } - contents['metadata'] = { - 'name': self.name, - 'langVersion': self.lang_graph.metadata['version'], - 'langID': self.lang_graph.metadata['id'], - 'malVersion': '0.1.0-SNAPSHOT', - 'MAL-Toolbox Version': __version__, - 'info': 'Created by the mal-toolbox model python module.', - } - - logger.debug('Translating assets to dictionary.') - for asset in self.assets.values(): - contents['assets'].update(asset._to_dict()) - - return contents - - def save_to_file(self, filename: str) -> None: - """Save to json/yml depending on extension""" - logger.debug('Save instance model to file "%s".', filename) - return save_dict_to_file(filename, self._to_dict()) - - @classmethod - def _from_dict( - cls, - serialized_object: dict, - lang_graph: LanguageGraph, - ) -> Model: - """Create a model from dict representation - - Arguments: - --------- - serialized_object - Model in dict format - lang_graph - - - """ - maltoolbox_version = serialized_object['metadata'].get( - 'MAL Toolbox Version', __version__ - ) - model = Model( - serialized_object['metadata']['name'], - lang_graph, - mt_version=maltoolbox_version, - ) - - # Reconstruct the assets - for asset_id, asset_dict in serialized_object['assets'].items(): - if logger.isEnabledFor(logging.DEBUG): - # Avoid running json.dumps when not in debug - logger.debug('Loading asset:\n%s', json.dumps(asset_dict, indent=2)) - - # Allow defining an asset via type only. - asset_dict = ( - asset_dict - if isinstance(asset_dict, dict) - else {'type': asset_dict, 'name': f'{asset_dict}:{asset_id}'} - ) - - model.add_asset( - asset_type=asset_dict['type'], - name=asset_dict['name'], - defenses={ - defense: float(value) - for defense, value in asset_dict.get('defenses', {}).items() - }, - extras=asset_dict.get('extras', {}), - asset_id=int(asset_id), - ) - - # Reconstruct the association links - for asset_id, asset_dict in serialized_object['assets'].items(): - asset = model.assets[int(asset_id)] - assoc_assets_dict = asset_dict['associated_assets'].items() - for fieldname, assoc_assets in assoc_assets_dict: - asset.add_associated_assets( - fieldname, - { - model.assets[int(assoc_asset_id)] - for assoc_asset_id in assoc_assets - }, - ) - - # Attackers no longer part of mal-toolbox - if 'attackers' in serialized_object: - msg = ( - 'Defining attackers in a model file is deprecated,' - ' use mal-simulator for attacker simulations.' - ) - print(msg) - logger.warning(msg) - - return model - - @classmethod - def load_from_file( - cls, - filename: str, - lang_graph: LanguageGraph, - ) -> Model: - """Create from json or yaml file depending on file extension""" - logger.debug('Load instance model from file "%s".', filename) - serialized_model = None - if filename.endswith(('.yml', '.yaml')): - serialized_model = load_dict_from_yaml_file(filename) - elif filename.endswith('.json'): - serialized_model = load_dict_from_json_file(filename) - else: - raise ValueError('Unknown file extension, expected json/yml/yaml') - try: - return cls._from_dict(serialized_model, lang_graph) - except Exception as e: - raise ModelException( - 'Could not load model. It might be of an older version. ' - "Try to upgrade it with 'maltoolbox upgrade-model'" - ) from e - - -class ModelAsset: - def __init__( - self, - name: str, - asset_id: int, - lg_asset: LanguageGraphAsset, - defenses: dict[str, float] | None = None, - extras: dict | None = None, - ): - - self.name: str = name - self._id: int = asset_id - self.lg_asset: LanguageGraphAsset = lg_asset - self.type = self.lg_asset.name - self.defenses: dict[str, float] = defenses or {} - self.extras: dict = extras or {} - self._associated_assets: dict[str, set[ModelAsset]] = {} - self.attack_step_nodes: list = [] - - def _to_dict(self) -> dict[int, dict[str, Any]]: - """Get dictionary representation of the asset.""" - logger.debug('Translating "%s"(%d) to dictionary.', self.name, self.id) - - asset_dict: dict[str, Any] = { - 'name': self.name, - 'type': self.type, - 'defenses': {}, - 'associated_assets': {}, - } - - for defense, defense_value in self.defenses.items(): - asset_dict['defenses'][defense] = defense_value - - for fieldname, assets in self.associated_assets.items(): - asset_dict['associated_assets'][fieldname] = { - asset.id: asset.name for asset in assets - } - - if len(asset_dict['defenses']) == 0: - # Do not include an empty defenses dictionary - del asset_dict['defenses'] - - if self.extras != {}: - # Add optional metadata to dict - asset_dict['extras'] = self.extras - - return {self.id: asset_dict} - - def __repr__(self): - return f'ModelAsset(name: "{self.name}", id: {self.id}, type: {self.type})' - - def associations_with(self, b: ModelAsset) -> set[LanguageGraphAssociation]: - """Returns all associations from self to `b`""" - assocs_in_common = set() - for assoc in self.lg_asset.associations.values(): - assets_to_left = self.associated_assets.get( - assoc.left_field.fieldname, set() - ) - assets_to_right = self.associated_assets.get( - assoc.right_field.fieldname, set() - ) - if b in assets_to_left or b in assets_to_right: - assocs_in_common.add(assoc) - - return assocs_in_common - - def has_association_with(self, b: ModelAsset, assoc_name: str) -> bool: - """Returns True if association `assoc_name` exists between self and `b`""" - for fieldname, associated_assets in self.associated_assets.items(): - assoc = self.lg_asset.associations[fieldname] - if assoc.name == assoc_name and b in associated_assets: - return True - - return False - - def validate_associated_assets( - self, fieldname: str, assets_to_add: set[ModelAsset] - ): - """Validate an association we want to add (through `fieldname`) - is valid with the assets given in param `assets_to_add`: - - fieldname is valid for the asset type of this ModelAsset - - type of `assets_to_add` is valid for the association - - no more assets than 'field.maximum' are added to the field - - Raises - ------ - LookupError - fieldname can not be found for this ModelAsset - ValueError - there will be too many assets in the field - if we add this association - TypeError - if the asset type of `assets_to_add` is not valid - - """ - # Validate that the field name is allowed for this asset type - if fieldname not in self.lg_asset.associations: - accepted_fieldnames = list(self.lg_asset.associations.keys()) - raise LookupError( - f"Fieldname '{fieldname}' is not an accepted association " - f'fieldname from asset type {self.lg_asset.name}. ' - f'Did you mean one of {accepted_fieldnames}?' - ) - - lg_assoc = self.lg_asset.associations[fieldname] - assoc_field = lg_assoc.get_field(fieldname) - - # Validate that the asset to add association to is of correct type - for asset_to_add in assets_to_add: - if not asset_to_add.lg_asset.is_subasset_of(assoc_field.asset): - raise TypeError( - f"Asset '{asset_to_add.name}' of type " - f"'{asset_to_add.type}' can not be added to association " - f"'{self.name}.{fieldname}'. Expected type of " - f"'{fieldname}' is {assoc_field.asset.name}." - ) - - # Validate that there will not be too many assets in field - assets_in_field_before = self.associated_assets.get(fieldname, set()) - assets_in_field_after = assets_in_field_before | set(assets_to_add) - max_assets_in_field = assoc_field.maximum or math.inf - - if len(assets_in_field_after) > max_assets_in_field: - raise ValueError( - f'You can have maximum {assoc_field.maximum} ' - f'assets for association field {fieldname}' - ) - - def add_associated_assets(self, fieldname: str, assets: set[ModelAsset]): - """Add the assets provided as a parameter to the set of associated - assets dictionary entry corresponding to the given fieldname. - """ - if fieldname not in self.lg_asset.associations: - if assets: - to_asset_type = next(iter(assets)).lg_asset - possible_associations = self.lg_asset.associations_to(to_asset_type) - else: - to_asset_type = None - possible_associations = self.lg_asset.associations - raise ValueError( - f'Association fieldname "{fieldname}" does not exist from ' - f'<{self.lg_asset.name}> to <{to_asset_type.name if to_asset_type else "Any"}>' - ', must be one of:\n -' - + '\n -'.join([a for a in possible_associations]) - ) - - lg_assoc = self.lg_asset.associations[fieldname] - other_fieldname = lg_assoc.get_opposite_fieldname(fieldname) - - # Validation from both sides - self.validate_associated_assets(fieldname, assets) - for asset in assets: - asset.validate_associated_assets(other_fieldname, {self}) - - # Add the associated assets to this asset's dictionary - self._associated_assets.setdefault(fieldname, set()).update(assets) - - # Add this asset to the associated assets' corresponding dictionaries - for asset in assets: - asset._associated_assets.setdefault(other_fieldname, set()).add(self) - - def remove_associated_assets(self, fieldname: str, assets: set[ModelAsset]): - """Remove the assets provided as a parameter from the set of - associated assets dictionary entry corresponding to the fieldname - parameter. - """ - # Remove this asset from its associated assets' dictionaries - lg_assoc = self.lg_asset.associations[fieldname] - other_fieldname = lg_assoc.get_opposite_fieldname(fieldname) - for asset in assets: - asset._associated_assets[other_fieldname].remove(self) - if len(asset._associated_assets[other_fieldname]) == 0: - del asset._associated_assets[other_fieldname] - - # Remove associated assets from this asset - self._associated_assets[fieldname] -= set(assets) - if len(self._associated_assets[fieldname]) == 0: - del self._associated_assets[fieldname] - - @property - def associated_assets(self): - return self._associated_assets - - @property - def id(self): - return self._id diff --git a/py-bindings/Cargo.lock b/py-bindings/Cargo.lock new file mode 100644 index 00000000..d10b9bf1 --- /dev/null +++ b/py-bindings/Cargo.lock @@ -0,0 +1,627 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "maltoolbox-attackgraph" +version = "0.1.0" +dependencies = [ + "indexmap", + "maltoolbox-io", + "maltoolbox-language", + "maltoolbox-model", + "rustc-hash", + "serde", + "serde_json", + "serde_yaml", + "slotmap", + "thiserror", +] + +[[package]] +name = "maltoolbox-attackgraph-py" +version = "0.1.0" +dependencies = [ + "maltoolbox-attackgraph", + "maltoolbox-io", + "maltoolbox-language", + "maltoolbox-language-py", + "maltoolbox-model", + "maltoolbox-model-py", + "pyo3", + "pythonize", + "serde_json", + "thiserror", +] + +[[package]] +name = "maltoolbox-io" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "serde_yaml", + "thiserror", +] + +[[package]] +name = "maltoolbox-language" +version = "0.1.0" +dependencies = [ + "indexmap", + "maltoolbox-io", + "rustc-hash", + "serde", + "serde_json", + "serde_yaml", + "slotmap", + "thiserror", + "tree-sitter", + "tree-sitter-mal", + "zip", +] + +[[package]] +name = "maltoolbox-language-py" +version = "0.1.0" +dependencies = [ + "indexmap", + "maltoolbox-io", + "maltoolbox-language", + "pyo3", + "pythonize", + "serde_json", + "slotmap", + "thiserror", +] + +[[package]] +name = "maltoolbox-model" +version = "0.1.0" +dependencies = [ + "maltoolbox-io", + "maltoolbox-language", + "rustc-hash", + "serde", + "serde_json", + "serde_yaml", + "thiserror", +] + +[[package]] +name = "maltoolbox-model-py" +version = "0.1.0" +dependencies = [ + "maltoolbox-io", + "maltoolbox-language", + "maltoolbox-language-py", + "maltoolbox-model", + "pyo3", + "pythonize", + "serde_json", + "thiserror", +] + +[[package]] +name = "maltoolbox-pyo3" +version = "0.1.0" +dependencies = [ + "maltoolbox-attackgraph-py", + "maltoolbox-language-py", + "maltoolbox-model-py", + "pyo3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "700d18fa267b73b9b521fd7e13580e2f446916f176cacee1ab63fcc8191f1655" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b3fc0c4d08f6bb10e71fe39dfb9e2f59c6eb6854e22ec8092f50c69a4499adb" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc0b8e19df29aad7086cf977bb0c2a2f143e30567eb113e9cf72b62ca698330" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6100e8a4b5eba53afaa5ed078364851a0b2499c553a44c31026b929049b49dc6" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6143877a16e82b5a727b7127ff4cd86858a24a28d745d72f43e6f227c7b1bdb3" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pythonize" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ec376e1216e0c929a74964ce2020012a1a39f32d80e78aa688721219ea7fb89" +dependencies = [ + "pyo3", + "serde", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tree-sitter" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" + +[[package]] +name = "tree-sitter-mal" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ca67bb562b91b7224a339c0e67312fd873ad1f96a024a77d81c14aed4aa1aba" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/py-bindings/Cargo.toml b/py-bindings/Cargo.toml new file mode 100644 index 00000000..f599cb32 --- /dev/null +++ b/py-bindings/Cargo.toml @@ -0,0 +1,26 @@ +[workspace] +resolver = "2" +members = [ + "maltoolbox-language-py", + "maltoolbox-model-py", + "maltoolbox-attackgraph-py", + "maltoolbox-pyo3", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +publish = false + +[workspace.dependencies] +maltoolbox-language-py = { path = "maltoolbox-language-py" } +maltoolbox-model-py = { path = "maltoolbox-model-py" } +maltoolbox-attackgraph-py = { path = "maltoolbox-attackgraph-py" } + +pyo3 = "0.29.3" +pythonize = "0.29.0" +serde_json = { version = "1", features = ["preserve_order"] } +thiserror = "2" +indexmap = "2" +slotmap = "1" diff --git a/py-bindings/maltoolbox-attackgraph-py/Cargo.toml b/py-bindings/maltoolbox-attackgraph-py/Cargo.toml new file mode 100644 index 00000000..1412e4ad --- /dev/null +++ b/py-bindings/maltoolbox-attackgraph-py/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "maltoolbox-attackgraph-py" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[lib] +name = "maltoolbox_attackgraph_py" +path = "src/lib.rs" + +[dependencies] +maltoolbox-attackgraph = { path = "../../core/maltoolbox-attackgraph" } +maltoolbox-model = { path = "../../core/maltoolbox-model" } +maltoolbox-language = { path = "../../core/maltoolbox-language" } +maltoolbox-language-py = { workspace = true } +maltoolbox-model-py = { workspace = true } +maltoolbox-io = { path = "../../core/maltoolbox-io" } +serde_json = { workspace = true } +thiserror = { workspace = true } +pyo3 = { workspace = true } +pythonize = { workspace = true } diff --git a/py-bindings/maltoolbox-attackgraph-py/src/detector.rs b/py-bindings/maltoolbox-attackgraph-py/src/detector.rs new file mode 100644 index 00000000..532be92b --- /dev/null +++ b/py-bindings/maltoolbox-attackgraph-py/src/detector.rs @@ -0,0 +1,194 @@ +//! Mirrors `maltoolbox/attackgraph/detector.py`'s `Detector`. Unlike other +//! wrappers in this layer, `Detector` is a freely constructible value type +//! (`#[new]`), since it only ever wraps a reference to an *existing* +//! `AttackGraphNode` handle and never needs detached construction. + +use pyo3::basic::CompareOp; +use pyo3::exceptions::PyTypeError; +use pyo3::prelude::*; +use pyo3::types::PyDict; +use pyo3::IntoPyObjectExt; + +use serde_json::{json, Value}; + +use crate::node::PyAttackGraphNode; + +#[pyclass( + name = "Detector", + module = "maltoolbox._native", + unsendable, + skip_from_py_object +)] +pub struct PyDetector { + pub name: Option, + /// The owner-cached `Py` handle, so `.node` returns + /// the identical Python object every time. + pub node: Py, + pub potential_context: Py, + pub tprate: Option, + pub fprate: Option, +} + +impl PyDetector { + pub fn new( + name: Option, + node: Py, + potential_context: Py, + tprate: Option, + fprate: Option, + ) -> Self { + PyDetector { + name, + node, + potential_context, + tprate, + fprate, + } + } +} + +#[pymethods] +impl PyDetector { + #[new] + #[pyo3(signature = (name, node, potential_context, tprate=None, fprate=None))] + fn py_new( + py: Python<'_>, + name: Option, + node: PyRef<'_, PyAttackGraphNode>, + potential_context: Py, + tprate: Option, + fprate: Option, + ) -> PyResult { + // Route through the node's owner cache so the stored handle is the + // canonical cached node object, not a detached copy. A `Detector` + // always references a real node in a real graph, so a `Detached` + // node here is a programming error. + let (owner_py, id) = match &node.repr { + crate::node::NodeRepr::Owned { owner_py, id } => (owner_py.clone_ref(py), *id), + crate::node::NodeRepr::Detached(_) => { + return Err(pyo3::exceptions::PyNotImplementedError::new_err( + "Detector cannot be constructed from a detached AttackGraphNode (one with no owning AttackGraph).", + )) + } + }; + drop(node); + let graph = owner_py.borrow(py); + let cached_node = graph.node_handle(&owner_py, py, id)?; + drop(graph); + Ok(PyDetector::new( + name, + cached_node, + potential_context, + tprate, + fprate, + )) + } + + #[getter] + fn name(&self) -> Option { + self.name.clone() + } + + #[getter] + fn node(&self, py: Python<'_>) -> Py { + self.node.clone_ref(py) + } + + #[getter] + fn potential_context(&self, py: Python<'_>) -> Py { + self.potential_context.clone_ref(py) + } + + #[getter] + fn tprate(&self) -> Option { + self.tprate + } + + #[getter] + fn fprate(&self) -> Option { + self.fprate + } + + /// Matches the core's simplified node-detector serialization + /// (`node_to_dict`'s detector branch in `graph.rs`), not Python's own + /// `Detector.to_dict()`, which embeds live object references and + /// isn't actually JSON-serializable. + fn to_dict<'py>(&self, py: Python<'py>) -> PyResult> { + let dict: Value = json!({ "name": self.name, "tprate": self.tprate }); + pythonize::pythonize(py, &dict) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn __repr__(&self, py: Python<'_>) -> String { + format!( + "Detector(name: {:?}, node: {})", + self.name, + self.node.borrow(py).full_name_or_fallback(py) + ) + } + + /// `potential_context` is a `dict`, which is unhashable, so Python's + /// own dataclass `__hash__` would raise the same `TypeError`. + fn __hash__(&self) -> PyResult { + Err(PyTypeError::new_err("unhashable type: 'Detector'")) + } + + /// `PyDetector`'s `#[new]` requires mandatory arguments, so pickle's + /// default `copyreg.__newobj__` protocol can't construct one without + /// this. The class itself is directly callable with these positional + /// args, so no `_from_pickle_state` indirection is needed; the nested + /// `node` field pickles via its own `__reduce__`. + #[allow(clippy::type_complexity)] + fn __reduce__<'py>( + &self, + py: Python<'py>, + ) -> PyResult<( + Bound<'py, PyAny>, + ( + Option, + Py, + Py, + Option, + Option, + ), + )> { + let cls = py.get_type::().into_any(); + Ok(( + cls, + ( + self.name.clone(), + self.node.clone_ref(py), + self.potential_context.clone_ref(py), + self.tprate, + self.fprate, + ), + )) + } + + fn __richcmp__( + &self, + other: &PyDetector, + op: CompareOp, + py: Python<'_>, + ) -> PyResult> { + let eq = match op { + CompareOp::Eq | CompareOp::Ne => { + let fields_eq = self.name == other.name + && self.node.borrow(py).eq_with(&other.node.borrow(py), py) + && self.tprate == other.tprate + && self.fprate == other.fprate; + fields_eq + && self + .potential_context + .bind(py) + .eq(other.potential_context.bind(py))? + } + _ => return Ok(py.NotImplemented()), + }; + match op { + CompareOp::Eq => eq.into_py_any(py), + CompareOp::Ne => (!eq).into_py_any(py), + _ => unreachable!(), + } + } +} diff --git a/py-bindings/maltoolbox-attackgraph-py/src/detector_support.rs b/py-bindings/maltoolbox-attackgraph-py/src/detector_support.rs new file mode 100644 index 00000000..0f18075d --- /dev/null +++ b/py-bindings/maltoolbox-attackgraph-py/src/detector_support.rs @@ -0,0 +1,98 @@ +//! Shared plumbing for seeding the compat-layer-owned detector containers +//! (`PyAttackGraph.detectors`, `PyAttackGraphNode.detectors`) from the Rust +//! core's generation-time data - see `graph.rs`/`node.rs`'s `.detectors` +//! getters. +//! +//! The core's [`maltoolbox_attackgraph::Detector`] stores its *label* only +//! as the surrounding `HashMap`'s key, and identifies its node/context via +//! [`maltoolbox_attackgraph::ids::AttackGraphNodeId`] (a slotmap key, not +//! the stable Python-facing `i64`) - both need resolving against a live +//! `&AttackGraph` borrow. [`DetectorSnapshot`] captures everything needed +//! to build a `PyDetector` with no further core-graph borrow, avoiding a +//! reentrant re-borrow of the same `RefCell` while constructing Python +//! objects. + +use std::collections::HashMap; + +use pyo3::prelude::*; +use pyo3::types::{PyDict, PySet}; + +use maltoolbox_attackgraph::ids::AttackGraphNodeId; +use maltoolbox_attackgraph::AttackGraph; + +use crate::detector::PyDetector; +use crate::graph::PyAttackGraph; + +#[derive(Clone)] +pub struct DetectorSnapshot { + pub label: String, + pub node_id: i64, + pub name: Option, + /// fieldname -> ids of the nodes it could potentially refer to. + pub potential_context: HashMap>, + pub tprate: Option, + pub fprate: Option, +} + +/// Snapshots every detector on the given nodes (identified by core +/// slotmap key) into owned, borrow-free data. +pub fn detector_snapshots_for( + graph: &AttackGraph, + keys: &[AttackGraphNodeId], +) -> Vec { + let mut out = Vec::new(); + for &key in keys { + let Some(node) = graph.nodes.get(key) else { + continue; + }; + for (label, det) in &node.detectors { + let mut potential_context = HashMap::new(); + for (fieldname, ids) in &det.potential_context { + let resolved: Vec = ids + .iter() + .filter_map(|&k| graph.nodes.get(k).map(|n| n.id)) + .collect(); + potential_context.insert(fieldname.clone(), resolved); + } + out.push(DetectorSnapshot { + label: label.clone(), + node_id: node.id, + name: det.name.clone(), + potential_context, + tprate: det.tprate, + fprate: det.fprate, + }); + } + } + out +} + +/// Builds a real `PyDetector` from a snapshot - safe to call with no +/// outstanding `AttackGraph` borrow. +pub fn build_py_detector( + py: Python<'_>, + owner_py: &Py, + snap: &DetectorSnapshot, +) -> PyResult> { + let graph = owner_py.borrow(py); + let node = graph.node_handle(owner_py, py, snap.node_id)?; + let context_dict = PyDict::new(py); + for (fieldname, ids) in &snap.potential_context { + let set = PySet::empty(py)?; + for &id in ids { + set.add(graph.node_handle(owner_py, py, id)?)?; + } + context_dict.set_item(fieldname, set)?; + } + drop(graph); + Py::new( + py, + PyDetector::new( + snap.name.clone(), + node, + context_dict.unbind(), + snap.tprate, + snap.fprate, + ), + ) +} diff --git a/py-bindings/maltoolbox-attackgraph-py/src/exceptions.rs b/py-bindings/maltoolbox-attackgraph-py/src/exceptions.rs new file mode 100644 index 00000000..5557debf --- /dev/null +++ b/py-bindings/maltoolbox-attackgraph-py/src/exceptions.rs @@ -0,0 +1,73 @@ +//! Exception hierarchy mirroring `maltoolbox/exceptions.py`'s +//! `AttackGraphException` branch, built on the `MalToolboxException` type +//! from `maltoolbox-language-py`. +//! +//! The core's `maltoolbox_attackgraph::GraphError` is overloaded: its +//! `Malformed(String)` variant covers several Python conditions that map +//! to *different* exception types there (plain lookup failures raise +//! `LookupError` directly in `node_getters.py`/`attack_graph_from_dict`, +//! not a custom exception). `graph_error_to_py` below is the context-free +//! default; call sites that know they're wrapping one of Python's +//! `LookupError`-raising paths (`get_node_by_full_name`, `from_dict`'s +//! "failed to find ..." messages) map directly to `PyLookupError` instead +//! - see `graph.rs`. + +use pyo3::create_exception; +use pyo3::exceptions::{PyLookupError, PyOSError, PyValueError}; +use pyo3::prelude::*; + +use maltoolbox_attackgraph::GraphError; +use maltoolbox_language_py::exceptions::MalToolboxException; + +create_exception!(_native, AttackGraphException, MalToolboxException); +create_exception!( + _native, + AttackGraphStepExpressionError, + AttackGraphException +); + +/// Context-free default mapping for a [`GraphError`]: +/// - `DuplicateNodeId` -> `PyValueError`, matching `AttackGraph.add_node`'s +/// plain `ValueError` in Python. +/// - `StepExpression` -> `AttackGraphStepExpressionError`, matching +/// `generate.py`'s step-expression resolution failures. +/// - `Malformed` falls back to the base `AttackGraphException` (a +/// catch-all for conditions that are plain `LookupError`s at specific +/// call sites instead - see the module doc comment above). +/// - `Language`/`Model` delegate to the respective layer's own mapper. +pub fn graph_error_to_py(err: GraphError) -> PyErr { + let msg = err.to_string(); + match err { + GraphError::DuplicateNodeId(_) => PyValueError::new_err(msg), + GraphError::StepExpression(_) => AttackGraphStepExpressionError::new_err(msg), + GraphError::Malformed(_) => AttackGraphException::new_err(msg), + GraphError::Language(e) => maltoolbox_language_py::exceptions::graph_error_to_py(e), + GraphError::Model(e) => maltoolbox_model_py::exceptions::model_error_to_py(e), + GraphError::FileUtil(e) => PyOSError::new_err(e.to_string()), + } +} + +/// For call sites that know any failure here corresponds to a +/// `LookupError` in Python (`get_node_by_full_name`, +/// `attack_graph_from_dict`'s "Failed to find ..." messages); bypasses +/// `graph_error_to_py`'s generic `Malformed` -> `AttackGraphException` +/// default. +pub fn graph_error_to_lookup(err: GraphError) -> PyErr { + match err { + GraphError::Language(e) => maltoolbox_language_py::exceptions::graph_error_to_py(e), + GraphError::Model(e) => maltoolbox_model_py::exceptions::model_error_to_py(e), + _ => PyLookupError::new_err(err.to_string()), + } +} + +pub fn register(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add( + "AttackGraphException", + py.get_type::(), + )?; + m.add( + "AttackGraphStepExpressionError", + py.get_type::(), + )?; + Ok(()) +} diff --git a/py-bindings/maltoolbox-attackgraph-py/src/factories.rs b/py-bindings/maltoolbox-attackgraph-py/src/factories.rs new file mode 100644 index 00000000..b16034c9 --- /dev/null +++ b/py-bindings/maltoolbox-attackgraph-py/src/factories.rs @@ -0,0 +1,59 @@ +//! Port of `maltoolbox/attackgraph/factories.py::create_attack_graph`. +//! +//! Unlike the Rust core's `maltoolbox_attackgraph::factories::create_attack_graph` +//! (path-only, returns `(AttackGraph, Model)` since the core doesn't store +//! `model` on `AttackGraph`), this mirrors Python's signature: `lang`/`model` +//! each accept either a path string or an already-loaded object, and only +//! the `AttackGraph` is returned (with `.model`/`.lang_graph` set via this +//! compat layer's own back-reference). +//! +//! The debug-dump side effects of Python's original +//! (`log_configs['langspec_file']`/etc.) are dropped - that config/logging +//! subsystem isn't ported anywhere in this project. + +use pyo3::exceptions::PyTypeError; +use pyo3::prelude::*; + +use maltoolbox_language_py::PyLanguageGraph; +use maltoolbox_model_py::PyModel; + +use crate::graph::PyAttackGraph; + +#[pyfunction] +pub fn create_attack_graph( + py: Python<'_>, + lang: &Bound<'_, PyAny>, + model: &Bound<'_, PyAny>, +) -> PyResult { + let lang_graph_py: Py = if let Ok(lg) = lang.extract::>() { + lg + } else if let Ok(path) = lang.extract::() { + let graph = match maltoolbox_language::from_mar_archive(&path) { + Ok(g) => g, + Err(_) => maltoolbox_language::from_mal_spec(&path) + .map_err(maltoolbox_language_py::exceptions::load_error_to_py)?, + }; + Py::new(py, PyLanguageGraph::wrap(graph))? + } else { + return Err(PyTypeError::new_err( + "`lang` must be either string or LanguageGraph", + )); + }; + + let model_py: Py = if let Ok(m) = model.extract::>() { + m + } else if let Ok(path) = model.extract::() { + let loaded = PyModel::load_from_file( + py, + std::path::PathBuf::from(&path), + lang_graph_py.clone_ref(py), + )?; + Py::new(py, loaded)? + } else { + return Err(PyTypeError::new_err( + "`model` must be either string or Model", + )); + }; + + PyAttackGraph::new(py, Some(lang_graph_py), Some(model_py)) +} diff --git a/py-bindings/maltoolbox-attackgraph-py/src/graph.rs b/py-bindings/maltoolbox-attackgraph-py/src/graph.rs new file mode 100644 index 00000000..82387a8d --- /dev/null +++ b/py-bindings/maltoolbox-attackgraph-py/src/graph.rs @@ -0,0 +1,1132 @@ +//! Mirrors `maltoolbox/attackgraph/attackgraph.py`'s `AttackGraph`. The +//! one "container" type in this crate - see +//! PYTHON_BINDINGS_IMPLEMENTATION.md's "Container / handle pattern". +//! +//! Holds, in addition to the core `Rc>`, the actual +//! `Py`/`Option>` objects passed in, so +//! `.lang_graph`/`.model` always return the same Python object. `.model` +//! exists here even though the Rust core's `AttackGraph` has no such +//! field (see `PORTING_NOTES.md` §2) because mal-simulator depends on +//! `attack_graph.model` directly. + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::ffi::c_void; +use std::path::PathBuf; +use std::ptr::NonNull; +use std::rc::Rc; + +use pyo3::prelude::*; +use pyo3::types::{PyCapsule, PyDict, PyList, PySet}; +use pyo3::IntoPyObjectExt; + +use maltoolbox_attackgraph::ids::AttackGraphNodeId; +use maltoolbox_attackgraph::AttackGraph; +use maltoolbox_language::graph::{generate_graph as build_graph_from_langspec, LanguageGraph}; +use maltoolbox_language_py::handle::{cached_handle, new_handle_cache, HandleCache}; +use maltoolbox_language_py::PyLanguageGraph; +use maltoolbox_model::{AssetSnapshot, Model}; +use maltoolbox_model_py::PyModel; + +use crate::detector_support::{build_py_detector, detector_snapshots_for}; +use crate::exceptions::{graph_error_to_lookup, graph_error_to_py}; +use crate::node::{PyAttackGraphNode, PyAttackGraphNodesView}; + +/// Per-node `(children, parents)` `PySet` pair cache - see +/// `PyAttackGraph::node_edges_cache`'s doc comment. +type NodeEdgesCache = Rc, Py)>>>; + +/// Shared contract with consumers (currently mal-simulator's `malsim-pyo3`, +/// see its PORTING_NOTES.md §2.2/§10 for why this exists). +const INNER_CAPSULE_NAME: &std::ffi::CStr = c"maltoolbox._native.AttackGraph.inner"; + +/// Destructor for the capsule handed out by `PyAttackGraph::__inner_capsule__`. +/// Reclaims exactly the one `Rc` strong reference that capsule's creation +/// cloned - see that method's doc comment. +unsafe extern "C" fn drop_inner_capsule(capsule: *mut pyo3::ffi::PyObject) { + let name = unsafe { pyo3::ffi::PyCapsule_GetName(capsule) }; + let ptr = unsafe { pyo3::ffi::PyCapsule_GetPointer(capsule, name) }; + if !ptr.is_null() { + drop(unsafe { Rc::from_raw(ptr as *const RefCell) }); + } +} + +#[pyclass( + name = "AttackGraph", + module = "maltoolbox._native", + unsendable, + weakref +)] +pub struct PyAttackGraph { + pub inner: Rc>, + pub lang_graph_py: Py, + pub model_py: Option>, + /// Lazily-seeded flat detectors list: `None` until first accessed via + /// `.detectors`, then the sole source of truth from then on (never + /// rebuilt from the core again). + pub detectors_list: Rc>>>, + /// Per-node detectors dict side table, lazily populated the same way - + /// see `node.rs`'s `.detectors` getter. + pub node_detectors: Rc>>>, + /// Same pattern as `node_detectors`, for `AttackGraphNode.extras` - + /// see `node.rs`'s `.extras` getter. Seeded once per node and never + /// re-derived, so in-place mutation (`node.extras['x'] = y`) sticks + /// across accesses. + pub node_extras: Rc>>>, + /// Per-owner `PyAttackGraphNode` handle cache, shared (same `Rc`) with + /// every node handle this graph ever hands out, so repeated lookups + /// for the same id return the identical Python object. + pub node_cache: HandleCache, + /// Read-only record of each removed node's final state (post-unlink, + /// pre-delete), keyed by id. Lets a `PyAttackGraphNode` handle whose + /// entry is gone from `inner.id_to_node` keep resolving its simple + /// fields (`node.rs`'s `with_node_value`), plus - via + /// `PyNodeTombstone`'s pre-resolved fields, each computed once at + /// removal time while the graph could still resolve them - + /// `.children`/`.parents`, `.detectors`, and `.full_name`/`__repr__`. + /// `to_dict()` is the one remaining gap: it would need a + /// `children`/`parents` dict of full *names*, not just ids, which + /// this struct doesn't carry (see `node.rs`'s doc comment). + pub tombstones: Rc>>, + /// Per-node `(children, parents)` `PySet` pair, lazily built once per + /// id and cached from then on, since malsim's hot loop reads + /// `.parents` per traversability check and rebuilding a `PySet` from + /// scratch each time is expensive. Cleared wholesale (not per-id) on + /// any structural mutation (`add_node`/`remove_node`/ + /// `regenerate_graph`/`partially_regenerate_graph` - see + /// `evict_edges_cache`), never on per-node state-only changes + /// (`enabled_defenses`, `existence_status`, ...) that don't touch + /// topology. + pub node_edges_cache: NodeEdgesCache, + /// When `Some`, `.nodes` returns this dict as-is instead of + /// constructing a `PyAttackGraphNodesView`, letting + /// `attack_graph.nodes = {...}` freely override the live view - + /// matching the pure-Python original's plain, freely-settable `dict` + /// attribute. Reset to `None` by `regenerate_graph`, which rebuilds + /// live nodes from scratch and should surface those again. + pub nodes_override: Rc>>>, +} + +impl PyAttackGraph { + #[allow(clippy::type_complexity)] + fn empty_state() -> ( + Rc>>>, + Rc>>>, + Rc>>>, + ) { + ( + Rc::new(RefCell::new(None)), + Rc::new(RefCell::new(HashMap::new())), + Rc::new(RefCell::new(HashMap::new())), + ) + } + + fn wrap( + inner: AttackGraph, + lang_graph_py: Py, + model_py: Option>, + ) -> Self { + let (detectors_list, node_detectors, node_extras) = Self::empty_state(); + PyAttackGraph { + inner: Rc::new(RefCell::new(inner)), + lang_graph_py, + model_py, + detectors_list, + node_detectors, + node_extras, + node_cache: new_handle_cache(), + tombstones: Rc::new(RefCell::new(HashMap::new())), + nodes_override: Rc::new(RefCell::new(None)), + node_edges_cache: Rc::new(RefCell::new(HashMap::new())), + } + } + + /// Invalidates the whole `.children`/`.parents` cache. Whole-cache, + /// not per-id: adding or removing one node can change the + /// children/parents sets of arbitrary other existing nodes, so a + /// precise per-id invalidation isn't meaningfully cheaper than + /// letting the next access per node rebuild lazily. + fn evict_edges_cache(&self) { + self.node_edges_cache.borrow_mut().clear(); + } + + /// Resets the lazily-seeded detector containers. Used after a full + /// `regenerate_graph`, which discards every old node and rebuilds + /// from scratch, so previously-seeded detector containers would + /// otherwise reference stale/gone nodes. + fn reset_detector_state(&self) { + *self.detectors_list.borrow_mut() = None; + self.node_detectors.borrow_mut().clear(); + self.node_extras.borrow_mut().clear(); + } + + /// Cache-aware constructor for a node handle owned by this graph - + /// see the `node_cache` field doc comment. `owner_py` must be the + /// `Py` wrapping this very `PyAttackGraph` (a plain + /// `&self` method can't produce a `Py` of itself, hence callers + /// pass one in via the `self_: &Bound<'_, Self>` receiver pattern + /// used throughout this file). + pub fn node_handle( + &self, + owner_py: &Py, + py: Python<'_>, + id: i64, + ) -> PyResult> { + let owner_py = owner_py.clone_ref(py); + cached_handle(&self.node_cache, py, id, move || { + PyAttackGraphNode::new(owner_py, id) + }) + } + + /// Evicts `id`'s cache entry, if any. Needed because `add_node`'s + /// caller-chosen `node_id` can legitimately collide with a + /// previously-removed node's id, which must build a genuinely new + /// handle rather than resurface the stale cached one. Not done on + /// removal itself. + fn evict_node_handle(&self, id: i64) { + self.node_cache.borrow_mut().remove(&id); + } + + /// Shared implementation for `PyAttackGraph::__deepcopy__` and + /// `PyAttackGraphNodesView::__deepcopy__`/`PyAttackGraphNode::__deepcopy__` + /// (`node.rs`) - port of `AttackGraph.__deepcopy__` + /// (`maltoolbox/attackgraph/attackgraph.py`). `memo` is a real Python + /// dict keyed by `id(python_object)`, exactly like the pure-Python + /// original, so a whole-graph `copy.deepcopy` call (or one that also + /// separately touches individual nodes reachable from this graph) + /// produces exactly one copy of this graph and of each node, with + /// every cross-reference pointing at the shared copies. + /// + /// Unlike the Python original (which builds an empty `AttackGraph`, + /// then deep-copies each node and re-links `children`/`parents` + /// individually), this clones the whole core `AttackGraph` in one + /// shot (`AttackGraph` is `#[derive(Clone)]`): `AttackGraphNodeId` + /// slotmap keys stay valid and mutually consistent across the clone, + /// so no manual re-linking is needed. Does not clone `lang_graph` (an + /// `Rc`, cheaply shared) or `model` (kept identical - the instance + /// model should remain shared, not copied). + /// + /// Registers every node in `memo` (keyed by the original node + /// handle's `id(...)`) so that deep-copying a node reachable from + /// this graph elsewhere in the same `copy.deepcopy` call resolves to + /// the same copied handle this call produces, not a separate + /// duplicate - mirrors the Python original's `memo[id(node)] = + /// copied_node` bookkeeping. Does not register the graph itself in + /// `memo` first (unlike the Python original's `if id(self) in memo` + /// early-out): nothing in this codebase deep-copies a structure that + /// reaches the same `AttackGraph` via two different paths, so that + /// re-entrancy case doesn't arise in practice. + pub fn deepcopy_graph( + self_: &Bound<'_, Self>, + memo: &Bound<'_, PyDict>, + ) -> PyResult> { + let py = self_.py(); + let self_py: Py = self_.clone().unbind(); + let slf = self_.borrow(); + + let cloned_inner = slf.inner.borrow().clone(); + let ids: Vec = slf.inner.borrow().id_to_node.keys().copied().collect(); + let lang_graph_py = slf.lang_graph_py.clone_ref(py); + let model_py = slf.model_py.as_ref().map(|m| m.clone_ref(py)); + + let new_graph = Py::new( + py, + PyAttackGraph::wrap(cloned_inner, lang_graph_py, model_py), + )?; + + // Register each node in `memo`, keyed by the original handle's + // `id(...)`, pointing at the corresponding handle on the new + // graph - see this method's doc comment. + let new_graph_ref = new_graph.borrow(py); + for id in ids { + let original_handle = slf.node_handle(&self_py, py, id)?; + let copied_handle = new_graph_ref.node_handle(&new_graph, py, id)?; + memo.set_item(original_handle.bind(py).as_ptr() as isize, copied_handle)?; + } + drop(slf); + + Ok(new_graph.clone_ref(py)) + } + + /// Borrows `self.model_py` (if any) down to a `&Model` in one scope, + /// for the core API's `Option<&Model>` parameters. + pub fn with_model(&self, py: Python<'_>, f: impl FnOnce(Option<&Model>) -> R) -> R { + match &self.model_py { + Some(m) => { + let model_ref = m.borrow(py); + let core_model = model_ref.inner.borrow(); + f(Some(&core_model)) + } + None => f(None), + } + } + + /// Builds the core's `to_dict()` `Value`, then overlays each node's + /// live `node_extras` side-table entry (if seeded) onto its + /// `"extras"` field, since the core's own `node_to_dict` only knows + /// about `extras` as captured at generation time and can't see + /// Python-side mutation of the lazily-seeded dict + /// (`node.extras['x'] = y`). Operates on the `serde_json::Value` + /// directly (before `fix_children_parents_int_keys` gives + /// `children`/`parents` integer keys, which aren't valid JSON) so + /// `to_dict`/`save_to_file` can share this one pass. + fn to_dict_value(&self, py: Python<'_>) -> serde_json::Value { + let mut dict = self.with_model(py, |model| self.inner.borrow().to_dict(model)); + if let Some(steps) = dict.get_mut("attack_steps").and_then(|v| v.as_object_mut()) { + let node_extras = self.node_extras.borrow(); + for node_value in steps.values_mut() { + let Some(id) = node_value.get("id").and_then(|v| v.as_i64()) else { + continue; + }; + let Some(extras) = node_extras.get(&id) else { + continue; + }; + let extras = extras.bind(py); + if extras.is_empty() { + if let Some(obj) = node_value.as_object_mut() { + obj.remove("extras"); + } + continue; + } + if let Ok(value) = pythonize::depythonize::(extras) { + node_value["extras"] = value; + } + } + } + dict + } + + fn bare_lang_graph(py: Python<'_>, lang_graph_py: &Py) -> Rc { + lang_graph_py.borrow(py).inner.clone() + } + + /// Extracts `.id` from every `ModelAsset` in an arbitrary Python + /// iterable - same reasoning as `maltoolbox-model-py`'s `ids_of` + /// (`Vec>` extraction only accepts `Sequence`s, not sets). + fn asset_ids_of(obj: &Bound<'_, PyAny>) -> PyResult> { + obj.try_iter()? + .map(|item| { + Ok(item? + .extract::>()? + .id) + }) + .collect() + } + + /// Extracts `(left_id, fieldname, right_id)` from a Python + /// `set[tuple[ModelAsset, str, ModelAsset]]`. + fn assoc_ids_of(obj: &Bound<'_, PyAny>) -> PyResult> { + obj.try_iter()? + .map(|item| { + let tuple = item?; + let left: PyRef<'_, maltoolbox_model_py::PyModelAsset> = + tuple.get_item(0)?.extract()?; + let fieldname: String = tuple.get_item(1)?.extract()?; + let right: PyRef<'_, maltoolbox_model_py::PyModelAsset> = + tuple.get_item(2)?.extract()?; + Ok((left.id, fieldname, right.id)) + }) + .collect() + } + + /// Resolves a removed asset's id to an `AssetSnapshot`, trying the + /// live `model.assets` entry first and falling back to the model's + /// tombstone record (same live-then-tombstone logic as + /// `PyModelAsset::with_asset`, duplicated here since that helper is + /// private to `maltoolbox-model-py`). + fn asset_snapshot_for( + model_py: &Py, + py: Python<'_>, + id: i64, + ) -> PyResult { + let model = model_py.borrow(py); + { + let core_model = model.inner.borrow(); + if let Some(live) = core_model.assets.get(&id) { + return Ok(AssetSnapshot { + name: live.name.clone(), + lg_asset: live.lg_asset, + final_state: live.clone(), + }); + } + } + let tombstones = model.tombstones.borrow(); + if let Some(final_state) = tombstones.get(&id) { + return Ok(AssetSnapshot { + name: final_state.name.clone(), + lg_asset: final_state.lg_asset, + final_state: final_state.clone(), + }); + } + Err(pyo3::exceptions::PyLookupError::new_err(format!( + "Asset with id {id} not found in model (never added, or removed with no tombstone recorded)." + ))) + } +} + +#[pymethods] +impl PyAttackGraph { + /// `lang_graph=None` is accepted purely so `AttackGraph(None)` + /// succeeds, matching the pure-Python original. A minimal placeholder + /// `PyLanguageGraph` (built from an empty langspec `{}`) is + /// constructed internally rather than threading `Option` through + /// every other field/method that assumes a real one. + #[new] + #[pyo3(signature = (lang_graph, model=None))] + pub fn new( + py: Python<'_>, + lang_graph: Option>, + model: Option>, + ) -> PyResult { + let lang_graph = match lang_graph { + Some(lg) => lg, + None => Py::new( + py, + PyLanguageGraph::wrap( + build_graph_from_langspec(serde_json::json!({})) + .map_err(maltoolbox_language_py::exceptions::graph_error_to_py)?, + ), + )?, + }; + let inner = match &model { + Some(m) => { + let model_ref = m.borrow(py); + let core_model = model_ref.inner.borrow(); + AttackGraph::from_model(&core_model).map_err(graph_error_to_py)? + } + None => AttackGraph::empty(Self::bare_lang_graph(py, &lang_graph)), + }; + Ok(Self::wrap(inner, lang_graph, model)) + } + + /// Makes the reference cycle `PyAttackGraph.node_cache -> + /// Py -> NodeRepr::Owned.owner_py -> back to this + /// `PyAttackGraph`` visible to CPython's cyclic GC. Without this (and + /// `PyAttackGraphNode::__traverse__`/`__clear__` on the other side, + /// `node.rs`), both `Py` legs live inside Rust-side + /// `Rc>` containers invisible to `tp_traverse`, so + /// `gc.collect()` could never find and break the cycle, leaking every + /// `AttackGraph` that ever handed out a node handle. Visits every + /// `Py` field that can reach back into a cycle; `inner`/ + /// `tombstones` are plain Rust data, so nothing to visit there. + fn __traverse__(&self, visit: pyo3::PyVisit<'_>) -> Result<(), pyo3::PyTraverseError> { + visit.call(&self.lang_graph_py)?; + if let Some(model_py) = &self.model_py { + visit.call(model_py)?; + } + if let Some(list) = self.detectors_list.borrow().as_ref() { + visit.call(list)?; + } + for v in self.node_detectors.borrow().values() { + visit.call(v)?; + } + for v in self.node_extras.borrow().values() { + visit.call(v)?; + } + for v in self.node_cache.borrow().values() { + visit.call(v)?; + } + for (children, parents) in self.node_edges_cache.borrow().values() { + visit.call(children)?; + visit.call(parents)?; + } + if let Some(dict) = self.nodes_override.borrow().as_ref() { + visit.call(dict)?; + } + Ok(()) + } + + /// Breaks the cycle described in `__traverse__`'s doc comment by + /// dropping every `Py` this graph holds (plus the + /// other lazily-cached `Py` containers), so the cycle collector + /// can finish reclaiming both sides. Matches the `CycleWithClear` + /// pattern in pyo3's own `test_gc.rs`: it's fine to leave the object + /// method-unusable afterward since `__clear__` only runs during + /// cycle collection, when nothing reachable from Python still uses it. + fn __clear__(&self) { + self.node_cache.borrow_mut().clear(); + self.node_edges_cache.borrow_mut().clear(); + self.node_detectors.borrow_mut().clear(); + self.node_extras.borrow_mut().clear(); + *self.detectors_list.borrow_mut() = None; + *self.nodes_override.borrow_mut() = None; + } + + #[getter] + fn lang_graph(&self, py: Python<'_>) -> Py { + self.lang_graph_py.clone_ref(py) + } + + #[getter] + fn model(&self, py: Python<'_>) -> Option> { + self.model_py.as_ref().map(|m| m.clone_ref(py)) + } + + #[setter] + fn set_model(&mut self, model: Option>) { + self.model_py = model; + } + + #[getter] + fn next_node_id(&self) -> i64 { + self.inner.borrow().next_node_id + } + + /// A lazy read-only Mapping view, not a real `dict` - see `node.rs`'s + /// `PyAttackGraphNodesView`. Needs `Py` to hand out, hence the + /// `&Bound<'_, Self>` receiver instead of `&self`. When + /// `nodes_override` has been set (via the `#[setter]` below), that + /// real `dict` is returned as-is instead, matching the pure-Python + /// original's freely settable `dict` attribute + /// (`attack_graph.nodes = {...}`), which the patternfinder tests rely + /// on to hand-build small graphs of detached nodes. + #[getter] + fn nodes(self_: &Bound<'_, Self>) -> PyResult> { + let py = self_.py(); + let slf = self_.borrow(); + if let Some(dict) = &*slf.nodes_override.borrow() { + return Ok(dict.clone_ref(py).into_any()); + } + drop(slf); + let view = PyAttackGraphNodesView { + owner_py: self_.clone().unbind(), + }; + view.into_py_any(py) + } + + /// See the `.nodes` getter's doc comment. Cleared back to `None` by + /// `regenerate_graph`, so a full rebuild surfaces the + /// freshly-generated live nodes again instead of a stale override. + #[setter] + fn set_nodes(&self, value: &Bound<'_, PyDict>) { + *self.nodes_override.borrow_mut() = Some(value.clone().unbind()); + } + + /// `dict[str, AttackGraphNode]`, rebuilt fresh per access - not a + /// hot path like `.nodes`, so no caching is needed here. + #[getter] + fn full_name_to_node<'py>(self_: &Bound<'py, Self>) -> PyResult> { + let py = self_.py(); + let owner_py: Py = self_.clone().unbind(); + let slf = self_.borrow(); + let ids: Vec<(String, i64)> = { + let graph = slf.inner.borrow(); + graph + .full_name_to_node + .iter() + .map(|(full_name, &key)| (full_name.clone(), graph.nodes[key].id)) + .collect() + }; + let dict = PyDict::new(py); + for (full_name, id) in ids { + dict.set_item(full_name, slf.node_handle(&owner_py, py, id)?)?; + } + Ok(dict) + } + + #[getter] + fn attack_steps(self_: &Bound<'_, Self>) -> PyResult>> { + let py = self_.py(); + let owner_py: Py = self_.clone().unbind(); + let slf = self_.borrow(); + let ids: Vec = { + let graph = slf.inner.borrow(); + graph + .attack_steps + .iter() + .map(|&key| graph.nodes[key].id) + .collect() + }; + ids.into_iter() + .map(|id| slf.node_handle(&owner_py, py, id)) + .collect() + } + + #[getter] + fn defense_steps(self_: &Bound<'_, Self>) -> PyResult>> { + let py = self_.py(); + let owner_py: Py = self_.clone().unbind(); + let slf = self_.borrow(); + let ids: Vec = { + let graph = slf.inner.borrow(); + graph + .defense_steps + .iter() + .map(|&key| graph.nodes[key].id) + .collect() + }; + ids.into_iter() + .map(|id| slf.node_handle(&owner_py, py, id)) + .collect() + } + + /// Live, mutable flat list: lazily seeded from every node's current + /// core-side detector data on first access, then the same + /// `Py` object on every subsequent access, so external + /// `.append()`/`.remove()` mutation is visible to later reads. Never + /// kept in sync with the per-node `.detectors` dicts after seeding, + /// matching the pure-Python original. + #[getter] + fn detectors(self_: &Bound<'_, Self>) -> PyResult> { + let py = self_.py(); + { + let slf = self_.borrow(); + let cell = slf.detectors_list.borrow(); + if let Some(list) = &*cell { + return Ok(list.clone_ref(py)); + } + } + let owner_py: Py = self_.clone().unbind(); + let snapshot = { + let slf = self_.borrow(); + let graph = slf.inner.borrow(); + let keys: Vec = graph.nodes.keys().collect(); + detector_snapshots_for(&graph, &keys) + }; + let list = PyList::empty(py); + for snap in &snapshot { + list.append(build_py_detector(py, &owner_py, snap)?)?; + } + let slf = self_.borrow(); + *slf.detectors_list.borrow_mut() = Some(list.clone().unbind()); + Ok(list.unbind()) + } + + fn get_node_by_full_name( + self_: &Bound<'_, Self>, + full_name: &str, + ) -> PyResult> { + let py = self_.py(); + let owner_py: Py = self_.clone().unbind(); + let slf = self_.borrow(); + let id = { + let graph = slf.inner.borrow(); + let key = graph + .get_node_by_full_name(full_name) + .map_err(graph_error_to_lookup)?; + graph.nodes[key].id + }; + slf.node_handle(&owner_py, py, id) + } + + /// Matches Python's `regenerate_graph`: a full rebuild from + /// `self.model`, discarding every existing node, so any already-held + /// `PyAttackGraphNode` handle for an old id correctly fails to + /// resolve afterward. Also clears `node_cache` entirely, since fresh + /// generation may reuse the same `i64` ids for logically different + /// nodes. + fn regenerate_graph(&self, py: Python<'_>) -> PyResult<()> { + let model_py = self.model_py.as_ref().ok_or_else(|| { + pyo3::exceptions::PyAssertionError::new_err("Model required to generate graph") + })?; + let model_ref = model_py.borrow(py); + let core_model = model_ref.inner.borrow(); + self.inner + .borrow_mut() + .regenerate_graph(&core_model) + .map_err(graph_error_to_py)?; + self.reset_detector_state(); + self.node_cache.borrow_mut().clear(); + self.evict_edges_cache(); + // A full rebuild should surface the freshly-generated live + // nodes, not a stale `.nodes = {...}` override from before the + // rebuild - see the `.nodes` getter/setter's doc comment. + *self.nodes_override.borrow_mut() = None; + Ok(()) + } + + #[pyo3(signature = (new_assets=None, new_associations=None, removed_assets=None, removed_associations=None))] + fn partially_regenerate_graph( + self_: &Bound<'_, Self>, + new_assets: Option<&Bound<'_, PyAny>>, + new_associations: Option<&Bound<'_, PyAny>>, + removed_assets: Option<&Bound<'_, PyAny>>, + removed_associations: Option<&Bound<'_, PyAny>>, + ) -> PyResult>> { + let py = self_.py(); + let owner_py: Py = self_.clone().unbind(); + let slf = self_.borrow(); + let model_py = slf + .model_py + .as_ref() + .map(|m| m.clone_ref(py)) + .ok_or_else(|| { + pyo3::exceptions::PyAssertionError::new_err("Model required to generate graph") + })?; + + let new_assets = new_assets + .map(Self::asset_ids_of) + .transpose()? + .unwrap_or_default(); + let new_associations = new_associations + .map(Self::assoc_ids_of) + .transpose()? + .unwrap_or_default(); + let removed_asset_ids = removed_assets + .map(Self::asset_ids_of) + .transpose()? + .unwrap_or_default(); + let removed_associations = removed_associations + .map(Self::assoc_ids_of) + .transpose()? + .unwrap_or_default(); + + let mut removed_snapshots: HashMap = HashMap::new(); + for id in &removed_asset_ids { + removed_snapshots.insert(*id, Self::asset_snapshot_for(&model_py, py, *id)?); + } + + // Snapshot every node's full state before the call, so any node + // removed as a side effect (the core's own internal + // `removal_candidates`, not surfaced back to this binding) can + // still have its stale `node_detectors` entry purged and its + // final state recorded in `tombstones` below. Cloning every node + // (not just ids) is more than the detector-purge path alone + // needs, but this method isn't a simulation hot path, so the + // cost is acceptable for the tombstone coverage it buys. + // `key_to_id_before` is captured in the same borrow as + // `nodes_before`, so a removed node's `children`/`parents` keys + // are resolved against slotmap state as it stood immediately + // before this call's mutations - resolving against the + // *post*-call state instead would risk a key already recycled + // for an unrelated new node (see `PyNodeTombstone`'s doc + // comment). `detector_snapshots_before`/`full_names_before` do + // the same for `.detectors`/`.full_name`. + let (nodes_before, key_to_id_before, detector_snapshots_before, full_names_before) = { + let graph = slf.inner.borrow(); + let model_ref = model_py.borrow(py); + let core_model = model_ref.inner.borrow(); + let nodes_before: HashMap = + graph.nodes.values().map(|n| (n.id, n.clone())).collect(); + let key_to_id_before: HashMap = + graph.nodes.iter().map(|(k, n)| (k, n.id)).collect(); + let all_keys: Vec = key_to_id_before.keys().copied().collect(); + let mut detector_snapshots_before: HashMap< + i64, + Vec, + > = HashMap::new(); + for snap in detector_snapshots_for(&graph, &all_keys) { + detector_snapshots_before + .entry(snap.node_id) + .or_default() + .push(snap); + } + let full_names_before: HashMap = key_to_id_before + .iter() + .map(|(&k, &id)| (id, graph.full_name_of(k, Some(&core_model)))) + .collect(); + ( + nodes_before, + key_to_id_before, + detector_snapshots_before, + full_names_before, + ) + }; + let node_ids_before: HashSet = nodes_before.keys().copied().collect(); + + let created_ids: Vec = { + let model_ref = model_py.borrow(py); + let core_model = model_ref.inner.borrow(); + let created = slf + .inner + .borrow_mut() + .partially_regenerate_graph( + &core_model, + &new_assets, + &new_associations, + &removed_snapshots, + &removed_associations, + ) + .map_err(graph_error_to_py)?; + let graph = slf.inner.borrow(); + created.into_iter().map(|key| graph.nodes[key].id).collect() + }; + + slf.evict_edges_cache(); + + // Purge stale per-node detector entries, and record a tombstone, + // for any node removed as a side effect of this call (see + // `nodes_before`/`node_ids_before` above). + { + let node_ids_after: HashSet = + slf.inner.borrow().id_to_node.keys().copied().collect(); + let mut table = slf.node_detectors.borrow_mut(); + let mut extras_table = slf.node_extras.borrow_mut(); + let mut tombstones = slf.tombstones.borrow_mut(); + for removed_id in node_ids_before.difference(&node_ids_after) { + table.remove(removed_id); + extras_table.remove(removed_id); + if let Some(final_state) = nodes_before.get(removed_id) { + let children_ids: Vec = final_state + .children + .iter() + .filter_map(|k| key_to_id_before.get(k).copied()) + .collect(); + let parents_ids: Vec = final_state + .parents + .iter() + .filter_map(|k| key_to_id_before.get(k).copied()) + .collect(); + tombstones.insert( + *removed_id, + crate::node::PyNodeTombstone { + state: final_state.clone(), + children_ids, + parents_ids, + detector_snapshots: detector_snapshots_before + .get(removed_id) + .cloned() + .unwrap_or_default(), + full_name: full_names_before + .get(removed_id) + .cloned() + .unwrap_or_else(|| final_state.fallback_full_name()), + }, + ); + } + } + } + + // Seed detector containers for newly-created nodes only; existing + // nodes' already-seeded state is left untouched, matching + // Python's `partially_regenerate_graph` only ever calling + // `_create_detectors` for the newly-created nodes. + let snapshot = { + let graph = slf.inner.borrow(); + let keys: Vec = created_ids + .iter() + .filter_map(|&id| graph.id_to_node.get(&id).copied()) + .collect(); + detector_snapshots_for(&graph, &keys) + }; + let mut by_node: HashMap> = + HashMap::new(); + for snap in &snapshot { + by_node.entry(snap.node_id).or_default().push(snap); + } + for (&node_id, snaps) in &by_node { + let dict = PyDict::new(py); + for snap in snaps { + dict.set_item(&snap.label, build_py_detector(py, &owner_py, snap)?)?; + } + slf.node_detectors + .borrow_mut() + .insert(node_id, dict.unbind()); + } + if let Some(list) = &*slf.detectors_list.borrow() { + let list = list.bind(py); + for snap in &snapshot { + list.append(build_py_detector(py, &owner_py, snap)?)?; + } + } + + created_ids + .into_iter() + .map(|id| slf.node_handle(&owner_py, py, id)) + .collect() + } + + #[pyo3(signature = (lg_attack_step, node_id=None, model_asset=None, ttc_dist=None, existence_status=None, full_name=None))] + #[allow(clippy::too_many_arguments)] + fn add_node( + self_: &Bound<'_, Self>, + lg_attack_step: PyRef<'_, maltoolbox_language_py::PyLanguageGraphAttackStep>, + node_id: Option, + model_asset: Option>, + ttc_dist: Option<&Bound<'_, PyAny>>, + existence_status: Option, + full_name: Option, + ) -> PyResult> { + let py = self_.py(); + let owner_py: Py = self_.clone().unbind(); + let slf = self_.borrow(); + + let ttc_value = ttc_dist + .map(|v| -> PyResult { + pythonize::depythonize(v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + }) + .transpose()?; + + let key = { + let mut graph = slf.inner.borrow_mut(); + slf.with_model(py, |model| { + graph.add_node( + lg_attack_step.id, + model, + node_id, + model_asset.as_ref().map(|a| a.id), + ttc_value, + existence_status, + full_name, + ) + }) + .map_err(graph_error_to_py)? + }; + let id = slf.inner.borrow().nodes[key].id; + // See `evict_node_handle`'s doc comment. + slf.evict_node_handle(id); + // Deliberately not `evict_edges_cache()` here: the core's + // `add_node` always creates the new node with empty + // `children`/`parents`, never linked into any existing node's + // edges as a side effect, so no other node's cached `(children, + // parents)` pair can have gone stale. Wiping the whole cache + // here would also silently discard any direct Python-side + // mutation of an already-fetched edges set + // (`some_node.parents.add(new_node)`) made before this call: the + // mutated `PySet` is correct in-memory, but an evicted cache + // would rebuild from the (unmodified) core and lose it. + slf.node_handle(&owner_py, py, id) + } + + fn remove_node(&self, node: &PyAttackGraphNode, py: Python<'_>) -> PyResult<()> { + let node_id = node.id(); + let mut graph = self.inner.borrow_mut(); + let key = graph + .id_to_node + .get(&node_id) + .copied() + .ok_or_else(|| pyo3::exceptions::PyKeyError::new_err(node_id))?; + // Capture detector snapshots and full_name before removal, while + // `key` still resolves to a live node - both need the node + // present at `key`, unlike the children/parents translation + // below (which only needs other nodes' keys, untouched by + // removing this one). + let detector_snapshots = detector_snapshots_for(&graph, &[key]); + let full_name = match &self.model_py { + Some(m) => { + let model_ref = m.borrow(py); + let core_model = model_ref.inner.borrow(); + graph.full_name_of(key, Some(&core_model)) + } + None => graph.full_name_of(key, None), + }; + let final_state = graph.remove_node(key).map_err(graph_error_to_py)?; + // Resolve children/parents to stable i64 ids while `graph` is + // still borrowed, right after removal - a single non-batch + // removal doesn't touch any other node's slotmap key, so this is + // the simplest point at which the translation is guaranteed + // valid. + let children_ids: Vec = final_state + .children + .iter() + .filter_map(|k| graph.nodes.get(*k).map(|n| n.id)) + .collect(); + let parents_ids: Vec = final_state + .parents + .iter() + .filter_map(|k| graph.nodes.get(*k).map(|n| n.id)) + .collect(); + drop(graph); + self.node_detectors.borrow_mut().remove(&node_id); + self.node_extras.borrow_mut().remove(&node_id); + self.tombstones.borrow_mut().insert( + node_id, + crate::node::PyNodeTombstone { + state: final_state, + children_ids, + parents_ids, + detector_snapshots, + full_name, + }, + ); + self.evict_edges_cache(); + Ok(()) + } + + /// Each node's `children`/`parents` need the same int-key fixup + /// `PyAttackGraphNode::to_dict` applies standalone - see + /// `fix_children_parents_int_keys`. + fn to_dict<'py>(&self, py: Python<'py>) -> PyResult> { + let dict = self.to_dict_value(py); + let pythonized = pythonize::pythonize(py, &dict) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let pythonized = pythonized.cast::()?; + if let Some(steps) = pythonized.get_item("attack_steps")? { + let steps = steps.cast::()?; + for (_, node_dict) in steps.iter() { + let node_dict = node_dict.cast::()?; + crate::node::fix_children_parents_int_keys(py, node_dict)?; + } + } + Ok(pythonized.clone().into_any()) + } + + fn _to_dict<'py>(&self, py: Python<'py>) -> PyResult> { + self.to_dict(py) + } + + /// `PathBuf`, not `&str` - pyo3 extracts it from both a plain `str` + /// and any `os.PathLike` (e.g. `pathlib.Path`), matching the Python + /// original's file APIs. + /// + /// Routed through `self.to_dict_value()` (not the core's own + /// `AttackGraph::save_to_file`, which only sees the generation-time + /// snapshot) so the `node_extras` overlay is reflected on disk too - + /// matching the Python original's `save_to_file`, which always + /// delegated to `self._to_dict()`. + fn save_to_file(&self, py: Python<'_>, filename: PathBuf) -> PyResult<()> { + let value = self.to_dict_value(py); + maltoolbox_io::save_dict_to_file(filename, &value) + .map_err(|e| pyo3::exceptions::PyOSError::new_err(e.to_string())) + } + + #[staticmethod] + #[pyo3(signature = (filename, lang_graph, model=None))] + fn load_from_file( + py: Python<'_>, + filename: PathBuf, + lang_graph: Py, + model: Option>, + ) -> PyResult { + let bare_lang_graph = Self::bare_lang_graph(py, &lang_graph); + let inner = match &model { + Some(m) => { + let model_ref = m.borrow(py); + let core_model = model_ref.inner.borrow(); + AttackGraph::load_from_file(filename, bare_lang_graph, Some(&core_model)) + } + None => AttackGraph::load_from_file(filename, bare_lang_graph, None), + } + .map_err(graph_error_to_py)?; + Ok(Self::wrap(inner, lang_graph, model)) + } + + fn __repr__(&self, py: Python<'_>) -> String { + let graph = self.inner.borrow(); + let model_repr = match &self.model_py { + Some(m) => m + .bind(py) + .repr() + .map(|r| r.to_string()) + .unwrap_or_else(|_| "None".to_string()), + None => "None".to_string(), + }; + format!( + "AttackGraph(Number of nodes: {}, model: {}, language: {}", + graph.nodes.len(), + model_repr, + self.lang_graph_py + .bind(py) + .repr() + .map(|r| r.to_string()) + .unwrap_or_else(|_| "None".to_string()), + ) + } + + /// Bundles this graph's own `_to_dict()` plus its `lang_graph`'s + /// serialized state (always present) and its `model`'s (when there + /// is one), so unpickling can rebuild the whole chain bottom-up with + /// no original Python object or on-disk file needed, including a + /// model-less graph. `lang_graph_state` is always included + /// independently of `model_state` so that case round-trips too. + #[staticmethod] + fn _from_pickle_state( + py: Python<'_>, + state: &Bound<'_, PyAny>, + lang_graph_state: &Bound<'_, PyAny>, + model_state: Option>, + ) -> PyResult { + let native = py.import("maltoolbox._native")?; + + let (lang_graph_py, model_py): (Py, Option>) = + match model_state { + Some(model_dict) => { + // Let `Model._from_pickle_state` reconstruct the nested + // `LanguageGraph` itself, then pull the resulting object + // back off `model.lang_graph` rather than building a + // second, separate `LanguageGraph` here. + let model_cls = native.getattr("Model")?; + let model_obj = model_cls + .call_method1("_from_pickle_state", (model_dict, lang_graph_state))?; + let lang_graph_obj = model_obj.getattr("lang_graph")?; + (lang_graph_obj.extract()?, Some(model_obj.extract()?)) + } + None => { + let lg_cls = native.getattr("LanguageGraph")?; + let lang_graph_obj = + lg_cls.call_method1("_from_pickle_state", (lang_graph_state,))?; + (lang_graph_obj.extract()?, None) + } + }; + + let value: serde_json::Value = pythonize::depythonize(state) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let bare_lang_graph = Self::bare_lang_graph(py, &lang_graph_py); + let model_ref = model_py.as_ref().map(|m| m.borrow(py)); + let core_model = model_ref.as_ref().map(|m| m.inner.borrow()); + let inner = AttackGraph::from_dict(&value, bare_lang_graph, core_model.as_deref()) + .map_err(graph_error_to_py)?; + drop(core_model); + drop(model_ref); + Ok(Self::wrap(inner, lang_graph_py, model_py)) + } + + /// Uses `to_dict_value` (string-keyed `children`/`parents`), not + /// `self.to_dict()`/`._to_dict()` (which runs + /// `fix_children_parents_int_keys` to give Python callers integer + /// keys). `_from_pickle_state` feeds `state` back through + /// `pythonize::depythonize` into a `serde_json::Value`, and + /// `serde_json::Value::Object` only accepts string keys, so the + /// int-keyed form would fail to unpickle any graph with a linked + /// node. The core's own `AttackGraph::from_dict` parses + /// `children`/`parents` ids back out of string keys too, so this + /// round-trips correctly. + #[allow(clippy::type_complexity)] + fn __reduce__<'py>( + &self, + py: Python<'py>, + ) -> PyResult<( + Bound<'py, PyAny>, + ( + Bound<'py, PyAny>, + Bound<'py, PyAny>, + Option>, + ), + )> { + let cls = py.get_type::(); + let func = cls.getattr("_from_pickle_state")?; + let state = pythonize::pythonize(py, &self.to_dict_value(py)) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let lang_graph_state = self.lang_graph_py.bind(py).call_method0("_to_dict")?; + let model_state = match &self.model_py { + Some(m) => Some(m.bind(py).call_method0("_to_dict")?), + None => None, + }; + Ok((func, (state, lang_graph_state, model_state))) + } + + /// Hands out a new strong reference to this graph's shared + /// `Rc>`, wrapped in a `PyCapsule` - the standard + /// CPython mechanism for passing a native pointer between two + /// independently-compiled extension modules. Needed because a pyo3 + /// `pyclass` from a shared dependency crate gets a separate, unrelated + /// type object in every cdylib that statically links it, so a direct + /// downcast across e.g. `maltoolbox._native` and `malsim._native` + /// doesn't work even from identical pinned source - see + /// `INNER_CAPSULE_NAME`'s doc comment. Each call clones the `Rc` + /// (bumping the strong count); the capsule's destructor + /// (`drop_inner_capsule`) drops exactly that one clone when the + /// capsule itself is garbage-collected, so callers don't need to track + /// this `PyAttackGraph`'s own lifetime. + fn __inner_capsule__<'py>(&self, py: Python<'py>) -> PyResult> { + let raw = Rc::into_raw(self.inner.clone()) as *mut c_void; + let ptr = NonNull::new(raw).expect("Rc::into_raw is never null"); + unsafe { + PyCapsule::new_with_pointer_and_destructor( + py, + ptr, + INNER_CAPSULE_NAME, + Some(drop_inner_capsule), + ) + } + } + + /// See `deepcopy_graph`'s doc comment for the full rationale. + fn __deepcopy__( + self_: &Bound<'_, Self>, + memo: &Bound<'_, PyDict>, + ) -> PyResult> { + Self::deepcopy_graph(self_, memo) + } +} diff --git a/py-bindings/maltoolbox-attackgraph-py/src/lib.rs b/py-bindings/maltoolbox-attackgraph-py/src/lib.rs new file mode 100644 index 00000000..02c7fb6c --- /dev/null +++ b/py-bindings/maltoolbox-attackgraph-py/src/lib.rs @@ -0,0 +1,30 @@ +//! PyO3 compatibility layer mirroring `maltoolbox/attackgraph/`'s public +//! surface (`AttackGraph`, `AttackGraphNode`, `Detector`, +//! `create_attack_graph`) on top of the pure-Rust `maltoolbox-attackgraph` +//! crate. See `py-bindings/maltoolbox-language-py/src/lib.rs`'s module doc for +//! the shared conventions this follows. + +mod detector; +mod detector_support; +mod exceptions; +mod factories; +mod graph; +mod node; + +use pyo3::prelude::*; + +pub use detector::PyDetector; +pub use graph::PyAttackGraph; +pub use node::PyAttackGraphNode; + +/// Registers this layer's classes/exceptions/functions onto the umbrella +/// `_native` module. Called from `maltoolbox-pyo3`'s `#[pymodule]`. +pub fn register(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(node::_rebuild_attack_graph_node, m)?)?; + m.add_function(wrap_pyfunction!(factories::create_attack_graph, m)?)?; + exceptions::register(py, m)?; + Ok(()) +} diff --git a/py-bindings/maltoolbox-attackgraph-py/src/node.rs b/py-bindings/maltoolbox-attackgraph-py/src/node.rs new file mode 100644 index 00000000..f2dfed6d --- /dev/null +++ b/py-bindings/maltoolbox-attackgraph-py/src/node.rs @@ -0,0 +1,1036 @@ +//! Mirrors `maltoolbox/attackgraph/node.py`'s `AttackGraphNode`. A handle +//! holding `owner_py: Py`, resolving the core's +//! `AttackGraphNodeId` slotmap key from the stable, Python-facing `i64` +//! id on every access. Cached per-owner (`PyAttackGraph::node_cache`) so +//! repeated lookups for the same id return the identical Python object; +//! see `handle.rs`/PYTHON_BINDINGS_IMPLEMENTATION.md. +//! +//! Also hosts `PyAttackGraphNodesView`, a lazy read-only Mapping-protocol +//! wrapper for `PyAttackGraph.nodes`. + +use pyo3::basic::CompareOp; +use pyo3::exceptions::{PyKeyError, PyLookupError}; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyList, PySet}; +use pyo3::IntoPyObjectExt; + +use maltoolbox_attackgraph::ids::AttackGraphNodeId; +use maltoolbox_attackgraph::AttackGraph; +use maltoolbox_language_py::handle::composite_hash; +use maltoolbox_language_py::{ + model_effect_to_py, PyLanguageGraphAttackStep, PyLanguageGraphModelEffect, +}; +use maltoolbox_model_py::PyModelAsset; + +use crate::detector_support::{build_py_detector, detector_snapshots_for, DetectorSnapshot}; +use crate::graph::PyAttackGraph; + +/// `children`/`parents` are `{other_node_id: other_node_full_name}` with +/// *integer* keys in the Python original, but JSON object keys are +/// always strings, so `pythonize`-ing the core's `node_to_dict` output +/// hands back `{"132": "..."}` instead of `{132: "..."}` unless +/// corrected here. Same root cause as `maltoolbox-model-py`'s +/// `fix_associated_assets_int_keys`. Shared by `PyAttackGraphNode::to_dict` +/// and `PyAttackGraph::to_dict`. +pub fn fix_children_parents_int_keys( + py: Python<'_>, + node_dict: &Bound<'_, PyDict>, +) -> PyResult<()> { + for field in ["children", "parents"] { + let Some(value) = node_dict.get_item(field)? else { + continue; + }; + let Ok(sub_dict) = value.cast::() else { + continue; + }; + let fixed = PyDict::new(py); + for (key, val) in sub_dict.iter() { + let id: i64 = key.extract::()?.parse().map_err(|_| { + pyo3::exceptions::PyValueError::new_err(format!("non-integer key in {field}")) + })?; + fixed.set_item(id, val)?; + } + node_dict.set_item(field, fixed)?; + } + Ok(()) +} + +/// A standalone node built directly via `AttackGraphNode(...)` from +/// Python with no owning `AttackGraph`. Deliberately minimal: only +/// `.id`/`.name`/`.children`/`.parents` (get+set) plus `__repr__`/default +/// identity hash+eq are stored and supported, since that's all +/// `maltoolbox/patternfinder/attackgraph_patterns.py` needs. `__init__` +/// accepts `lg_attack_step`/`model_asset`/`ttc_dist`/`existence_status`/ +/// `full_name` (matching the Python original's positional signature) but +/// nothing reads them back, so they aren't stored. +pub struct DetachedNode { + pub id: i64, + pub name: String, + pub children: Py, + pub parents: Py, +} + +/// A removed node's recorded post-removal state, plus its +/// children/parents **pre-resolved to stable `i64` ids at the moment of +/// removal**. `state.children`/`state.parents` stay as raw +/// `AttackGraphNodeId` slotmap keys, which the graph can reuse for +/// logically-unrelated new nodes afterward - unsafe to resolve against +/// the graph's *current* state later. `children_ids`/`parents_ids` +/// sidestep that by capturing the id translation once, while it's still +/// valid (see `edges_sets`'s tombstone fallback). `detector_snapshots`/ +/// `full_name` apply the same idea to `.detectors`/`.full_name`/ +/// `__repr__`, each captured once at removal time since the graph can't +/// resolve a removed id's topology later via reused slotmap keys. +pub struct PyNodeTombstone { + pub state: maltoolbox_attackgraph::AttackGraphNode, + pub children_ids: Vec, + pub parents_ids: Vec, + pub detector_snapshots: Vec, + pub full_name: String, +} + +/// Either a live handle into an owning `AttackGraph` (`Owned`, see this +/// module's top doc comment) or a `Detached` standalone node with no +/// owner. +pub enum NodeRepr { + Owned { + owner_py: Py, + id: i64, + }, + Detached(DetachedNode), +} + +#[pyclass(name = "AttackGraphNode", unsendable, skip_from_py_object)] +pub struct PyAttackGraphNode { + pub repr: NodeRepr, +} + +impl PyAttackGraphNode { + pub fn new(owner_py: Py, id: i64) -> Self { + PyAttackGraphNode { + repr: NodeRepr::Owned { owner_py, id }, + } + } + + /// Manual `Clone` equivalent (`Py` needs a GIL token to + /// bump its refcount, so this type can't `#[derive(Clone)]`). Only + /// meaningful for `Owned` handles, the only variant ever put in + /// `node_cache`; panics on `Detached`, which is never cached. + pub fn clone_ref(&self, py: Python<'_>) -> Self { + match &self.repr { + NodeRepr::Owned { owner_py, id } => PyAttackGraphNode { + repr: NodeRepr::Owned { + owner_py: owner_py.clone_ref(py), + id: *id, + }, + }, + NodeRepr::Detached(_) => unreachable!("Detached nodes are never cloned via clone_ref"), + } + } + + /// Unwraps the `Owned` fields, or a clear `NotImplementedError` for + /// `Detached` - used by every method outside `DetachedNode`'s minimal + /// scope. + fn owned(&self) -> PyResult<(&Py, i64)> { + match &self.repr { + NodeRepr::Owned { owner_py, id } => Ok((owner_py, *id)), + NodeRepr::Detached(_) => Err(pyo3::exceptions::PyNotImplementedError::new_err( + "This operation is not supported on a detached AttackGraphNode (one constructed directly, with no owning AttackGraph).", + )), + } + } + + fn owner_ptr(&self, py: Python<'_>) -> PyResult { + let (owner_py, _) = self.owned()?; + let owner = owner_py.borrow(py); + Ok(std::rc::Rc::as_ptr(&owner.inner) as usize) + } + + fn not_found(&self, id: i64) -> PyErr { + PyLookupError::new_err(format!("Attack graph node with id {} not found.", id)) + } + + fn node_key(&self, graph: &AttackGraph, id: i64) -> PyResult { + graph + .id_to_node + .get(&id) + .copied() + .ok_or_else(|| self.not_found(id)) + } + + /// Borrows the owning graph transiently and runs `f` against the + /// resolved node. **No tombstone fallback** - a removed node's id + /// simply fails to resolve here, since this hands back a *key* into + /// the live graph and there's no live slot for a removed node. + /// Callers that need a removed-node fallback (`edges_sets`, + /// `.detectors`, `.full_name`/`__repr__`) check liveness first and + /// fall back to a precomputed `PyNodeTombstone` field instead of + /// calling this at all in the removed case. `Owned`-only - see + /// `owned()`. + fn with_node( + &self, + py: Python<'_>, + f: impl FnOnce(&AttackGraph, AttackGraphNodeId) -> PyResult, + ) -> PyResult { + let (owner_py, id) = self.owned()?; + let owner = owner_py.borrow(py); + let graph = owner.inner.borrow(); + let key = self.node_key(&graph, id)?; + f(&graph, key) + } + + /// Like `with_node`, but resolves to the node's *own data* directly + /// (`&AttackGraphNode`, not a graph+key pair) and **does** fall back + /// to the owning graph's tombstone record when the live lookup + /// misses, mirroring `PyModelAsset::with_asset`'s live-then-tombstone + /// pattern. Used by every getter that only needs this node's own + /// fields, not a graph-wide key lookup or cross-referencing other + /// nodes. `Owned`-only - see `owned()`. + fn with_node_value( + &self, + py: Python<'_>, + f: impl FnOnce(&maltoolbox_attackgraph::AttackGraphNode) -> PyResult, + ) -> PyResult { + let (owner_py, id) = self.owned()?; + let owner = owner_py.borrow(py); + let graph = owner.inner.borrow(); + if let Some(&key) = graph.id_to_node.get(&id) { + return f(&graph.nodes[key]); + } + drop(graph); + let tombstones = owner.tombstones.borrow(); + if let Some(tomb) = tombstones.get(&id) { + return f(&tomb.state); + } + Err(self.not_found(id)) + } + + /// Like `with_node`, but also resolves `owner.model_py` (if any) down + /// to a `&Model` in the same scope - needed for `full_name`/`to_dict`, + /// which need a model reference to resolve a node's asset-derived + /// name. `Owned`-only - see `owned()`. + fn with_node_and_model( + &self, + py: Python<'_>, + f: impl FnOnce(&AttackGraph, AttackGraphNodeId, Option<&maltoolbox_model::Model>) -> PyResult, + ) -> PyResult { + let (owner_py, id) = self.owned()?; + let owner = owner_py.borrow(py); + let graph = owner.inner.borrow(); + let key = self.node_key(&graph, id)?; + match &owner.model_py { + Some(m) => { + let model_ref = m.borrow(py); + let core_model = model_ref.inner.borrow(); + f(&graph, key, Some(&core_model)) + } + None => f(&graph, key, None), + } + } + + /// Owner-ptr+id equality, usable without a pyo3 richcmp dance - + /// shared by `PyDetector::__richcmp__` (comparing its `node` field) + /// and this type's own `__richcmp__`/native `PartialEq`-style use. + /// Returns `false` rather than erroring if either side is `Detached`, + /// since this is a structural equality check, not a context where + /// raising is appropriate. + pub fn eq_with(&self, other: &PyAttackGraphNode, py: Python<'_>) -> bool { + match ( + self.owner_ptr(py), + other.owner_ptr(py), + self.owned(), + other.owned(), + ) { + (Ok(a), Ok(b), Ok((_, self_id)), Ok((_, other_id))) => a == b && self_id == other_id, + _ => false, + } + } + + /// Best-effort `full_name`, falling back to the id if the node can no + /// longer be resolved. Used only for `__repr__`-style display (e.g. + /// inside `PyDetector::__repr__`) where non-panicking matters more + /// than correctness. `Detached` nodes (no `full_name` concept) fall + /// back the same way. + pub fn full_name_or_fallback(&self, py: Python<'_>) -> String { + match &self.repr { + NodeRepr::Owned { id, .. } => self + .with_node(py, |g, k| Ok(g.full_name_of(k, None))) + .unwrap_or_else(|_| format!("{}:", id)), + NodeRepr::Detached(d) => format!("{}:", d.id), + } + } + + /// Builds (or returns the cached) `(children, parents)` `PySet` pair + /// for this `Owned` node - lazily built once per (owner, id) and + /// cached in `PyAttackGraph::node_edges_cache` from then on (same + /// pattern as `.extras`/`.detectors` below), invalidated only on + /// structural mutation (see `PyAttackGraph::evict_edges_cache`'s call + /// sites), never on unrelated per-node state changes. This matters + /// because malsim's hot loop reads `.parents` per traversability + /// check without mutating edges mid-run, so rebuilding a fresh + /// `PySet` on every access would be wasteful. Builds both sets in one + /// graph borrow since `children`/`parents` are almost always read + /// together. Falls back to this node's own tombstone record + /// (`PyNodeTombstone::children_ids`/`parents_ids`) when the id is no + /// longer live - a Python caller can legitimately still hold a + /// handle to a node a prior `partially_regenerate_graph`/ + /// `remove_node` call removed (e.g. mal-simulator's + /// `performed_nodes`). `Owned`-only - see `owned()`. + fn edges_sets(&self, py: Python<'_>) -> PyResult<(Py, Py)> { + let (owner_py, id) = self.owned()?; + let owner = owner_py.borrow(py); + { + let cache = owner.node_edges_cache.borrow(); + if let Some((children, parents)) = cache.get(&id) { + return Ok((children.clone_ref(py), parents.clone_ref(py))); + } + } + let live_key = { + let graph = owner.inner.borrow(); + graph.id_to_node.get(&id).copied() + }; + let (child_ids, parent_ids): (Vec, Vec) = match live_key { + Some(key) => { + let graph = owner.inner.borrow(); + ( + graph.nodes[key] + .children + .iter() + .map(|&c| graph.nodes[c].id) + .collect(), + graph.nodes[key] + .parents + .iter() + .map(|&p| graph.nodes[p].id) + .collect(), + ) + } + None => { + let tombstones = owner.tombstones.borrow(); + let tomb = tombstones.get(&id).ok_or_else(|| self.not_found(id))?; + (tomb.children_ids.clone(), tomb.parents_ids.clone()) + } + }; + let children_set = PySet::empty(py)?; + for cid in child_ids { + children_set.add(owner.node_handle(owner_py, py, cid)?)?; + } + let parents_set = PySet::empty(py)?; + for pid in parent_ids { + parents_set.add(owner.node_handle(owner_py, py, pid)?)?; + } + let children_unbind = children_set.unbind(); + let parents_unbind = parents_set.unbind(); + owner.node_edges_cache.borrow_mut().insert( + id, + (children_unbind.clone_ref(py), parents_unbind.clone_ref(py)), + ); + Ok((children_unbind, parents_unbind)) + } + + /// Shared `Owned`-only implementation for `set_children`/`set_parents` + /// (`is_children` picks which side): resolves `value` (an iterable of + /// `AttackGraphNode`s) to core `AttackGraphNodeId`s, writes them into + /// the live node's `children`/`parents` `FxHashSet`, then refreshes + /// this id's `node_edges_cache` entry so the getter's cached `PySet` + /// reflects the write immediately (see `edges_sets`'s doc comment). + fn set_edge_field( + &self, + py: Python<'_>, + value: &Bound<'_, PyAny>, + is_children: bool, + ) -> PyResult<()> { + let (owner_py, id) = self.owned()?; + let owner = owner_py.borrow(py); + let items: Vec> = value.try_iter()?.collect::>>()?; + let new_ids: Vec = items + .iter() + .map(|item| -> PyResult { + let node_ref = item + .extract::>() + .map_err(pyo3::PyErr::from)?; + Ok(node_ref.id()) + }) + .collect::>>()?; + { + let mut graph = owner.inner.borrow_mut(); + let key = self.node_key(&graph, id)?; + let mut new_keys = std::collections::HashSet::with_capacity_and_hasher( + new_ids.len(), + Default::default(), + ); + for nid in &new_ids { + new_keys.insert(self.node_key(&graph, *nid)?); + } + if is_children { + graph.nodes[key].children = new_keys; + } else { + graph.nodes[key].parents = new_keys; + } + } + owner.node_edges_cache.borrow_mut().remove(&id); + drop(owner); + self.edges_sets(py)?; + Ok(()) + } + + /// Shared conversion for `additive_model_effects`/ + /// `subtractive_model_effects`. `Owned`-only - see `owned()`. + fn model_effects_to_py( + &self, + py: Python<'_>, + effects: Option>, + ) -> PyResult>>> { + let Some(effects) = effects else { + return Ok(None); + }; + let (owner_py, _) = self.owned()?; + let owner = owner_py.borrow(py); + let lang_graph = owner.lang_graph_py.borrow(py); + let lang_owner = lang_graph.inner.clone(); + let lang_caches = lang_graph.caches.clone(); + effects + .iter() + .map(|e| model_effect_to_py(py, lang_owner.clone(), lang_caches.clone(), e)) + .collect::>>() + .map(Some) + } +} + +#[pymethods] +impl PyAttackGraphNode { + /// Matches the Python original's positional signature + /// `(node_id, lg_attack_step, model_asset=None, ttc_dist=None, + /// existence_status=None, full_name=None)`. Always builds a + /// `Detached` node - `Owned` handles are only ever vended internally + /// by an owning `PyAttackGraph` (`add_node`/`node_handle`/the nodes + /// view), never constructed directly from Python. + #[new] + #[pyo3(signature = (node_id, lg_attack_step, model_asset=None, ttc_dist=None, existence_status=None, full_name=None))] + #[allow(unused_variables, clippy::too_many_arguments)] + fn py_new( + py: Python<'_>, + node_id: i64, + lg_attack_step: &Bound<'_, PyAny>, + model_asset: Option<&Bound<'_, PyAny>>, + ttc_dist: Option<&Bound<'_, PyAny>>, + existence_status: Option, + full_name: Option, + ) -> PyResult { + let name: String = lg_attack_step.getattr("name")?.extract()?; + Ok(PyAttackGraphNode { + repr: NodeRepr::Detached(DetachedNode { + id: node_id, + name, + children: PySet::empty(py)?.unbind(), + parents: PySet::empty(py)?.unbind(), + }), + }) + } + + /// Other half of the reference cycle described in + /// `PyAttackGraph::__traverse__`'s doc comment + /// (`owning graph -> node_cache -> this node -> owner_py -> owning + /// graph`): an `Owned` node's `owner_py` is a strong `Py` + /// back-reference, so the cycle collector needs this edge visible too. + /// `Detached` nodes hold `Py` children/parents directly, which + /// could themselves reference other `AttackGraphNode`s, so those are + /// visited too. + fn __traverse__(&self, visit: pyo3::PyVisit<'_>) -> Result<(), pyo3::PyTraverseError> { + match &self.repr { + NodeRepr::Owned { owner_py, .. } => visit.call(owner_py)?, + NodeRepr::Detached(d) => { + visit.call(&d.children)?; + visit.call(&d.parents)?; + } + } + Ok(()) + } + + /// Deliberately a no-op. `NodeRepr::Owned.owner_py` is a plain + /// (non-`Option`) `Py`, so there's no GIL-free way to + /// null it out here. Breaking one edge of a cycle is enough for the + /// whole cycle to become collectible, and `PyAttackGraph::__clear__` + /// already does that by dropping every `Py` in + /// `node_cache`; normal refcounting finishes the job from there. + /// `__traverse__` above still reports this edge, which is required + /// for the GC to recognize the cycle at all. + fn __clear__(&self) {} + + #[getter] + pub fn id(&self) -> i64 { + match &self.repr { + NodeRepr::Owned { id, .. } => *id, + NodeRepr::Detached(d) => d.id, + } + } + + #[getter] + fn name(&self, py: Python<'_>) -> PyResult { + match &self.repr { + NodeRepr::Owned { .. } => self.with_node_value(py, |n| Ok(n.name.clone())), + NodeRepr::Detached(d) => Ok(d.name.clone()), + } + } + + #[getter(r#type)] + fn step_type(&self, py: Python<'_>) -> PyResult<&'static str> { + self.with_node_value(py, |n| Ok(n.step_type.as_str())) + } + + #[getter] + fn lg_attack_step(&self, py: Python<'_>) -> PyResult> { + let step_id = self.with_node_value(py, |n| Ok(n.lg_attack_step))?; + let (owner_py, _) = self.owned()?; + let owner = owner_py.borrow(py); + let lang_graph = owner.lang_graph_py.borrow(py); + lang_graph.step_handle(py, step_id) + } + + #[getter] + fn causal_mode(&self, py: Python<'_>) -> PyResult> { + self.with_node_value(py, |n| Ok(n.causal_mode.map(|m| m.as_str()))) + } + + #[getter] + fn ttc<'py>(&self, py: Python<'py>) -> PyResult> { + self.with_node_value(py, |n| match &n.ttc { + Some(v) => pythonize::pythonize(py, v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())), + None => Ok(py.None().into_bound(py)), + }) + } + + #[getter] + fn tags(&self, py: Python<'_>) -> PyResult> { + self.with_node_value(py, |n| Ok(n.tags.clone())) + } + + /// `None` when there are no model effects, else a list of + /// `LanguageGraphModelEffect` wrappers - see `maltoolbox-language-py`'s + /// `model_effect.rs` for the wrapper hierarchy. + #[getter] + fn additive_model_effects( + &self, + py: Python<'_>, + ) -> PyResult>>> { + let effects = self.with_node_value(py, |n| Ok(n.additive_model_effects.clone()))?; + self.model_effects_to_py(py, effects) + } + + #[getter] + fn subtractive_model_effects( + &self, + py: Python<'_>, + ) -> PyResult>>> { + let effects = self.with_node_value(py, |n| Ok(n.subtractive_model_effects.clone()))?; + self.model_effects_to_py(py, effects) + } + + #[getter] + fn model_asset(&self, py: Python<'_>) -> PyResult>> { + let asset_id = self.with_node_value(py, |n| Ok(n.model_asset))?; + let Some(asset_id) = asset_id else { + return Ok(None); + }; + let (owner_py, _) = self.owned()?; + let owner = owner_py.borrow(py); + let Some(model_py) = owner.model_py.as_ref() else { + return Ok(None); + }; + let model = model_py.borrow(py); + Ok(Some(model.asset_handle(py, asset_id)?)) + } + + #[getter] + fn existence_status(&self, py: Python<'_>) -> PyResult> { + self.with_node_value(py, |n| Ok(n.existence_status)) + } + + /// Plain mutable attribute in the pure-Python original, freely + /// reassigned afterwards (e.g. by `analyzers.py`'s viability/necessity + /// propagation). `Owned`-only: writes through to the live node's core + /// field; `Detached` nodes don't store this field at all (see + /// `DetachedNode`'s doc comment), so setting it there is unsupported. + #[setter] + fn set_existence_status(&self, py: Python<'_>, value: Option) -> PyResult<()> { + let (owner_py, id) = self.owned()?; + let owner = owner_py.borrow(py); + let mut graph = owner.inner.borrow_mut(); + let key = self.node_key(&graph, id)?; + graph.nodes[key].existence_status = value; + Ok(()) + } + + /// `Owned`: lazily built, then cached, per (owner, id) - see + /// `edges_sets`. `Detached`: the plain `Py` set directly by + /// Python code (`node1.children = {node2, node3}`) - see the + /// `#[setter]` below. + #[getter] + fn children<'py>(&self, py: Python<'py>) -> PyResult> { + match &self.repr { + NodeRepr::Owned { .. } => { + let (children, _parents) = self.edges_sets(py)?; + Ok(children.into_bound(py)) + } + NodeRepr::Detached(d) => Ok(d.children.bind(py).clone()), + } + } + + /// Plain mutable set in the pure-Python original - `AttackGraphNode` + /// sets `self.children`/`self.parents` as bare `set()` attributes with + /// no linking method, so this applies to `Owned` nodes too; e.g. + /// `analyzers.py`'s apriori viability/necessity propagation reassigns + /// `.parents` directly on live, owned nodes. For `Owned`, writes + /// through to the core's `FxHashSet` *and* refreshes + /// `node_edges_cache` for this id so the cached `PySet` returned by the + /// getter reflects the new value (see `edges_sets`'s doc comment). + #[setter] + fn set_children(&self, py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult<()> { + match &self.repr { + NodeRepr::Owned { .. } => self.set_edge_field(py, value, true), + NodeRepr::Detached(d) => { + let set = PySet::new(py, value.try_iter()?.collect::>>()?)?; + d.children.bind(py).clear(); + for item in set.iter() { + d.children.bind(py).add(item)?; + } + Ok(()) + } + } + } + + /// See `children`'s doc comment - same `Owned`/`Detached` split. + #[getter] + fn parents<'py>(&self, py: Python<'py>) -> PyResult> { + match &self.repr { + NodeRepr::Owned { .. } => { + let (_children, parents) = self.edges_sets(py)?; + Ok(parents.into_bound(py)) + } + NodeRepr::Detached(d) => Ok(d.parents.bind(py).clone()), + } + } + + /// See `set_children`'s doc comment - same `Owned`/`Detached` split. + #[setter] + fn set_parents(&self, py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult<()> { + match &self.repr { + NodeRepr::Owned { .. } => self.set_edge_field(py, value, false), + NodeRepr::Detached(d) => { + let set = PySet::new(py, value.try_iter()?.collect::>>()?)?; + d.parents.bind(py).clear(); + for item in set.iter() { + d.parents.bind(py).add(item)?; + } + Ok(()) + } + } + } + + /// Live, mutable per-node dict (see `PyAttackGraph::node_extras`'s + /// doc comment): lazily seeded from the core's `n.extras` on first + /// access, then the same `Py` object is returned every + /// subsequent access, so `node.extras['x'] = y` mutation is visible + /// to later reads. + #[getter] + fn extras(&self, py: Python<'_>) -> PyResult> { + let (owner_py, id) = self.owned()?; + let owner = owner_py.borrow(py); + { + let table = owner.node_extras.borrow(); + if let Some(existing) = table.get(&id) { + return Ok(existing.clone_ref(py)); + } + } + let value = + self.with_node_value(py, |n| Ok(serde_json::Value::Object(n.extras.clone())))?; + let pythonized = pythonize::pythonize(py, &value) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let dict = pythonized.cast::()?; + owner + .node_extras + .borrow_mut() + .insert(id, dict.clone().unbind()); + Ok(dict.clone().unbind()) + } + + /// Live, mutable per-node dict: lazily seeded from the core's + /// generation-time detector data on first access, then the same + /// `Py` object is returned every subsequent access, so + /// external `node.detectors['x'] = Detector(...)` mutation is visible + /// to later reads. Falls back to this node's own tombstone record + /// (`PyNodeTombstone::detector_snapshots`) when the id is no longer + /// live: mal-simulator's `event_logger.py::collect_logs` reads + /// `attack_step.detectors.values()` on a node that was compromised + /// and then removed within the same DynaMAL step, the same + /// held-handle-across-removal shape as `edges_sets`'s fallback. + #[getter] + fn detectors(&self, py: Python<'_>) -> PyResult> { + let (owner_py, id) = self.owned()?; + let owner = owner_py.borrow(py); + { + let table = owner.node_detectors.borrow(); + if let Some(existing) = table.get(&id) { + return Ok(existing.clone_ref(py)); + } + } + let live_key = { + let graph = owner.inner.borrow(); + graph.id_to_node.get(&id).copied() + }; + let snapshot: Vec = match live_key { + Some(key) => { + let graph = owner.inner.borrow(); + detector_snapshots_for(&graph, &[key]) + } + None => { + let tombstones = owner.tombstones.borrow(); + let tomb = tombstones.get(&id).ok_or_else(|| self.not_found(id))?; + tomb.detector_snapshots.clone() + } + }; + let dict = PyDict::new(py); + for snap in &snapshot { + let det = build_py_detector(py, owner_py, snap)?; + dict.set_item(&snap.label, det)?; + } + owner + .node_detectors + .borrow_mut() + .insert(id, dict.clone().unbind()); + Ok(dict.unbind()) + } + + /// Falls back to this node's own tombstone record + /// (`PyNodeTombstone::full_name`, precomputed at removal time) when + /// the id is no longer live - same rationale as `.detectors` and + /// `edges_sets`'s `.children`/`.parents` fallback. + #[getter] + fn full_name(&self, py: Python<'_>) -> PyResult { + let (owner_py, id) = self.owned()?; + let owner = owner_py.borrow(py); + let live_key = { + let graph = owner.inner.borrow(); + graph.id_to_node.get(&id).copied() + }; + match live_key { + Some(_) => self.with_node_and_model(py, |g, k, model| Ok(g.full_name_of(k, model))), + None => { + let tombstones = owner.tombstones.borrow(); + let tomb = tombstones.get(&id).ok_or_else(|| self.not_found(id))?; + Ok(tomb.full_name.clone()) + } + } + } + + #[getter] + fn info<'py>(&self, py: Python<'py>) -> PyResult> { + let step_id = self.with_node_value(py, |n| Ok(n.lg_attack_step))?; + let (owner_py, _) = self.owned()?; + let lang_graph = { + let owner = owner_py.borrow(py); + let rc = owner.lang_graph_py.borrow(py).inner.clone(); + rc + }; + let graph = lang_graph.as_ref(); + let dict = PyDict::new(py); + for (k, v) in &graph.step(step_id).info { + dict.set_item(k, v)?; + } + Ok(dict) + } + + /// Overlays the live `node_extras` side-table entry (if seeded) onto + /// the serialized `"extras"` field, since the core's own + /// `node_to_dict` can't see compat-layer-side mutation of `.extras` + /// (see `PyAttackGraph::to_dict`'s doc comment). **Known gap**: no + /// tombstone fallback - unlike `edges_sets`/`.detectors`/`.full_name`/ + /// `__repr__`, this still raises `LookupError` if the node's own id + /// was removed by a prior `partially_regenerate_graph`/`remove_node` + /// call (tracked in PYTHON_BINDINGS_IMPLEMENTATION.md's Phase 6 + /// status). + fn to_dict<'py>(&self, py: Python<'py>) -> PyResult> { + let dict = self.with_node_and_model(py, |g, k, model| Ok(g.node_to_dict(k, model)))?; + let pythonized = pythonize::pythonize(py, &dict) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let pythonized = pythonized.cast::()?; + fix_children_parents_int_keys(py, pythonized)?; + if let Ok((owner_py, id)) = self.owned() { + let owner = owner_py.borrow(py); + let node_extras = owner.node_extras.borrow(); + if let Some(extras) = node_extras.get(&id) { + let extras = extras.bind(py); + if extras.len() > 0 { + pythonized.set_item("extras", extras)?; + } else { + pythonized.del_item("extras").ok(); + } + } + } + Ok(pythonized.clone().into_any()) + } + + /// Falls back to the tombstone record for a removed node's own id + /// (same rationale as `.detectors`/`.full_name`) rather than raising - + /// display/debugging code shouldn't crash just because the node it's + /// describing was since removed. + fn __repr__(&self, py: Python<'_>) -> PyResult { + match &self.repr { + NodeRepr::Owned { owner_py, id } => { + let owner = owner_py.borrow(py); + let live_key = { + let graph = owner.inner.borrow(); + graph.id_to_node.get(id).copied() + }; + match live_key { + Some(_) => self.with_node_and_model(py, |g, k, model| { + let node = &g.nodes[k]; + Ok(format!( + "AttackGraphNode(name: \"{}\", id: {}, type: {})", + g.full_name_of(k, model), + node.id, + node.step_type.as_str() + )) + }), + None => { + let tombstones = owner.tombstones.borrow(); + let tomb = tombstones.get(id).ok_or_else(|| self.not_found(*id))?; + Ok(format!( + "AttackGraphNode(name: \"{}\", id: {}, type: {})", + tomb.full_name, + tomb.state.id, + tomb.state.step_type.as_str() + )) + } + } + } + NodeRepr::Detached(d) => Ok(format!( + "AttackGraphNode(name: \"{}\", id: {}, type: detached)", + d.name, d.id + )), + } + } + + /// `Owned`: owner-ptr+id composite hash. `Detached`: plain Python + /// object identity (pointer of this very `Py`), matching the + /// Python original's lack of custom `__eq__`/`__hash__` on + /// `AttackGraphNode`. + fn __hash__(self_: &Bound<'_, Self>, py: Python<'_>) -> isize { + let slf = self_.borrow(); + match &slf.repr { + NodeRepr::Owned { .. } => composite_hash(slf.owner_ptr(py).unwrap_or(0), slf.id()), + NodeRepr::Detached(_) => self_.as_ptr() as isize, + } + } + + /// See `__hash__`'s doc comment - same `Owned`/`Detached` split for + /// `__richcmp__`'s `Eq`/`Ne`. A `Detached` node compared against an + /// `Owned` one is never equal, consistent with default identity + /// semantics. + fn __richcmp__( + self_: &Bound<'_, Self>, + other: &Bound<'_, PyAny>, + op: CompareOp, + py: Python<'_>, + ) -> PyResult> { + let eq = { + let slf = self_.borrow(); + match &slf.repr { + NodeRepr::Owned { .. } => match other.extract::>() { + Ok(other_ref) => slf.eq_with(&other_ref, py), + Err(_) => false, + }, + NodeRepr::Detached(_) => self_.is(other), + } + }; + match op { + CompareOp::Eq => eq.into_py_any(py), + CompareOp::Ne => (!eq).into_py_any(py), + _ => Ok(py.NotImplemented()), + } + } + + /// Delegates to `(owner_py, id)` rather than snapshotting this node's + /// own data, since `owner_py` is a real, identity-stable object that + /// pickle's own protocol memoizes automatically within one combined + /// pickle call (e.g. the same graph reachable both directly and via + /// one of its nodes). `Detached` nodes aren't picklable this way (no + /// owner to delegate to), so this errors clearly instead. + #[allow(clippy::type_complexity)] + fn __reduce__(&self, py: Python<'_>) -> PyResult<(Py, (Py, i64))> { + let (owner_py, id) = self.owned()?; + // Looked up by import path rather than hardcoding a + // `Py` reference, so it resolves correctly regardless + // of how this module gets packaged. + let func = py + .import("maltoolbox._native")? + .getattr("_rebuild_attack_graph_node")? + .unbind(); + Ok((func, (owner_py.clone_ref(py), id))) + } + + /// Deep-copying a single `Owned` node standalone still needs to + /// produce a correct result and consult/populate the same `memo`, so + /// this delegates to the owning graph's `deepcopy_graph` (consistent + /// `children`/`parents` cross references), then returns just this + /// node's copy. Checks `memo` first (keyed by `id(self)`, matching + /// the pure-Python original) so a node already copied via an earlier + /// `deepcopy_graph` call sharing this `memo` - e.g. + /// `copy.deepcopy((attack_graph, a_node))` - returns the *same* copy + /// rather than cloning the whole graph again. `Detached` nodes aren't + /// owned by any graph to delegate to, so this errors clearly instead. + fn __deepcopy__( + self_: &Bound<'_, Self>, + memo: &Bound<'_, PyDict>, + ) -> PyResult> { + let py = self_.py(); + if let Some(existing) = memo.get_item(self_.as_ptr() as isize)? { + return existing.extract().map_err(PyErr::from); + } + let slf = self_.borrow(); + let (owner_py, id) = slf.owned()?; + let owner_py = owner_py.clone_ref(py); + drop(slf); + let owner_bound = owner_py.bind(py); + let new_graph = PyAttackGraph::deepcopy_graph(owner_bound, memo)?; + let new_graph_ref = new_graph.borrow(py); + new_graph_ref.node_handle(&new_graph, py, id) + } +} + +/// A small, read-only, lazy Mapping-protocol wrapper for +/// `PyAttackGraph.nodes` (also reused for `.full_name_to_node`'s value +/// side): constructs a `PyAttackGraphNode` handle only for key(s) +/// actually touched, never materializing the whole collection upfront. +/// Not part of the public API surface (never `m.add_class`'d) - only +/// ever handed out by `PyAttackGraph.nodes`. +#[pyclass(name = "AttackGraphNodesView", unsendable)] +pub struct PyAttackGraphNodesView { + pub owner_py: Py, +} + +impl PyAttackGraphNodesView { + fn ids(&self, py: Python<'_>) -> Vec { + self.owner_py + .borrow(py) + .inner + .borrow() + .id_to_node + .keys() + .copied() + .collect() + } +} + +#[pymethods] +impl PyAttackGraphNodesView { + fn __getitem__(&self, py: Python<'_>, id: i64) -> PyResult> { + let owner = self.owner_py.borrow(py); + let present = owner.inner.borrow().id_to_node.contains_key(&id); + if present { + owner.node_handle(&self.owner_py, py, id) + } else { + Err(PyKeyError::new_err(id)) + } + } + + fn __len__(&self, py: Python<'_>) -> usize { + self.owner_py.borrow(py).inner.borrow().id_to_node.len() + } + + fn __contains__(&self, py: Python<'_>, id: i64) -> bool { + self.owner_py + .borrow(py) + .inner + .borrow() + .id_to_node + .contains_key(&id) + } + + fn __iter__<'py>(&self, py: Python<'py>) -> PyResult> { + let ids = self.ids(py); + let list = PyList::new(py, ids)?; + Ok(list.try_iter()?.into_any()) + } + + fn keys(&self, py: Python<'_>) -> Vec { + self.ids(py) + } + + fn values(&self, py: Python<'_>) -> PyResult>> { + let owner = self.owner_py.borrow(py); + self.ids(py) + .into_iter() + .map(|id| owner.node_handle(&self.owner_py, py, id)) + .collect() + } + + fn items(&self, py: Python<'_>) -> PyResult)>> { + let owner = self.owner_py.borrow(py); + self.ids(py) + .into_iter() + .map(|id| Ok((id, owner.node_handle(&self.owner_py, py, id)?))) + .collect() + } + + #[pyo3(signature = (id, default=None))] + fn get( + &self, + py: Python<'_>, + id: i64, + default: Option>, + ) -> PyResult>> { + let owner = self.owner_py.borrow(py); + let present = owner.inner.borrow().id_to_node.contains_key(&id); + if present { + Ok(Some( + owner.node_handle(&self.owner_py, py, id)?.into_py_any(py)?, + )) + } else { + Ok(default) + } + } + + /// The pure-Python original's `AttackGraph.nodes` is a plain, freely + /// settable `dict[int, AttackGraphNode]`, so `copy.deepcopy(attack_graph.nodes, + /// memo)` is just a generic dict deepcopy that deep-copies each value + /// via `AttackGraphNode.__deepcopy__` and rebuilds a plain `dict`. + /// This view isn't a real dict, so it needs its own `__deepcopy__` + /// returning a real `dict` instead of falling through to pickle-based + /// generic deepcopy (which can't pickle this Rust-defined class at + /// all). Delegates to `PyAttackGraph::deepcopy_graph` (the same + /// whole-graph clone `PyAttackGraph::__deepcopy__` uses) so the + /// returned nodes' `children`/`parents` cross-references stay + /// mutually consistent, then returns `{id: copied_node}` built from + /// the copied graph, matching the original's plain-dict return type. + fn __deepcopy__(&self, py: Python<'_>, memo: &Bound<'_, PyDict>) -> PyResult> { + let owner_bound = self.owner_py.bind(py); + let new_graph = PyAttackGraph::deepcopy_graph(owner_bound, memo)?; + let new_graph_ref = new_graph.borrow(py); + let ids = self.ids(py); + let dict = PyDict::new(py); + for id in ids { + dict.set_item(id, new_graph_ref.node_handle(&new_graph, py, id)?)?; + } + Ok(dict.unbind()) + } +} + +/// Rebuilds a node handle from a pickled `(owner, id)` pair - the +/// `__reduce__` target for `PyAttackGraphNode`. A plain function +/// (registered in `lib.rs`), not a method, since `__reduce__`'s callable +/// must be importable by name for pickle to locate it. Goes through the +/// owner's cache so that if the same owner is reachable from multiple +/// places in one combined `pickle.loads` (e.g. the graph itself plus one +/// of its nodes), the second occurrence resolves to the same Python +/// object as the first, same as live access. +#[pyfunction] +pub fn _rebuild_attack_graph_node( + py: Python<'_>, + owner: Py, + id: i64, +) -> PyResult> { + let graph = owner.borrow(py); + graph.node_handle(&owner, py, id) +} diff --git a/py-bindings/maltoolbox-language-py/Cargo.toml b/py-bindings/maltoolbox-language-py/Cargo.toml new file mode 100644 index 00000000..0ef7e3e8 --- /dev/null +++ b/py-bindings/maltoolbox-language-py/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "maltoolbox-language-py" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[lib] +name = "maltoolbox_language_py" +path = "src/lib.rs" + +[dependencies] +maltoolbox-language = { path = "../../core/maltoolbox-language" } +maltoolbox-io = { path = "../../core/maltoolbox-io" } +serde_json = { workspace = true } +thiserror = { workspace = true } +pythonize = { workspace = true } +pyo3 = { workspace = true } +indexmap = { workspace = true } +slotmap = { workspace = true } diff --git a/py-bindings/maltoolbox-language-py/src/asset.rs b/py-bindings/maltoolbox-language-py/src/asset.rs new file mode 100644 index 00000000..6a2e6b27 --- /dev/null +++ b/py-bindings/maltoolbox-language-py/src/asset.rs @@ -0,0 +1,307 @@ +//! Mirrors `maltoolbox/language/language_graph_asset.py`'s +//! `LanguageGraphAsset`. A handle (`owner` + `AssetId`), cached per-owner +//! (`caches.assets`) so repeated lookups for the same id return the +//! identical Python object; see `handle.rs`. + +use std::rc::Rc; + +use pyo3::basic::CompareOp; +use pyo3::prelude::*; +use pyo3::types::PyDict; +use pyo3::IntoPyObjectExt; +use slotmap::Key; + +use maltoolbox_language::graph::ids::AssetId; +use maltoolbox_language::graph::LanguageGraph; + +use crate::assoc::PyLanguageGraphAssociation; +use crate::attack_step::PyLanguageGraphAttackStep; +use crate::exceptions::graph_error_to_py; +use crate::expr_chain::expr_chain_to_py; +use crate::handle::{cached_handle, composite_hash, SharedLangGraphCaches}; + +#[pyclass(name = "LanguageGraphAsset", unsendable, skip_from_py_object)] +#[derive(Clone)] +pub struct PyLanguageGraphAsset { + pub owner: Rc, + pub id: AssetId, + pub caches: SharedLangGraphCaches, +} + +impl PyLanguageGraphAsset { + pub fn new( + owner: Rc, + id: AssetId, + caches: SharedLangGraphCaches, + ) -> Self { + PyLanguageGraphAsset { owner, id, caches } + } + + fn owner_ptr(&self) -> usize { + Rc::as_ptr(&self.owner) as usize + } + + /// Cache-aware constructor for a sibling asset handle owned by the + /// same `LanguageGraph`. + fn asset_handle(&self, py: Python<'_>, id: AssetId) -> PyResult> { + let owner = self.owner.clone(); + let caches = self.caches.clone(); + cached_handle(&self.caches.assets, py, id, move || { + PyLanguageGraphAsset::new(owner, id, caches) + }) + } + + /// Cache-aware constructor for an attack-step handle owned by the + /// same `LanguageGraph`. + fn step_handle( + &self, + py: Python<'_>, + id: maltoolbox_language::graph::ids::AttackStepId, + ) -> PyResult> { + let owner = self.owner.clone(); + let caches = self.caches.clone(); + cached_handle(&self.caches.steps, py, id, move || { + PyLanguageGraphAttackStep::new(owner, id, caches) + }) + } +} + +#[pymethods] +impl PyLanguageGraphAsset { + #[getter] + fn name(&self) -> String { + self.owner.as_ref().asset(self.id).name.clone() + } + + #[getter] + fn is_abstract(&self) -> bool { + self.owner.as_ref().asset(self.id).is_abstract + } + + #[getter] + fn info<'py>(&self, py: Python<'py>) -> PyResult> { + let graph = self.owner.as_ref(); + let dict = PyDict::new(py); + for (k, v) in &graph.asset(self.id).info { + dict.set_item(k, v)?; + } + Ok(dict) + } + + /// Own-only associations, by fieldname (not own+inherited - see + /// `.associations` for that). + #[getter] + fn own_associations<'py>(&self, py: Python<'py>) -> PyResult> { + let graph = self.owner.as_ref(); + let dict = PyDict::new(py); + for (fieldname, assoc) in &graph.asset(self.id).own_associations { + dict.set_item( + fieldname, + PyLanguageGraphAssociation::new( + self.owner.clone(), + assoc.clone(), + self.caches.clone(), + ), + )?; + } + Ok(dict) + } + + /// Own + inherited attack steps, by name - matches Python's + /// `attack_steps` dict (which `_inherit_attack_steps` also + /// populates with synthesized inherited entries at build time, so + /// "own" vs "inherited" isn't a distinction left to make here). + #[getter] + fn attack_steps<'py>(&self, py: Python<'py>) -> PyResult> { + let steps: Vec<(String, maltoolbox_language::graph::ids::AttackStepId)> = self + .owner + .as_ref() + .asset(self.id) + .attack_steps + .iter() + .map(|(n, &id)| (n.clone(), id)) + .collect(); + let dict = PyDict::new(py); + for (name, step_id) in steps { + dict.set_item(name, self.step_handle(py, step_id)?)?; + } + Ok(dict) + } + + #[getter] + fn own_super_asset(&self, py: Python<'_>) -> PyResult>> { + let super_id = self.owner.as_ref().asset(self.id).own_super_asset; + super_id.map(|id| self.asset_handle(py, id)).transpose() + } + + #[getter] + fn own_sub_assets(&self, py: Python<'_>) -> PyResult>> { + let ids: Vec = self.owner.as_ref().asset(self.id).own_sub_assets.clone(); + ids.into_iter() + .map(|id| self.asset_handle(py, id)) + .collect() + } + + /// This asset plus every asset that directly or indirectly extends + /// it. The list is recomputed on each access (Python's is a + /// `cached_property`, this isn't); the handles it returns are still + /// cached, so the objects inside are stable even though the list + /// itself is not. + #[getter] + fn sub_assets(&self, py: Python<'_>) -> PyResult>> { + let ids = self.owner.as_ref().sub_assets(self.id); + ids.into_iter() + .map(|id| self.asset_handle(py, id)) + .collect() + } + + /// This asset plus every asset it directly or indirectly extends, + /// closest ancestor first. + #[getter] + fn super_assets(&self, py: Python<'_>) -> PyResult>> { + let ids = self.owner.as_ref().super_assets(self.id); + ids.into_iter() + .map(|id| self.asset_handle(py, id)) + .collect() + } + + /// Own + inherited associations, by fieldname. + #[getter] + fn associations<'py>(&self, py: Python<'py>) -> PyResult> { + let graph = self.owner.as_ref(); + let dict = PyDict::new(py); + for (fieldname, assoc) in graph.associations(self.id) { + dict.set_item( + fieldname, + PyLanguageGraphAssociation::new(self.owner.clone(), assoc, self.caches.clone()), + )?; + } + Ok(dict) + } + + fn associations_to<'py>( + &self, + py: Python<'py>, + asset_type: &PyLanguageGraphAsset, + ) -> PyResult> { + let graph = self.owner.as_ref(); + let dict = PyDict::new(py); + for (fieldname, assoc) in graph.associations_to(self.id, asset_type.id) { + dict.set_item( + fieldname, + PyLanguageGraphAssociation::new(self.owner.clone(), assoc, self.caches.clone()), + )?; + } + Ok(dict) + } + + /// Own + inherited variables: name -> (target asset, optional + /// expression chain). The expression chain is a real `ExpressionsChain` + /// wrapper, not a dict, since callers access attributes like + /// `.right_link.fieldname` directly on it. + #[getter] + fn variables<'py>(&self, py: Python<'py>) -> PyResult> { + let graph = self.owner.as_ref(); + let dict = PyDict::new(py); + for (name, (asset_id, expr)) in graph.variables(self.id) { + let asset_handle = self.asset_handle(py, asset_id)?; + let expr_obj = match &expr { + Some(e) => { + expr_chain_to_py(py, self.owner.clone(), self.caches.clone(), e)?.into_any() + } + None => py.None(), + }; + dict.set_item(name, (asset_handle, expr_obj))?; + } + Ok(dict) + } + + fn is_subasset_of(&self, target_asset: &PyLanguageGraphAsset) -> bool { + self.owner.as_ref().is_subasset_of(self.id, target_asset.id) + } + + fn get_all_common_superassets( + &self, + other: &PyLanguageGraphAsset, + ) -> std::collections::HashSet { + self.owner + .as_ref() + .get_all_common_superassets(self.id, other.id) + } + + fn to_dict<'py>(&self, py: Python<'py>) -> PyResult> { + let graph = self.owner.as_ref(); + let dict = graph + .asset(self.id) + .to_dict(&graph) + .map_err(graph_error_to_py)?; + pythonize::pythonize(py, &dict).map_err(|e| { + crate::exceptions::graph_error_to_py(maltoolbox_language::graph::GraphError::Malformed( + e.to_string(), + )) + }) + } + + pub fn __repr__(&self) -> String { + format!("LanguageGraphAsset(name: \"{}\")", self.name()) + } + + fn __hash__(&self) -> isize { + composite_hash(self.owner_ptr(), self.id) + } + + fn __richcmp__( + &self, + other: &PyLanguageGraphAsset, + op: CompareOp, + py: Python<'_>, + ) -> PyResult> { + let eq = self.owner_ptr() == other.owner_ptr() && self.id == other.id; + match op { + CompareOp::Eq => eq.into_py_any(py), + CompareOp::Ne => (!eq).into_py_any(py), + _ => Ok(py.NotImplemented()), + } + } + + /// Pickles as `(owner, ffi_id)`, where `owner` is a fresh temporary + /// `PyLanguageGraph` wrapping the same `Rc` and `ffi_id` + /// is the `AssetId` round-tripped through slotmap's stable `KeyData` + /// ffi representation (since `AssetId` itself isn't picklable). + /// Pickle recursively pickles the temporary owner via its own + /// `__reduce__`, so no graph-serialization logic is needed here. + #[allow(clippy::type_complexity)] + fn __reduce__( + &self, + py: Python<'_>, + ) -> PyResult<(Py, (Py, u64))> { + let temp_owner = Py::new( + py, + crate::language_graph::PyLanguageGraph { + inner: self.owner.clone(), + caches: self.caches.clone(), + }, + )?; + let func = py + .import("maltoolbox._native")? + .getattr("_rebuild_language_graph_asset")? + .unbind(); + Ok((func, (temp_owner, self.id.data().as_ffi()))) + } +} + +/// Rebuilds a `PyLanguageGraphAsset` handle from a pickled +/// `(owner, ffi_id)` pair - the `__reduce__` target. A plain function +/// (registered in `lib.rs`), not a method, since `__reduce__`'s callable +/// must be importable by name. Goes through the owner's handle cache, +/// same as live access. +#[pyfunction] +pub fn _rebuild_language_graph_asset( + py: Python<'_>, + owner: Py, + ffi_id: u64, +) -> PyResult> { + let id = AssetId::from(slotmap::KeyData::from_ffi(ffi_id)); + let graph = owner.borrow(py); + graph.asset_handle(py, id) +} diff --git a/py-bindings/maltoolbox-language-py/src/assoc.rs b/py-bindings/maltoolbox-language-py/src/assoc.rs new file mode 100644 index 00000000..3122f195 --- /dev/null +++ b/py-bindings/maltoolbox-language-py/src/assoc.rs @@ -0,0 +1,226 @@ +//! Mirrors `maltoolbox/language/language_graph_assoc.py`'s +//! `LanguageGraphAssociation`/`LanguageGraphAssociationField`. +//! +//! Unlike `LanguageGraphAsset`/`LanguageGraphAttackStep`, these wrap the +//! actual `Rc`/cloned `LanguageGraphAssociationField` +//! value directly rather than an owner+id pair: the core crate already +//! gives `LanguageGraphAssociation` structural `PartialEq`/`Hash` +//! (excluding `info`, matching the Python dataclass's +//! `field(compare=False)`), and both sides of an association already +//! share the same `Rc`, matching Python's `left_asset.own_associations[...] +//! = assoc; right_asset.own_associations[...] = assoc` (the same object +//! on both sides). + +use std::rc::Rc; + +use pyo3::basic::CompareOp; +use pyo3::prelude::*; +use pyo3::types::PyDict; +use pyo3::IntoPyObjectExt; + +use maltoolbox_language::graph::assoc::{LanguageGraphAssociation, LanguageGraphAssociationField}; +use maltoolbox_language::graph::LanguageGraph; + +use crate::asset::PyLanguageGraphAsset; +use crate::exceptions::graph_error_to_py; +use crate::handle::{cached_handle, SharedLangGraphCaches}; + +fn struct_hash(value: &T) -> isize { + use std::hash::Hasher; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + value.hash(&mut hasher); + hasher.finish() as isize +} + +/// `caches` is threaded through so `.asset` resolves to the same +/// per-owner-cached `PyLanguageGraphAsset` handle used everywhere else. +/// `LanguageGraphAssociation`/`Field` themselves aren't cached handles +/// (they already have structural equality, per the module doc comment); +/// this just keeps the *asset* handles they hand out consistent with +/// every other path to the same asset. +#[pyclass( + name = "LanguageGraphAssociationField", + unsendable, + skip_from_py_object +)] +#[derive(Clone)] +pub struct PyLanguageGraphAssociationField { + pub owner: Rc, + pub field: LanguageGraphAssociationField, + pub caches: SharedLangGraphCaches, +} + +#[pymethods] +impl PyLanguageGraphAssociationField { + #[getter] + fn asset(&self, py: Python<'_>) -> PyResult> { + let owner = self.owner.clone(); + let id = self.field.asset; + let caches = self.caches.clone(); + cached_handle(&self.caches.assets, py, id, move || { + PyLanguageGraphAsset::new(owner, id, caches) + }) + } + + #[getter] + fn fieldname(&self) -> String { + self.field.fieldname.clone() + } + + #[getter] + fn minimum(&self) -> i64 { + self.field.minimum + } + + #[getter] + fn maximum(&self) -> Option { + self.field.maximum + } + + pub fn __repr__(&self) -> String { + let asset_name = self.owner.as_ref().asset(self.field.asset).name.clone(); + format!( + "LanguageGraphAssociationField(asset: LanguageGraphAsset(name: \"{}\"), fieldname: \"{}\", minimum: {}, maximum: {:?})", + asset_name, + self.field.fieldname, + self.field.minimum, + self.field.maximum + ) + } + + fn __hash__(&self) -> isize { + struct_hash(&self.field) + } + + fn __richcmp__( + &self, + other: &PyLanguageGraphAssociationField, + op: CompareOp, + py: Python<'_>, + ) -> PyResult> { + let eq = self.field == other.field; + match op { + CompareOp::Eq => eq.into_py_any(py), + CompareOp::Ne => (!eq).into_py_any(py), + _ => Ok(py.NotImplemented()), + } + } +} + +#[pyclass(name = "LanguageGraphAssociation", unsendable, skip_from_py_object)] +#[derive(Clone)] +pub struct PyLanguageGraphAssociation { + pub owner: Rc, + pub assoc: Rc, + pub caches: SharedLangGraphCaches, +} + +impl PyLanguageGraphAssociation { + pub fn new( + owner: Rc, + assoc: Rc, + caches: SharedLangGraphCaches, + ) -> Self { + PyLanguageGraphAssociation { + owner, + assoc, + caches, + } + } + + fn wrap_field(&self, field: &LanguageGraphAssociationField) -> PyLanguageGraphAssociationField { + PyLanguageGraphAssociationField { + owner: self.owner.clone(), + field: field.clone(), + caches: self.caches.clone(), + } + } +} + +#[pymethods] +impl PyLanguageGraphAssociation { + #[getter] + fn name(&self) -> String { + self.assoc.name.clone() + } + + #[getter] + fn left_field(&self) -> PyLanguageGraphAssociationField { + self.wrap_field(&self.assoc.left_field) + } + + #[getter] + fn right_field(&self) -> PyLanguageGraphAssociationField { + self.wrap_field(&self.assoc.right_field) + } + + #[getter] + fn info<'py>(&self, py: Python<'py>) -> PyResult> { + let dict = PyDict::new(py); + for (k, v) in &self.assoc.info { + dict.set_item(k, v)?; + } + Ok(dict) + } + + #[getter] + fn full_name(&self) -> String { + self.assoc.full_name() + } + + fn get_field(&self, fieldname: &str) -> PyLanguageGraphAssociationField { + self.wrap_field(self.assoc.get_field(fieldname)) + } + + fn contains_fieldname(&self, fieldname: &str) -> bool { + self.assoc.contains_fieldname(fieldname) + } + + fn contains_asset(&self, asset: &PyLanguageGraphAsset) -> bool { + let graph = self.owner.as_ref(); + self.assoc.contains_asset(&graph, asset.id) + } + + fn get_opposite_fieldname(&self, fieldname: &str) -> PyResult { + self.assoc + .get_opposite_fieldname(fieldname) + .map_err(graph_error_to_py) + } + + fn to_dict<'py>(&self, py: Python<'py>) -> PyResult> { + let graph = self.owner.as_ref(); + let dict = self.assoc.to_dict(&graph); + pythonize::pythonize(py, &dict).map_err(|e| { + graph_error_to_py(maltoolbox_language::graph::GraphError::Malformed( + e.to_string(), + )) + }) + } + + fn __repr__(&self) -> String { + format!( + "LanguageGraphAssociation(name: \"{}\", left_field: {}, right_field: {})", + self.assoc.name, + self.left_field().__repr__(), + self.right_field().__repr__() + ) + } + + fn __hash__(&self) -> isize { + struct_hash(self.assoc.as_ref()) + } + + fn __richcmp__( + &self, + other: &PyLanguageGraphAssociation, + op: CompareOp, + py: Python<'_>, + ) -> PyResult> { + let eq = self.assoc == other.assoc; + match op { + CompareOp::Eq => eq.into_py_any(py), + CompareOp::Ne => (!eq).into_py_any(py), + _ => Ok(py.NotImplemented()), + } + } +} diff --git a/py-bindings/maltoolbox-language-py/src/attack_step.rs b/py-bindings/maltoolbox-language-py/src/attack_step.rs new file mode 100644 index 00000000..460e965d --- /dev/null +++ b/py-bindings/maltoolbox-language-py/src/attack_step.rs @@ -0,0 +1,411 @@ +//! Mirrors `maltoolbox/language/language_graph_attack_step.py`'s +//! `LanguageGraphAttackStep`. A handle (`owner` + `AttackStepId`), cached +//! per-owner (`caches.steps`) so repeated lookups for the same id return +//! the identical Python object; see `handle.rs`. + +use std::rc::Rc; + +use pyo3::basic::CompareOp; +use pyo3::exceptions::PyNotImplementedError; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyList}; +use pyo3::IntoPyObjectExt; + +use maltoolbox_language::graph::ids::AttackStepId; +use maltoolbox_language::graph::LanguageGraph; + +use crate::asset::PyLanguageGraphAsset; +use crate::detector::detector_to_py; +use crate::exceptions::graph_error_to_py; +use crate::expr_chain::expr_chain_to_py; +use crate::handle::{cached_handle, composite_hash, SharedLangGraphCaches}; +use crate::model_effect::{model_effect_to_py, PyLanguageGraphModelEffect}; + +#[pyclass(name = "LanguageGraphAttackStep", unsendable, skip_from_py_object)] +#[derive(Clone)] +pub struct PyLanguageGraphAttackStep { + pub owner: Rc, + pub id: AttackStepId, + pub caches: SharedLangGraphCaches, +} + +impl PyLanguageGraphAttackStep { + pub fn new( + owner: Rc, + id: AttackStepId, + caches: SharedLangGraphCaches, + ) -> Self { + PyLanguageGraphAttackStep { owner, id, caches } + } + + fn owner_ptr(&self) -> usize { + Rc::as_ptr(&self.owner) as usize + } + + fn step_handle( + &self, + py: Python<'_>, + id: AttackStepId, + ) -> PyResult> { + let owner = self.owner.clone(); + let caches = self.caches.clone(); + cached_handle(&self.caches.steps, py, id, move || { + PyLanguageGraphAttackStep::new(owner, id, caches) + }) + } + + fn asset_handle( + &self, + py: Python<'_>, + id: maltoolbox_language::graph::ids::AssetId, + ) -> PyResult> { + let owner = self.owner.clone(); + let caches = self.caches.clone(); + cached_handle(&self.caches.assets, py, id, move || { + PyLanguageGraphAsset::new(owner, id, caches) + }) + } + + /// Builds a `list[ExpressionsChain | None]` of real wrapper objects, + /// since callers access attributes like `.right_link.fieldname`/ + /// `.fieldname` directly on them rather than a pythonized dict form. + fn chains_to_pylist<'py>( + &self, + py: Python<'py>, + chains: &[Option], + ) -> PyResult> { + let mut items: Vec> = Vec::new(); + for chain in chains { + let item = match chain { + Some(c) => { + expr_chain_to_py(py, self.owner.clone(), self.caches.clone(), c)?.into_any() + } + None => py.None(), + }; + items.push(item); + } + PyList::new(py, items) + } + + fn step_map_to_pydict<'py>( + &self, + py: Python<'py>, + _graph: &LanguageGraph, + map: &indexmap::IndexMap< + AttackStepId, + Vec>, + >, + ) -> PyResult> { + let dict = PyDict::new(py); + for (step_id, chains) in map { + let handle = self.step_handle(py, *step_id)?; + let list = self.chains_to_pylist(py, chains)?; + dict.set_item(handle, list)?; + } + Ok(dict) + } +} + +#[pymethods] +impl PyLanguageGraphAttackStep { + #[getter] + fn name(&self) -> String { + self.owner.as_ref().step(self.id).name.clone() + } + + #[getter(r#type)] + fn step_type(&self) -> &'static str { + self.owner.as_ref().step(self.id).step_type.as_str() + } + + #[getter] + fn asset(&self, py: Python<'_>) -> PyResult> { + let asset_id = self.owner.as_ref().step(self.id).asset; + self.asset_handle(py, asset_id) + } + + #[getter] + fn causal_mode(&self) -> Option<&'static str> { + self.owner + .as_ref() + .step(self.id) + .causal_mode + .map(|m| m.as_str()) + } + + #[getter] + fn ttc<'py>(&self, py: Python<'py>) -> PyResult> { + let graph = self.owner.as_ref(); + match &graph.step(self.id).ttc { + Some(v) => pythonize::pythonize(py, v).map_err(|e| { + graph_error_to_py(maltoolbox_language::graph::GraphError::Malformed( + e.to_string(), + )) + }), + None => Ok(py.None().into_bound(py)), + } + } + + #[getter] + fn overrides(&self) -> bool { + self.owner.as_ref().step(self.id).overrides + } + + #[getter] + fn info<'py>(&self, py: Python<'py>) -> PyResult> { + let graph = self.owner.as_ref(); + let dict = PyDict::new(py); + for (k, v) in &graph.step(self.id).info { + dict.set_item(k, v)?; + } + Ok(dict) + } + + #[getter] + fn tags(&self) -> Vec { + self.owner.as_ref().step(self.id).tags.clone() + } + + #[getter] + fn inherits(&self, py: Python<'_>) -> PyResult>> { + let inherits_id = self.owner.as_ref().step(self.id).inherits; + inherits_id.map(|id| self.step_handle(py, id)).transpose() + } + + #[getter] + fn own_children<'py>(&self, py: Python<'py>) -> PyResult> { + let graph = self.owner.as_ref(); + self.step_map_to_pydict(py, &graph, &graph.step(self.id).own_children) + } + + #[getter] + fn own_parents<'py>(&self, py: Python<'py>) -> PyResult> { + let graph = self.owner.as_ref(); + self.step_map_to_pydict(py, &graph, &graph.step(self.id).own_parents) + } + + /// Own + inherited children. + #[getter] + fn children<'py>(&self, py: Python<'py>) -> PyResult> { + let graph = self.owner.as_ref(); + let all = graph.step(self.id).children(&graph); + self.step_map_to_pydict(py, &graph, &all) + } + + /// Matches the Python original exactly: fetching `.parents` always + /// raises - it was never implemented there either. + #[getter] + fn parents(&self) -> PyResult<()> { + Err(PyNotImplementedError::new_err( + "Fetching parents is not supported.", + )) + } + + #[getter] + fn own_requires<'py>(&self, py: Python<'py>) -> PyResult> { + let graph = self.owner.as_ref(); + let reqs = &graph.step(self.id).own_requires; + let wrapped: Vec> = + reqs.iter().cloned().map(Some).collect(); + self.chains_to_pylist(py, &wrapped) + } + + /// Own + inherited requirements. + #[getter] + fn requires<'py>(&self, py: Python<'py>) -> PyResult> { + let graph = self.owner.as_ref(); + let reqs = graph.step(self.id).requires(&graph); + let wrapped: Vec> = + reqs.into_iter().map(Some).collect(); + self.chains_to_pylist(py, &wrapped) + } + + /// `own_*` is the step's own (non-inherited) list of model effects; + /// the non-`own_` getters add inherited effects, mirroring the + /// core's own `additive_model_effects(&graph)`/ + /// `subtractive_model_effects(&graph)`. + #[getter] + fn own_additive_model_effects( + &self, + py: Python<'_>, + ) -> PyResult>> { + let graph = self.owner.as_ref(); + graph + .step(self.id) + .own_additive_model_effects + .iter() + .map(|e| model_effect_to_py(py, self.owner.clone(), self.caches.clone(), e)) + .collect() + } + + #[getter] + fn own_subtractive_model_effects( + &self, + py: Python<'_>, + ) -> PyResult>> { + let graph = self.owner.as_ref(); + graph + .step(self.id) + .own_subtractive_model_effects + .iter() + .map(|e| model_effect_to_py(py, self.owner.clone(), self.caches.clone(), e)) + .collect() + } + + #[getter] + fn additive_model_effects( + &self, + py: Python<'_>, + ) -> PyResult>> { + let graph = self.owner.as_ref(); + graph + .step(self.id) + .additive_model_effects(&graph) + .iter() + .map(|e| model_effect_to_py(py, self.owner.clone(), self.caches.clone(), e)) + .collect() + } + + #[getter] + fn subtractive_model_effects( + &self, + py: Python<'_>, + ) -> PyResult>> { + let graph = self.owner.as_ref(); + graph + .step(self.id) + .subtractive_model_effects(&graph) + .iter() + .map(|e| model_effect_to_py(py, self.owner.clone(), self.caches.clone(), e)) + .collect() + } + + #[getter] + fn full_name(&self) -> String { + let graph = self.owner.as_ref(); + graph.step(self.id).full_name(&graph) + } + + /// `{name: LanguageGraphDetector}`, matching the Python original's + /// `detectors: dict[str, LanguageGraphDetector]` field shape. + #[getter] + fn detectors<'py>(&self, py: Python<'py>) -> PyResult> { + let graph = self.owner.as_ref(); + let dict = PyDict::new(py); + for (name, det) in &graph.step(self.id).detectors { + dict.set_item( + name, + detector_to_py(py, self.owner.clone(), self.caches.clone(), det)?, + )?; + } + Ok(dict) + } + + fn to_dict<'py>(&self, py: Python<'py>) -> PyResult> { + let graph = self.owner.as_ref(); + let dict = graph + .step(self.id) + .to_dict(&graph) + .map_err(graph_error_to_py)?; + pythonize::pythonize(py, &dict).map_err(|e| { + graph_error_to_py(maltoolbox_language::graph::GraphError::Malformed( + e.to_string(), + )) + }) + } + + pub fn __repr__(&self) -> String { + format!( + "LanguageGraphAttackStep(name: \"{}\", type: \"{}\")", + self.name(), + self.step_type() + ) + } + + fn __hash__(&self) -> isize { + composite_hash(self.owner_ptr(), self.id) + } + + fn __richcmp__( + &self, + other: &PyLanguageGraphAttackStep, + op: CompareOp, + py: Python<'_>, + ) -> PyResult> { + let eq = self.owner_ptr() == other.owner_ptr() && self.id == other.id; + match op { + CompareOp::Eq => eq.into_py_any(py), + CompareOp::Ne => (!eq).into_py_any(py), + _ => Ok(py.NotImplemented()), + } + } + + /// Pickles as `(owner, asset_name, step_name)`, where `owner` is a + /// fresh temporary `PyLanguageGraph` (see + /// `PyLanguageGraphAsset::__reduce__`). Unlike assets, attack steps + /// are resolved by name rather than by raw slotmap ffi id: step + /// insertion order (and hence ffi id) differs between + /// `generate_graph`'s two-pass construction (own steps for all + /// assets, then synthesized inherited copies) and + /// `language_graph_from_dict`'s one-shot-per-asset construction, even + /// though both produce the same logical graph. Resolving by name + /// sidesteps that discrepancy. + #[allow(clippy::type_complexity)] + fn __reduce__( + &self, + py: Python<'_>, + ) -> PyResult<( + Py, + (Py, String, String), + )> { + let temp_owner = Py::new( + py, + crate::language_graph::PyLanguageGraph { + inner: self.owner.clone(), + caches: self.caches.clone(), + }, + )?; + let graph = self.owner.as_ref(); + let step = graph.step(self.id); + let asset_name = graph.asset(step.asset).name.clone(); + let step_name = step.name.clone(); + let func = py + .import("maltoolbox._native")? + .getattr("_rebuild_language_graph_attack_step")? + .unbind(); + Ok((func, (temp_owner, asset_name, step_name))) + } +} + +/// Rebuilds a `PyLanguageGraphAttackStep` handle from a pickled +/// `(owner, asset_name, step_name)` triple - the `__reduce__` target; see +/// `PyLanguageGraphAttackStep::__reduce__` for why this resolves by name +/// instead of by raw ffi id. +#[pyfunction] +pub fn _rebuild_language_graph_attack_step( + py: Python<'_>, + owner: Py, + asset_name: String, + step_name: String, +) -> PyResult> { + let graph = owner.borrow(py); + let id = { + let inner = graph.inner.as_ref(); + let asset_id = inner.asset_id(&asset_name).ok_or_else(|| { + graph_error_to_py(maltoolbox_language::graph::GraphError::Lookup(format!( + "Unknown asset type \"{asset_name}\" while unpickling attack step" + ))) + })?; + inner + .asset(asset_id) + .attack_steps + .get(&step_name) + .copied() + .ok_or_else(|| { + graph_error_to_py(maltoolbox_language::graph::GraphError::Lookup(format!( + "Unknown attack step \"{step_name}\" on asset \"{asset_name}\" while unpickling" + ))) + })? + }; + graph.step_handle(py, id) +} diff --git a/py-bindings/maltoolbox-language-py/src/detector.rs b/py-bindings/maltoolbox-language-py/src/detector.rs new file mode 100644 index 00000000..18367f40 --- /dev/null +++ b/py-bindings/maltoolbox-language-py/src/detector.rs @@ -0,0 +1,175 @@ +//! Mirrors `maltoolbox/language/language_graph_detector.py`'s +//! `LanguageGraphDetector`/`LanguageGraphContextItem`, wrapping the Rust +//! core's `maltoolbox_language::graph::detector` types exposed on +//! `LanguageGraphAttackStep.detectors`. +//! +//! Like `model_effect.rs`/`expr_chain.rs`, these are plain, +//! freely-constructible value snapshots rather than owner+id handles: +//! no identity caching, `__richcmp__`, or `__hash__`. + +use std::rc::Rc; + +use pyo3::prelude::*; +use pyo3::types::PyDict; + +use maltoolbox_language::graph::detector::{LanguageGraphContextItem, LanguageGraphDetector}; +use maltoolbox_language::graph::LanguageGraph; + +use crate::asset::PyLanguageGraphAsset; +use crate::expr_chain::{expr_chain_to_py, PyExpressionsChain}; +use crate::handle::{cached_handle, SharedLangGraphCaches}; + +#[derive(Clone)] +struct Ctx { + owner: Rc, + caches: SharedLangGraphCaches, +} + +#[pyclass( + name = "LanguageGraphContextItem", + module = "maltoolbox._native", + unsendable +)] +pub struct PyLanguageGraphContextItem { + label: String, + asset_type: maltoolbox_language::graph::ids::AssetId, + attack_step_name: Option, + expr: Option, + ctx: Ctx, +} + +#[pymethods] +impl PyLanguageGraphContextItem { + #[getter] + fn label(&self) -> String { + self.label.clone() + } + + /// The real `LanguageGraphAsset` object (not just its name), per the + /// pure-Python original's dataclass shape. + #[getter] + fn asset_type(&self, py: Python<'_>) -> PyResult> { + let owner = self.ctx.owner.clone(); + let caches = self.ctx.caches.clone(); + let id = self.asset_type; + cached_handle(&self.ctx.caches.assets, py, id, move || { + PyLanguageGraphAsset::new(owner, id, caches) + }) + } + + #[getter] + fn attack_step_name(&self) -> Option { + self.attack_step_name.clone() + } + + #[getter] + fn expr(&self, py: Python<'_>) -> PyResult>> { + self.expr + .as_ref() + .map(|e| expr_chain_to_py(py, self.ctx.owner.clone(), self.ctx.caches.clone(), e)) + .transpose() + } + + fn __repr__(&self) -> String { + format!( + "LanguageGraphContextItem(label: {:?}, attack_step_name: {:?})", + self.label, self.attack_step_name + ) + } +} + +#[pyclass( + name = "LanguageGraphDetector", + module = "maltoolbox._native", + unsendable +)] +pub struct PyLanguageGraphDetector { + name: Option, + context: Vec<(String, LanguageGraphContextItem)>, + detector_type: Option, + tprate: Option, + fprate: Option, + ctx: Ctx, +} + +#[pymethods] +impl PyLanguageGraphDetector { + #[getter] + fn name(&self) -> Option { + self.name.clone() + } + + #[getter] + fn context<'py>(&self, py: Python<'py>) -> PyResult> { + let dict = PyDict::new(py); + for (label, item) in &self.context { + dict.set_item( + label, + Py::new( + py, + PyLanguageGraphContextItem { + label: item.label.clone(), + asset_type: item.asset_type, + attack_step_name: item.attack_step_name.clone(), + expr: item.expr.clone(), + ctx: self.ctx.clone(), + }, + )?, + )?; + } + Ok(dict) + } + + /// Python attribute name is `type` (matches the original dataclass + /// field) - `#[getter(r#type)]` avoids colliding with the Rust + /// keyword, same idiom as `AttackGraphNode.type`/ + /// `LanguageGraphAttackStep.type` elsewhere in this layer. + #[getter(r#type)] + fn detector_type(&self) -> Option { + self.detector_type.clone() + } + + #[getter] + fn tprate(&self) -> Option { + self.tprate + } + + #[getter] + fn fprate(&self) -> Option { + self.fprate + } + + fn __repr__(&self) -> String { + format!( + "LanguageGraphDetector(name: {:?}, type: {:?}, tprate: {:?}, fprate: {:?})", + self.name, self.detector_type, self.tprate, self.fprate + ) + } +} + +/// Converts a core `LanguageGraphDetector` into its Python wrapper. +/// `owner`/`caches` identify the `LanguageGraph` any `asset_type`/`expr` +/// should resolve its handles against. +pub fn detector_to_py( + py: Python<'_>, + owner: Rc, + caches: SharedLangGraphCaches, + detector: &LanguageGraphDetector, +) -> PyResult> { + let ctx = Ctx { owner, caches }; + Py::new( + py, + PyLanguageGraphDetector { + name: detector.name.clone(), + context: detector + .context + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + detector_type: detector.detector_type.clone(), + tprate: detector.tprate, + fprate: detector.fprate, + ctx, + }, + ) +} diff --git a/py-bindings/maltoolbox-language-py/src/exceptions.rs b/py-bindings/maltoolbox-language-py/src/exceptions.rs new file mode 100644 index 00000000..bbc5c525 --- /dev/null +++ b/py-bindings/maltoolbox-language-py/src/exceptions.rs @@ -0,0 +1,182 @@ +//! Exception hierarchy mirroring `maltoolbox/exceptions.py` (the +//! `LanguageGraphException` branch) plus two separate compiler +//! hierarchies that are NOT under `MalToolboxException`: +//! `maltoolbox.language.compiler.exceptions.MalCompilerError` and +//! `maltoolbox.language.compiler.mal_analyzer.malAnalyzerException`, +//! matching the Python originals which subclass `Exception` directly. + +use pyo3::create_exception; +use pyo3::exceptions::{PyException, PyOSError}; +use pyo3::prelude::*; + +use maltoolbox_language::compiler::CompileError; +use maltoolbox_language::graph::file::LoadError; +use maltoolbox_language::graph::GraphError; + +// The module string given here becomes `__module__` (what `pickle`/ +// `repr` use), set to where these live in `maltoolbox/exceptions.py`, +// not this crate's own `_native` module. +create_exception!(maltoolbox.exceptions, MalToolboxException, PyException); +create_exception!( + maltoolbox.exceptions, + LanguageGraphException, + MalToolboxException +); +create_exception!( + maltoolbox.exceptions, + LanguageGraphSuperAssetNotFoundError, + LanguageGraphException +); +create_exception!( + maltoolbox.exceptions, + LanguageGraphAssociationError, + LanguageGraphException +); +create_exception!( + maltoolbox.exceptions, + LanguageGraphStepExpressionError, + LanguageGraphException +); + +// `maltoolbox.language.compiler.exceptions` - a separate hierarchy, not +// under `MalToolboxException`. `__module__` is set to +// `maltoolbox.language.compiler.exceptions` (not this crate's own +// `_native.language.compiler.exceptions` nesting) so pickling/repr +// resolve to the same path as the Python originals. +create_exception!( + maltoolbox.language.compiler.exceptions, + MalCompilerError, + PyException +); +create_exception!( + maltoolbox.language.compiler.exceptions, + MalSyntaxError, + MalCompilerError +); +create_exception!( + maltoolbox.language.compiler.exceptions, + MalParseError, + MalCompilerError +); +create_exception!( + maltoolbox.language.compiler.exceptions, + MalTypeError, + MalCompilerError +); +create_exception!( + maltoolbox.language.compiler.exceptions, + MalNameError, + MalCompilerError +); +create_exception!( + maltoolbox.language.compiler.exceptions, + MalCompilationError, + MalCompilerError +); + +// `maltoolbox.language.compiler.mal_analyzer.malAnalyzerException` - yet +// another separate hierarchy. The unconventional lowercase-`m` name is +// preserved as-is: `assoc_traversal_processor.py` imports and raises it +// by this exact name. +#[allow(non_camel_case_types)] +mod mal_analyzer_exception { + use super::{create_exception, PyException}; + create_exception!( + maltoolbox.language.compiler.mal_analyzer, + malAnalyzerException, + PyException + ); +} +pub use mal_analyzer_exception::malAnalyzerException; + +pub fn graph_error_to_py(err: GraphError) -> PyErr { + let msg = err.to_string(); + match err { + GraphError::SuperAssetNotFound { .. } => LanguageGraphSuperAssetNotFoundError::new_err(msg), + GraphError::AssociationAssetNotFound { .. } => LanguageGraphAssociationError::new_err(msg), + GraphError::StepExpression(_) => LanguageGraphStepExpressionError::new_err(msg), + GraphError::Malformed(_) | GraphError::Lookup(_) => LanguageGraphException::new_err(msg), + } +} + +/// Maps a `CompileError` to the corresponding Python exception. This is a +/// many-to-fewer mapping: the Rust compiler doesn't distinguish +/// parse/type/name sub-cases as finely as Python's `MalCompilerError` +/// hierarchy does, so `Malformed` collapses to the base +/// `MalCompilerError` rather than picking one of +/// `MalParseError`/`MalTypeError`/`MalNameError` arbitrarily. +pub fn compile_error_to_py(err: CompileError) -> PyErr { + let msg = err.to_string(); + match err { + CompileError::Io { .. } => PyOSError::new_err(msg), + CompileError::Syntax { line, column, .. } => { + // MalSyntaxError's Python `__init__(self, message, line=None, + // column=None)` takes extra positional args beyond the + // message - pass them through the same way. + MalSyntaxError::new_err((msg, line, column)) + } + CompileError::Malformed(_) => MalCompilerError::new_err(msg), + CompileError::Semantic(_) => malAnalyzerException::new_err(msg), + } +} + +/// `LoadError` wraps a `CompileError`, a `GraphError`, or file-I/O +/// failures that occur during `LanguageGraph::load_from_file` and +/// friends (not just plain compilation). +pub fn load_error_to_py(err: LoadError) -> PyErr { + match err { + LoadError::Compile(e) => compile_error_to_py(e), + LoadError::Graph(e) => graph_error_to_py(e), + LoadError::FileUtil(e) => PyOSError::new_err(e.to_string()), + LoadError::Archive(path, reason) => { + PyOSError::new_err(format!("failed to read mar archive '{path}': {reason}")) + } + LoadError::UnknownExtension => pyo3::exceptions::PyTypeError::new_err( + "Unknown file extension, expected json/mal/mar/yml/yaml", + ), + } +} + +/// Registers the `_native` module's top-level exceptions: +/// `MalToolboxException` and the `LanguageGraphException` branch. The +/// compiler-error submodules are registered separately by +/// `register_compiler_exceptions`/`register_analyzer_exceptions` below; +/// see `lib.rs::register` for how the caller nests them. +pub fn register(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("MalToolboxException", py.get_type::())?; + m.add( + "LanguageGraphException", + py.get_type::(), + )?; + m.add( + "LanguageGraphSuperAssetNotFoundError", + py.get_type::(), + )?; + m.add( + "LanguageGraphAssociationError", + py.get_type::(), + )?; + m.add( + "LanguageGraphStepExpressionError", + py.get_type::(), + )?; + Ok(()) +} + +pub fn register_compiler_exceptions(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("MalCompilerError", py.get_type::())?; + m.add("MalSyntaxError", py.get_type::())?; + m.add("MalParseError", py.get_type::())?; + m.add("MalTypeError", py.get_type::())?; + m.add("MalNameError", py.get_type::())?; + m.add("MalCompilationError", py.get_type::())?; + Ok(()) +} + +pub fn register_analyzer_exceptions(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add( + "malAnalyzerException", + py.get_type::(), + )?; + Ok(()) +} diff --git a/py-bindings/maltoolbox-language-py/src/expr_chain.rs b/py-bindings/maltoolbox-language-py/src/expr_chain.rs new file mode 100644 index 00000000..a215880a --- /dev/null +++ b/py-bindings/maltoolbox-language-py/src/expr_chain.rs @@ -0,0 +1,154 @@ +//! Mirrors `maltoolbox/language/expression_chain.py`'s `ExpressionsChain`. +//! The core represents this as an enum (one variant per shape - see +//! `maltoolbox_language::graph::expr_chain`'s module doc), but the Python +//! original is one flat dataclass with many `Option` fields; this wrapper +//! exposes that same flat shape +//! (`type`/`left_link`/`right_link`/`sub_link`/`fieldname`/`association`/ +//! `subtype`/`multiplicity`), reading `None` for whichever fields don't +//! apply to the wrapped variant. +//! +//! No identity caching, `__richcmp__`, or `__hash__` - this is a plain, +//! freely-constructible value snapshot. `association`/`subtype` resolve +//! through the owning graph's real handles/caches for consistency with +//! every other path to the same asset/association. + +use std::rc::Rc; + +use pyo3::prelude::*; + +use maltoolbox_language::graph::expr_chain::ExpressionsChain; +use maltoolbox_language::graph::LanguageGraph; + +use crate::asset::PyLanguageGraphAsset; +use crate::assoc::PyLanguageGraphAssociation; +use crate::handle::{cached_handle, SharedLangGraphCaches}; + +#[derive(Clone)] +struct Ctx { + owner: Rc, + caches: SharedLangGraphCaches, +} + +#[pyclass(name = "ExpressionsChain", module = "maltoolbox._native", unsendable)] +pub struct PyExpressionsChain { + chain: ExpressionsChain, + ctx: Ctx, +} + +#[pymethods] +impl PyExpressionsChain { + #[getter(r#type)] + fn expr_type(&self) -> &'static str { + self.chain.expr_type().as_str() + } + + #[getter] + fn left_link(&self, py: Python<'_>) -> PyResult>> { + match &self.chain { + ExpressionsChain::Binary { left, .. } => left + .as_deref() + .map(|c| chain_to_py(py, self.ctx.clone(), c.clone())) + .transpose(), + _ => Ok(None), + } + } + + #[getter] + fn right_link(&self, py: Python<'_>) -> PyResult>> { + match &self.chain { + ExpressionsChain::Binary { right, .. } => right + .as_deref() + .map(|c| chain_to_py(py, self.ctx.clone(), c.clone())) + .transpose(), + _ => Ok(None), + } + } + + #[getter] + fn sub_link(&self, py: Python<'_>) -> PyResult>> { + match &self.chain { + ExpressionsChain::Transitive { sub } + | ExpressionsChain::SubType { sub, .. } + | ExpressionsChain::AssocOp { sub } + | ExpressionsChain::Multiplicity { sub, .. } => { + Ok(Some(chain_to_py(py, self.ctx.clone(), (**sub).clone())?)) + } + _ => Ok(None), + } + } + + #[getter] + fn fieldname(&self) -> Option { + match &self.chain { + ExpressionsChain::Field { fieldname, .. } => Some(fieldname.clone()), + _ => None, + } + } + + #[getter] + fn association(&self, py: Python<'_>) -> PyResult>> { + match &self.chain { + ExpressionsChain::Field { association, .. } => Ok(Some(Py::new( + py, + PyLanguageGraphAssociation::new( + self.ctx.owner.clone(), + association.clone(), + self.ctx.caches.clone(), + ), + )?)), + _ => Ok(None), + } + } + + #[getter] + fn subtype(&self, py: Python<'_>) -> PyResult>> { + match &self.chain { + ExpressionsChain::SubType { subtype, .. } => { + let owner = self.ctx.owner.clone(); + let caches = self.ctx.caches.clone(); + Ok(Some(cached_handle( + &self.ctx.caches.assets, + py, + *subtype, + move || PyLanguageGraphAsset::new(owner, *subtype, caches), + )?)) + } + _ => Ok(None), + } + } + + #[getter] + fn multiplicity<'py>(&self, py: Python<'py>) -> PyResult>> { + match &self.chain { + ExpressionsChain::Multiplicity { multiplicity, .. } => Ok(Some( + pythonize::pythonize(py, multiplicity) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?, + )), + _ => Ok(None), + } + } + + fn __repr__(&self) -> String { + format!("ExpressionsChain(type: \"{}\")", self.expr_type()) + } +} + +fn chain_to_py( + py: Python<'_>, + ctx: Ctx, + chain: ExpressionsChain, +) -> PyResult> { + Py::new(py, PyExpressionsChain { chain, ctx }) +} + +/// Converts a core `ExpressionsChain` into its Python wrapper. `owner`/ +/// `caches` identify the `LanguageGraph` that `association`/`subtype` +/// resolve their handles against. +pub fn expr_chain_to_py( + py: Python<'_>, + owner: Rc, + caches: SharedLangGraphCaches, + chain: &ExpressionsChain, +) -> PyResult> { + chain_to_py(py, Ctx { owner, caches }, chain.clone()) +} diff --git a/py-bindings/maltoolbox-language-py/src/handle.rs b/py-bindings/maltoolbox-language-py/src/handle.rs new file mode 100644 index 00000000..ff86e2d4 --- /dev/null +++ b/py-bindings/maltoolbox-language-py/src/handle.rs @@ -0,0 +1,91 @@ +//! Shared helper for the owner+id "handle" pattern used by +//! `PyLanguageGraphAsset`/`PyLanguageGraphAttackStep`: each handle borrows +//! its owner transiently and looks itself up by id rather than holding a +//! copy. `__hash__`/`__richcmp__` are keyed on `(owner pointer identity, +//! id)` rather than CPython's default object-identity hash/eq, so two +//! separately-built handles for the same logical item compare equal and +//! hash the same, which `dict`/`set` keying requires. +//! +//! On top of that, `HandleCache`/`new_handle_cache`/`cached_handle` add a +//! per-owner cache (one `HashMap>` per container instance) +//! so repeated lookups for the same id on the same owner return the +//! identical Python object (`id(a) == id(b)`), which Python code that +//! relies on object identity (not just `==`) needs. Two different owner +//! instances never share identity, even if structurally identical, since +//! each owns a separate cache. This is additive to `composite_hash`/ +//! `__richcmp__`, not a replacement: those still matter on a cache miss +//! and for cross-owner comparisons, which the cache deliberately doesn't +//! unify. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::rc::Rc; + +use pyo3::prelude::*; +use pyo3::PyClass; + +use maltoolbox_language::graph::ids::{AssetId, AttackStepId}; + +use crate::asset::PyLanguageGraphAsset; +use crate::attack_step::PyLanguageGraphAttackStep; + +/// Computes a stable `isize` hash for a `(owner ptr, id)` pair, suitable +/// for returning directly from `__hash__`. Truncating the `u64` to +/// `isize` is fine: Python only requires equal objects to hash equally +/// within a process, not within a specific range. +pub fn composite_hash(owner_ptr: usize, id: T) -> isize { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + owner_ptr.hash(&mut hasher); + id.hash(&mut hasher); + hasher.finish() as isize +} + +/// Per-owner cache of previously-built handles, keyed by stable id. +pub type HandleCache = Rc>>>; + +pub fn new_handle_cache() -> HandleCache { + Rc::new(RefCell::new(HashMap::new())) +} + +/// Looks up `key` in `cache`. On a hit, returns the same `Py` via +/// `clone_ref` (bumps the refcount, doesn't construct a new Python +/// object). On a miss, builds a fresh `V` via `build`, wraps it in a new +/// `Py`, inserts it into the cache, and returns it. +pub fn cached_handle( + cache: &HandleCache, + py: Python<'_>, + key: K, + build: impl FnOnce() -> V, +) -> PyResult> +where + K: Eq + Hash, + V: PyClass + Into>, +{ + if let Some(existing) = cache.borrow().get(&key) { + return Ok(existing.clone_ref(py)); + } + let py_obj = Py::new(py, build())?; + cache.borrow_mut().insert(key, py_obj.clone_ref(py)); + Ok(py_obj) +} + +/// Bundles every handle-type cache a `LanguageGraph` needs into one +/// `Rc`, so `PyLanguageGraph`, its handles, and cross-crate consumers +/// (e.g. `PyModelAsset`, `PyAttackGraphNode`) can all share the same +/// caches via one field and a cheap `Rc` clone. +pub struct LangGraphCaches { + pub assets: HandleCache, + pub steps: HandleCache, +} + +impl LangGraphCaches { + pub fn new() -> Rc { + Rc::new(LangGraphCaches { + assets: new_handle_cache(), + steps: new_handle_cache(), + }) + } +} + +pub type SharedLangGraphCaches = Rc; diff --git a/py-bindings/maltoolbox-language-py/src/language_graph.rs b/py-bindings/maltoolbox-language-py/src/language_graph.rs new file mode 100644 index 00000000..910fe954 --- /dev/null +++ b/py-bindings/maltoolbox-language-py/src/language_graph.rs @@ -0,0 +1,278 @@ +//! Mirrors `maltoolbox/language/languagegraph.py`'s `LanguageGraph`. +//! The one "container" type in this crate - see +//! PYTHON_BINDINGS_IMPLEMENTATION.md's "Container / handle pattern" section. + +use std::path::PathBuf; +use std::rc::Rc; + +use pyo3::prelude::*; +use pyo3::types::{PyDict, PySet, PyTuple}; + +use maltoolbox_language::graph::file as lang_file; +use maltoolbox_language::graph::file::language_graph_from_dict; +use maltoolbox_language::graph::ids::{AssetId, AttackStepId}; +use maltoolbox_language::graph::{ + generate_graph as build_graph_from_langspec, language_graph_to_dict, LanguageGraph, +}; + +use crate::asset::PyLanguageGraphAsset; +use crate::assoc::PyLanguageGraphAssociation; +use crate::attack_step::PyLanguageGraphAttackStep; +use crate::exceptions::{graph_error_to_py, load_error_to_py}; +use crate::handle::{cached_handle, LangGraphCaches, SharedLangGraphCaches}; + +/// Mirrors `maltoolbox.language.languagegraph.LanguageGraph`. +/// +/// `unsendable`: holds an `Rc`, not an `Arc` - the GIL already serializes +/// access from Python. The graph is immutable after construction, so it +/// needs no `RefCell` and is shared with `Model` directly. +#[pyclass(name = "LanguageGraph", module = "maltoolbox._native", unsendable)] +pub struct PyLanguageGraph { + pub inner: Rc, + /// Per-owner asset/attack-step handle caches, shared (same `Rc`) with + /// every `PyLanguageGraphAsset`/`PyLanguageGraphAttackStep`/ + /// `PyLanguageGraphAssociation`(`Field`) this graph hands out, so + /// repeated lookups for the same id return the identical Python + /// object. + pub caches: SharedLangGraphCaches, +} + +impl PyLanguageGraph { + pub fn wrap(graph: LanguageGraph) -> Self { + PyLanguageGraph { + inner: Rc::new(graph), + caches: LangGraphCaches::new(), + } + } + + pub fn asset_handle(&self, py: Python<'_>, id: AssetId) -> PyResult> { + let owner = self.inner.clone(); + let caches = self.caches.clone(); + cached_handle(&self.caches.assets, py, id, move || { + PyLanguageGraphAsset::new(owner, id, caches) + }) + } + + pub fn step_handle( + &self, + py: Python<'_>, + id: AttackStepId, + ) -> PyResult> { + let owner = self.inner.clone(); + let caches = self.caches.clone(); + cached_handle(&self.caches.steps, py, id, move || { + PyLanguageGraphAttackStep::new(owner, id, caches) + }) + } +} + +#[pymethods] +impl PyLanguageGraph { + /// Matches the Python original's `LanguageGraph(lang_spec: dict)` + /// constructor - builds a graph directly from an already-compiled + /// langspec dict (the shape `MalCompiler().compile(...)` produces, + /// same as what's inside a `.mar` archive's `langspec.json`). + /// + /// Deliberately **not** `Option<...>` defaulting to `None` the way + /// Python's `lang_spec: dict | None = None` is: that default builds + /// an empty, freeform-mutable graph, a construction mode this crate + /// doesn't support (see PYTHON_BINDINGS_IMPLEMENTATION.md). + #[new] + fn new(lang_spec: &Bound<'_, PyAny>) -> PyResult { + let value: serde_json::Value = pythonize::depythonize(lang_spec) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let graph = build_graph_from_langspec(value).map_err(graph_error_to_py)?; + Ok(Self::wrap(graph)) + } + + /// `PathBuf`, not `&str`, for every path parameter in this impl block + /// - pyo3 extracts `PathBuf` from both a plain `str` and any + /// `os.PathLike` (e.g. `pathlib.Path`), matching the Python + /// original's file APIs, which accept both. + #[staticmethod] + fn load_from_file(path: PathBuf) -> PyResult { + let graph = lang_file::load_from_file(path).map_err(load_error_to_py)?; + Ok(Self::wrap(graph)) + } + + #[staticmethod] + fn from_mal_spec(path: PathBuf) -> PyResult { + let graph = lang_file::from_mal_spec(path).map_err(load_error_to_py)?; + Ok(Self::wrap(graph)) + } + + #[staticmethod] + fn from_mar_archive(path: PathBuf) -> PyResult { + let graph = lang_file::from_mar_archive(path).map_err(load_error_to_py)?; + Ok(Self::wrap(graph)) + } + + fn save_to_file(&self, path: PathBuf) -> PyResult<()> { + let graph = self.inner.as_ref(); + lang_file::save_to_file(&graph, path).map_err(load_error_to_py) + } + + fn to_mar_archive(&self, path: PathBuf) -> PyResult<()> { + let graph = self.inner.as_ref(); + lang_file::to_mar_archive(&graph, path).map_err(load_error_to_py) + } + + /// Always writes JSON regardless of `filename`'s extension, matching + /// the Python original (`json.dump(self.lang_spec, file, indent=4)` + /// unconditionally). + fn save_language_specification_to_json(&self, filename: PathBuf) -> PyResult<()> { + let graph = self.inner.as_ref(); + let json = serde_json::to_string_pretty(&graph.lang_spec).map_err(|e| { + graph_error_to_py(maltoolbox_language::graph::GraphError::Malformed( + e.to_string(), + )) + })?; + std::fs::write(filename, json) + .map_err(|e| pyo3::exceptions::PyOSError::new_err(e.to_string())) + } + + /// Deprecated no-op kept so callers of the old API don't break. + fn regenerate_graph(&self, py: Python<'_>) -> PyResult<()> { + PyErr::warn( + py, + &py.get_type::(), + c"LanguageGraph.regenerate_graph() is deprecated and does nothing; it will be removed in a future release.", + 1, + ) + } + + #[getter] + fn metadata<'py>(&self, py: Python<'py>) -> PyResult> { + let graph = self.inner.as_ref(); + let dict = PyDict::new(py); + dict.set_item("version", &graph.metadata.version)?; + dict.set_item("id", &graph.metadata.id)?; + Ok(dict) + } + + #[getter] + fn lang_spec<'py>(&self, py: Python<'py>) -> PyResult> { + let graph = self.inner.as_ref(); + pythonize::pythonize(py, &graph.lang_spec).map_err(|e| { + graph_error_to_py(maltoolbox_language::graph::GraphError::Malformed( + e.to_string(), + )) + }) + } + + #[getter] + fn assets<'py>(&self, py: Python<'py>) -> PyResult> { + let names: Vec<(String, AssetId)> = { + let graph = self.inner.as_ref(); + graph + .asset_order + .iter() + .map(|&id| (graph.asset(id).name.clone(), id)) + .collect() + }; + let dict = PyDict::new(py); + for (name, id) in names { + dict.set_item(name, self.asset_handle(py, id)?)?; + } + Ok(dict) + } + + /// All associations in the language graph, as a Python `set`. + #[getter] + fn associations<'py>(&self, py: Python<'py>) -> PyResult> { + let graph = self.inner.as_ref(); + let set = PySet::empty(py)?; + for &asset_id in &graph.asset_order { + for assoc in graph.asset(asset_id).own_associations.values() { + set.add(PyLanguageGraphAssociation::new( + self.inner.clone(), + assoc.clone(), + self.caches.clone(), + ))?; + } + } + Ok(set) + } + + /// All attack steps in the language graph, as a Python `set`. + #[getter] + fn attack_steps<'py>(&self, py: Python<'py>) -> PyResult> { + let step_ids: Vec = { + let graph = self.inner.as_ref(); + graph + .asset_order + .iter() + .flat_map(|&asset_id| { + graph + .asset(asset_id) + .attack_steps + .values() + .copied() + .collect::>() + }) + .collect() + }; + let set = PySet::empty(py)?; + for step_id in step_ids { + set.add(self.step_handle(py, step_id)?)?; + } + Ok(set) + } + + /// Maps each association fieldname to the `(asset_type, + /// attack_step_name)` pairs whose children expression chains can + /// traverse that field. Computed fresh each call, not cached - see + /// `LanguageGraph::fieldname_to_candidate_steps` in the core crate. + #[getter] + fn fieldname_to_candidate_steps<'py>(&self, py: Python<'py>) -> PyResult> { + let graph = self.inner.as_ref(); + let dict = PyDict::new(py); + for (fieldname, pairs) in graph.fieldname_to_candidate_steps() { + let set = PySet::empty(py)?; + for (asset_type, step_name) in pairs { + set.add(PyTuple::new(py, [asset_type, step_name])?)?; + } + dict.set_item(fieldname, set)?; + } + Ok(dict) + } + + fn _to_dict<'py>(&self, py: Python<'py>) -> PyResult> { + let graph = self.inner.as_ref(); + let dict = language_graph_to_dict(&graph).map_err(graph_error_to_py)?; + pythonize::pythonize(py, &dict).map_err(|e| { + graph_error_to_py(maltoolbox_language::graph::GraphError::Malformed( + e.to_string(), + )) + }) + } + + fn __repr__(&self) -> String { + let graph = self.inner.as_ref(); + format!( + "LanguageGraph(id: \"{}\", version: \"{}\")", + graph.metadata.id, graph.metadata.version + ) + } + + /// Pickling target for `__reduce__` - a plain function, since + /// `PyLanguageGraph` has no `#[new]` that takes a dict directly. + #[staticmethod] + fn _from_pickle_state(py: Python<'_>, state: &Bound<'_, PyAny>) -> PyResult { + let value: serde_json::Value = pythonize::depythonize(state) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let _ = py; + let graph = language_graph_from_dict(&value).map_err(graph_error_to_py)?; + Ok(Self::wrap(graph)) + } + + fn __reduce__<'py>( + &self, + py: Python<'py>, + ) -> PyResult<(Bound<'py, PyAny>, (Bound<'py, PyAny>,))> { + let cls = py.get_type::(); + let func = cls.getattr("_from_pickle_state")?; + let state = self._to_dict(py)?; + Ok((func, (state,))) + } +} diff --git a/py-bindings/maltoolbox-language-py/src/lib.rs b/py-bindings/maltoolbox-language-py/src/lib.rs new file mode 100644 index 00000000..bc4953aa --- /dev/null +++ b/py-bindings/maltoolbox-language-py/src/lib.rs @@ -0,0 +1,86 @@ +//! PyO3 compatibility layer mirroring `maltoolbox/language/`'s public +//! surface (class names, method names/signatures, exception hierarchy) +//! on top of the pure-Rust `maltoolbox-language` crate. +//! +//! This is a faithfulness layer, not a natural Rust API; see +//! `PYTHON_BINDINGS_IMPLEMENTATION.md` at the repo root for the overall +//! plan and rationale. + +mod asset; +mod assoc; +mod attack_step; +mod detector; +pub mod exceptions; +pub mod expr_chain; +pub mod handle; +mod language_graph; +pub mod model_effect; + +use pyo3::prelude::*; + +pub use asset::PyLanguageGraphAsset; +pub use assoc::{PyLanguageGraphAssociation, PyLanguageGraphAssociationField}; +pub use attack_step::PyLanguageGraphAttackStep; +pub use detector::{detector_to_py, PyLanguageGraphContextItem, PyLanguageGraphDetector}; +pub use expr_chain::{expr_chain_to_py, PyExpressionsChain}; +pub use language_graph::PyLanguageGraph; +pub use model_effect::{ + model_effect_to_py, PyAssocSet, PyAssocTraversal, PyDynTarget, PyGlobAssocTraversal, + PyLanguageGraphModelEffect, +}; + +/// Registers this layer's classes/exceptions onto the umbrella `_native` +/// module, including the nested `language.compiler.exceptions`/ +/// `language.compiler.mal_analyzer` submodules so they're importable at +/// the same paths as the Python originals (e.g. +/// `maltoolbox.language.compiler.exceptions.MalCompilerError`). Called +/// from `maltoolbox-pyo3`'s `#[pymodule]`. +pub fn register(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(asset::_rebuild_language_graph_asset, m)?)?; + m.add_function(wrap_pyfunction!( + attack_step::_rebuild_language_graph_attack_step, + m + )?)?; + exceptions::register(py, m)?; + + let sys_modules = py.import("sys")?.getattr("modules")?; + + let language_mod = PyModule::new(py, "language")?; + m.add_submodule(&language_mod)?; + sys_modules.set_item("maltoolbox._native.language", &language_mod)?; + + let compiler_mod = PyModule::new(py, "compiler")?; + language_mod.add_submodule(&compiler_mod)?; + sys_modules.set_item("maltoolbox._native.language.compiler", &compiler_mod)?; + + let compiler_exceptions_mod = PyModule::new(py, "exceptions")?; + exceptions::register_compiler_exceptions(py, &compiler_exceptions_mod)?; + compiler_mod.add_submodule(&compiler_exceptions_mod)?; + sys_modules.set_item( + "maltoolbox._native.language.compiler.exceptions", + &compiler_exceptions_mod, + )?; + + let mal_analyzer_mod = PyModule::new(py, "mal_analyzer")?; + exceptions::register_analyzer_exceptions(py, &mal_analyzer_mod)?; + compiler_mod.add_submodule(&mal_analyzer_mod)?; + sys_modules.set_item( + "maltoolbox._native.language.compiler.mal_analyzer", + &mal_analyzer_mod, + )?; + + Ok(()) +} diff --git a/py-bindings/maltoolbox-language-py/src/model_effect.rs b/py-bindings/maltoolbox-language-py/src/model_effect.rs new file mode 100644 index 00000000..8ce2db3b --- /dev/null +++ b/py-bindings/maltoolbox-language-py/src/model_effect.rs @@ -0,0 +1,318 @@ +//! Mirrors `maltoolbox/language/language_graph_model_effect.py`'s +//! `LanguageGraphModelEffect`/`AssocTraversal`/`GlobAssocTraversal`/ +//! `AssocSet`/`DynTarget`/`ModelEffectType`, wrapping the Rust core's +//! `maltoolbox_language::graph::model_effect` types. +//! +//! No identity caching, `__richcmp__`, or `__hash__` - these are plain, +//! freely-constructible value snapshots, not owner+id handles. + +use std::rc::Rc; + +use pyo3::prelude::*; +use pyo3::types::{PyList, PyTuple}; + +use maltoolbox_language::graph::model_effect::{ + AssocSet, AssocTraversal, AssocTraversalChain, AssocTraversalElem, DynTarget, + GlobAssocTraversal, LanguageGraphModelEffect, ModelEffectType, QuantityFilter, +}; +use maltoolbox_language::graph::LanguageGraph; + +use crate::asset::PyLanguageGraphAsset; +use crate::handle::SharedLangGraphCaches; + +/// Everything needed to resolve an `asset_filter: AssetId` into a real +/// `PyLanguageGraphAsset` handle while converting a core model-effect +/// value into its Python wrapper. +#[derive(Clone)] +struct Ctx { + owner: Rc, + caches: SharedLangGraphCaches, +} + +fn quantity_to_py(py: Python<'_>, q: Option) -> PyResult> { + match q { + None => Ok(py.None()), + Some(QuantityFilter::Exact(n)) => Ok(n.into_pyobject(py)?.into_any().unbind()), + Some(QuantityFilter::Range(lo, hi)) => Ok(PyTuple::new(py, [lo, hi])?.into_any().unbind()), + } +} + +#[pyclass(name = "AssocTraversal", module = "maltoolbox._native", unsendable)] +pub struct PyAssocTraversal { + field_name: String, + asset_filter: Option, + quantity_filter: Option, + ctx: Ctx, +} + +#[pymethods] +impl PyAssocTraversal { + #[getter] + fn field_name(&self) -> String { + self.field_name.clone() + } + + #[getter] + fn asset_filter(&self, py: Python<'_>) -> PyResult>> { + self.asset_filter + .map(|id| cached_asset(py, &self.ctx, id)) + .transpose() + } + + #[getter] + fn quantity_filter(&self, py: Python<'_>) -> PyResult> { + quantity_to_py(py, self.quantity_filter) + } + + fn __repr__(&self) -> String { + format!( + "AssocTraversal(field_name: {:?}, asset_filter: {:?}, quantity_filter: {:?})", + self.field_name, self.asset_filter, self.quantity_filter + ) + } +} + +/// Resolves an `asset_filter: AssetId` to a `PyLanguageGraphAsset` +/// handle via the owning graph's real cache, for consistency with every +/// other path to the same asset. +fn cached_asset( + py: Python<'_>, + ctx: &Ctx, + id: maltoolbox_language::graph::ids::AssetId, +) -> PyResult> { + let owner = ctx.owner.clone(); + let caches = ctx.caches.clone(); + crate::handle::cached_handle(&ctx.caches.assets, py, id, move || { + PyLanguageGraphAsset::new(owner, id, caches) + }) +} + +#[pyclass(name = "GlobAssocTraversal", module = "maltoolbox._native", unsendable)] +pub struct PyGlobAssocTraversal { + pattern: AssocTraversalChain, + quantity_filter: Option, + ctx: Ctx, +} + +#[pymethods] +impl PyGlobAssocTraversal { + #[getter] + fn pattern<'py>(&self, py: Python<'py>) -> PyResult> { + chain_to_pylist(py, &self.ctx, &self.pattern) + } + + #[getter] + fn quantity_filter(&self, py: Python<'_>) -> PyResult> { + quantity_to_py(py, self.quantity_filter) + } + + fn __repr__(&self) -> String { + format!( + "GlobAssocTraversal(pattern: [{} elem(s)])", + self.pattern.len() + ) + } +} + +#[pyclass(name = "AssocSet", module = "maltoolbox._native", unsendable)] +pub struct PyAssocSet { + set_op: SetOperationPy, + left: AssocTraversalChain, + right: AssocTraversalChain, + quantity_filter: Option, + ctx: Ctx, +} + +#[derive(Clone, Copy)] +struct SetOperationPy(maltoolbox_language::graph::model_effect::SetOperation); + +#[pymethods] +impl PyAssocSet { + #[getter] + fn set_op(&self) -> &'static str { + use maltoolbox_language::graph::model_effect::SetOperation::*; + match self.set_op.0 { + Union => "UNION", + Difference => "DIFFERENCE", + Intersection => "INTERSECTION", + } + } + + #[getter] + fn left<'py>(&self, py: Python<'py>) -> PyResult> { + chain_to_pylist(py, &self.ctx, &self.left) + } + + #[getter] + fn right<'py>(&self, py: Python<'py>) -> PyResult> { + chain_to_pylist(py, &self.ctx, &self.right) + } + + #[getter] + fn quantity_filter(&self, py: Python<'_>) -> PyResult> { + quantity_to_py(py, self.quantity_filter) + } + + fn __repr__(&self) -> String { + format!("AssocSet(set_op: {})", self.set_op()) + } +} + +#[pyclass(name = "DynTarget", module = "maltoolbox._native", unsendable)] +pub struct PyDynTarget { + assoc_op: bool, + assoc_traversal: AssocTraversalChain, + ctx: Ctx, +} + +#[pymethods] +impl PyDynTarget { + #[getter] + fn assoc_op(&self) -> bool { + self.assoc_op + } + + #[getter] + fn assoc_traversal<'py>(&self, py: Python<'py>) -> PyResult> { + chain_to_pylist(py, &self.ctx, &self.assoc_traversal) + } + + fn __repr__(&self) -> String { + format!( + "DynTarget(assoc_op: {}, assoc_traversal: [{} elem(s)])", + self.assoc_op, + self.assoc_traversal.len() + ) + } +} + +#[pyclass( + name = "LanguageGraphModelEffect", + module = "maltoolbox._native", + unsendable +)] +pub struct PyLanguageGraphModelEffect { + model_effect_type: ModelEffectType, + base: AssocTraversalChain, + targets: Vec, + ctx: Ctx, +} + +#[pymethods] +impl PyLanguageGraphModelEffect { + #[getter(model_effect_type)] + fn model_effect_type_py(&self) -> &'static str { + match self.model_effect_type { + ModelEffectType::Additive => "ADDITIVE", + ModelEffectType::Subtractive => "SUBTRACTIVE", + } + } + + #[getter] + fn base<'py>(&self, py: Python<'py>) -> PyResult> { + chain_to_pylist(py, &self.ctx, &self.base) + } + + #[getter] + fn targets(&self, py: Python<'_>) -> PyResult>> { + self.targets + .iter() + .map(|t| { + Py::new( + py, + PyDynTarget { + assoc_op: t.assoc_op, + assoc_traversal: t.assoc_traversal.clone(), + ctx: self.ctx.clone(), + }, + ) + }) + .collect() + } + + fn __repr__(&self) -> String { + format!( + "LanguageGraphModelEffect(model_effect_type: {}, base: [{} elem(s)], targets: [{} elem(s)])", + self.model_effect_type_py(), + self.base.len(), + self.targets.len() + ) + } +} + +fn elem_to_py(py: Python<'_>, ctx: &Ctx, elem: &AssocTraversalElem) -> PyResult> { + match elem { + AssocTraversalElem::Traversal(AssocTraversal { + field_name, + asset_filter, + quantity_filter, + }) => Ok(Py::new( + py, + PyAssocTraversal { + field_name: field_name.clone(), + asset_filter: *asset_filter, + quantity_filter: *quantity_filter, + ctx: ctx.clone(), + }, + )? + .into_any()), + AssocTraversalElem::Glob(GlobAssocTraversal { + pattern, + quantity_filter, + }) => Ok(Py::new( + py, + PyGlobAssocTraversal { + pattern: pattern.clone(), + quantity_filter: *quantity_filter, + ctx: ctx.clone(), + }, + )? + .into_any()), + AssocTraversalElem::Set(AssocSet { + set_op, + left, + right, + quantity_filter, + }) => Ok(Py::new( + py, + PyAssocSet { + set_op: SetOperationPy(*set_op), + left: left.clone(), + right: right.clone(), + quantity_filter: *quantity_filter, + ctx: ctx.clone(), + }, + )? + .into_any()), + } +} + +fn chain_to_pylist<'py>( + py: Python<'py>, + ctx: &Ctx, + chain: &AssocTraversalChain, +) -> PyResult> { + let items: PyResult>> = chain.iter().map(|e| elem_to_py(py, ctx, e)).collect(); + PyList::new(py, items?) +} + +/// Converts a core `LanguageGraphModelEffect` into its Python wrapper. +/// `owner`/`caches` identify the `LanguageGraph` that any `asset_filter` +/// resolves its `PyLanguageGraphAsset` handles against. +pub fn model_effect_to_py( + py: Python<'_>, + owner: Rc, + caches: SharedLangGraphCaches, + effect: &LanguageGraphModelEffect, +) -> PyResult> { + let ctx = Ctx { owner, caches }; + Py::new( + py, + PyLanguageGraphModelEffect { + model_effect_type: effect.model_effect_type, + base: effect.base.clone(), + targets: effect.targets.clone(), + ctx, + }, + ) +} diff --git a/py-bindings/maltoolbox-model-py/Cargo.toml b/py-bindings/maltoolbox-model-py/Cargo.toml new file mode 100644 index 00000000..20fe2173 --- /dev/null +++ b/py-bindings/maltoolbox-model-py/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "maltoolbox-model-py" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[lib] +name = "maltoolbox_model_py" +path = "src/lib.rs" + +[dependencies] +maltoolbox-model = { path = "../../core/maltoolbox-model" } +maltoolbox-language = { path = "../../core/maltoolbox-language" } +maltoolbox-language-py = { workspace = true } +maltoolbox-io = { path = "../../core/maltoolbox-io" } +serde_json = { workspace = true } +thiserror = { workspace = true } +pyo3 = { workspace = true } +pythonize = { workspace = true } diff --git a/py-bindings/maltoolbox-model-py/src/asset.rs b/py-bindings/maltoolbox-model-py/src/asset.rs new file mode 100644 index 00000000..4b3e358a --- /dev/null +++ b/py-bindings/maltoolbox-model-py/src/asset.rs @@ -0,0 +1,393 @@ +//! Mirrors `maltoolbox/model.py`'s `ModelAsset`. A handle (`owner` + +//! `i64` id), cached per-owner (`handle_cache`) so repeated lookups for +//! the same id return the identical Python object, same scheme as +//! `PyLanguageGraphAsset`/`PyLanguageGraphAttackStep`. No `#[new]`: the +//! real Python `ModelAsset.__init__` takes no `Model` argument at all +//! (fully detached construction), which is incompatible with this +//! handle design. `ModelAsset` is only ever handed out by `PyModel`. +//! +//! **Post-removal readability**: a handle whose `id` has been removed +//! from `owner.assets` (via `Model.remove_asset`) falls back to +//! `owner`'s `Tombstones` map (populated by `PyModel::remove_asset` from +//! `AssetSnapshot::final_state`) for every *read-only* getter, matching +//! the real Python `ModelAsset` object staying fully readable after +//! removal. Mutating methods (`add_associated_assets`/ +//! `remove_associated_assets`/`validate_associated_assets`) do *not* get +//! this fallback - they delegate straight to the core `Model` methods, +//! which correctly reject an unknown/removed id on their own. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use pyo3::basic::CompareOp; +use pyo3::exceptions::PyLookupError; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PySet}; +use pyo3::IntoPyObjectExt; + +use maltoolbox_language::graph::LanguageGraph; +use maltoolbox_language_py::handle::{ + cached_handle, composite_hash, HandleCache, SharedLangGraphCaches, +}; +use maltoolbox_language_py::PyLanguageGraphAsset; + +/// `{fieldname: {other_asset_id: other_asset_name}}`'s inner keys are +/// *integers* in the Python original, but the core's own `to_dict()` +/// can only produce `serde_json::Map` (string keys only, a JSON +/// limitation) - `pythonize` therefore hands back `{"0": "App1"}` +/// instead of `{0: "App1"}` unless corrected here, after pythonizing, by +/// rebuilding each inner dict with parsed-back-to-int keys. Shared by +/// `PyModelAsset::_to_dict` and `PyModel::to_dict`. +pub fn fix_associated_assets_int_keys( + py: Python<'_>, + asset_dict: &Bound<'_, PyDict>, +) -> PyResult<()> { + let Some(associated) = asset_dict.get_item("associated_assets")? else { + return Ok(()); + }; + let associated = associated.cast::()?; + for (fieldname, sub_dict) in associated.iter() { + let sub_dict = sub_dict.cast::()?; + let fixed = PyDict::new(py); + for (key, value) in sub_dict.iter() { + let id: i64 = key.extract::()?.parse().map_err(|_| { + pyo3::exceptions::PyValueError::new_err("non-integer key in associated_assets") + })?; + fixed.set_item(id, value)?; + } + associated.set_item(fieldname, fixed)?; + } + Ok(()) +} +use maltoolbox_model::{Model, ModelAsset}; + +use crate::exceptions::model_error_to_py; + +/// Read-only record of each removed asset's final state, shared between +/// a `PyModel` and every `PyModelAsset` handle it ever hands out - see +/// `PyModel::tombstones`'s doc comment. +pub type Tombstones = Rc>>; + +/// Extracts `.id` from every `ModelAsset` in an arbitrary Python +/// iterable (`set`/`list`/etc.), since `Vec>` only +/// accepts `Sequence`s, not sets, and Python callers pass +/// `set[ModelAsset]` almost everywhere. +fn ids_of(obj: &Bound<'_, PyAny>) -> PyResult> { + obj.try_iter()? + .map(|item| Ok(item?.extract::>()?.id)) + .collect() +} + +#[pyclass(name = "ModelAsset", unsendable, skip_from_py_object)] +#[derive(Clone)] +pub struct PyModelAsset { + pub owner: Rc>, + pub id: i64, + /// The owning `PyModel`'s language graph, shared (same `Rc`) so + /// `.lg_asset` handles compare equal via the usual owner-pointer + /// scheme. + pub lang_graph: Rc, + /// The owning `PyModel`'s `PyLanguageGraph.caches`, needed so + /// `.lg_asset` resolves to the same per-owner-cached + /// `PyLanguageGraphAsset` handle as every other path to it. + pub lang_caches: SharedLangGraphCaches, + pub tombstones: Tombstones, + /// This type's own per-owner handle cache, shared (same `Rc`) with + /// `PyModel` and every sibling `PyModelAsset` handle, so e.g. + /// `model.assets[id]` and `asset.associated_assets['field']`'s + /// members return the identical Python object for the same id. + pub handle_cache: HandleCache, +} + +impl PyModelAsset { + pub fn new( + owner: Rc>, + id: i64, + lang_graph: Rc, + lang_caches: SharedLangGraphCaches, + tombstones: Tombstones, + handle_cache: HandleCache, + ) -> Self { + PyModelAsset { + owner, + id, + lang_graph, + lang_caches, + tombstones, + handle_cache, + } + } + + fn owner_ptr(&self) -> usize { + Rc::as_ptr(&self.owner) as usize + } + + /// Cache-aware constructor for a sibling `ModelAsset` handle owned by + /// the same `Model`. + fn asset_handle(&self, py: Python<'_>, id: i64) -> PyResult> { + let owner = self.owner.clone(); + let lang_graph = self.lang_graph.clone(); + let lang_caches = self.lang_caches.clone(); + let tombstones = self.tombstones.clone(); + let handle_cache = self.handle_cache.clone(); + cached_handle(&self.handle_cache, py, id, move || { + PyModelAsset::new(owner, id, lang_graph, lang_caches, tombstones, handle_cache) + }) + } + + fn not_found(&self) -> PyErr { + PyLookupError::new_err(format!( + "Asset with id {} not found in model (never added, or removed and no \ + tombstone recorded? this shouldn't happen for an id reached via a \ + real PyModelAsset handle).", + self.id + )) + } + + /// Resolves `self` to its backing data - the live entry in + /// `owner.assets` if still present, else the `Tombstones` fallback + /// recorded at removal time - and runs `f` against it. This is the + /// one place "where does this handle's data live right now" is + /// decided; every read-only getter below goes through it so the + /// post-removal-readability fallback applies uniformly. + fn with_asset(&self, f: impl FnOnce(&ModelAsset) -> PyResult) -> PyResult { + let model = self.owner.borrow(); + if let Some(asset) = model.assets.get(&self.id) { + return f(asset); + } + drop(model); + let tombstones = self.tombstones.borrow(); + if let Some(asset) = tombstones.get(&self.id) { + return f(asset); + } + Err(self.not_found()) + } +} + +#[pymethods] +impl PyModelAsset { + #[getter] + fn name(&self) -> PyResult { + self.with_asset(|asset| Ok(asset.name.clone())) + } + + #[getter] + fn id(&self) -> i64 { + self.id + } + + #[getter(r#type)] + fn asset_type(&self) -> PyResult { + self.with_asset(|asset| Ok(asset.asset_type.clone())) + } + + #[getter] + fn lg_asset(&self, py: Python<'_>) -> PyResult> { + let lg_id = self.with_asset(|asset| Ok(asset.lg_asset))?; + let owner = self.lang_graph.clone(); + let caches = self.lang_caches.clone(); + cached_handle(&self.lang_caches.assets, py, lg_id, move || { + PyLanguageGraphAsset::new(owner, lg_id, caches) + }) + } + + #[getter] + fn defenses<'py>(&self, py: Python<'py>) -> PyResult> { + self.with_asset(|asset| { + let dict = PyDict::new(py); + for (k, v) in &asset.defenses { + dict.set_item(k, v)?; + } + Ok(dict) + }) + } + + #[getter] + fn extras<'py>(&self, py: Python<'py>) -> PyResult> { + self.with_asset(|asset| { + pythonize::pythonize(py, &serde_json::Value::Object(asset.extras.clone())) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + }) + } + + /// Plain mutable attribute in the Python original + /// (`self.extras: dict = {}`, freely reassignable). Live-only, no + /// tombstone fallback - same as every other mutating method on this + /// type. + #[setter] + fn set_extras(&self, value: &Bound<'_, PyAny>) -> PyResult<()> { + let parsed: serde_json::Value = pythonize::depythonize(value) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let extras = parsed.as_object().cloned().unwrap_or_default(); + let mut model = self.owner.borrow_mut(); + let asset = model + .assets + .get_mut(&self.id) + .ok_or_else(|| self.not_found())?; + asset.extras = extras; + Ok(()) + } + + /// `dict[str, set[ModelAsset]]`, matching the Python `@property + /// associated_assets`. Rebuilt fresh per access, not cached. For a + /// tombstoned (removed) asset, `final_state.associated_assets` was + /// captured *after* `remove_asset`'s own cleanup already emptied it, + /// so this correctly yields `{}` rather than the pre-removal + /// associations. + #[getter] + fn associated_assets<'py>(&self, py: Python<'py>) -> PyResult> { + self.with_asset(|asset| { + let dict = PyDict::new(py); + for (fieldname, ids) in &asset.associated_assets { + let set = PySet::empty(py)?; + for &other_id in ids { + set.add(self.asset_handle(py, other_id)?)?; + } + dict.set_item(fieldname, set)?; + } + Ok(dict) + }) + } + + fn associations_with( + &self, + py: Python<'_>, + other: &PyModelAsset, + ) -> PyResult>> { + let model = self.owner.borrow(); + let assocs = model.associations_with(self.id, other.id); + assocs + .into_iter() + .map(|a| { + Py::new( + py, + maltoolbox_language_py::PyLanguageGraphAssociation::new( + self.lang_graph.clone(), + a, + self.lang_caches.clone(), + ), + ) + }) + .collect() + } + + fn has_association_with(&self, other: &PyModelAsset, assoc_name: &str) -> bool { + let model = self.owner.borrow(); + model.has_association_with(self.id, other.id, assoc_name) + } + + fn validate_associated_assets( + &self, + fieldname: &str, + assets_to_add: &Bound<'_, PyAny>, + ) -> PyResult<()> { + let model = self.owner.borrow(); + let ids = ids_of(assets_to_add)?; + model + .validate_associated_assets(self.id, fieldname, &ids) + .map_err(model_error_to_py) + } + + fn add_associated_assets(&self, fieldname: &str, assets: &Bound<'_, PyAny>) -> PyResult<()> { + let mut model = self.owner.borrow_mut(); + let ids = ids_of(assets)?; + model + .add_associated_assets(self.id, fieldname, ids) + .map_err(model_error_to_py) + } + + fn remove_associated_assets(&self, fieldname: &str, assets: &Bound<'_, PyAny>) -> PyResult<()> { + let mut model = self.owner.borrow_mut(); + let ids = ids_of(assets)?; + model + .remove_associated_assets(self.id, fieldname, &ids) + .map_err(model_error_to_py) + } + + fn to_dict<'py>(&self, py: Python<'py>) -> PyResult> { + self._to_dict(py) + } + + /// Matches the Python original's `ModelAsset._to_dict()` exactly: + /// returns `{self.id: {name, type, ...}}`, a single-key dict keyed + /// by this asset's own id, *not* just the inner field dict. The + /// core's own `ModelAsset::to_dict()` deliberately returns the + /// unwrapped inner dict (`Model::to_dict` wraps it per-asset + /// itself), so this id-wrapping step belongs here, not in the core. + fn _to_dict<'py>(&self, py: Python<'py>) -> PyResult> { + // `associated_assets` needs id->name resolution against the + // live model; only meaningful in the live-asset branch, since a + // tombstoned asset's `associated_assets` is already empty. + let model = self.owner.borrow(); + let dict = if let Some(asset) = model.assets.get(&self.id) { + let mut dict = asset.to_dict(); + let mut associated = serde_json::Map::new(); + for (fieldname, ids) in &asset.associated_assets { + let mut named = serde_json::Map::new(); + for other_id in ids { + if let Some(other) = model.assets.get(other_id) { + named.insert(other_id.to_string(), serde_json::json!(other.name)); + } + } + associated.insert(fieldname.clone(), serde_json::Value::Object(named)); + } + dict["associated_assets"] = serde_json::Value::Object(associated); + dict + } else { + let tombstones = self.tombstones.borrow(); + let asset = tombstones.get(&self.id).ok_or_else(|| self.not_found())?; + asset.to_dict() + }; + // Built as a real `PyDict` with an *integer* key, not via + // `pythonize` on a `serde_json::Map`: JSON objects only support + // string keys, which would silently turn `self.id` into `"0"`. + let inner = pythonize::pythonize(py, &dict) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let inner = inner.cast::()?; + fix_associated_assets_int_keys(py, inner)?; + let wrapped = PyDict::new(py); + wrapped.set_item(self.id, inner)?; + Ok(wrapped.into_any()) + } + + fn __repr__(&self) -> PyResult { + self.with_asset(|asset| { + Ok(format!( + "ModelAsset(name: \"{}\", id: {}, type: {})", + asset.name, asset.id, asset.asset_type + )) + }) + } + + fn __hash__(&self) -> isize { + composite_hash(self.owner_ptr(), self.id) + } + + fn __richcmp__( + &self, + other: &PyModelAsset, + op: CompareOp, + py: Python<'_>, + ) -> PyResult> { + let eq = self.owner_ptr() == other.owner_ptr() && self.id == other.id; + match op { + CompareOp::Eq => eq.into_py_any(py), + CompareOp::Ne => (!eq).into_py_any(py), + _ => Ok(py.NotImplemented()), + } + } +} + +impl PartialEq for PyModelAsset { + fn eq(&self, other: &Self) -> bool { + self.owner_ptr() == other.owner_ptr() && self.id == other.id + } +} +impl Eq for PyModelAsset {} +impl std::hash::Hash for PyModelAsset { + fn hash(&self, state: &mut H) { + self.owner_ptr().hash(state); + self.id.hash(state); + } +} diff --git a/py-bindings/maltoolbox-model-py/src/exceptions.rs b/py-bindings/maltoolbox-model-py/src/exceptions.rs new file mode 100644 index 00000000..2023994e --- /dev/null +++ b/py-bindings/maltoolbox-model-py/src/exceptions.rs @@ -0,0 +1,89 @@ +//! Exception hierarchy mirroring `maltoolbox/exceptions.py`'s +//! `ModelException` branch, built on the `MalToolboxException` type +//! from `maltoolbox-language-py`. +//! +//! `maltoolbox/model.py`'s own methods (`add_asset`/`remove_asset`/ +//! `add_associated_assets`/etc.) almost never raise `ModelException` +//! itself - they raise plain built-in `ValueError`/`LookupError`/ +//! `TypeError` directly. `ModelAssociationException` and +//! `DuplicateModelAssociationError` are dead exception classes in the +//! Python original (defined but never raised); they're kept here only +//! so the same importable names exist. `ModelException` itself is +//! raised only by `Model.load_from_file`'s broad exception wrapper. See +//! `model_error_to_py` (plain builtins, for direct method calls) vs +//! `load_error_to_py` (always `ModelException`, for `load_from_file`) +//! below. + +use pyo3::create_exception; +use pyo3::exceptions::{PyKeyError, PyLookupError, PyTypeError, PyValueError}; +use pyo3::prelude::*; + +use maltoolbox_language_py::exceptions::MalToolboxException; +use maltoolbox_model::file::LoadError; +use maltoolbox_model::ModelError; + +create_exception!(_native, ModelException, MalToolboxException); +create_exception!(_native, ModelAssociationException, ModelException); +create_exception!(_native, DuplicateModelAssociationError, ModelException); + +/// Maps a `ModelError` to the same plain built-in exception type +/// `maltoolbox/model.py`'s own code raises for the equivalent +/// condition. Two variants have no exact Python precedent, since the +/// Rust core validates a couple of things Python doesn't check +/// explicitly before a raw dict/set operation would fail: +/// `UnknownAssetId` is approximated as `LookupError` (consistent with +/// the other "not found" cases) and `NotAssociated` as `KeyError` +/// (matching the raw `KeyError` Python's +/// `_associated_assets[...].remove(...)` would raise). +pub fn model_error_to_py(err: ModelError) -> PyErr { + let msg = err.to_string(); + match err { + ModelError::DuplicateAssetId(_) => PyValueError::new_err(msg), + ModelError::UnknownAssetType { .. } => PyValueError::new_err(msg), + ModelError::DuplicateAssetName(_) => PyValueError::new_err(msg), + ModelError::AssetNotFound { .. } => PyLookupError::new_err(msg), + ModelError::UnknownFieldname { .. } => PyLookupError::new_err(msg), + ModelError::WrongAssociatedAssetType { .. } => PyTypeError::new_err(msg), + ModelError::TooManyAssetsInField(..) => PyValueError::new_err(msg), + ModelError::UnknownAssociation { .. } => PyValueError::new_err(msg), + ModelError::UnknownAssetId(_) => PyLookupError::new_err(msg), + ModelError::NotAssociated { .. } => PyKeyError::new_err(msg), + ModelError::Language(e) => maltoolbox_language_py::exceptions::graph_error_to_py(e), + ModelError::Malformed(_) => PyValueError::new_err(msg), + } +} + +/// `_from_dict` (the classmethod, not `load_from_file`) has no +/// try/except in the Python original - raw errors propagate. Used only +/// if/when `Model._from_dict` is bound directly (see `model.rs`). +pub fn from_dict_error_to_py(err: LoadError) -> PyErr { + match err { + LoadError::FileUtil(e) => pyo3::exceptions::PyOSError::new_err(e.to_string()), + LoadError::Model(e) => model_error_to_py(e), + LoadError::Malformed(msg) => PyValueError::new_err(msg), + } +} + +/// `load_from_file` always wraps *any* failure into `ModelException` +/// with this exact message, matching `maltoolbox/model.py`'s broad +/// `except Exception as e: raise ModelException(...) from e`. +pub fn load_error_to_py(_err: LoadError) -> PyErr { + ModelException::new_err( + "Could not load model. It might be of an older version. \ + Try to upgrade it with 'maltoolbox upgrade-model'" + .to_string(), + ) +} + +pub fn register(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("ModelException", py.get_type::())?; + m.add( + "ModelAssociationException", + py.get_type::(), + )?; + m.add( + "DuplicateModelAssociationError", + py.get_type::(), + )?; + Ok(()) +} diff --git a/py-bindings/maltoolbox-model-py/src/lib.rs b/py-bindings/maltoolbox-model-py/src/lib.rs new file mode 100644 index 00000000..38db5219 --- /dev/null +++ b/py-bindings/maltoolbox-model-py/src/lib.rs @@ -0,0 +1,21 @@ +//! PyO3 compatibility layer mirroring `maltoolbox/model.py`'s public +//! surface (`Model`, `ModelAsset`) on top of the pure-Rust +//! `maltoolbox-model` crate. + +pub mod asset; +pub mod exceptions; +mod model; + +use pyo3::prelude::*; + +pub use asset::PyModelAsset; +pub use model::PyModel; + +/// Registers this layer's classes/exceptions onto the umbrella +/// `_native` module. Called from `maltoolbox-pyo3`'s `#[pymodule]`. +pub fn register(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + exceptions::register(py, m)?; + Ok(()) +} diff --git a/py-bindings/maltoolbox-model-py/src/model.rs b/py-bindings/maltoolbox-model-py/src/model.rs new file mode 100644 index 00000000..37a07385 --- /dev/null +++ b/py-bindings/maltoolbox-model-py/src/model.rs @@ -0,0 +1,442 @@ +//! Mirrors `maltoolbox/model.py`'s `Model`. The one "container" type in +//! this crate - see PYTHON_BINDINGS_IMPLEMENTATION.md's "Container / +//! handle pattern" section. +//! +//! Holds both `inner` (the core `maltoolbox_model::Model`) and +//! `lang_graph_py` (the actual `Py` object passed to the +//! constructor, stored so `.lang_graph` returns the same Python object +//! every time). Both share one `Rc`; the graph is +//! immutable after construction, so no `RefCell` or copy is needed. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::ffi::c_void; +use std::path::PathBuf; +use std::ptr::NonNull; +use std::rc::Rc; + +use pyo3::prelude::*; +use pyo3::types::{PyCapsule, PyDict}; + +use maltoolbox_language_py::handle::{cached_handle, new_handle_cache, HandleCache}; +use maltoolbox_language_py::PyLanguageGraph; +use maltoolbox_model::{file as model_file, Model, ModelAsset}; + +use crate::asset::{fix_associated_assets_int_keys, PyModelAsset, Tombstones}; +use crate::exceptions::{from_dict_error_to_py, load_error_to_py, model_error_to_py}; + +/// Shared contract with consumers (currently mal-simulator's `malsim-pyo3`, +/// see its PORTING_NOTES.md §6/B3 for why this exists) - mirrors +/// `maltoolbox-attackgraph-py`'s `PyAttackGraph::INNER_CAPSULE_NAME`. +const INNER_CAPSULE_NAME: &std::ffi::CStr = c"maltoolbox._native.Model.inner"; + +/// Destructor for the capsule handed out by `PyModel::__inner_capsule__`. +/// Reclaims exactly the one `Rc` strong reference that capsule's creation +/// cloned - see that method's doc comment. +unsafe extern "C" fn drop_inner_capsule(capsule: *mut pyo3::ffi::PyObject) { + let name = unsafe { pyo3::ffi::PyCapsule_GetName(capsule) }; + let ptr = unsafe { pyo3::ffi::PyCapsule_GetPointer(capsule, name) }; + if !ptr.is_null() { + drop(unsafe { Rc::from_raw(ptr as *const RefCell) }); + } +} + +#[pyclass(name = "Model", module = "maltoolbox._native", unsendable)] +pub struct PyModel { + pub inner: Rc>, + pub lang_graph_py: Py, + /// Read-only record of each removed asset's final state (post + /// `remove_asset` cleanup), keyed by id - lets a `PyModelAsset` + /// handle whose entry is gone from `inner.assets` keep resolving + /// its own attributes, matching Python's `ModelAsset` objects + /// staying fully readable after removal. See + /// `AssetSnapshot::final_state` (`maltoolbox-model`). + pub tombstones: Tombstones, + /// Per-owner `PyModelAsset` handle cache, shared (same `Rc`) with + /// every `PyModelAsset` this model ever hands out, so repeated + /// lookups for the same id return the identical Python object. + pub handle_cache: HandleCache, +} + +impl PyModel { + fn lang_graph_rc( + py: Python<'_>, + lang_graph_py: &Py, + ) -> Rc { + lang_graph_py.borrow(py).inner.clone() + } + + fn wrap(py: Python<'_>, inner: Model, lang_graph_py: Py) -> PyResult { + let _ = py; + Ok(PyModel { + inner: Rc::new(RefCell::new(inner)), + lang_graph_py, + tombstones: Rc::new(RefCell::new(HashMap::::new())), + handle_cache: new_handle_cache(), + }) + } + + /// `pub` so cross-crate consumers (e.g. `maltoolbox-attackgraph-py`'s + /// `PyAttackGraphNode::model_asset`) can build a cache-consistent + /// `PyModelAsset` handle too, rather than constructing one directly + /// and bypassing this model's cache. + pub fn asset_handle(&self, py: Python<'_>, id: i64) -> PyResult> { + let owner = self.inner.clone(); + let lang_graph = Self::lang_graph_rc(py, &self.lang_graph_py); + let lang_caches = self.lang_graph_py.borrow(py).caches.clone(); + let tombstones = self.tombstones.clone(); + let handle_cache = self.handle_cache.clone(); + cached_handle(&self.handle_cache, py, id, move || { + PyModelAsset::new(owner, id, lang_graph, lang_caches, tombstones, handle_cache) + }) + } + + /// Evicts `id`'s cache entry, if any - used only by `add_asset` when + /// the newly-assigned id happens to collide with a *previously + /// removed* asset's id (the core's `add_asset` allows this: an + /// explicit `asset_id=` matching a removed id is not currently + /// occupied, so it succeeds and creates a genuinely new, unrelated + /// asset at that id). Without this, `asset_handle` would return the + /// stale cached object for the old tombstoned asset instead of a + /// fresh one for the new asset that now legitimately owns that id. + /// Deliberately *not* done on removal itself - eviction only happens + /// here, at the one point a genuinely new logical object is + /// introduced at a given id. + fn evict_handle(&self, id: i64) { + self.handle_cache.borrow_mut().remove(&id); + } + + /// Default `maltoolbox_version` must be the *live Python package's* + /// `maltoolbox.__version__`, not the Rust crate's own internal + /// `Cargo.toml` version (`maltoolbox_model::MALTOOLBOX_VERSION`) - + /// the two are unrelated numbers, and stamping a model with the + /// latter would be meaningless to anyone reading `"MAL-Toolbox + /// Version"` metadata. See PORTING_NOTES.md §3 for the related + /// `to_dict()` versioning behavior. + fn live_version(py: Python<'_>) -> PyResult { + py.import("maltoolbox")?.getattr("__version__")?.extract() + } + + /// The Python original tolerates *any* key type anywhere in the + /// input dict, but `pythonize::depythonize` requires + /// `serde_json::Value`-compatible input, which can only represent + /// string-keyed maps. Rather than chase every individual place an + /// int key might appear (`assets`, `attackers`, + /// `attackers[*].entry_points`, ... across old model-version + /// shapes), this recursively rebuilds the *entire* structure with + /// every dict key run through `.str()`, leaving values and list + /// contents otherwise untouched. Returns a new object; does not + /// mutate `value` in place. + fn stringify_all_keys<'py>( + py: Python<'py>, + value: &Bound<'py, PyAny>, + ) -> PyResult> { + if let Ok(dict) = value.cast::() { + let fixed = PyDict::new(py); + for (key, val) in dict.iter() { + fixed.set_item(key.str()?, Self::stringify_all_keys(py, &val)?)?; + } + return Ok(fixed.into_any()); + } + if let Ok(list) = value.cast::() { + let fixed = pyo3::types::PyList::empty(py); + for item in list.iter() { + fixed.append(Self::stringify_all_keys(py, &item)?)?; + } + return Ok(fixed.into_any()); + } + Ok(value.clone()) + } +} + +#[pymethods] +impl PyModel { + #[new] + #[pyo3(signature = (name, lang_graph, mt_version=None))] + fn new( + py: Python<'_>, + name: String, + lang_graph: Py, + mt_version: Option, + ) -> PyResult { + let lg_rc = Self::lang_graph_rc(py, &lang_graph); + let mut model = Model::new(name, lg_rc); + model.maltoolbox_version = match mt_version { + Some(v) => v, + None => Self::live_version(py)?, + }; + Self::wrap(py, model, lang_graph) + } + + #[getter] + fn name(&self) -> String { + self.inner.borrow().name.clone() + } + + #[setter] + fn set_name(&self, name: String) { + self.inner.borrow_mut().name = name; + } + + #[getter] + fn lang_graph(&self, py: Python<'_>) -> Py { + self.lang_graph_py.clone_ref(py) + } + + #[getter] + fn maltoolbox_version(&self) -> String { + self.inner.borrow().maltoolbox_version.clone() + } + + #[getter] + fn next_id(&self) -> i64 { + self.inner.borrow().next_id + } + + #[setter] + fn set_next_id(&self, value: i64) { + self.inner.borrow_mut().next_id = value; + } + + /// `dict[int, ModelAsset]`, matching Python's `.assets` exactly + /// (keyed by id, not name; `get_asset_by_name` covers name lookup). + /// Rebuilt fresh per access, not cached. + #[getter] + fn assets<'py>(&self, py: Python<'py>) -> PyResult> { + let ids: Vec = self.inner.borrow().asset_order.clone(); + let dict = PyDict::new(py); + for id in ids { + dict.set_item(id, self.asset_handle(py, id)?)?; + } + Ok(dict) + } + + #[pyo3(signature = (asset_type, name=None, asset_id=None, defenses=None, extras=None, allow_duplicate_names=true))] + #[allow(clippy::too_many_arguments)] + fn add_asset( + &self, + py: Python<'_>, + asset_type: &str, + name: Option, + asset_id: Option, + defenses: Option>, + extras: Option<&Bound<'_, PyAny>>, + allow_duplicate_names: bool, + ) -> PyResult> { + let extras_map = extras + .map( + |e| -> PyResult> { + let value: serde_json::Value = pythonize::depythonize(e) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(value.as_object().cloned().unwrap_or_default()) + }, + ) + .transpose()?; + let id = { + let mut model = self.inner.borrow_mut(); + model + .add_asset( + asset_type, + name, + asset_id, + defenses, + extras_map, + allow_duplicate_names, + ) + .map_err(model_error_to_py)? + }; + // A caller-chosen `asset_id` can legitimately collide with a + // previously-removed asset's id - see `evict_handle`. + self.evict_handle(id); + self.asset_handle(py, id) + } + + fn remove_asset(&self, asset: &PyModelAsset) -> PyResult<()> { + let snapshot = { + let mut model = self.inner.borrow_mut(); + model.remove_asset(asset.id).map_err(model_error_to_py)? + }; + self.tombstones + .borrow_mut() + .insert(asset.id, snapshot.final_state); + Ok(()) + } + + fn get_asset_by_id(&self, py: Python<'_>, asset_id: i64) -> PyResult>> { + let present = self.inner.borrow().get_asset_by_id(asset_id).is_some(); + if present { + Ok(Some(self.asset_handle(py, asset_id)?)) + } else { + Ok(None) + } + } + + fn get_asset_by_name( + &self, + py: Python<'_>, + asset_name: &str, + ) -> PyResult>> { + let id = self + .inner + .borrow() + .get_asset_by_name(asset_name) + .map(|a| a.id); + match id { + Some(id) => Ok(Some(self.asset_handle(py, id)?)), + None => Ok(None), + } + } + + /// `contents['assets']` is keyed by *integer* asset id in the Python + /// original, but the core's `to_dict()` can only produce + /// `serde_json::Map` (string keys only), so both the outer `assets` + /// dict's keys and each asset's nested `associated_assets` keys + /// need fixing up after pythonizing. + fn to_dict<'py>(&self, py: Python<'py>) -> PyResult> { + let model = self.inner.borrow(); + let dict = model.to_dict(); + let pythonized = pythonize::pythonize(py, &dict) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let pythonized = pythonized.cast::()?; + if let Some(assets) = pythonized.get_item("assets")? { + let assets = assets.cast::()?; + let fixed_assets = PyDict::new(py); + for (str_id, asset_dict) in assets.iter() { + let id: i64 = str_id.extract::()?.parse().map_err(|_| { + pyo3::exceptions::PyValueError::new_err("non-integer asset id key") + })?; + let asset_dict = asset_dict.cast::()?; + fix_associated_assets_int_keys(py, asset_dict)?; + fixed_assets.set_item(id, asset_dict)?; + } + pythonized.set_item("assets", fixed_assets)?; + } + Ok(pythonized.clone().into_any()) + } + + fn _to_dict<'py>(&self, py: Python<'py>) -> PyResult> { + self.to_dict(py) + } + + /// `PathBuf`, not `&str` - pyo3 extracts it from both a plain `str` + /// and any `os.PathLike` (e.g. `pathlib.Path`), matching the Python + /// original's file APIs. + fn save_to_file(&self, filename: PathBuf) -> PyResult<()> { + let model = self.inner.borrow(); + model_file::save_to_file(&model, filename) + .map_err(|e| pyo3::exceptions::PyOSError::new_err(e.to_string())) + } + + #[staticmethod] + pub fn load_from_file( + py: Python<'_>, + filename: PathBuf, + lang_graph: Py, + ) -> PyResult { + let lg_rc = Self::lang_graph_rc(py, &lang_graph); + let mut model = model_file::load_from_file(filename, lg_rc).map_err(load_error_to_py)?; + if model.maltoolbox_version == maltoolbox_model::MALTOOLBOX_VERSION { + model.maltoolbox_version = Self::live_version(py)?; + } + Self::wrap(py, model, lang_graph) + } + + /// Classmethod in the Python original (`cls._from_dict`); no + /// try/except there (unlike `load_from_file`) - raw errors + /// propagate, see `exceptions::from_dict_error_to_py`. + /// + /// The Python original's `assets.items()`-based loop tolerates + /// *either* a `str` or an `int` key for each asset (it always does + /// `int(asset_id)` itself) - old-version model YAML can have bare + /// unquoted integer keys, which `yaml.safe_load` parses as real + /// `int`s. `pythonize::depythonize` requires + /// `serde_json::Value`-compatible input, which can only represent + /// string-keyed maps, so `serialized['assets']`'s keys are + /// stringified first (on a shallow copy, not mutating the caller's + /// dict) so an int-keyed `assets` dict depythonizes either way. + #[staticmethod] + #[pyo3(name = "_from_dict")] + fn from_dict_py<'py>( + py: Python<'py>, + serialized: &Bound<'py, PyAny>, + lang_graph: Py, + ) -> PyResult { + let serialized = Self::stringify_all_keys(py, serialized)?; + let value: serde_json::Value = pythonize::depythonize(&serialized) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let lg_rc = Self::lang_graph_rc(py, &lang_graph); + let mut model = + model_file::from_dict(&value, lg_rc).map_err(from_dict_error_to_py)?; + if model.maltoolbox_version == maltoolbox_model::MALTOOLBOX_VERSION { + model.maltoolbox_version = Self::live_version(py)?; + } + Self::wrap(py, model, lang_graph) + } + + /// Pickling support: reconstructs the nested `lang_graph` from its + /// own pickled dict state first (via + /// `LanguageGraph._from_pickle_state`), then rebuilds `self` from + /// `_from_dict` the normal way - so a pickled `Model` never needs + /// the original `LanguageGraph` Python object to still be around. + #[staticmethod] + fn _from_pickle_state( + py: Python<'_>, + state: &Bound<'_, PyAny>, + lang_graph_state: &Bound<'_, PyAny>, + ) -> PyResult { + let lg_cls = py.import("maltoolbox._native")?.getattr("LanguageGraph")?; + let lang_graph_obj = lg_cls.call_method1("_from_pickle_state", (lang_graph_state,))?; + let lang_graph_py: Py = lang_graph_obj.extract()?; + Self::from_dict_py(py, state, lang_graph_py) + } + + #[allow(clippy::type_complexity)] + fn __reduce__<'py>( + &self, + py: Python<'py>, + ) -> PyResult<(Bound<'py, PyAny>, (Bound<'py, PyAny>, Bound<'py, PyAny>))> { + let cls = py.get_type::(); + let func = cls.getattr("_from_pickle_state")?; + let lang_graph_state = self.lang_graph_py.bind(py).call_method0("_to_dict")?; + let model_state = self._to_dict(py)?; + Ok((func, (model_state, lang_graph_state))) + } + + /// Hands out a new strong reference to this model's shared + /// `Rc>`, wrapped in a `PyCapsule` - the standard + /// CPython mechanism for passing a native pointer between two + /// independently-compiled extension modules. Needed because a pyo3 + /// `pyclass` from a shared dependency crate gets a separate, unrelated + /// type object in every cdylib that statically links it, so a direct + /// downcast across e.g. `maltoolbox._native` and `malsim._native` + /// doesn't work even from identical pinned source - see + /// `INNER_CAPSULE_NAME`'s doc comment and + /// `maltoolbox-attackgraph-py`'s `PyAttackGraph::__inner_capsule__`, + /// which this mirrors. Each call clones the `Rc` (bumping the strong + /// count); the capsule's destructor (`drop_inner_capsule`) drops + /// exactly that one clone when the capsule itself is garbage-collected, + /// so callers don't need to track this `PyModel`'s own lifetime. + fn __inner_capsule__<'py>(&self, py: Python<'py>) -> PyResult> { + let raw = Rc::into_raw(self.inner.clone()) as *mut c_void; + let ptr = NonNull::new(raw).expect("Rc::into_raw is never null"); + unsafe { + PyCapsule::new_with_pointer_and_destructor( + py, + ptr, + INNER_CAPSULE_NAME, + Some(drop_inner_capsule), + ) + } + } + + fn __repr__(&self, py: Python<'_>) -> String { + let lg = self.lang_graph_py.borrow(py); + let graph = lg.inner.as_ref(); + format!( + "Model(name: \"{}\", language: LanguageGraph(id: \"{}\", version: \"{}\"))", + self.name(), + graph.metadata.id, + graph.metadata.version + ) + } +} diff --git a/py-bindings/maltoolbox-pyo3/Cargo.toml b/py-bindings/maltoolbox-pyo3/Cargo.toml new file mode 100644 index 00000000..5c866c3d --- /dev/null +++ b/py-bindings/maltoolbox-pyo3/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "maltoolbox-pyo3" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[lib] +name = "_native" +crate-type = ["cdylib"] +path = "src/lib.rs" + +[dependencies] +maltoolbox-language-py = { workspace = true } +maltoolbox-model-py = { workspace = true } +maltoolbox-attackgraph-py = { workspace = true } +pyo3 = { workspace = true, features = ["extension-module"] } diff --git a/py-bindings/maltoolbox-pyo3/src/lib.rs b/py-bindings/maltoolbox-pyo3/src/lib.rs new file mode 100644 index 00000000..d045070c --- /dev/null +++ b/py-bindings/maltoolbox-pyo3/src/lib.rs @@ -0,0 +1,18 @@ +//! Umbrella crate: assembles each layer's compat crate +//! (`maltoolbox-language-py`, `maltoolbox-model-py`, +//! `maltoolbox-attackgraph-py`) into the single importable native +//! extension module, `maltoolbox._native`. +//! +//! This is the only crate in `py-bindings/` that enables pyo3's +//! `extension-module` feature - the layer crates stay plain `rlib`s so +//! they (and their tests) link against libpython normally. + +use pyo3::prelude::*; + +#[pymodule] +fn _native(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { + maltoolbox_language_py::register(py, m)?; + maltoolbox_model_py::register(py, m)?; + maltoolbox_attackgraph_py::register(py, m)?; + Ok(()) +} diff --git a/pyproject.toml b/pyproject.toml index a5ca4780..ade8f43a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,23 +51,23 @@ dev = [ "maltoolbox" = "maltoolbox.__main__:main" [build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" +requires = ["maturin>=1,<2"] +build-backend = "maturin" -[tool.setuptools.packages.find] -include = ["maltoolbox*"] - -[tool.setuptools.package-data] -maltoolbox = ["*.conf*", "py.typed"] +[tool.maturin] +manifest-path = "py-bindings/maltoolbox-pyo3/Cargo.toml" +module-name = "maltoolbox._native" +python-source = "python" +include = ["maltoolbox/py.typed", "maltoolbox/*.conf*", "maltoolbox/_native/**/*.pyi"] [tool.pytest.ini_options] -pythonpath = ['.'] +pythonpath = ['python'] filterwarnings = "ignore:invalid escape sequence" [tool.mypy] exclude = [ - 'maltoolbox/ingestors', - 'maltoolbox/translators', + 'python/maltoolbox/ingestors', + 'python/maltoolbox/translators', ] allow_redefinition = true @@ -81,6 +81,15 @@ dummy-variable-rgx = "^_$" # Try to fix all that it can fixable = ["ALL"] +# PLC0414 (useless-import-alias) flags the explicit `import X as X` +# re-export idiom as pointless, but that idiom is exactly what the +# native-shim modules (e.g. `maltoolbox/exceptions.py`, +# `maltoolbox/model.py`) use throughout to re-export symbols from +# `maltoolbox._native` - it's required so mypy treats them as +# intentional public re-exports rather than unused imports. See +# PYTHON_BINDINGS_IMPLEMENTATION.md for the shim-module pattern. +ignore = ["PLC0414"] + # Override certain rules based on file path [tool.ruff.lint.per-file-ignores] # Test files @@ -89,7 +98,7 @@ fixable = ["ALL"] "S101", # Allow `assert` statements "S311", # Allow use of the `random` module (no need for cryptographic security) ] -"maltoolbox/language/compiler/*.py" = [ +"python/maltoolbox/language/compiler/*.py" = [ "F405", # Allow defined from star import "F403", # Allow star import "E741", # Allow ambiguous variable name diff --git a/maltoolbox/__init__.py b/python/maltoolbox/__init__.py similarity index 100% rename from maltoolbox/__init__.py rename to python/maltoolbox/__init__.py diff --git a/maltoolbox/__main__.py b/python/maltoolbox/__main__.py similarity index 100% rename from maltoolbox/__main__.py rename to python/maltoolbox/__main__.py diff --git a/python/maltoolbox/_native/__init__.pyi b/python/maltoolbox/_native/__init__.pyi new file mode 100644 index 00000000..4476d0fc --- /dev/null +++ b/python/maltoolbox/_native/__init__.pyi @@ -0,0 +1,633 @@ +"""Type stubs for the compiled PyO3 extension module ``maltoolbox._native``. + +This directory holds stubs only: there is no real ``maltoolbox/_native/`` +package at runtime (the real thing is the compiled +``maltoolbox/_native.*.so`` extension module next to it, built via maturin +from ``py-bindings/maltoolbox-pyo3``, which the import system prefers over +this ``__init__.py``-less directory). It must be named exactly ``_native`` +- type checkers (mypy, pyright/Pylance) only resolve a submodule's stubs +from a same-named ``.pyi`` file or directory beside it; the PEP 561 +``-stubs`` suffix applies to top-level distributions only. + +The native module is assembled from three PyO3 "compat layer" crates: +``maltoolbox-language-py``, ``maltoolbox-model-py``, and +``maltoolbox-attackgraph-py``. Each crate's ``register()`` function (see +each crate's ``src/lib.rs``) adds its classes/exceptions/functions to this +module; ``maltoolbox-language-py``'s ``register()`` additionally nests +submodules at ``maltoolbox._native.language``, +``maltoolbox._native.language.compiler``, +``maltoolbox._native.language.compiler.exceptions``, and +``maltoolbox._native.language.compiler.mal_analyzer`` (see the sibling +``.pyi`` files under ``language/``). +""" + + +from collections.abc import Mapping +from typing import Any + +from typing_extensions import deprecated + +# --------------------------------------------------------------------------- +# Exceptions (maltoolbox-language-py/src/exceptions.rs, Phase 1 hierarchy; +# maltoolbox-model-py/src/exceptions.rs, Phase 2 hierarchy; +# maltoolbox-attackgraph-py/src/exceptions.rs, Phase 3 hierarchy) +# --------------------------------------------------------------------------- + +class MalToolboxException(Exception): + """Base exception for every MAL Toolbox-specific error.""" + +class LanguageGraphException(MalToolboxException): + """Base exception for LanguageGraph-related errors.""" + +class LanguageGraphSuperAssetNotFoundError(LanguageGraphException): + """Raised when an asset's declared super asset cannot be found.""" + +class LanguageGraphAssociationError(LanguageGraphException): + """Raised for invalid/malformed association lookups.""" + +class LanguageGraphStepExpressionError(LanguageGraphException): + """Raised when a step expression fails to resolve.""" + +class ModelException(MalToolboxException): + """Base exception for Model-related errors.""" + +class ModelAssociationException(ModelException): + """Defined for API compatibility; not currently raised by any Rust or + Python code path (see ``maltoolbox-model-py/src/exceptions.rs``).""" + +class DuplicateModelAssociationError(ModelException): + """Defined for API compatibility; not currently raised by any Rust or + Python code path (see ``maltoolbox-model-py/src/exceptions.rs``).""" + +class AttackGraphException(MalToolboxException): + """Base exception for AttackGraph-related errors.""" + +class AttackGraphStepExpressionError(AttackGraphException): + """Raised when a step expression fails to resolve while generating or + regenerating an attack graph.""" + +# --------------------------------------------------------------------------- +# Language layer (maltoolbox-language-py) +# --------------------------------------------------------------------------- + +class LanguageGraphAssociationField: + """One side (left or right) of a `LanguageGraphAssociation`.""" + + @property + def asset(self) -> LanguageGraphAsset: ... + @property + def fieldname(self) -> str: ... + @property + def minimum(self) -> int: ... + @property + def maximum(self) -> int | None: ... + def __hash__(self) -> int: ... + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... + +class LanguageGraphAssociation: + """An association (type) defined in a MAL language, linking two asset + types via a left and a right field.""" + + @property + def name(self) -> str: ... + @property + def left_field(self) -> LanguageGraphAssociationField: ... + @property + def right_field(self) -> LanguageGraphAssociationField: ... + @property + def info(self) -> dict[str, Any]: ... + @property + def full_name(self) -> str: ... + def get_field(self, fieldname: str) -> LanguageGraphAssociationField: ... + def contains_fieldname(self, fieldname: str) -> bool: ... + def contains_asset(self, asset: LanguageGraphAsset) -> bool: ... + def get_opposite_fieldname(self, fieldname: str) -> str: ... + def to_dict(self) -> dict[str, Any]: ... + def __hash__(self) -> int: ... + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... + +class ExpressionsChain: + """A node in a tree of operations describing how to traverse + associations in a language graph (union/intersection/difference/ + collect/field/transitive/subType/assoc_op/multiplicity). Field values + that don't apply to this node's `type` read as `None`.""" + + @property + def type(self) -> str: ... + @property + def left_link(self) -> ExpressionsChain | None: ... + @property + def right_link(self) -> ExpressionsChain | None: ... + @property + def sub_link(self) -> ExpressionsChain | None: ... + @property + def fieldname(self) -> str | None: ... + @property + def association(self) -> LanguageGraphAssociation | None: ... + @property + def subtype(self) -> LanguageGraphAsset | None: ... + @property + def multiplicity(self) -> Any | None: ... + +class LanguageGraphAttackStep: + """An attack step (type) defined on an asset type in a MAL language.""" + + @property + def name(self) -> str: ... + @property + def type(self) -> str: ... + @property + def asset(self) -> LanguageGraphAsset: ... + @property + def causal_mode(self) -> str | None: ... + @property + def ttc(self) -> Any | None: ... + @property + def overrides(self) -> bool: ... + @property + def info(self) -> dict[str, Any]: ... + @property + def tags(self) -> list[str]: ... + @property + def inherits(self) -> LanguageGraphAttackStep | None: ... + @property + def own_children(self) -> dict[LanguageGraphAttackStep, list[ExpressionsChain | None]]: ... + @property + def own_parents(self) -> dict[LanguageGraphAttackStep, list[ExpressionsChain | None]]: ... + @property + def children(self) -> dict[LanguageGraphAttackStep, list[ExpressionsChain | None]]: ... + @property + def parents(self) -> None: + """Always raises `NotImplementedError` - never implemented, on + either the Rust or the original pure-Python side.""" + @property + def own_requires(self) -> list[ExpressionsChain | None]: ... + @property + def requires(self) -> list[ExpressionsChain | None]: ... + @property + def own_additive_model_effects(self) -> list[LanguageGraphModelEffect]: ... + @property + def own_subtractive_model_effects(self) -> list[LanguageGraphModelEffect]: ... + @property + def additive_model_effects(self) -> list[LanguageGraphModelEffect]: ... + @property + def subtractive_model_effects(self) -> list[LanguageGraphModelEffect]: ... + @property + def full_name(self) -> str: ... + @property + def detectors(self) -> dict[str, LanguageGraphDetector]: ... + def to_dict(self) -> dict[str, Any]: ... + def __hash__(self) -> int: ... + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... + +class LanguageGraphAsset: + """An asset (type) defined in a MAL language.""" + + @property + def name(self) -> str: ... + @property + def is_abstract(self) -> bool: ... + @property + def info(self) -> dict[str, Any]: ... + @property + def own_associations(self) -> dict[str, LanguageGraphAssociation]: ... + @property + def attack_steps(self) -> dict[str, LanguageGraphAttackStep]: ... + @property + def own_super_asset(self) -> LanguageGraphAsset | None: ... + @property + def own_sub_assets(self) -> list[LanguageGraphAsset]: ... + @property + def sub_assets(self) -> list[LanguageGraphAsset]: ... + @property + def super_assets(self) -> list[LanguageGraphAsset]: ... + @property + def associations(self) -> dict[str, LanguageGraphAssociation]: ... + def associations_to(self, asset_type: LanguageGraphAsset) -> dict[str, LanguageGraphAssociation]: ... + @property + def variables(self) -> dict[str, tuple[LanguageGraphAsset, ExpressionsChain | None]]: ... + def is_subasset_of(self, target_asset: LanguageGraphAsset) -> bool: ... + def get_all_common_superassets(self, other: LanguageGraphAsset) -> set[str]: ... + def to_dict(self) -> dict[str, Any]: ... + def __hash__(self) -> int: ... + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... + +class LanguageGraphContextItem: + """One entry in a `LanguageGraphDetector.context` mapping.""" + + @property + def label(self) -> str: ... + @property + def asset_type(self) -> LanguageGraphAsset: ... + @property + def attack_step_name(self) -> str | None: ... + @property + def expr(self) -> ExpressionsChain | None: ... + +class LanguageGraphDetector: + """A detector declared on an attack step in a MAL language.""" + + @property + def name(self) -> str | None: ... + @property + def context(self) -> dict[str, LanguageGraphContextItem]: ... + @property + def type(self) -> str | None: ... + @property + def tprate(self) -> float | None: ... + @property + def fprate(self) -> float | None: ... + +class AssocTraversal: + """A single association-field traversal step within a model effect's + `base`/`targets` traversal chains.""" + + @property + def field_name(self) -> str: ... + @property + def asset_filter(self) -> LanguageGraphAsset | None: ... + @property + def quantity_filter(self) -> int | tuple[int, int] | None: ... + +class GlobAssocTraversal: + """A glob (`*`) traversal step matching any association field.""" + + @property + def pattern(self) -> list[AssocTraversal | GlobAssocTraversal | AssocSet]: ... + @property + def quantity_filter(self) -> int | tuple[int, int] | None: ... + +class AssocSet: + """A set-operation (union/difference/intersection) node within a + model effect's traversal chain.""" + + @property + def set_op(self) -> str: + """One of `"UNION"`, `"DIFFERENCE"`, `"INTERSECTION"`.""" + @property + def left(self) -> list[AssocTraversal | GlobAssocTraversal | AssocSet]: ... + @property + def right(self) -> list[AssocTraversal | GlobAssocTraversal | AssocSet]: ... + @property + def quantity_filter(self) -> int | tuple[int, int] | None: ... + +class DynTarget: + """A single target (association to add/remove) of a dynamic sentence's + model effect.""" + + @property + def assoc_op(self) -> bool: ... + @property + def assoc_traversal(self) -> list[AssocTraversal | GlobAssocTraversal | AssocSet]: ... + +class LanguageGraphModelEffect: + """A dynamic sentence's effect on the model instance (addition or + removal of assets/associations), triggered by compromising an attack + step.""" + + @property + def model_effect_type(self) -> str: + """One of `"ADDITIVE"`, `"SUBTRACTIVE"`.""" + @property + def base(self) -> list[AssocTraversal | GlobAssocTraversal | AssocSet]: ... + @property + def targets(self) -> list[DynTarget]: ... + +class LanguageGraph: + """Graph representation of a MAL language.""" + + def __init__(self, lang_spec: dict[str, Any]) -> None: + """Build a graph directly from an already-compiled langspec dict + (the shape `MalCompiler().compile(...)` produces, same as what's + inside a `.mar` archive's `langspec.json`).""" + @staticmethod + def load_from_file(path: str) -> LanguageGraph: + """Create a LanguageGraph from mal, mar, yaml, or json.""" + @staticmethod + def from_mal_spec(path: str) -> LanguageGraph: + """Create a LanguageGraph from a `.mal` file (a MAL spec).""" + @staticmethod + def from_mar_archive(path: str) -> LanguageGraph: + """Create a LanguageGraph from a `.mar` archive provided by malc.""" + def save_to_file(self, path: str) -> None: + """Save to json/yml/mar depending on extension.""" + def to_mar_archive(self, path: str) -> None: + """Save the LanguageGraph's `lang_spec` to a `.mar` archive that + can be loaded with `from_mar_archive`.""" + def save_language_specification_to_json(self, filename: str) -> None: + """Save the MAL language specification dictionary to a JSON file.""" + @deprecated('LanguageGraph.regenerate_graph() does nothing and will be removed.') + def regenerate_graph(self) -> None: + """Deprecated: does nothing and emits a `DeprecationWarning`.""" + @property + def metadata(self) -> dict[str, str]: ... + @property + def lang_spec(self) -> dict[str, Any]: ... + @property + def assets(self) -> dict[str, LanguageGraphAsset]: ... + @property + def associations(self) -> set[LanguageGraphAssociation]: ... + @property + def attack_steps(self) -> set[LanguageGraphAttackStep]: ... + @property + def fieldname_to_candidate_steps(self) -> dict[str, set[tuple[str, str]]]: ... + def _to_dict(self) -> dict[str, Any]: ... + def __reduce__(self) -> tuple[Any, ...]: ... + +# --------------------------------------------------------------------------- +# Model layer (maltoolbox-model-py) +# --------------------------------------------------------------------------- + +class ModelAsset: + """An instance of an asset (type) in a `Model`. Only ever handed out + by a `Model` - no public constructor.""" + + @property + def name(self) -> str: ... + @property + def id(self) -> int: ... + @property + def type(self) -> str: ... + @property + def lg_asset(self) -> LanguageGraphAsset: ... + @property + def defenses(self) -> dict[str, float]: ... + @property + def extras(self) -> dict[str, Any]: ... + @extras.setter + def extras(self, value: dict[str, Any]) -> None: ... + @property + def associated_assets(self) -> dict[str, set[ModelAsset]]: ... + def associations_with(self, other: ModelAsset) -> list[LanguageGraphAssociation]: ... + def has_association_with(self, other: ModelAsset, assoc_name: str) -> bool: ... + def validate_associated_assets( + self, fieldname: str, assets_to_add: set[ModelAsset] + ) -> None: ... + def add_associated_assets(self, fieldname: str, assets: set[ModelAsset]) -> None: ... + def remove_associated_assets(self, fieldname: str, assets: set[ModelAsset]) -> None: ... + def to_dict(self) -> dict[int, dict[str, Any]]: ... + def _to_dict(self) -> dict[int, dict[str, Any]]: ... + def __hash__(self) -> int: ... + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... + +class Model: + """An implementation of a MAL language model containing assets.""" + + def __init__( + self, + name: str, + lang_graph: LanguageGraph, + mt_version: str | None = None, + ) -> None: ... + @property + def name(self) -> str: ... + @name.setter + def name(self, value: str) -> None: ... + @property + def lang_graph(self) -> LanguageGraph: ... + @property + def maltoolbox_version(self) -> str: ... + @property + def next_id(self) -> int: ... + @next_id.setter + def next_id(self, value: int) -> None: ... + @property + def assets(self) -> dict[int, ModelAsset]: ... + def add_asset( + self, + asset_type: str, + name: str | None = None, + asset_id: int | None = None, + defenses: dict[str, float] | None = None, + extras: dict[str, Any] | None = None, + allow_duplicate_names: bool = True, + ) -> ModelAsset: + """Create an asset based on the provided parameters and add it to + the model.""" + def remove_asset(self, asset: ModelAsset) -> None: + """Remove an asset from the model.""" + def get_asset_by_id(self, asset_id: int) -> ModelAsset | None: ... + def get_asset_by_name(self, asset_name: str) -> ModelAsset | None: ... + def to_dict(self) -> dict[str, Any]: ... + def _to_dict(self) -> dict[str, Any]: ... + def save_to_file(self, filename: str) -> None: + """Save to json/yml depending on extension.""" + @staticmethod + def load_from_file(filename: str, lang_graph: LanguageGraph) -> Model: + """Create from json or yaml file depending on file extension.""" + @staticmethod + def _from_dict(serialized: dict[str, Any], lang_graph: LanguageGraph) -> Model: + """Deserialize a previously-`_to_dict`'d model dict. Used by + `maltoolbox.translators.updater` to rebuild older-schema models.""" + def __reduce__(self) -> tuple[Any, ...]: ... + +# --------------------------------------------------------------------------- +# Attack graph layer (maltoolbox-attackgraph-py) +# --------------------------------------------------------------------------- + +class Detector: + """A detector attached to an `AttackGraphNode`.""" + + def __init__( + self, + name: str | None, + node: AttackGraphNode, + potential_context: dict[str, set[AttackGraphNode]], + tprate: float | None = None, + fprate: float | None = None, + ) -> None: ... + @property + def name(self) -> str | None: ... + @property + def node(self) -> AttackGraphNode: ... + @property + def potential_context(self) -> dict[str, set[AttackGraphNode]]: ... + @property + def tprate(self) -> float | None: ... + @property + def fprate(self) -> float | None: ... + def to_dict(self) -> dict[str, Any]: ... + def __hash__(self) -> int: + """Always raises `TypeError` ('unhashable type: Detector') - + mirrors the real runtime effect of the original frozen + dataclass's auto-generated `__hash__` (its `potential_context` + dict field is itself unhashable).""" + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... + def __reduce__(self) -> tuple[Any, ...]: ... + +class AttackGraphNode: + """A single node (attack step or defense step instance) in an + `AttackGraph`. + + Nodes handed out by an owning `AttackGraph` (via `.nodes`, + `.attack_steps`, `.defense_steps`, `.add_node`, traversal, ...) support + the full API below. A node constructed directly + (`AttackGraphNode(node_id, lg_attack_step, ...)`) is "detached" - it + has no owning graph, and only supports `.id`/`.name`/`.children`/ + `.parents` (get and set) plus `__repr__`/default identity hash+eq; + every other attribute/method raises `NotImplementedError` on a + detached node. + """ + + def __init__( + self, + node_id: int, + lg_attack_step: LanguageGraphAttackStep, + model_asset: ModelAsset | None = None, + ttc_dist: dict[str, Any] | None = None, + existence_status: bool | None = None, + full_name: str | None = None, + ) -> None: ... + @property + def id(self) -> int: ... + @property + def name(self) -> str: ... + @property + def type(self) -> str: ... + @property + def lg_attack_step(self) -> LanguageGraphAttackStep: ... + @property + def causal_mode(self) -> str | None: ... + @property + def ttc(self) -> Any | None: ... + @property + def tags(self) -> list[str]: ... + @property + def additive_model_effects(self) -> list[LanguageGraphModelEffect] | None: ... + @property + def subtractive_model_effects(self) -> list[LanguageGraphModelEffect] | None: ... + @property + def model_asset(self) -> ModelAsset | None: ... + @property + def existence_status(self) -> bool | None: ... + @existence_status.setter + def existence_status(self, value: bool | None) -> None: ... + @property + def children(self) -> set[AttackGraphNode]: ... + @children.setter + def children(self, value: set[AttackGraphNode] | Any) -> None: ... + @property + def parents(self) -> set[AttackGraphNode]: ... + @parents.setter + def parents(self, value: set[AttackGraphNode] | Any) -> None: ... + @property + def extras(self) -> dict[str, Any]: ... + @property + def detectors(self) -> dict[str, Detector]: ... + @property + def full_name(self) -> str: ... + @property + def info(self) -> dict[str, Any]: ... + def to_dict(self) -> dict[str, Any]: ... + def __hash__(self) -> int: ... + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... + def __reduce__(self) -> tuple[Any, ...]: ... + def __deepcopy__(self, memo: dict[int, Any]) -> AttackGraphNode: ... + +class AttackGraph: + """A graph of `AttackGraphNode`s generated from a `Model` + a + `LanguageGraph`.""" + + def __init__( + self, + lang_graph: LanguageGraph | None, + model: Model | None = None, + ) -> None: ... + @property + def lang_graph(self) -> LanguageGraph: ... + @property + def model(self) -> Model | None: ... + @model.setter + def model(self, value: Model | None) -> None: ... + @property + def next_node_id(self) -> int: ... + @property + def nodes(self) -> Mapping[int, AttackGraphNode]: + """A lazy read-only `Mapping[int, AttackGraphNode]` view by + default; freely settable to any `dict[int, AttackGraphNode]` + (e.g. to hand-build a small graph of detached nodes), in which + case that dict is returned as-is until `regenerate_graph()` is + called.""" + @nodes.setter + def nodes(self, value: dict[int, AttackGraphNode]) -> None: ... + @property + def full_name_to_node(self) -> dict[str, AttackGraphNode]: ... + @property + def attack_steps(self) -> list[AttackGraphNode]: ... + @property + def defense_steps(self) -> list[AttackGraphNode]: ... + @property + def detectors(self) -> list[Detector]: + """Live, mutable flat list of every `Detector` in the graph, + lazily seeded on first access and never automatically kept in + sync with each node's own `.detectors` dict afterward.""" + def get_node_by_full_name(self, full_name: str) -> AttackGraphNode: ... + def regenerate_graph(self) -> None: + """Full rebuild from `self.model`, discarding every existing + node. Requires `self.model` to be set.""" + def partially_regenerate_graph( + self, + new_assets: set[ModelAsset] | None = None, + new_associations: set[tuple[ModelAsset, str, ModelAsset]] | None = None, + removed_assets: set[ModelAsset] | None = None, + removed_associations: set[tuple[ModelAsset, str, ModelAsset]] | None = None, + ) -> list[AttackGraphNode]: + """Incrementally update the graph for the given asset/association + deltas. Requires `self.model` to be set. Returns the newly + created nodes.""" + def add_node( + self, + lg_attack_step: LanguageGraphAttackStep, + node_id: int | None = None, + model_asset: ModelAsset | None = None, + ttc_dist: dict[str, Any] | None = None, + existence_status: bool | None = None, + full_name: str | None = None, + ) -> AttackGraphNode: ... + def remove_node(self, node: AttackGraphNode) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + def _to_dict(self) -> dict[str, Any]: ... + def save_to_file(self, filename: str) -> None: ... + @staticmethod + def load_from_file( + filename: str, + lang_graph: LanguageGraph, + model: Model | None = None, + ) -> AttackGraph: ... + def __reduce__(self) -> tuple[Any, ...]: ... + def __deepcopy__(self, memo: dict[int, Any]) -> AttackGraph: ... + +def create_attack_graph( + lang: str | LanguageGraph, + model: str | Model, +) -> AttackGraph: + """Create and return an attack graph. + + Arguments: + --------- + lang - path to a language file (`.mar` or `.mal`), or an + already-loaded `LanguageGraph` object + model - path to a model file (yaml or json), or an already-loaded + `Model` object + """ + +# --------------------------------------------------------------------------- +# Internal pickling/`__reduce__` helpers - not part of the public API, but +# importable (`maltoolbox._native._rebuild_...`) since `__reduce__` targets +# must be resolvable by name. +# --------------------------------------------------------------------------- + +def _rebuild_language_graph_asset(owner: LanguageGraph, ffi_id: int) -> LanguageGraphAsset: ... +def _rebuild_language_graph_attack_step( + owner: LanguageGraph, asset_name: str, step_name: str +) -> LanguageGraphAttackStep: ... +def _rebuild_attack_graph_node(owner: AttackGraph, id: int) -> AttackGraphNode: ... + +def __getattr__(name: str) -> Any: ... diff --git a/python/maltoolbox/_native/language/__init__.pyi b/python/maltoolbox/_native/language/__init__.pyi new file mode 100644 index 00000000..26626e0e --- /dev/null +++ b/python/maltoolbox/_native/language/__init__.pyi @@ -0,0 +1,8 @@ +"""Type stub for the ``maltoolbox._native.language`` nested submodule. + +Nothing imports directly from this level (only its ``compiler.exceptions``/ +``compiler.mal_analyzer`` leaves are used from Python - see +``maltoolbox/language/compiler/exceptions.py``/``mal_analyzer.py``), so +this stub is intentionally empty. +""" + diff --git a/python/maltoolbox/_native/language/compiler/__init__.pyi b/python/maltoolbox/_native/language/compiler/__init__.pyi new file mode 100644 index 00000000..366ac59c --- /dev/null +++ b/python/maltoolbox/_native/language/compiler/__init__.pyi @@ -0,0 +1,8 @@ +"""Type stub for the ``maltoolbox._native.language.compiler`` nested +submodule. + +Nothing imports directly from this level (only its leaf ``exceptions``/ +``mal_analyzer`` submodules are used from Python), so this stub is +intentionally empty. +""" + diff --git a/python/maltoolbox/_native/language/compiler/exceptions.pyi b/python/maltoolbox/_native/language/compiler/exceptions.pyi new file mode 100644 index 00000000..b5e3c9af --- /dev/null +++ b/python/maltoolbox/_native/language/compiler/exceptions.pyi @@ -0,0 +1,45 @@ +"""Type stub for ``maltoolbox._native.language.compiler.exceptions``. + +A *separate* exception hierarchy from `maltoolbox._native.MalToolboxException` +- matches the pure-Python originals in `maltoolbox/language/compiler/exceptions.py`, +which subclass `Exception` directly rather than any MAL Toolbox-specific +base. Imported directly by `maltoolbox/language/compiler/exceptions.py`. +""" + + +class MalCompilerError(Exception): + """Base exception for every MAL compiler error.""" + +class MalSyntaxError(MalCompilerError): + """Raised for a syntax error while parsing a `.mal` file. + + Takes extra positional args beyond the message: + `MalSyntaxError(message, line=None, column=None)`. + """ + + def __init__( + self, + message: str, + line: int | None = None, + column: int | None = None, + ) -> None: ... + +class MalParseError(MalCompilerError): + """Raised for a parse error. Not currently produced by the Rust + compiler (which collapses this case into the base + `MalCompilerError`), but kept importable for API compatibility.""" + +class MalTypeError(MalCompilerError): + """Raised for a type error. Not currently produced by the Rust + compiler (which collapses this case into the base + `MalCompilerError`), but kept importable for API compatibility.""" + +class MalNameError(MalCompilerError): + """Raised for a name-resolution error. Not currently produced by the + Rust compiler (which collapses this case into the base + `MalCompilerError`), but kept importable for API compatibility.""" + +class MalCompilationError(MalCompilerError): + """Raised for a generic compilation failure. Not currently produced + by the Rust compiler (which collapses this case into the base + `MalCompilerError`), but kept importable for API compatibility.""" diff --git a/python/maltoolbox/_native/language/compiler/mal_analyzer.pyi b/python/maltoolbox/_native/language/compiler/mal_analyzer.pyi new file mode 100644 index 00000000..e8139505 --- /dev/null +++ b/python/maltoolbox/_native/language/compiler/mal_analyzer.pyi @@ -0,0 +1,15 @@ +"""Type stub for ``maltoolbox._native.language.compiler.mal_analyzer``. + +A *separate* exception hierarchy, not under +`maltoolbox._native.MalToolboxException` nor +`maltoolbox._native.language.compiler.exceptions.MalCompilerError` - +matches the pure-Python original, which subclasses `Exception` directly. +The unconventional lowercase-`m` class name is intentional and exact: +`assoc_traversal_processor.py` (and other call sites) import and raise it +by this exact name. Imported directly by +`maltoolbox/language/compiler/mal_analyzer.py`. +""" + + +class malAnalyzerException(Exception): + """Raised by the MAL semantic analyzer for a semantic/analysis error.""" diff --git a/maltoolbox/attackgraph/__init__.py b/python/maltoolbox/attackgraph/__init__.py similarity index 100% rename from maltoolbox/attackgraph/__init__.py rename to python/maltoolbox/attackgraph/__init__.py diff --git a/maltoolbox/attackgraph/analyzers/__init__.py b/python/maltoolbox/attackgraph/analyzers/__init__.py similarity index 100% rename from maltoolbox/attackgraph/analyzers/__init__.py rename to python/maltoolbox/attackgraph/analyzers/__init__.py diff --git a/python/maltoolbox/attackgraph/attackgraph.py b/python/maltoolbox/attackgraph/attackgraph.py new file mode 100644 index 00000000..a605d9db --- /dev/null +++ b/python/maltoolbox/attackgraph/attackgraph.py @@ -0,0 +1,10 @@ +"""MAL-Toolbox Attack Graph Module + +`AttackGraph` is implemented in Rust, exposed here via +`maltoolbox._native` - see PYTHON_BINDINGS_IMPLEMENTATION.md at the +repo root for the full rationale and status. +""" + +from __future__ import annotations + +from maltoolbox._native import AttackGraph as AttackGraph diff --git a/python/maltoolbox/attackgraph/detector.py b/python/maltoolbox/attackgraph/detector.py new file mode 100644 index 00000000..a420a9ef --- /dev/null +++ b/python/maltoolbox/attackgraph/detector.py @@ -0,0 +1,9 @@ +"""MAL-Toolbox Attack Graph Detector + +Implemented in Rust, exposed here via `maltoolbox._native` - see +PYTHON_BINDINGS_IMPLEMENTATION.md at the repo root. +""" + +from __future__ import annotations + +from maltoolbox._native import Detector as Detector diff --git a/maltoolbox/attackgraph/factories.py b/python/maltoolbox/attackgraph/factories.py similarity index 100% rename from maltoolbox/attackgraph/factories.py rename to python/maltoolbox/attackgraph/factories.py diff --git a/maltoolbox/attackgraph/file_utils.py b/python/maltoolbox/attackgraph/file_utils.py similarity index 100% rename from maltoolbox/attackgraph/file_utils.py rename to python/maltoolbox/attackgraph/file_utils.py diff --git a/python/maltoolbox/attackgraph/node.py b/python/maltoolbox/attackgraph/node.py new file mode 100644 index 00000000..8f59cdb1 --- /dev/null +++ b/python/maltoolbox/attackgraph/node.py @@ -0,0 +1,9 @@ +"""MAL-Toolbox Attack Graph Node + +Implemented in Rust, exposed here via `maltoolbox._native` - see +PYTHON_BINDINGS_IMPLEMENTATION.md at the repo root. +""" + +from __future__ import annotations + +from maltoolbox._native import AttackGraphNode as AttackGraphNode diff --git a/python/maltoolbox/exceptions.py b/python/maltoolbox/exceptions.py new file mode 100644 index 00000000..8bea6032 --- /dev/null +++ b/python/maltoolbox/exceptions.py @@ -0,0 +1,27 @@ +"""MAL-Toolbox exception hierarchy. + +Implemented in Rust, exposed here via `maltoolbox._native` - see +PYTHON_BINDINGS_IMPLEMENTATION.md at the repo root (`### Error -> +exception mapping`) for the full hierarchy and rationale. +""" + +from maltoolbox._native import AttackGraphException as AttackGraphException +from maltoolbox._native import ( + AttackGraphStepExpressionError as AttackGraphStepExpressionError, +) +from maltoolbox._native import ( + DuplicateModelAssociationError as DuplicateModelAssociationError, +) +from maltoolbox._native import ( + LanguageGraphAssociationError as LanguageGraphAssociationError, +) +from maltoolbox._native import LanguageGraphException as LanguageGraphException +from maltoolbox._native import ( + LanguageGraphStepExpressionError as LanguageGraphStepExpressionError, +) +from maltoolbox._native import ( + LanguageGraphSuperAssetNotFoundError as LanguageGraphSuperAssetNotFoundError, +) +from maltoolbox._native import MalToolboxException as MalToolboxException +from maltoolbox._native import ModelAssociationException as ModelAssociationException +from maltoolbox._native import ModelException as ModelException diff --git a/maltoolbox/file_utils.py b/python/maltoolbox/file_utils.py similarity index 100% rename from maltoolbox/file_utils.py rename to python/maltoolbox/file_utils.py diff --git a/python/maltoolbox/language/__init__.py b/python/maltoolbox/language/__init__.py new file mode 100644 index 00000000..6f7fa55e --- /dev/null +++ b/python/maltoolbox/language/__init__.py @@ -0,0 +1,16 @@ +"""Contains tools to process MAL languages""" + +from .expression_chain import ExpressionsChain +from .language_graph_asset import LanguageGraphAsset +from .language_graph_assoc import LanguageGraphAssociation +from .language_graph_attack_step import LanguageGraphAttackStep +from .languagegraph import LanguageGraph, disaggregate_attack_step_full_name + +__all__ = [ + 'ExpressionsChain', + 'LanguageGraph', + 'LanguageGraphAsset', + 'LanguageGraphAssociation', + 'LanguageGraphAttackStep', + 'disaggregate_attack_step_full_name', +] diff --git a/maltoolbox/language/compiler/__init__.py b/python/maltoolbox/language/compiler/__init__.py similarity index 100% rename from maltoolbox/language/compiler/__init__.py rename to python/maltoolbox/language/compiler/__init__.py diff --git a/maltoolbox/language/compiler/distributions.py b/python/maltoolbox/language/compiler/distributions.py similarity index 100% rename from maltoolbox/language/compiler/distributions.py rename to python/maltoolbox/language/compiler/distributions.py diff --git a/python/maltoolbox/language/compiler/exceptions.py b/python/maltoolbox/language/compiler/exceptions.py new file mode 100644 index 00000000..5b00f6e1 --- /dev/null +++ b/python/maltoolbox/language/compiler/exceptions.py @@ -0,0 +1,27 @@ +"""MalCompiler exception hierarchy. + +Implemented in Rust, exposed here via `maltoolbox._native` - see +PYTHON_BINDINGS_IMPLEMENTATION.md at the repo root (Phase 4 decision 6) +for why these specific classes are shimmed even though `MalCompiler` +itself stays pure Python (Phase 4 decision 5): `LanguageGraph +.from_mal_spec` is native-backed and raises these same classes +internally, so the exception *class objects* must be shared for +`pytest.raises(MalCompilerError)`-style catching to work regardless of +which compiler (pure-Python `MalCompiler`, or the Rust one driving +`LanguageGraph.from_mal_spec`) raised it. +""" + +from maltoolbox._native.language.compiler.exceptions import ( + MalCompilationError as MalCompilationError, +) +from maltoolbox._native.language.compiler.exceptions import ( + MalCompilerError as MalCompilerError, +) +from maltoolbox._native.language.compiler.exceptions import MalNameError as MalNameError +from maltoolbox._native.language.compiler.exceptions import ( + MalParseError as MalParseError, +) +from maltoolbox._native.language.compiler.exceptions import ( + MalSyntaxError as MalSyntaxError, +) +from maltoolbox._native.language.compiler.exceptions import MalTypeError as MalTypeError diff --git a/maltoolbox/language/compiler/lang.py b/python/maltoolbox/language/compiler/lang.py similarity index 100% rename from maltoolbox/language/compiler/lang.py rename to python/maltoolbox/language/compiler/lang.py diff --git a/maltoolbox/language/compiler/mal_analyzer.py b/python/maltoolbox/language/compiler/mal_analyzer.py similarity index 99% rename from maltoolbox/language/compiler/mal_analyzer.py rename to python/maltoolbox/language/compiler/mal_analyzer.py index c09db9ef..623c634b 100644 --- a/maltoolbox/language/compiler/mal_analyzer.py +++ b/python/maltoolbox/language/compiler/mal_analyzer.py @@ -4,17 +4,15 @@ from tree_sitter import Node +from maltoolbox._native.language.compiler.mal_analyzer import ( + malAnalyzerException as malAnalyzerException, +) + from .distributions import Distributions, DistributionsException logger = logging.getLogger(__name__) -class malAnalyzerException(Exception): - def __init__(self, error_message): - self._error_message = error_message - super().__init__(self._error_message) - - class malAnalyzer: """ A class to preform syntax-checks for MAL. diff --git a/maltoolbox/language/compiler/mal_compiler.py b/python/maltoolbox/language/compiler/mal_compiler.py similarity index 100% rename from maltoolbox/language/compiler/mal_compiler.py rename to python/maltoolbox/language/compiler/mal_compiler.py diff --git a/maltoolbox/language/expression_chain.py b/python/maltoolbox/language/expression_chain.py similarity index 100% rename from maltoolbox/language/expression_chain.py rename to python/maltoolbox/language/expression_chain.py diff --git a/python/maltoolbox/language/language_graph_asset.py b/python/maltoolbox/language/language_graph_asset.py new file mode 100644 index 00000000..eb6c248b --- /dev/null +++ b/python/maltoolbox/language/language_graph_asset.py @@ -0,0 +1,10 @@ +"""LanguageGraphAsset functionality +- Represents an asset (type) defined in a MAL language + +Implemented in Rust, exposed here via `maltoolbox._native` - see +PYTHON_BINDINGS_IMPLEMENTATION.md at the repo root. +""" + +from __future__ import annotations + +from maltoolbox._native import LanguageGraphAsset as LanguageGraphAsset diff --git a/python/maltoolbox/language/language_graph_assoc.py b/python/maltoolbox/language/language_graph_assoc.py new file mode 100644 index 00000000..9cd5135f --- /dev/null +++ b/python/maltoolbox/language/language_graph_assoc.py @@ -0,0 +1,13 @@ +"""LanguageGraphAssoc functionality +- Represents an association (type) defined in a MAL language + +Implemented in Rust, exposed here via `maltoolbox._native` - see +PYTHON_BINDINGS_IMPLEMENTATION.md at the repo root. +""" + +from __future__ import annotations + +from maltoolbox._native import LanguageGraphAssociation as LanguageGraphAssociation +from maltoolbox._native import ( + LanguageGraphAssociationField as LanguageGraphAssociationField, +) diff --git a/python/maltoolbox/language/language_graph_attack_step.py b/python/maltoolbox/language/language_graph_attack_step.py new file mode 100644 index 00000000..842646cc --- /dev/null +++ b/python/maltoolbox/language/language_graph_attack_step.py @@ -0,0 +1,10 @@ +"""LanguageGraphAttackStep functionality +- Represents a step (type) defined in a MAL language + +Implemented in Rust, exposed here via `maltoolbox._native` - see +PYTHON_BINDINGS_IMPLEMENTATION.md at the repo root. +""" + +from __future__ import annotations + +from maltoolbox._native import LanguageGraphAttackStep as LanguageGraphAttackStep diff --git a/maltoolbox/language/language_graph_lookup.py b/python/maltoolbox/language/language_graph_lookup.py similarity index 100% rename from maltoolbox/language/language_graph_lookup.py rename to python/maltoolbox/language/language_graph_lookup.py diff --git a/python/maltoolbox/language/language_graph_model_effect.py b/python/maltoolbox/language/language_graph_model_effect.py new file mode 100644 index 00000000..1e6d4a8f --- /dev/null +++ b/python/maltoolbox/language/language_graph_model_effect.py @@ -0,0 +1,61 @@ +"""LanguageGraphModelEffect functionality +- Represents a dynamic sentence's effect on the model instance (addition + or removal of assets/associations), triggered by compromising an + attack step. + +`AssocTraversal`/`GlobAssocTraversal`/`AssocSet`/`DynTarget`/ +`LanguageGraphModelEffect` are implemented in Rust and exposed here via +the `maltoolbox._native` extension module - see +PYTHON_BINDINGS_IMPLEMENTATION.md at the repo root (Phase 4a, "model +effects wrapper") for the full rationale and status. + +`ModelEffectType`/`SetOperation` are not native pyclasses: the Rust side +returns `LanguageGraphModelEffect.model_effect_type` and `AssocSet.set_op` +as plain `str`s (`"ADDITIVE"`/`"SUBTRACTIVE"`, `"UNION"`/`"DIFFERENCE"`/ +`"INTERSECTION"`), so this shim re-creates both as `str`-backed `Enum`s to +preserve the old pure-Python call sites that compare them against the +enum members (e.g. `model_effect.model_effect_type == +ModelEffectType.ADDITIVE`, `assoc_set.set_op == SetOperation.UNION`). A +plain (non-`str`) `Enum` would make those comparisons always `False`. +""" + +from __future__ import annotations + +from enum import Enum + +from maltoolbox._native import AssocSet as AssocSet +from maltoolbox._native import AssocTraversal as AssocTraversal +from maltoolbox._native import DynTarget as DynTarget +from maltoolbox._native import GlobAssocTraversal as GlobAssocTraversal +from maltoolbox._native import LanguageGraphModelEffect as LanguageGraphModelEffect + + +class ModelEffectType(str, Enum): + """The type of effect on the model instance, i.e. addition or subtraction. + + Backed by `str` so it compares equal to the plain strings + (`"ADDITIVE"`/`"SUBTRACTIVE"`) returned by the native + `LanguageGraphModelEffect.model_effect_type` getter. + """ + + ADDITIVE = "ADDITIVE" + SUBTRACTIVE = "SUBTRACTIVE" + + +class SetOperation(str, Enum): + """A set operation between the results of two AssocTraversalChains. + + Backed by `str` so it compares equal to the plain strings + (`"UNION"`/`"DIFFERENCE"`/`"INTERSECTION"`) returned by the native + `AssocSet.set_op` getter. + """ + + UNION = "UNION" + DIFFERENCE = "DIFFERENCE" + INTERSECTION = "INTERSECTION" + + +# Mirrors the old pure-Python `AssocTraversalChain: TypeAlias = list[...]` - +# kept as a plain type alias for type hints; the native side builds these +# as ordinary Python lists of the element pyclasses above. +AssocTraversalChain = list[AssocTraversal | GlobAssocTraversal | AssocSet] diff --git a/python/maltoolbox/language/languagegraph.py b/python/maltoolbox/language/languagegraph.py new file mode 100644 index 00000000..c9e46ad4 --- /dev/null +++ b/python/maltoolbox/language/languagegraph.py @@ -0,0 +1,98 @@ +"""MAL-Toolbox Language Graph functionality + +`LanguageGraph` itself is implemented in Rust and exposed here via the +`maltoolbox._native` extension module - see PYTHON_BINDINGS_IMPLEMENTATION.md +at the repo root for the full rationale. This module only keeps the +pieces that have no native equivalent: `disaggregate_attack_step_full_name` +(a trivial string helper) and `load_language_graph_from_file`'s `.git` +URL branch (`language_graph_from_git_url` needs a live git clone at +runtime - explicitly out of scope for the Rust port, see +`PORTING_NOTES.md` §1). + +The pure-Python original's `LanguageGraph.load_from_file` classmethod +*was* `load_language_graph_from_file` (it dispatched `.git`-suffixed +filenames to a clone-then-load path before falling through to the +mal/mar/yaml/json loader). The Rust-native `LanguageGraph.load_from_file` +staticmethod never got that `.git` dispatch ported, so any caller that +does `from maltoolbox._native import LanguageGraph` (directly, or via +`maltoolbox.language`'s re-export) and then calls +`LanguageGraph.load_from_file(git_url)` - e.g. mal-simulator's +`Scenario` loader - got `TypeError: Unknown file extension`. Rather than +require every such caller to route through +`load_language_graph_from_file` instead, we monkeypatch the native +staticmethod itself at import time below: `LanguageGraph.load_from_file` +now checks for a `.git` suffix first and only falls through to the +original native implementation otherwise. This keeps the fix in one +place and makes it transparent to every existing call site. +""" + +from __future__ import annotations + +import logging + +from maltoolbox._native import LanguageGraph +from maltoolbox.file_utils import download_git_repo + +logger = logging.getLogger(__name__) + +_native_load_from_file = LanguageGraph.load_from_file + + +def disaggregate_attack_step_full_name(attack_step_full_name: str) -> list[str]: + """From an attack step full name, get (asset_name, attack_step_name)""" + return attack_step_full_name.split(':') + + +def language_graph_from_git_url(git_url: str) -> LanguageGraph: + """Create a LanguageGraph from a git url pointing to MAL lang. + + The git repository contains a src/main/mal/main.mal + which is the file to load as the MAL language specification. + The git repo will be cloned to a local directory named ./.langs. + + Arguments: + --------- + git_url - the git url pointing to the MAL language specification + + """ + logger.info('Loading language graph from git url %s', git_url) + + dir = download_git_repo(git_url) + mal_files = list(dir.rglob('*.mal')) + + if not mal_files: + raise FileNotFoundError( + 'Execution failed: No .mal files found in the cloned repository.' + ) + + if len(mal_files) == 1: + mal_file = mal_files[0] + else: + main_mal_files = [f for f in mal_files if f.name == 'main.mal'] + if main_mal_files: + mal_file = main_mal_files[0] + else: + raise ValueError( + 'Execution failed: .mal files found but no main.mal file in the repository.' + ) + + return LanguageGraph.from_mal_spec(str(mal_file)) + + +def _load_from_file_with_git_support(filename: str) -> LanguageGraph: + """Create LanguageGraph from mal, mar, yaml, json or git url. + + Patched onto `LanguageGraph.load_from_file` below so every caller + (direct native import or via this module) gets `.git` dispatch. + """ + if filename.endswith('.git'): + return language_graph_from_git_url(filename) + return _native_load_from_file(filename) + + +LanguageGraph.load_from_file = staticmethod(_load_from_file_with_git_support) # type: ignore[method-assign, assignment] + + +def load_language_graph_from_file(filename: str) -> LanguageGraph: + """Create LanguageGraph from mal, mar, yaml, json or git url""" + return LanguageGraph.load_from_file(filename) diff --git a/python/maltoolbox/model.py b/python/maltoolbox/model.py new file mode 100644 index 00000000..d09e6f1a --- /dev/null +++ b/python/maltoolbox/model.py @@ -0,0 +1,12 @@ +"""MAL-Toolbox Model Module + +`Model`/`ModelAsset` are implemented in Rust and exposed here via the +`maltoolbox._native` extension module - see +PYTHON_BINDINGS_IMPLEMENTATION.md at the repo root for the full +rationale and status. +""" + +from __future__ import annotations + +from maltoolbox._native import Model as Model +from maltoolbox._native import ModelAsset as ModelAsset diff --git a/maltoolbox/patternfinder/__init__.py b/python/maltoolbox/patternfinder/__init__.py similarity index 100% rename from maltoolbox/patternfinder/__init__.py rename to python/maltoolbox/patternfinder/__init__.py diff --git a/maltoolbox/patternfinder/attackgraph_patterns.py b/python/maltoolbox/patternfinder/attackgraph_patterns.py similarity index 100% rename from maltoolbox/patternfinder/attackgraph_patterns.py rename to python/maltoolbox/patternfinder/attackgraph_patterns.py diff --git a/maltoolbox/py.typed b/python/maltoolbox/py.typed similarity index 100% rename from maltoolbox/py.typed rename to python/maltoolbox/py.typed diff --git a/maltoolbox/str_utils.py b/python/maltoolbox/str_utils.py similarity index 100% rename from maltoolbox/str_utils.py rename to python/maltoolbox/str_utils.py diff --git a/maltoolbox/translators/__init__.py b/python/maltoolbox/translators/__init__.py similarity index 100% rename from maltoolbox/translators/__init__.py rename to python/maltoolbox/translators/__init__.py diff --git a/maltoolbox/translators/networkx.py b/python/maltoolbox/translators/networkx.py similarity index 100% rename from maltoolbox/translators/networkx.py rename to python/maltoolbox/translators/networkx.py diff --git a/maltoolbox/translators/updater.py b/python/maltoolbox/translators/updater.py similarity index 100% rename from maltoolbox/translators/updater.py rename to python/maltoolbox/translators/updater.py diff --git a/maltoolbox/visualization/__init__.py b/python/maltoolbox/visualization/__init__.py similarity index 100% rename from maltoolbox/visualization/__init__.py rename to python/maltoolbox/visualization/__init__.py diff --git a/maltoolbox/visualization/draw_io_utils.py b/python/maltoolbox/visualization/draw_io_utils.py similarity index 100% rename from maltoolbox/visualization/draw_io_utils.py rename to python/maltoolbox/visualization/draw_io_utils.py diff --git a/maltoolbox/visualization/graphviz_utils.py b/python/maltoolbox/visualization/graphviz_utils.py similarity index 100% rename from maltoolbox/visualization/graphviz_utils.py rename to python/maltoolbox/visualization/graphviz_utils.py diff --git a/maltoolbox/visualization/neo4j_utils.py b/python/maltoolbox/visualization/neo4j_utils.py similarity index 100% rename from maltoolbox/visualization/neo4j_utils.py rename to python/maltoolbox/visualization/neo4j_utils.py diff --git a/maltoolbox/visualization/utils.py b/python/maltoolbox/visualization/utils.py similarity index 100% rename from maltoolbox/visualization/utils.py rename to python/maltoolbox/visualization/utils.py diff --git a/tests/attackgraph/test_attackgraph.py b/tests/attackgraph/test_attackgraph.py index 66bb1cfb..21dad92f 100644 --- a/tests/attackgraph/test_attackgraph.py +++ b/tests/attackgraph/test_attackgraph.py @@ -2,7 +2,6 @@ import copy import pickle -from unittest.mock import patch import pytest from conftest import path_testdata @@ -15,18 +14,15 @@ from maltoolbox.model import Model, ModelAsset -def test_attackgraph_init(corelang_lang_graph, model): +def test_attackgraph_init(corelang_lang_graph, example_model): """Test init with different params given""" - # _generate_graph is called when langspec and model is given to init - with patch('maltoolbox.attackgraph.attackgraph.generate_graph') as _generate_graph: - _generate_graph.return_value = None, None, None, None, None - AttackGraph(lang_graph=corelang_lang_graph, model=model) - assert _generate_graph.call_count == 1 + # Graph is populated when langspec and model is given to init + ag_with_model = AttackGraph(lang_graph=corelang_lang_graph, model=example_model) + assert len(ag_with_model.nodes) > 0 - # _generate_graph is not called when no model is given - with patch('maltoolbox.attackgraph.attackgraph.generate_graph') as _generate_graph: - AttackGraph(lang_graph=corelang_lang_graph, model=None) - assert _generate_graph.call_count == 0 + # Graph stays empty when no model is given + ag_without_model = AttackGraph(lang_graph=corelang_lang_graph, model=None) + assert len(ag_without_model.nodes) == 0 def test_load_attack_graph(corelang_lang_graph: LanguageGraph): @@ -168,13 +164,14 @@ def test_attackgraph_generate_graph(example_attackgraph: AttackGraph): def test_attackgraph_get_node_by_full_name(example_attackgraph: AttackGraph): - with pytest.raises(LookupError) as e: + with pytest.raises( + LookupError, + match=( + r'Could not find node with name "Application 2"\. ' + r'Did you mean: Application 2:read, Application 2:deny\?' + ), + ): example_attackgraph.get_node_by_full_name('Application 2') - assert repr(e) == ( - "" - ) def test_attackgraph_according_to_corelang(corelang_lang_graph, model): @@ -315,6 +312,26 @@ def test_attackgraph_remove_node(example_attackgraph: AttackGraph): assert node_to_remove not in child.parents +def test_attackgraph_remove_node_children_parents_still_readable( + example_attackgraph: AttackGraph, +): + """A removed node's own .children/.parents must stay readable and + correct, matching its pre-removal state - a caller elsewhere (e.g. + mal-simulator holding a node in `performed_nodes`) can legitimately + still hold a handle to a node after it's been removed from the + graph, and removal doesn't invalidate held handles anywhere else in + this codebase either.""" + node_to_remove = example_attackgraph.nodes[10] + parent_ids = {parent.id for parent in node_to_remove.parents} + children_ids = {child.id for child in node_to_remove.children} + assert parent_ids and children_ids # make sure this node is a meaningful case + + example_attackgraph.remove_node(node_to_remove) + + assert {parent.id for parent in node_to_remove.parents} == parent_ids + assert {child.id for child in node_to_remove.children} == children_ids + + def test_attackgraph_deepcopy(example_attackgraph: AttackGraph): """Try to deepcopy an attackgraph object. The nodes of the attack graph should be duplicated into new objects, while references to the instance diff --git a/tests/attackgraph/test_partial_regeneration.py b/tests/attackgraph/test_partial_regeneration.py index 094fe216..eadc0531 100644 --- a/tests/attackgraph/test_partial_regeneration.py +++ b/tests/attackgraph/test_partial_regeneration.py @@ -2,18 +2,9 @@ import pytest -from maltoolbox.attackgraph import AttackGraph, AttackGraphNode -from maltoolbox.attackgraph.partially_generate import ( - assoc_affected_expr_chain, - assoc_left_assets, - correct_node_children_on_modified_assoc, - nodes_to_be_removed, - switch_fieldname, -) -from maltoolbox.exceptions import AttackGraphException +from maltoolbox.attackgraph import AttackGraph from maltoolbox.language import LanguageGraph from maltoolbox.language.compiler import MalCompiler -from maltoolbox.language.expression_chain import ExpressionsChain, ExprType from maltoolbox.model import Model @@ -722,147 +713,3 @@ def test_partial_regeneration_shared_assoc_sibling() -> None: AG.partially_regenerate_graph(new_associations={(a, 'target', t1)}) regenerated_AG = AttackGraph(lang_graph=lang_graph, model=model) check_graph_equivalence(regenerated_AG, AG) - - -def test_switch_fieldname_unknown_fieldname_raises( - trainingLang_lang_graph: LanguageGraph, -) -> None: - model = Model('Test Model', trainingLang_lang_graph) - network = model.add_asset(asset_type='Network', name='LAN') - with pytest.raises(AttackGraphException, match='not found in associations'): - switch_fieldname(network, 'doesNotExist') - - -def test_correct_node_children_on_modified_assoc_missing_asset_link_raises( - trainingLang_lang_graph: LanguageGraph, -) -> None: - model = Model('Test Model', trainingLang_lang_graph) - lg_attack_step = trainingLang_lang_graph.assets['Network'].attack_steps['access'] - node = AttackGraphNode(node_id=0, lg_attack_step=lg_attack_step, model_asset=None) - with pytest.raises(AttackGraphException, match='missing asset link'): - correct_node_children_on_modified_assoc(model, node, {}) - - -def test_correct_node_children_on_modified_assoc_missing_target_node_raises( - trainingLang_lang_graph: LanguageGraph, -) -> None: - model = Model('Test Model', trainingLang_lang_graph) - network = model.add_asset(asset_type='Network', name='LAN') - host = model.add_asset(asset_type='Host', name='Host0') - network.add_associated_assets('hosts', {host}) - AG = AttackGraph(lang_graph=trainingLang_lang_graph, model=model) - access_node = AG.get_node_by_full_name('LAN:access') - assert access_node is not None - - # An intentionally incomplete full_name_to_node map: Host0:connect (a - # child reachable from LAN:access) is missing from it. - with pytest.raises(AttackGraphException, match='Failed to find target node'): - correct_node_children_on_modified_assoc(model, access_node, {}) - - -def test_nodes_to_be_removed_missing_node_raises( - trainingLang_lang_graph: LanguageGraph, -) -> None: - model = Model('Test Model', trainingLang_lang_graph) - network = model.add_asset(asset_type='Network', name='LAN') - with pytest.raises(AttackGraphException, match='Failed to find'): - nodes_to_be_removed({network}, {}) - - -def test_assoc_affected_expr_chain_default_modified_fieldnames( - trainingLang_lang_graph: LanguageGraph, -) -> None: - """When modified_fieldnames isn't passed in, it should be derived from - affected_assoc_dict.""" - model = Model('Test Model', trainingLang_lang_graph) - network = model.add_asset(asset_type='Network', name='LAN') - host = model.add_asset(asset_type='Host', name='Host0') - network.add_associated_assets('hosts', {host}) - - hosts_assoc = trainingLang_lang_graph.assets['Network'].associations['hosts'] - field_chain = ExpressionsChain( - type=ExprType.FIELD, association=hosts_assoc, fieldname='hosts' - ) - affected_assoc_dict = {network: {'hosts': {host}}} - - assert assoc_affected_expr_chain(model, {network}, affected_assoc_dict, field_chain) - - -def test_assoc_affected_expr_chain_subtype( - trainingLang_lang_graph: LanguageGraph, -) -> None: - model = Model('Test Model', trainingLang_lang_graph) - network = model.add_asset(asset_type='Network', name='LAN') - host = model.add_asset(asset_type='Host', name='Host0') - network.add_associated_assets('hosts', {host}) - - hosts_assoc = trainingLang_lang_graph.assets['Network'].associations['hosts'] - field_chain = ExpressionsChain( - type=ExprType.FIELD, association=hosts_assoc, fieldname='hosts' - ) - subtype_chain = ExpressionsChain( - type=ExprType.SUBTYPE, - sub_link=field_chain, - subtype=trainingLang_lang_graph.assets['Host'], - ) - affected_assoc_dict = {network: {'hosts': {host}}} - modified_fieldnames = frozenset({'hosts'}) - - assert assoc_affected_expr_chain( - model, {network}, affected_assoc_dict, subtype_chain, modified_fieldnames - ) - - -def test_assoc_affected_expr_chain_transitive( - trainingLang_lang_graph: LanguageGraph, -) -> None: - model = Model('Test Model', trainingLang_lang_graph) - network1 = model.add_asset(asset_type='Network', name='Network1') - network2 = model.add_asset(asset_type='Network', name='Network2') - network3 = model.add_asset(asset_type='Network', name='Network3') - network1.add_associated_assets('toNetworks', {network2}) - network2.add_associated_assets('toNetworks', {network3}) - - to_networks_assoc = trainingLang_lang_graph.assets['Network'].associations['toNetworks'] - field_chain = ExpressionsChain( - type=ExprType.FIELD, association=to_networks_assoc, fieldname='toNetworks' - ) - transitive_chain = ExpressionsChain(type=ExprType.TRANSITIVE, sub_link=field_chain) - modified_fieldnames = frozenset({'toNetworks'}) - - # Network2's outgoing toNetworks association changed; reachable from - # Network1 two hops deep into the transitive closure. - affected_assoc_dict = {network2: {'toNetworks': {network3}}} - assert assoc_affected_expr_chain( - model, {network1}, affected_assoc_dict, transitive_chain, modified_fieldnames - ) - - # Network3 is a leaf: nothing changed on any Network reachable from it. - assert not assoc_affected_expr_chain( - model, {network3}, affected_assoc_dict, transitive_chain, modified_fieldnames - ) - - -def test_affected_root_assets_subtype( - trainingLang_lang_graph: LanguageGraph, -) -> None: - model = Model('Test Model', trainingLang_lang_graph) - network = model.add_asset(asset_type='Network', name='LAN') - host = model.add_asset(asset_type='Host', name='Host0') - network.add_associated_assets('hosts', {host}) - - hosts_assoc = trainingLang_lang_graph.assets['Network'].associations['hosts'] - field_chain = ExpressionsChain( - type=ExprType.FIELD, association=hosts_assoc, fieldname='hosts' - ) - subtype_chain = ExpressionsChain( - type=ExprType.SUBTYPE, - sub_link=field_chain, - subtype=trainingLang_lang_graph.assets['Host'], - ) - affected_assoc_dict = {network: {'hosts': {host}}} - modified_fieldnames = frozenset({'hosts'}) - - assert assoc_left_assets( - model, affected_assoc_dict, subtype_chain, modified_fieldnames - ) == {network} diff --git a/tests/conftest.py b/tests/conftest.py index 4087c494..dc5a4cfd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,11 +5,7 @@ import pytest from maltoolbox.attackgraph import AttackGraph -from maltoolbox.language import ( - LanguageGraph, - LanguageGraphAsset, - LanguageGraphAttackStep, -) +from maltoolbox.language import LanguageGraph from maltoolbox.model import Model # Helpers @@ -54,38 +50,18 @@ def model(corelang_lang_graph): @pytest.fixture -def dummy_lang_graph(corelang_lang_graph): +def dummy_lang_graph(): """Fixture that generates a dummy LanguageGraph with a dummy - LanguageGraphAsset and LanguageGraphAttackStep + LanguageGraphAsset ('DummyAsset') and one LanguageGraphAttackStep of + each type ('DummyOrAttackStep'/'DummyAndAttackStep'/ + 'DummyDefenseAttackStep'/'DummyExistAttackStep'/ + 'DummyNotExistAttackStep'), compiled from a real minimal .mal spec + (testdata/dummy_lang.mal) rather than hand-built dataclass instances - + the latter has no backing LanguageGraph, which the Rust-backed + bindings' owner+id handle design can't represent (see + PYTHON_BINDINGS_IMPLEMENTATION.md). """ - lang_graph = LanguageGraph() - dummy_asset = LanguageGraphAsset(name='DummyAsset') - lang_graph.assets['DummyAsset'] = dummy_asset - dummy_or_attack_step_node = LanguageGraphAttackStep( - name='DummyOrAttackStep', type='or', asset=dummy_asset - ) - dummy_asset.attack_steps['DummyOrAttackStep'] = dummy_or_attack_step_node - - dummy_and_attack_step_node = LanguageGraphAttackStep( - name='DummyAndAttackStep', type='and', asset=dummy_asset - ) - dummy_asset.attack_steps['DummyAndAttackStep'] = dummy_and_attack_step_node - - dummy_defense_attack_step_node = LanguageGraphAttackStep( - name='DummyDefenseAttackStep', type='defense', asset=dummy_asset - ) - dummy_asset.attack_steps['DummyDefenseAttackStep'] = dummy_defense_attack_step_node - - dummy_exist_attack_step_node = LanguageGraphAttackStep( - name='DummyExistAttackStep', type='exist', asset=dummy_asset - ) - dummy_asset.attack_steps['DummyExistAttackStep'] = dummy_exist_attack_step_node - - dummy_exist_attack_step_node = LanguageGraphAttackStep( - name='DummyNotExistAttackStep', type='notExist', asset=dummy_asset - ) - dummy_asset.attack_steps['DummyNotExistAttackStep'] = dummy_exist_attack_step_node - return lang_graph + return LanguageGraph.from_mal_spec(path_testdata('dummy_lang.mal')) @pytest.fixture diff --git a/tests/test_model.py b/tests/test_model.py index af27b624..01aef733 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -4,7 +4,7 @@ from conftest import path_testdata from maltoolbox.language.languagegraph import LanguageGraph -from maltoolbox.model import Model, ModelAsset +from maltoolbox.model import Model # Helper functions @@ -93,12 +93,9 @@ def test_model_remove_asset_with_association(model: Model): def test_model_remove_nonexisting_asset(model: Model): """Removing a non existing asset leads to lookup error""" - # Create an asset but don't add it to the model before removing it - asset1 = ModelAsset( - name='TestAsset', - asset_id=1, - lg_asset=model.lang_graph.assets['Application'], - ) + # Create a real asset, but in a different model - not part of `model` + other_model = Model('Other Model', model.lang_graph) + asset1 = other_model.add_asset(asset_type='Application', name='TestAsset') with pytest.raises(LookupError): model.remove_asset(asset1) diff --git a/tests/testdata/dummy_lang.mal b/tests/testdata/dummy_lang.mal new file mode 100644 index 00000000..04561046 --- /dev/null +++ b/tests/testdata/dummy_lang.mal @@ -0,0 +1,24 @@ +// Minimal language used by tests/conftest.py's `dummy_lang_graph` fixture: +// one asset with one attack step of each type. Replaces an earlier version +// of this fixture that hand-built LanguageGraphAsset/LanguageGraphAttackStep +// dataclasses directly with no backing LanguageGraph - incompatible with the +// Rust bindings' owner+id handle design (see PYTHON_BINDINGS_IMPLEMENTATION.md). +#id: "org.maltoolbox.test.dummy" +#version: "1.0.0" + +category DummyCategory { + asset DummyAsset { + | DummyOrAttackStep + -> relatedDummies.DummyAndAttackStep + & DummyAndAttackStep + # DummyDefenseAttackStep + E DummyExistAttackStep + <- relatedDummies + !E DummyNotExistAttackStep + <- relatedDummies + } +} + +associations { + DummyAsset [dummy] * <-- DummyAssoc --> * [relatedDummies] DummyAsset +} diff --git a/tests/translators/test_updater.py b/tests/translators/test_updater.py index 108e866b..fb49751b 100644 --- a/tests/translators/test_updater.py +++ b/tests/translators/test_updater.py @@ -3,6 +3,27 @@ from maltoolbox.translators.updater import load_model_from_older_version +def _to_dict_ignoring_version(model: Model) -> dict: + """Like `model._to_dict()`, but with `"MAL-Toolbox Version"` removed. + + Neither `simple_example_model_0.0.38.json` nor + `simple_example_model_0.1.8.yml` ever recorded a version (the field + predates them), so `load_model_from_older_version` picks up the + "no version recorded" default - the live `maltoolbox.__version__` - + which will never match `simple_example_model.yml`'s own explicitly + recorded version. This comparison only ever passed before because of + a separate, documented pure-Python bug where `to_dict()` ignored + `self.maltoolbox_version` entirely and always wrote the live version + on *both* sides regardless (see PORTING_NOTES.md §3); fixing that bug + (an intentional, accepted divergence - see + PYTHON_BINDINGS_IMPLEMENTATION.md's Phase 2 status) means this field + is no longer meaningful to compare here. + """ + d = model._to_dict() + d['metadata'].pop('MAL-Toolbox Version', None) + return d + + def test_converts_from_0_0(corelang_lang_graph: LanguageGraph): old_model_file = 'tests/testdata/simple_example_model_0.0.38.json' @@ -14,7 +35,7 @@ def test_converts_from_0_0(corelang_lang_graph: LanguageGraph): new_model = Model.load_from_file(new_model_file, corelang_lang_graph) - assert converted_old_model._to_dict() == new_model._to_dict() + assert _to_dict_ignoring_version(converted_old_model) == _to_dict_ignoring_version(new_model) def test_converts_from_0_1(corelang_lang_graph: LanguageGraph): @@ -31,7 +52,7 @@ def test_converts_from_0_1(corelang_lang_graph: LanguageGraph): corelang_lang_graph, ) - assert converted_old_model._to_dict() == new_model._to_dict() + assert _to_dict_ignoring_version(converted_old_model) == _to_dict_ignoring_version(new_model) def test_converts_from_0_2(corelang_lang_graph: LanguageGraph):