From 3ca1bda0663ab21e8bd856126399ffdaf2fab0ca Mon Sep 17 00:00:00 2001 From: Omar Date: Sat, 18 Jul 2026 11:27:35 -0400 Subject: [PATCH] feat: harden fuzzing runtime and evaluation - isolate configuration and mutable run state - validate compiled artifacts and support resumable fuzzing - add authorization differential and subscription coverage - strengthen persistence, benchmarks, CI, and documentation --- .github/workflows/dockerhub_release.yml | 6 +- .github/workflows/e2e_tests.yml | 17 +- .github/workflows/githubcr_release.yml | 6 +- .github/workflows/integration_tests.yml | 19 +- .github/workflows/lint.yml | 2 +- .github/workflows/pypi_release.yml | 6 +- .github/workflows/type_check.yml | 2 +- .github/workflows/unit_tests.yml | 17 +- README.md | 26 +- benchmark/README.md | 28 +- docs/architecture.md | 43 ++- graphqler/__main__.py | 209 ++++++++------ graphqler/compiler/compiler.py | 79 +++-- .../compiler/parsers/mutation_list_parser.py | 9 +- .../compiler/parsers/object_list_parser.py | 2 +- .../compiler/parsers/query_list_parser.py | 2 +- .../parsers/subscription_list_parser.py | 2 +- graphqler/config.py | 120 ++++++-- graphqler/core.py | 43 +-- graphqler/fuzzer/__init__.py | 17 +- graphqler/fuzzer/engine/dengine.py | 31 +- graphqler/fuzzer/engine/detectors/__init__.py | 8 +- .../authorization_differential_detector.py | 97 +++++++ graphqler/fuzzer/engine/detectors/detector.py | 17 +- .../field_charset_fuzzing_detector.py | 20 +- .../field_fuzzing/id_enumeration_detector.py | 25 +- .../field_suggestion_detector.py | 23 +- .../nosql_injection_detector.py | 28 +- .../query_deny_bypass_detector.py | 54 ++-- .../sql_injection/sql_injection_detector.py | 19 +- .../time_sql_injection_detector.py | 19 +- graphqler/fuzzer/engine/fengine.py | 88 +++--- graphqler/fuzzer/engine/types/result.py | 69 +++-- graphqler/fuzzer/fuzzer.py | 272 ++++++++++++------ graphqler/graph/graph_generator.py | 14 +- graphqler/tui/screens/fuzz_screen.py | 19 +- graphqler/utils/artifact_manifest.py | 112 ++++++++ graphqler/utils/cli_utils.py | 26 +- graphqler/utils/file_utils.py | 32 +++ graphqler/utils/logging_utils.py | 15 +- graphqler/utils/mcp_utils/server.py | 94 ++---- graphqler/utils/objects_bucket.py | 104 ++++--- graphqler/utils/run_context.py | 18 ++ graphqler/utils/singleton.py | 28 -- graphqler/utils/stats.py | 256 +++++++++-------- graphqler/utils/websocket_utils.py | 16 +- pyproject.toml | 1 - tests/e2e/conftest.py | 28 +- tests/integration/test_cli_modes.py | 66 ++++- ...est_authorization_differential_detector.py | 92 ++++++ .../fuzzer/engine/test_profile_execution.py | 54 ++++ .../fengine/test_field_fuzzing_detectors.py | 20 +- .../test_time_sql_injection_detector.py | 4 +- .../fuzzer/test_authorization_differential.py | 107 +++++++ tests/unit/fuzzer/test_dep_retry_phase.py | 51 ++-- tests/unit/fuzzer/test_resume.py | 64 +++++ tests/unit/mcp/test_server.py | 3 + tests/unit/utils/test_artifact_manifest.py | 76 +++++ .../utils/test_objects_bucket_connection.py | 9 +- tests/unit/utils/test_run_context.py | 62 ++++ tests/unit/utils/test_state_persistence.py | 113 ++++++++ tests/unit/utils/test_websocket_utils.py | 52 ++++ uv.lock | 11 - 63 files changed, 2026 insertions(+), 946 deletions(-) create mode 100644 graphqler/fuzzer/engine/detectors/authorization_differential_detector.py create mode 100644 graphqler/utils/artifact_manifest.py create mode 100644 graphqler/utils/run_context.py delete mode 100644 graphqler/utils/singleton.py create mode 100644 tests/unit/fuzzer/engine/test_authorization_differential_detector.py create mode 100644 tests/unit/fuzzer/engine/test_profile_execution.py create mode 100644 tests/unit/fuzzer/test_authorization_differential.py create mode 100644 tests/unit/fuzzer/test_resume.py create mode 100644 tests/unit/utils/test_artifact_manifest.py create mode 100644 tests/unit/utils/test_run_context.py create mode 100644 tests/unit/utils/test_state_persistence.py create mode 100644 tests/unit/utils/test_websocket_utils.py diff --git a/.github/workflows/dockerhub_release.yml b/.github/workflows/dockerhub_release.yml index 385e5f2e..50cdcced 100644 --- a/.github/workflows/dockerhub_release.yml +++ b/.github/workflows/dockerhub_release.yml @@ -10,17 +10,17 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install the latest version of uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 - name: login to dockerhub uses: docker/login-action@v3 diff --git a/.github/workflows/e2e_tests.yml b/.github/workflows/e2e_tests.yml index 68ef0423..dc9305e0 100644 --- a/.github/workflows/e2e_tests.yml +++ b/.github/workflows/e2e_tests.yml @@ -8,32 +8,29 @@ permissions: jobs: setup-and-test: runs-on: ubuntu-latest + env: + TZ: UTC steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - - name: Setup timezone - uses: zcong1993/setup-timezone@master - with: - timezone: UTC - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: '20' - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install the latest version of uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 - name: Install Python dependencies run: uv sync - name: Run e2e tests in parallel - run: | - uv run pytest tests/e2e/ -n auto --dist loadfile --verbose --cov=. --cov-report html + run: uv run pytest tests/e2e/ -n auto --dist loadfile --verbose diff --git a/.github/workflows/githubcr_release.yml b/.github/workflows/githubcr_release.yml index b017ca03..8498d134 100644 --- a/.github/workflows/githubcr_release.yml +++ b/.github/workflows/githubcr_release.yml @@ -10,17 +10,17 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install the latest version of uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 - name: Login to GitHub Container Registry uses: docker/login-action@v3 diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 4ede9161..8b562092 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -9,31 +9,28 @@ jobs: integration-tests: name: Run GraphQLer Integration Tests runs-on: ubuntu-latest + env: + TZ: UTC steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - name: Setup timezone - uses: zcong1993/setup-timezone@master - with: - timezone: UTC - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: '20' - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install the latest version of uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 - name: Install Python dependencies run: uv sync - - name: Run integration tests (CLI modes + subscription support) - run: | - uv run pytest tests/integration/ --exitfirst --verbose --failed-first --cov=. --cov-report html + - name: Run integration tests + run: uv run pytest tests/integration/ --exitfirst --verbose --failed-first diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 29072854..b36893d8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,6 +6,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: astral-sh/ruff-action@v1 + - uses: astral-sh/ruff-action@v3 with: changed-files: "true" diff --git a/.github/workflows/pypi_release.yml b/.github/workflows/pypi_release.yml index a9e67657..341326da 100644 --- a/.github/workflows/pypi_release.yml +++ b/.github/workflows/pypi_release.yml @@ -13,17 +13,17 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install the latest version of uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 - name: Publish to PyPI env: diff --git a/.github/workflows/type_check.yml b/.github/workflows/type_check.yml index aa501947..9aa73687 100644 --- a/.github/workflows/type_check.yml +++ b/.github/workflows/type_check.yml @@ -10,7 +10,7 @@ jobs: - uses: actions/checkout@v4 - name: Install the latest version of uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 - name: Install Python dependencies run: uv sync --extra mcp diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index afedf390..41c03f29 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -6,21 +6,19 @@ jobs: name: Run GraphQLer Unit Tests runs-on: ubuntu-latest + env: + TZ: UTC steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - name: Setup timezone - uses: zcong1993/setup-timezone@master - with: - timezone: UTC - name: Set up Python 3.12 - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: 3.12 - name: Install the latest version of uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 - name: Install Python dependencies run: uv sync --extra mcp @@ -31,6 +29,5 @@ jobs: sudo apt install -y $(grep -o ^[^#][[:alnum:]-]* packages.list) fi - - name: Test with pytest - run: | - uv run pytest tests/unit/ --exitfirst --verbose --failed-first --cov=. --cov-report html + - name: Test with pytest and enforce coverage + run: uv run pytest tests/unit/ --exitfirst --verbose --failed-first --cov=graphqler --cov-branch --cov-report=term-missing --cov-report=xml --cov-fail-under=50 diff --git a/README.md b/README.md index 5cfb6703..ce9338cf 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,26 @@ python -m graphqler --mode fuzz --url --path While fuzzing, statistics related to the GraphQL API and any ongoing request counts are logged in the console. Any request return codes are written to `/stats.txt`. All logs during fuzzing are kept in `/logs/fuzzer.log`. The log file will tell you exactly which requests are sent to which endpoints, and what the response was. This can be used for further result analysis. If IDOR chains were generated during compile, the fuzzer automatically tests them and writes detection results to `/detections/`. +Compilation writes `/manifest.json` with the artifact schema version, target endpoint, compile phase, and SHA-256 hashes. Fuzzing validates this contract before loading YAML, so incomplete, stale, endpoint-mismatched, or modified artifacts fail with a specific error instead of failing later during graph execution. + +Interrupted fuzz runs can continue from their latest atomic `serialized/stats.json` and `serialized/objects_bucket.json` checkpoints: + +```sh +python -m graphqler --mode fuzz --url --path --resume +``` + +For broader authorization testing, provide named identities and enable differential replay. GraphQLer replays likely private queries and mutations with the exact same payload under anonymous and alternate profiles, compares returned fields, and also checks subscription event exposure when subscriptions are enabled: + +```sh +python -m graphqler --mode fuzz --url --path \ + --auth primary='Bearer ' \ + --auth user-b='Bearer ' \ + --auth admin='Bearer ' \ + --authorization-differential --subscriptions +``` + +Differential replay is opt-in because it adds requests and can repeat mutation side effects. Anonymous exact-data matches are confirmed findings; alternate authenticated-profile responses are potential findings unless an ownership-aware IDOR chain confirms them. + ### IDOR Checking mode ```sh @@ -192,7 +212,7 @@ python -m graphqler --mode idor --url --path [Insecure direct object reference (IDOR)](https://portswigger.net/web-security/access-control/idor) detection works via multi-profile chain replay. During **compile**, `--idor-auth` enables generation of IDOR candidate chains: endpoints that create or expose user-scoped objects are identified via heuristics (and optionally an LLM classifier), then split into primary-profile steps (authenticated user) and secondary-profile steps (attacker token). These chains are saved to `compiled/chains/idor.yml`. -During **fuzz**, the `IDORChainDetector` executes each IDOR chain — the primary profile creates or retrieves the object, then the secondary profile attempts to access it. Any data returned to the secondary profile is flagged as a potential IDOR vulnerability and written to `/detections/IDOR//`. +During **fuzz**, the `IDORChainDetector` executes each IDOR chain — the primary profile creates or retrieves the object, then the secondary profile attempts to access it. Any data returned to the secondary profile is flagged as a potential IDOR vulnerability and written to `/detections/IDOR_CHAIN//`. The standalone **idor** mode re-executes only the IDOR chains without running regular fuzzing. This is useful for targeted re-testing after fixing an issue, or when you only want to check access-control without the overhead of a full fuzz run. @@ -236,6 +256,10 @@ There are also variables that can be modified with the `--config` flag as a TOML | SKIP_NODES | Nodes to skip (query or mutation names) | List | [] | | DISABLE_MUTATIONS | Only generate and run Query chains — all Mutation nodes are excluded from chain generation and fuzzing. Can also be set via `--disable-mutations` CLI flag. | Boolean | False | | IDOR_SECONDARY_AUTH | Secondary (attacker) authentication token for IDOR chain detection (e.g. `"Bearer token2"`). If not set, the IDOR chain phase is skipped. | String | None | +| AUTHORIZATION_DIFFERENTIAL | Replay likely private operations under anonymous and alternate profiles. Opt-in because it adds requests and repeats mutation payloads. | Boolean | False | +| PROFILES | Named runtime profiles with auth tokens, headers, or variables; CLI equivalent is repeated `--auth name=token`. | Object | `{}` | +| SKIP_SUBSCRIPTIONS | Disable WebSocket subscription execution. Set false or pass `--subscriptions` to enable it. | Boolean | True | +| RESUME | Continue an interrupted fuzz run from atomic checkpoints. CLI equivalent is `--resume`. | Boolean | False | ## AI Features diff --git a/benchmark/README.md b/benchmark/README.md index ec20d9de..cb04fe52 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -151,6 +151,28 @@ uv run python benchmark/benchmark_llm_chains.py \ --- +### `benchmark_detector_accuracy.py` — Vulnerability detector accuracy + +Measures detector precision, recall, and F1 against the versioned labels in +`ground_truth/detectors.yml`. Expected detector/node/level tuples are positives; +any additional finding in the same completed runs is treated as a negative-control +false positive. This prevents integration tests that only prove one vulnerable +example from being reported as scanner-wide accuracy. + +The results root must contain the E2E output directories named by the corpus: + +```bash +uv run python benchmark/benchmark_detector_accuracy.py \ + --results-root . \ + --output benchmark/detector_accuracy_results.json +``` + +The JSON report includes aggregate and per-detector TP/FP/FN, precision, recall, +F1, and the exact unmatched findings. Update labels only with a reproducible API +revision and retained run output. + +--- + ## Directory structure ``` @@ -159,10 +181,12 @@ benchmark/ ├── benchmark_oob.py # Objects-bucket-only baseline ├── benchmark_ablation.py # 4-config ablation study ├── benchmark_inference_accuracy.py # Dependency inference precision/recall +├── benchmark_detector_accuracy.py # Detector precision/recall/F1 corpus ├── benchmark_llm_chains.py # LLM vs heuristic chain generation ├── ground_truth/ │ ├── countries.yml │ ├── rick_and_morty.yml -│ └── graphql_zero.yml -└── readme.md +│ ├── graphql_zero.yml +│ └── detectors.yml +└── README.md ``` diff --git a/docs/architecture.md b/docs/architecture.md index d14bb6c3..ef8fd561 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,7 +76,7 @@ flowchart TD GRAPH_PNG["dependency_graph.png"] INTROSPECTION_JSON["introspection_result.json"] STATS_FILES["stats.txt · stats.json\nlogs/fuzzer.log"] - OBJECTS_PKL["objects_bucket.pkl"] + OBJECTS_PKL["serialized state\nstats.json · objects_bucket.json\nmanifest.json"] DETECTIONS_DIR["detections/\n VULN_NAME/NODE/\n raw_log.txt\n summary.txt"] end @@ -84,10 +84,10 @@ flowchart TD FUZZER_MAIN["Fuzzer\nsave_path · url\nprofiles{primary,secondary}"] API_OBJ["API\nqueries · mutations · objects\nenums · unions · interfaces"] GRAPH_LOAD["GraphGenerator\nloads DiGraph from compiled YAML"] - OBJECTS_BUCKET["ObjectsBucket\nobject store keyed by type\npickle-persisted"] + OBJECTS_BUCKET["ObjectsBucket\nrun-scoped object store keyed by type\nversioned JSON state"] subgraph FENGINE["FEngine — fuzzer/engine/fengine.py"] - FENGINE_MAIN["FEngine ★ singleton\napi · logger"] + FENGINE_MAIN["FEngine\napi · stats · logger"] subgraph MATERIALIZERS["Materializers — engine/materializers/"] MAT_BASE["Materializer (base)\nget_payload()"] REG_MAT["RegularPayloadMaterializer"] @@ -130,13 +130,14 @@ flowchart TD end subgraph UTILS["Shared Utils — utils/"] - STATS["Stats ★ singleton\nhttp_status_codes · vulnerabilities\nresults · timings · counts"] + STATS["Stats\nrun-scoped counters · findings\nresults · timings · checkpoints"] + RUN_CONTEXT["RunContext\nsettings · stats · objects_bucket"] PLUGINS_HDR["plugins_handler\nget_request_utils()"] REQUEST_UTILS["RequestUtils\nsend_graphql_request()\nimplements RequestUtilsProtocol"] REQ_PROTO["RequestUtilsProtocol\n(interface — swappable)"] DET_WRITER["detection_writer\nwrite_from_detector()\nwrite_from_chain()"] LOGGER["Logger\ncompiler · fuzzer · detector"] - CONFIG["config.py ⚠ global module\nAUTHORIZATION · IDOR_SECONDARY_AUTH\nMAX_TIME · detection flags\n50+ settings"] + CONFIG["config.py context-local proxy\nRunSettings snapshots\nCLI defaults · detection flags"] end %% ── CLI wiring ────────────────────────────────────────────── @@ -201,7 +202,7 @@ flowchart TD REQUEST_UTILS -.->|implements| REQ_PROTO COMPILER_MAIN & FENGINE_MAIN & DETECTORS --> PLUGINS_HDR - %% ── Global config (tight coupling — dashed) ────────────────── + %% ── Context-local config proxy (dashed implicit dependencies) ──────────── CONFIG -.->|imported directly| COMPILER_MAIN CONFIG -.->|imported directly| FUZZER_MAIN CONFIG -.->|imported directly| FENGINE_MAIN @@ -212,14 +213,14 @@ flowchart TD CONFIG -.->|imported directly| CHAIN_GEN %% ── Styles ─────────────────────────────────────────────────── - classDef singleton fill:#f4a261,stroke:#e76f51,color:#000 - classDef tight_coupling fill:#e63946,stroke:#c1121f,color:#fff + classDef context fill:#f4a261,stroke:#e76f51,color:#000 + classDef implicit_dependency fill:#e63946,stroke:#c1121f,color:#fff classDef interface fill:#2a9d8f,stroke:#21867a,color:#fff classDef disk fill:#457b9d,stroke:#1d3557,color:#fff classDef detector fill:#6a4c93,stroke:#4a3770,color:#fff - class STATS,FENGINE_MAIN singleton - class CONFIG tight_coupling + class RUN_CONTEXT context + class CONFIG implicit_dependency class REQ_PROTO interface class YAML_RAW,YAML_COMPILED,CHAINS_YAML,INTROSPECTION_JSON,STATS_FILES,OBJECTS_PKL,DETECTIONS_DIR,GRAPH_PNG disk class SQL_DET,NOSQL_DET,TSQL_DET,SSRF_DET,OS_DET,XSS_DET,PATH_DET,QDB_DET,FCF_DET,IDE_DET,INTRO_DET,FS_DET,IDOR_CHAIN_DET detector @@ -246,11 +247,10 @@ flowchart TD | Coupling | Location | Impact | |---|---|---| -| **`config.py` global module** | Imported directly by 50+ files | Any test that needs different config values must monkeypatch module-level variables. Impossible to run two configurations in the same process. | -| **`Stats` singleton** | Accessed via `Stats()` from detectors, fengine, fuzzer | Detectors cannot be unit-tested in isolation without the singleton accumulating state across tests. Stats can only be reset by calling `__init__` directly or reimporting the module. | -| **`plugins_handler.get_request_utils()`** | Called as a module-level function from compiler, fengine, detectors | The HTTP layer is a global service rather than an injected dependency. Mocking requires patching the module, not passing a mock. | -| **`API` reads disk at `__init__`** | `Fuzzer → API(url, save_path)` reads YAML immediately in constructor | Fuzzer construction fails if compiled files don't exist yet. No lazy loading. | -| **`ObjectsBucket` path from `config`** | Save/load path is always `config.OUTPUT_DIRECTORY / ...` | Cannot have two buckets for different outputs in the same process. | +| **Context-local config proxy** | Engine and detector modules still import `config` directly | Runs are isolated through `RunSettings` + `config.activate()`, but dependencies remain implicit and require an active context. | +| **`plugins_handler.get_request_utils()`** | Called as a module-level function from compiler, fengine, detectors | The HTTP implementation remains process-global. Dynamic plugin selection and logger capture keep MCP tool execution serialized. | +| **`API` artifact loading** | `Fuzzer → API(url, save_path)` reads compiled YAML during construction | `manifest.json` now validates completeness, phase, version, endpoint, and hashes first; loading remains eager by design. | +| **File-backed reports/state** | `Stats` and `ObjectsBucket` own paths below one run directory | Each run has isolated paths and atomic JSON checkpoints, but storage is intentionally local-filesystem-only. | --- @@ -289,7 +289,7 @@ Target GraphQL API └── Stats.save() + ObjectsBucket.save() + detection_writer → files │ ▼ - stats.txt · stats.json · logs/ · detections/ · objects_bucket.pkl + stats.txt · stats.json · serialized/*.json · manifest.json · logs/ · detections/ ``` --- @@ -302,15 +302,14 @@ Target GraphQL API | **Template Method** | `Detector` abstract base | Subclasses implement `_is_vulnerable()` / `_is_potentially_vulnerable()`; base handles the rest | | **Plugin / Protocol** | `plugins_handler` + `RequestUtilsProtocol` | Entire HTTP layer swappable at runtime | | **Factory** | `DEngine` instantiates detector lists | Adding a detector is one line in `detectors/__init__.py` | -| **Singleton** | `Stats`, `FEngine`, `ObjectsBucket` | ⚠️ Makes parallelism and isolated testing difficult | +| **Context Object** | `RunContext` + immutable `RunSettings` | One run owns its settings, stats, bucket, and output paths | | **Facade** | `core.py` | Clean programmatic API hiding the full compiler+fuzzer pipeline | --- ## Recommendations -1. **Inject config** — pass a `Config` dataclass rather than importing the global module. Enables multiple concurrent configurations and eliminates monkeypatching in tests. -2. **Break the `Stats` singleton** — pass `Stats` as a constructor argument to `FEngine`, `DEngine`, and detectors. State would no longer leak between runs in the same process. -3. **Break the `FEngine` singleton** — `Fuzzer` already owns `FEngine`; the singleton decorator adds no value and prevents isolated unit tests. -4. **Lazy-load `API`** — reading all YAML in the constructor means `Fuzzer(path, url)` fails if compilation hasn't run yet. Lazy loading would give a clearer error message. -5. **Abstract storage** — introduce a `StorageBackend` interface so file paths aren't hard-coded via `config` throughout every component. +1. **Inject the request client** — replace the process-global `plugins_handler` lookup with a `RequestUtilsProtocol` instance on `RunContext`. This would remove the remaining reason MCP execution is serialized. +2. **Make settings dependencies explicit** — continue moving engine and detector constructors from context-proxy reads to typed `RunSettings` fields where it improves testability. +3. **Version artifact migrations** — keep strict manifest rejection as the default, and add explicit migrations only when a future schema version has a safe, tested conversion. +4. **Keep storage concrete until needed** — local atomic JSON is sufficient today. Introduce a storage interface only alongside a real remote, database, or in-memory backend. diff --git a/graphqler/__main__.py b/graphqler/__main__.py index 4f39556f..a7d4703d 100644 --- a/graphqler/__main__.py +++ b/graphqler/__main__.py @@ -9,17 +9,18 @@ from graphqler.compiler.compiler import Compiler from graphqler.fuzzer import Fuzzer from graphqler.graph import GraphGenerator -from graphqler.utils.stats import Stats -from graphqler.utils.cli_utils import set_auth_token_constant, set_idor_auth_token_constant, is_compiled +from graphqler.utils.artifact_manifest import ArtifactValidationError, validate_manifest +from graphqler.utils.cli_utils import set_auth_token_constant, set_idor_auth_token_constant from graphqler.utils.config_handler import parse_config, set_config, generate_new_config, does_config_file_exist_in_path, write_config_to_toml from graphqler.utils.file_utils import get_or_create_directory from graphqler import config + def run_compile_mode(compiler: Compiler, path: str, url: str): """Runs the full compilation pipeline by delegating to compile-graph then compile-chains. Args: - compiler (Compiler): An instance of the Compiler class to use for compilation. + compiler (Compiler): An instance of the Compiler class to use for compilation. path (str): Directory for all compilation outputs to be saved to url (str): URL of the target """ @@ -64,6 +65,8 @@ def run_compile_chains_mode(compiler: Compiler, path: str, url: str): url (str): URL of the target """ + manifest = validate_manifest(path, "graph", compiler.settings, expected_endpoint=url or None) + compiler.url = url or manifest.get("endpoint") print("(C) In compile-chains mode!") dependency_graph = GraphGenerator(path).get_dependency_graph() in_degrees = dict(dependency_graph.in_degree()) @@ -84,8 +87,8 @@ def run_fuzz_mode(fuzzer: Fuzzer, path: str, url: str): url (str): URL of the target """ print("(F) Initializing stats file") - stats = Stats() - stats.set_file_paths(path) + stats = fuzzer.stats + stats.set_file_paths(path, reset=not fuzzer.settings.RESUME) print("(F) Starting fuzzer") if not config.USE_OBJECTS_BUCKET: @@ -117,35 +120,30 @@ def run_single_mode(path: str, url: str, name: str): def main(args: dict): # Run either compilation or fuzzing mode - if 'mode' not in args or not args['mode']: + if "mode" not in args or not args["mode"]: print("Please provide a mode to run the program in") sys.exit(1) # compile-chains works from disk — URL not needed; all other modes require it - if args['mode'] != "compile-chains" and not args.get('url'): + if args["mode"] != "compile-chains" and not args.get("url"): print(f"--url is required for mode '{args['mode']}'") sys.exit(1) - # If not compile mode, check if compiled directory exists - if args['mode'] not in ["compile", "compile-graph", "compile-chains", "run", "single", "idor"] and not is_compiled(args['path']): - print("(!) Compiled directory does not exist, please run in compile mode first") - sys.exit(1) - # Set the path if provided and create the directory if it doesn't exist - if 'path' in args and args['path']: - config.OUTPUT_DIRECTORY = args['path'] + if "path" in args and args["path"]: + config.OUTPUT_DIRECTORY = args["path"] get_or_create_directory(config.OUTPUT_DIRECTORY) # Set proxy if provided - if 'proxy' in args and args['proxy']: - config.PROXY = args['proxy'] + if "proxy" in args and args["proxy"]: + config.PROXY = args["proxy"] # Parse config if provided - if 'config' in args and args['config']: + if "config" in args and args["config"]: print("(P) Using provided config file") - new_config = parse_config(args['config']) + new_config = parse_config(args["config"]) set_config(new_config) - elif does_config_file_exist_in_path(args['path']): + elif does_config_file_exist_in_path(args["path"]): print("(P) Using config file in path") new_config = parse_config(f"{args['path']}/{config.CONFIG_FILE_NAME}") set_config(new_config) @@ -154,19 +152,19 @@ def main(args: dict): generate_new_config(f"{args['path']}/{config.CONFIG_FILE_NAME}") # Parse plugins if defined - if 'plugins_path' in args and args['plugins_path']: - config.PLUGINS_PATH = args['plugins_path'] + if "plugins_path" in args and args["plugins_path"]: + config.PLUGINS_PATH = args["plugins_path"] print(f"(P) Using plugins from {config.PLUGINS_PATH}") # CLI overrides — applied after set_config so they always win over the config file # Re-assert --path here so that a config file containing OUTPUT_DIRECTORY does not # silently override the directory the user explicitly specified on the command line. - if 'path' in args and args['path']: - config.OUTPUT_DIRECTORY = args['path'] + if "path" in args and args["path"]: + config.OUTPUT_DIRECTORY = args["path"] - if args.get('auth'): + if args.get("auth"): # Multi-auth support: --auth profile=token or just --auth token (defaults to primary) - for auth_entry in args['auth']: + for auth_entry in args["auth"]: if "=" in auth_entry: profile_name, token = auth_entry.split("=", 1) config.PROFILES[profile_name] = token @@ -178,39 +176,39 @@ def main(args: dict): config.PROFILES["primary"] = auth_entry set_auth_token_constant(auth_entry) - if args.get('idor_auth'): - set_idor_auth_token_constant(args['idor_auth']) - config.PROFILES["secondary"] = args['idor_auth'] + if args.get("idor_auth"): + set_idor_auth_token_constant(args["idor_auth"]) + config.PROFILES["secondary"] = args["idor_auth"] print("(P) IDOR secondary auth token set") # Apply LLM CLI overrides — these take precedence over config file values - if args.get('use_llm'): + if args.get("use_llm"): config.USE_LLM = True print("(P) LLM mode enabled via CLI flag") - if args.get('no_llm_compilation'): + if args.get("no_llm_compilation"): config.LLM_USE_FOR_COMPILATION = False print("(P) LLM disabled for compilation phase") - if args.get('no_llm_fuzzing'): + if args.get("no_llm_fuzzing"): config.LLM_USE_FOR_FUZZING = False print("(P) LLM disabled for fuzzing phase") - if args.get('llm_report'): + if args.get("llm_report"): config.LLM_ENABLE_REPORTER = True - if args.get('llm_model'): - config.LLM_MODEL = args['llm_model'] - if args.get('llm_api_key'): - config.LLM_API_KEY = args['llm_api_key'] - if args.get('llm_base_url'): - config.LLM_BASE_URL = args['llm_base_url'] - if args.get('llm_max_retries') is not None: - config.LLM_MAX_RETRIES = args['llm_max_retries'] + if args.get("llm_model"): + config.LLM_MODEL = args["llm_model"] + if args.get("llm_api_key"): + config.LLM_API_KEY = args["llm_api_key"] + if args.get("llm_base_url"): + config.LLM_BASE_URL = args["llm_base_url"] + if args.get("llm_max_retries") is not None: + config.LLM_MAX_RETRIES = args["llm_max_retries"] # Apply mutation CLI override - if args.get('disable_mutations'): + if args.get("disable_mutations"): config.DISABLE_MUTATIONS = True print("(P) Mutation fuzzing disabled — only Query chains will be generated") # Apply detections CLI override - if args.get('no_detections'): + if args.get("no_detections"): config.SKIP_INJECTION_ATTACKS = True config.SKIP_MISC_ATTACKS = True config.SKIP_DOS_ATTACKS = True @@ -218,29 +216,37 @@ def main(args: dict): print("(P) All detections disabled") # Apply ablation CLI overrides - if args.get('no_objects_bucket'): + if args.get("no_objects_bucket"): config.USE_OBJECTS_BUCKET = False print("(P) Ablation: objects bucket disabled") - if args.get('no_dependency_graph'): + if args.get("no_dependency_graph"): config.USE_DEPENDENCY_GRAPH = False print("(P) Ablation: dependency graph guidance disabled") - if args.get('max_iterations') is not None: - config.MAX_FUZZING_ITERATIONS = args['max_iterations'] + if args.get("max_iterations") is not None: + config.MAX_FUZZING_ITERATIONS = args["max_iterations"] print(f"(P) Max chain iterations set to {config.MAX_FUZZING_ITERATIONS}") - if args.get('allow_deletion'): + if args.get("allow_deletion"): config.ALLOW_DELETION_OF_OBJECTS = True print("(P) Deletion of objects from bucket enabled") - if args.get('subscriptions'): + if args.get("subscriptions"): config.SKIP_SUBSCRIPTIONS = False print("(P) Subscription fuzzing enabled") - - if args.get('no_endpoint_results'): + if args.get("authorization_differential"): + config.AUTHORIZATION_DIFFERENTIAL = True + print("(P) Multi-profile authorization differential testing enabled") + if args.get("resume"): + if args["mode"] != "fuzz": + raise SystemExit("--resume is only valid with fuzz mode") + config.RESUME = True + print("(P) Resuming from the latest run checkpoint") + + if args.get("no_endpoint_results"): config.SAVE_ENDPOINT_RESULTS = False print("(P) Endpoint results writing disabled") - if args.get('classic_coverage'): + if args.get("classic_coverage"): config.NO_DATA_COUNT_AS_SUCCESS = True - if args.get('debug'): + if args.get("debug"): config.DEBUG = True print("(P) Classic coverage mode enabled — all non-error responses count as successes") @@ -248,28 +254,34 @@ def main(args: dict): write_config_to_toml(f"{args['path']}/{config.CONFIG_FILE_NAME}") # Initialize the compiler and fuzzer - compiler = Compiler(args['path'], args['url']) + compiler = Compiler(args["path"], args["url"]) # Start the program - if args['mode'] == "compile": - run_compile_mode(compiler, config.OUTPUT_DIRECTORY, args['url']) - elif args['mode'] == "compile-graph": - run_compile_graph_mode(compiler, config.OUTPUT_DIRECTORY, args['url']) - elif args['mode'] == "compile-chains": - run_compile_chains_mode(compiler, config.OUTPUT_DIRECTORY, args['url']) - elif args['mode'] == "fuzz": - fuzzer = Fuzzer(args['path'], args['url']) - run_fuzz_mode(fuzzer, config.OUTPUT_DIRECTORY, args['url']) - elif args['mode'] == "run": - run_compile_mode(compiler, config.OUTPUT_DIRECTORY, args['url']) - fuzzer = Fuzzer(args['path'], args['url']) - run_fuzz_mode(fuzzer, config.OUTPUT_DIRECTORY, args['url']) - elif args['mode'] == "idor": - run_idor_mode(config.OUTPUT_DIRECTORY, args['url']) - elif args['mode'] == "single": - if 'node' not in args or not args['node']: + if args["mode"] == "compile": + run_compile_mode(compiler, config.OUTPUT_DIRECTORY, args["url"]) + elif args["mode"] == "compile-graph": + run_compile_graph_mode(compiler, config.OUTPUT_DIRECTORY, args["url"]) + elif args["mode"] == "compile-chains": + run_compile_chains_mode(compiler, config.OUTPUT_DIRECTORY, args["url"]) + elif args["mode"] == "fuzz": + try: + fuzzer = Fuzzer(args["path"], args["url"]) + except ArtifactValidationError as exc: + raise SystemExit(f"(!) {exc}") from exc + run_fuzz_mode(fuzzer, config.OUTPUT_DIRECTORY, args["url"]) + elif args["mode"] == "run": + run_compile_mode(compiler, config.OUTPUT_DIRECTORY, args["url"]) + try: + fuzzer = Fuzzer(args["path"], args["url"]) + except ArtifactValidationError as exc: + raise SystemExit(f"(!) {exc}") from exc + run_fuzz_mode(fuzzer, config.OUTPUT_DIRECTORY, args["url"]) + elif args["mode"] == "idor": + run_idor_mode(config.OUTPUT_DIRECTORY, args["url"]) + elif args["mode"] == "single": + if "node" not in args or not args["node"]: print("Please provide a node to run in single mode") sys.exit(1) - run_single_mode(args['path'], args['url'], args['node']) + run_single_mode(args["path"], args["url"], args["node"]) # If running as a CLI @@ -294,8 +306,7 @@ def main(args: dict): from graphqler.utils.mcp_utils.server import serve, TRANSPORTS except ImportError: print( - "The 'mcp' package is required to run the MCP server.\n" - "Install it with: pip install GraphQLer[mcp]", + "The 'mcp' package is required to run the MCP server.\nInstall it with: pip install GraphQLer[mcp]", file=sys.stderr, ) sys.exit(1) @@ -303,6 +314,7 @@ def main(args: dict): print(f"Invalid transport '{transport}'. Choose from: {', '.join(TRANSPORTS)}", file=sys.stderr) sys.exit(1) from typing import cast, Literal + serve(transport=cast(Literal["stdio", "http", "sse", "streamable-http"], transport)) sys.exit(0) @@ -324,9 +336,24 @@ def main(args: dict): parser.add_argument("--proxy", help="proxy to use for requests (ie. http://127.0.0.1:8080)", required=False) parser.add_argument("--node", help="node to run (only used in single mode)", required=False) parser.add_argument("--plugins-path", help="path to plugins directory", required=False) - parser.add_argument("--use-llm", help="enable LLM-powered features: dependency graph inference, endpoint classification, IDOR chain classification, and UAF chain classification (requires LLM_MODEL and credentials)", action="store_true", default=False) - parser.add_argument("--no-llm-compilation", help="disable LLM during the compilation phase (dependency resolver, IDOR/UAF chain classifiers) — overrides --use-llm for that phase", action="store_true", default=False) - parser.add_argument("--no-llm-fuzzing", help="disable LLM during the fuzzing phase (payload generation, error retry, endpoint classification, report) — overrides --use-llm for that phase", action="store_true", default=False) + parser.add_argument( + "--use-llm", + help="enable LLM-powered features: dependency graph inference, endpoint classification, IDOR chain classification, and UAF chain classification (requires LLM_MODEL and credentials)", + action="store_true", + default=False, + ) + parser.add_argument( + "--no-llm-compilation", + help="disable LLM during the compilation phase (dependency resolver, IDOR/UAF chain classifiers) — overrides --use-llm for that phase", + action="store_true", + default=False, + ) + parser.add_argument( + "--no-llm-fuzzing", + help="disable LLM during the fuzzing phase (payload generation, error retry, endpoint classification, report) — overrides --use-llm for that phase", + action="store_true", + default=False, + ) parser.add_argument("--llm-report", help="generate an LLM vulnerability report (report.md) after fuzzing completes — requires --use-llm", action="store_true", default=False) parser.add_argument("--llm-model", help="litellm model string, e.g. 'gpt-4o-mini', 'ollama/llama3', 'anthropic/claude-3-5-haiku-20241022'", required=False) parser.add_argument("--llm-api-key", help="API key for the LLM provider (or set OPENAI_API_KEY / ANTHROPIC_API_KEY env var)", required=False) @@ -337,10 +364,24 @@ def main(args: dict): # Ablation / research flags parser.add_argument("--no-objects-bucket", help="ablation: disable the objects bucket — requests carry no state from prior responses", action="store_true", default=False) - parser.add_argument("--no-dependency-graph", help="ablation: disable dependency-graph chain ordering — all nodes run independently without chaining", action="store_true", default=False) + parser.add_argument( + "--no-dependency-graph", help="ablation: disable dependency-graph chain ordering — all nodes run independently without chaining", action="store_true", default=False + ) parser.add_argument("--max-iterations", help=f"number of times to iterate through all chains (default: {config.MAX_FUZZING_ITERATIONS})", type=int, required=False) parser.add_argument("--allow-deletion", help="remove objects from the bucket when a DELETE mutation succeeds (default: off)", action="store_true", default=False) - parser.add_argument("--subscriptions", help="enable fuzzing of GraphQL subscriptions via WebSocket (disabled by default — requires WebSocket support on the target)", action="store_true", default=False) + parser.add_argument( + "--subscriptions", + help="enable fuzzing of GraphQL subscriptions via WebSocket (disabled by default — requires WebSocket support on the target)", + action="store_true", + default=False, + ) + parser.add_argument( + "--authorization-differential", + help="replay likely private queries, mutations, and subscriptions under anonymous and alternate --auth profiles", + action="store_true", + default=False, + ) + parser.add_argument("--resume", help="resume an interrupted fuzz run from its latest checkpoint", action="store_true", default=False) parser.add_argument("--no-endpoint-results", help="skip writing per-endpoint result files to disk (useful when results are very large)", action="store_true", default=False) parser.add_argument("--classic-coverage", help="count responses with no data as successes (sets NO_DATA_COUNT_AS_SUCCESS=true)", action="store_true", default=False) @@ -348,12 +389,18 @@ def main(args: dict): # MCP server flags (handled before argument parsing; registered here for --help visibility) parser.add_argument("--mcp", help="launch the GraphQLer MCP server (requires pip install GraphQLer[mcp])", action="store_true", default=False) - parser.add_argument("--mcp-transport", help="MCP transport to use: 'stdio' (default), 'sse', 'streamable-http', or 'http'", default="stdio", choices=["stdio", "sse", "streamable-http", "http"], metavar="TRANSPORT") + parser.add_argument( + "--mcp-transport", + help="MCP transport to use: 'stdio' (default), 'sse', 'streamable-http', or 'http'", + default="stdio", + choices=["stdio", "sse", "streamable-http", "http"], + metavar="TRANSPORT", + ) args = parser.parse_args() args_as_dict = vars(args) # Some massaging - if args_as_dict['path'] is None: - args_as_dict['path'] = config.OUTPUT_DIRECTORY + if args_as_dict["path"] is None: + args_as_dict["path"] = config.OUTPUT_DIRECTORY main(args_as_dict) diff --git a/graphqler/compiler/compiler.py b/graphqler/compiler/compiler.py index 626a5595..5a8e8b35 100644 --- a/graphqler/compiler/compiler.py +++ b/graphqler/compiler/compiler.py @@ -8,10 +8,30 @@ from pathlib import Path from graphqler.utils import plugins_handler from graphqler.utils.file_utils import write_dict_to_yaml, write_json_to_file, initialize_file, intialize_file_if_not_exists +from graphqler.utils.artifact_manifest import write_manifest from graphqler.utils.logging_utils import Logger from .introspection_query import introspection_query -from .parsers import QueryListParser, ObjectListParser, MutationListParser, SubscriptionListParser, InputObjectListParser, EnumListParser, UnionListParser, InterfaceListParser, Parser -from .resolvers import ObjectDependencyResolver, ObjectMethodResolver, MutationObjectResolver, QueryObjectResolver, SubscriptionObjectResolver, LLMMutationObjectResolver, LLMQueryObjectResolver, ResolverComparison +from .parsers import ( + QueryListParser, + ObjectListParser, + MutationListParser, + SubscriptionListParser, + InputObjectListParser, + EnumListParser, + UnionListParser, + InterfaceListParser, + Parser, +) +from .resolvers import ( + ObjectDependencyResolver, + ObjectMethodResolver, + MutationObjectResolver, + QueryObjectResolver, + SubscriptionObjectResolver, + LLMMutationObjectResolver, + LLMQueryObjectResolver, + ResolverComparison, +) from graphqler.chains import ChainGenerator, TopologicalChainStrategy, IDORChainStrategy, UAFChainStrategy, Chain from graphqler.graph import GraphGenerator from graphqler import config @@ -22,7 +42,7 @@ class Compiler: - def __init__(self, save_path: str, url: str): + def __init__(self, save_path: str, url: str, settings: config.RunSettings | None = None): """Initializes the compiler, creates all necessary file paths to save the outputs for run if doesn't already exist @@ -31,20 +51,21 @@ def __init__(self, save_path: str, url: str): url (str): URL for graphql introspection query to hit """ self.save_path = save_path - self.introspection_result_save_path = Path(save_path) / Path(config.INTROSPECTION_RESULT_FILE_NAME) - self.object_list_save_path = Path(save_path) / config.OBJECT_LIST_FILE_NAME - self.input_object_list_save_path = Path(save_path) / config.INPUT_OBJECT_LIST_FILE_NAME - self.mutation_parameter_save_path = Path(save_path) / config.MUTATION_PARAMETER_FILE_NAME - self.query_parameter_save_path = Path(save_path) / config.QUERY_PARAMETER_FILE_NAME - self.subscription_parameter_save_path = Path(save_path) / config.SUBSCRIPTION_PARAMETER_FILE_NAME - self.enum_list_save_path = Path(save_path) / config.ENUM_LIST_FILE_NAME - self.union_list_save_path = Path(save_path) / config.UNION_LIST_FILE_NAME - self.interface_list_save_path = Path(save_path) / config.INTERFACE_LIST_FILE_NAME - - self.compiled_objects_save_path = Path(save_path) / config.COMPILED_OBJECTS_FILE_NAME - self.compiled_mutations_save_path = Path(save_path) / config.COMPILED_MUTATIONS_FILE_NAME - self.compiled_queries_save_path = Path(save_path) / config.COMPILED_QUERIES_FILE_NAME - self.compiled_subscriptions_save_path = Path(save_path) / config.COMPILED_SUBSCRIPTIONS_FILE_NAME + self.settings = settings or config.snapshot() + self.introspection_result_save_path = Path(save_path) / Path(self.settings.INTROSPECTION_RESULT_FILE_NAME) + self.object_list_save_path = Path(save_path) / self.settings.OBJECT_LIST_FILE_NAME + self.input_object_list_save_path = Path(save_path) / self.settings.INPUT_OBJECT_LIST_FILE_NAME + self.mutation_parameter_save_path = Path(save_path) / self.settings.MUTATION_PARAMETER_FILE_NAME + self.query_parameter_save_path = Path(save_path) / self.settings.QUERY_PARAMETER_FILE_NAME + self.subscription_parameter_save_path = Path(save_path) / self.settings.SUBSCRIPTION_PARAMETER_FILE_NAME + self.enum_list_save_path = Path(save_path) / self.settings.ENUM_LIST_FILE_NAME + self.union_list_save_path = Path(save_path) / self.settings.UNION_LIST_FILE_NAME + self.interface_list_save_path = Path(save_path) / self.settings.INTERFACE_LIST_FILE_NAME + + self.compiled_objects_save_path = Path(save_path) / self.settings.COMPILED_OBJECTS_FILE_NAME + self.compiled_mutations_save_path = Path(save_path) / self.settings.COMPILED_MUTATIONS_FILE_NAME + self.compiled_queries_save_path = Path(save_path) / self.settings.COMPILED_QUERIES_FILE_NAME + self.compiled_subscriptions_save_path = Path(save_path) / self.settings.COMPILED_SUBSCRIPTIONS_FILE_NAME self.url = url # Initialize the parsers we will use @@ -57,16 +78,12 @@ def __init__(self, save_path: str, url: str): self.union_list_parser = UnionListParser() self.interface_list_parser = InterfaceListParser() - # Initialize the logger - self.logger = Logger().get_compiler_logger() + with config.activate(self.settings): + self.logger = Logger().get_compiler_logger() + self.request_utils = plugins_handler.get_request_utils() + self.chain_generator = ChainGenerator() - # Initialize the plugins handler to get request utils - self.request_utils = plugins_handler.get_request_utils() - - # ChainGenerator — populated after run() completes - self.chain_generator: ChainGenerator = ChainGenerator() - - # Create empty files for these files + def _initialize_output_files(self) -> None: Path(self.save_path).mkdir(parents=True, exist_ok=True) initialize_file(self.introspection_result_save_path) initialize_file(self.object_list_save_path) @@ -82,6 +99,7 @@ def __init__(self, save_path: str, url: str): intialize_file_if_not_exists(self.compiled_queries_save_path) intialize_file_if_not_exists(self.compiled_subscriptions_save_path) + @config.use_settings def run(self): """The only function required to be run from the caller, will perform: 1. Introspection query @@ -89,6 +107,7 @@ def run(self): 3. Run the parsers, storing files into objects / query / mutations 4. Creating dependencies between objects and attaching methods (query/mutations) to objects """ + self._initialize_output_files() introspection_result = self.get_introspection_query_results() if introspection_result is None or introspection_result == {}: print("(C) Introspection query failed, trying clairvoyance") @@ -99,6 +118,7 @@ def run(self): self.run_parsers_and_save(introspection_result) self.run_resolvers_and_save(introspection_result) + write_manifest(self.save_path, self.url, "graph", self.settings) def get_introspection_query_results(self) -> dict: """Run the introspection query, grab results and output to file. Raises error if introspection query wasn't successful @@ -215,6 +235,7 @@ def run_resolvers_and_save(self, introspection_result: dict): write_dict_to_yaml(queries, self.compiled_queries_save_path) write_dict_to_yaml(subscriptions, self.compiled_subscriptions_save_path) + @config.use_settings def run_chain_generation_and_save(self): """Builds the dependency graph, runs each configured strategy, and persists the chains. @@ -225,6 +246,7 @@ def run_chain_generation_and_save(self): in_degrees = dict(dependency_graph.in_degree()) if not in_degrees: self.logger.warning("Dependency graph is empty — no chains generated") + write_manifest(self.save_path, self.url, "chains", self.settings) return min_degree = min(in_degrees.values()) @@ -241,9 +263,7 @@ def run_chain_generation_and_save(self): if not strategy.is_enabled(): continue - chains = self.chain_generator.generate_with_strategy( - strategy, dependency_graph, starter_nodes, regular_chains - ) + chains = self.chain_generator.generate_with_strategy(strategy, dependency_graph, starter_nodes, regular_chains) # If this was the first strategy, its output becomes the 'source' for others if not regular_chains: @@ -253,3 +273,4 @@ def run_chain_generation_and_save(self): self.chain_generator.save_to_yaml(self.save_path) self.logger.info(f"Chains saved to {self.save_path}/{config.CHAINS_DIR_NAME}/") + write_manifest(self.save_path, self.url, "chains", self.settings) diff --git a/graphqler/compiler/parsers/mutation_list_parser.py b/graphqler/compiler/parsers/mutation_list_parser.py index 72c6d00a..6e782c16 100644 --- a/graphqler/compiler/parsers/mutation_list_parser.py +++ b/graphqler/compiler/parsers/mutation_list_parser.py @@ -1,4 +1,4 @@ -"""Simple singleton class to parse mutation listings from the introspection query""" +"""Parse mutation listings from an introspection response.""" from .parser import Parser @@ -33,7 +33,12 @@ def parse(self, introspection_data: dict) -> dict: is_deprecated = mutation["isDeprecated"] description = mutation["description"] - return_type = {"kind": mutation["type"].get("kind"), "name": mutation["type"].get("name"), "ofType": self.extract_oftype(mutation["type"]), "type": mutation["type"].get("name")} + return_type = { + "kind": mutation["type"].get("kind"), + "name": mutation["type"].get("name"), + "ofType": self.extract_oftype(mutation["type"]), + "type": mutation["type"].get("name"), + } mutation_info_dict[mutation_name] = {"name": mutation_name, "inputs": mutation_args, "output": return_type, "isDepracated": is_deprecated, "description": description} diff --git a/graphqler/compiler/parsers/object_list_parser.py b/graphqler/compiler/parsers/object_list_parser.py index 57ab71d6..29f6e2bb 100644 --- a/graphqler/compiler/parsers/object_list_parser.py +++ b/graphqler/compiler/parsers/object_list_parser.py @@ -1,4 +1,4 @@ -"""Simple singleton class to parse object listings from the introspection query""" +"""Parse object listings from an introspection response.""" from .parser import Parser diff --git a/graphqler/compiler/parsers/query_list_parser.py b/graphqler/compiler/parsers/query_list_parser.py index 50eb74ac..60a71f1e 100644 --- a/graphqler/compiler/parsers/query_list_parser.py +++ b/graphqler/compiler/parsers/query_list_parser.py @@ -1,4 +1,4 @@ -"""Simple singleton class to parse query listings from the introspection query""" +"""Parse query listings from an introspection response.""" from .parser import Parser diff --git a/graphqler/compiler/parsers/subscription_list_parser.py b/graphqler/compiler/parsers/subscription_list_parser.py index 55957f65..690fa79b 100644 --- a/graphqler/compiler/parsers/subscription_list_parser.py +++ b/graphqler/compiler/parsers/subscription_list_parser.py @@ -1,4 +1,4 @@ -"""Simple singleton class to parse subscription listings from the introspection query""" +"""Parse subscription listings from an introspection response.""" from .parser import Parser diff --git a/graphqler/config.py b/graphqler/config.py index cbc1ee59..ae14b025 100644 --- a/graphqler/config.py +++ b/graphqler/config.py @@ -1,3 +1,14 @@ +from __future__ import annotations + +import copy +import sys +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass +from functools import wraps +from types import ModuleType +from typing import Any, Callable, Iterator, Mapping, TypeVar, cast + # Configuration """Debugging purposes""" @@ -19,11 +30,13 @@ DETECTIONS_DIR_NAME = "detections" INTROSPECTION_RESULT_FILE_NAME = "introspection_result.json" +ARTIFACT_MANIFEST_FILE_NAME = "manifest.json" +ARTIFACT_SCHEMA_VERSION = 1 CONFIG_FILE_NAME = "config.toml" -"""Pickle files -- mainly for cross-process communication""" -OBJECTS_BUCKET_PICKLE_FILE_NAME = "objects_bucket.pkl" -STATS_PICKLE_FILE_NAME = "stats.pkl" +"""Versioned JSON state files used for cross-process communication.""" +OBJECTS_BUCKET_STATE_FILE_NAME = "objects_bucket.json" +STATS_STATE_FILE_NAME = "stats.json" QUERY_PARAMETER_FILE_NAME = f"{EXTRACTED_DIR_NAME}/query_parameter_list.yml" MUTATION_PARAMETER_FILE_NAME = f"{EXTRACTED_DIR_NAME}/mutation_parameter_list.yml" @@ -53,17 +66,17 @@ Ollama: "ollama/llama3" (set LLM_BASE_URL to "http://localhost:11434") LiteLLM proxy: "openai/my-model" (set LLM_BASE_URL to your proxy URL) """ -USE_LLM: bool = False # Master toggle: use LLM for dependency graph inference, endpoint classification, and IDOR chain classification -LLM_USE_FOR_COMPILATION: bool = True # When USE_LLM=True, use LLM during the compilation phase (dependency resolver, IDOR/UAF chain classifiers) -LLM_USE_FOR_FUZZING: bool = True # When USE_LLM=True, use LLM during the fuzzing phase (payload generation, error retry, endpoint classification, reporting) -LLM_MODEL: str = "gpt-4o-mini" # litellm model string (encodes provider + model) -LLM_API_KEY: str = "" # API key; if empty, reads from env (OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.) -LLM_BASE_URL: str = "" # Custom base URL (required for Ollama and LiteLLM proxies) -LLM_RESOLVER_FALLBACK_TO_ID: bool = True # Fall back to classic ID-based resolver if LLM call fails -LLM_RESOLVER_SAVE_COMPARISON: bool = True # Save a side-by-side comparison JSON of LLM vs classic results -LLM_MAX_RETRIES: int = 2 # How many times to retry when the LLM returns non-JSON -LLM_ENABLE_REPORTER: bool = False # Independent toggle: generate an LLM vulnerability report at end of fuzzing (requires USE_LLM=True) -LLM_REPORT_FILE_NAME: str = "report.md" # Output filename for the LLM-generated report +USE_LLM: bool = False # Master toggle: use LLM for dependency graph inference, endpoint classification, and IDOR chain classification +LLM_USE_FOR_COMPILATION: bool = True # When USE_LLM=True, use LLM during the compilation phase (dependency resolver, IDOR/UAF chain classifiers) +LLM_USE_FOR_FUZZING: bool = True # When USE_LLM=True, use LLM during the fuzzing phase (payload generation, error retry, endpoint classification, reporting) +LLM_MODEL: str = "gpt-4o-mini" # litellm model string (encodes provider + model) +LLM_API_KEY: str = "" # API key; if empty, reads from env (OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.) +LLM_BASE_URL: str = "" # Custom base URL (required for Ollama and LiteLLM proxies) +LLM_RESOLVER_FALLBACK_TO_ID: bool = True # Fall back to classic ID-based resolver if LLM call fails +LLM_RESOLVER_SAVE_COMPARISON: bool = True # Save a side-by-side comparison JSON of LLM vs classic results +LLM_MAX_RETRIES: int = 2 # How many times to retry when the LLM returns non-JSON +LLM_ENABLE_REPORTER: bool = False # Independent toggle: generate an LLM vulnerability report at end of fuzzing (requires USE_LLM=True) +LLM_REPORT_FILE_NAME: str = "report.md" # Output filename for the LLM-generated report """For the linker""" GRAPH_VISUALIZATION_OUTPUT = "dependency_graph.png" @@ -108,6 +121,7 @@ SKIP_DOS_ATTACKS: bool = True # This mode is for when we want to skip the DoS check SKIP_INJECTION_ATTACKS: bool = False # This mode is for when we want to skip the injection check SKIP_MISC_ATTACKS: bool = False # This mode is for when we want to skip the miscellaneous attacks +RESUME: bool = False # Continue from the latest atomic stats/object-bucket checkpoint SKIP_SUBSCRIPTIONS: bool = True # Subscriptions require WebSocket transport; disabled by default (opt-in with --subscriptions) SUBSCRIPTION_TIMEOUT: int = 1 # Seconds to wait for events when executing a subscription SUBSCRIPTION_PROTOCOL: str = "graphql-transport-ws" # WebSocket sub-protocol: "graphql-transport-ws" (modern) or "subscriptions-transport-ws" (legacy Apollo) @@ -126,7 +140,9 @@ # Charset used for field-level enumeration fuzzing (printable ASCII minus obvious injection chars) FIELD_CHARSET: str = "0123456789abcdefghijklmnopqrstuvwxyz" MAX_CHARSET_FUZZ_FIELDS: int = 3 # Max string fields to fuzz per node -FIELD_RESPONSE_LENGTH_VARIANCE_THRESHOLD = 0.5 # Flag if (max-min)/avg response length exceeds this ratio (raised from 0.2 to reduce FPs; paired with near-empty fraction check in detector) +FIELD_RESPONSE_LENGTH_VARIANCE_THRESHOLD = ( + 0.5 # Flag if (max-min)/avg response length exceeds this ratio (raised from 0.2 to reduce FPs; paired with near-empty fraction check in detector) +) # ID / integer enumeration (IDOR detection) ID_ENUMERATION_COUNT: int = 10 # Number of integer IDs to probe (1 .. N) ID_ENUMERATION_SUCCESS_THRESHOLD = 2 # Min distinct IDs that must return data to flag IDOR @@ -143,18 +159,19 @@ CUSTOM_HEADERS = {} """For chain-based IDOR detection (cross-user access testing)""" -IDOR_SECONDARY_AUTH: str | None = None # Attacker/secondary auth token (e.g. "Bearer token2"); if None, chain-based IDOR phase is skipped -SKIP_IDOR_CHAIN_FUZZING: bool = False # Set True to disable the chain-based IDOR phase entirely +IDOR_SECONDARY_AUTH: str | None = None # Attacker/secondary auth token (e.g. "Bearer token2"); if None, chain-based IDOR phase is skipped +SKIP_IDOR_CHAIN_FUZZING: bool = False # Set True to disable the chain-based IDOR phase entirely IDOR_HEURISTIC_CONFIDENCE_THRESHOLD: float = 0.5 # Chains scoring below this trigger LLM fallback (when enabled) -IDOR_USE_LLM_FALLBACK: bool = False # When True, use LLM classifier for low-confidence chains +IDOR_USE_LLM_FALLBACK: bool = False # When True, use LLM classifier for low-confidence chains """For chain-based UAF detection (use-after-delete / use-after-free testing)""" -SKIP_UAF_CHAIN_FUZZING: bool = False # Set True to disable the chain-based UAF phase entirely +SKIP_UAF_CHAIN_FUZZING: bool = False # Set True to disable the chain-based UAF phase entirely UAF_HEURISTIC_CONFIDENCE_THRESHOLD: float = 0.5 # Chains scoring below this trigger LLM fallback (when enabled) -UAF_USE_LLM_FALLBACK: bool = False # When True, use LLM classifier for low-confidence chains +UAF_USE_LLM_FALLBACK: bool = False # When True, use LLM classifier for low-confidence chains """For arbitrary runtime profiles (multi-auth, custom headers, etc.)""" PROFILES = {} +AUTHORIZATION_DIFFERENTIAL: bool = False # Replay private operations under anonymous and alternate profiles; opt in because this adds requests # TUI-only: last URL entered in the TUI (not persisted to config.toml, not used by CLI) TUI_LAST_URL: str = "" @@ -163,3 +180,66 @@ # callbacks and log capture work inside the Textual event loop. Set by the TUI # at startup; never written to config.toml and never read by the CLI. TUI_MODE: bool = False + + +@dataclass(frozen=True) +class RunSettings: + """Immutable snapshot of uppercase configuration values for one run.""" + + values: Mapping[str, Any] + + def __getattr__(self, name: str) -> Any: + try: + return self.values[name] + except KeyError as exc: + raise AttributeError(name) from exc + + +_ACTIVE_SETTINGS: ContextVar[RunSettings | None] = ContextVar("graphqler_active_settings", default=None) +_F = TypeVar("_F", bound=Callable[..., Any]) + + +def snapshot(overrides: Mapping[str, Any] | None = None) -> RunSettings: + """Capture module defaults plus validated per-run overrides.""" + module = sys.modules[__name__] + values = {name: copy.deepcopy(ModuleType.__getattribute__(module, name)) for name in ModuleType.__dir__(module) if name.isupper() and not name.startswith("_")} + for name, value in (overrides or {}).items(): + if name not in values: + raise KeyError(f"Unknown GraphQLer configuration key: {name}") + values[name] = copy.deepcopy(value) + return RunSettings(values) + + +@contextmanager +def activate(settings: RunSettings) -> Iterator[None]: + """Expose a run's immutable settings through existing ``config.X`` reads.""" + token = _ACTIVE_SETTINGS.set(settings) + try: + yield + finally: + _ACTIVE_SETTINGS.reset(token) + + +def use_settings(method: _F) -> _F: + """Run an instance method under ``self.settings``.""" + + @wraps(method) + def wrapped(self, *args, **kwargs): + with activate(self.settings): + return method(self, *args, **kwargs) + + return cast(_F, wrapped) + + +class _RuntimeConfigModule(ModuleType): + """Resolve uppercase settings from the active run before module defaults.""" + + def __getattribute__(self, name: str) -> Any: + if name.isupper() and not name.startswith("_"): + active = _ACTIVE_SETTINGS.get() + if active is not None and name in active.values: + return active.values[name] + return ModuleType.__getattribute__(self, name) + + +sys.modules[__name__].__class__ = _RuntimeConfigModule diff --git a/graphqler/core.py b/graphqler/core.py index b0b847e4..470ddd26 100644 --- a/graphqler/core.py +++ b/graphqler/core.py @@ -1,9 +1,9 @@ -from graphqler.fuzzer import Fuzzer +from graphqler import config from graphqler.compiler import Compiler -from graphqler.utils.config_handler import set_config -from graphqler.utils.stats import Stats -from graphqler.utils.objects_bucket import ObjectsBucket +from graphqler.fuzzer import Fuzzer from graphqler.utils.api import API +from graphqler.utils.objects_bucket import ObjectsBucket +from graphqler.utils.stats import Stats def compile_and_fuzz(path: str, url: str, input_config: dict | None = None) -> dict: @@ -32,26 +32,27 @@ def compile_and_fuzz(path: str, url: str, input_config: dict | None = None) -> d - 'interfaces' (dict): A dictionary of interfaces { interface_name: interface_info }. - 'results' (dict): A dictionary of results { endpoint: Set(Result) }. See the `Result` class for more details. """ - if input_config: - set_config(input_config) - - compiler = Compiler(path, url) - compiler.run() - - stats = Stats() - stats.set_file_paths(path) + overrides = dict(input_config or {}) + overrides["OUTPUT_DIRECTORY"] = path + settings = config.snapshot(overrides) + with config.activate(settings): + compiler = Compiler(path, url, settings=settings) + compiler.run() + compiler.run_chain_generation_and_save() - fuzzer = Fuzzer(path, url) - fuzzer.run() + stats = Stats() + stats.set_file_paths(path) - api: API = fuzzer.api + fuzzer = Fuzzer(path, url, stats=stats, settings=settings) + fuzzer.run() - objects_bucket = ObjectsBucket(api=api).load() - stats = Stats().load() + api: API = fuzzer.api + objects_bucket = ObjectsBucket(api=api).load() + stats.load() return { - 'objects_bucket': objects_bucket, - 'stats': stats, - 'api': fuzzer.api, - 'results': stats.results + "objects_bucket": objects_bucket, + "stats": stats, + "api": fuzzer.api, + "results": stats.results, } diff --git a/graphqler/fuzzer/__init__.py b/graphqler/fuzzer/__init__.py index ed456cf0..de9f9f86 100644 --- a/graphqler/fuzzer/__init__.py +++ b/graphqler/fuzzer/__init__.py @@ -1,5 +1,14 @@ -from .fuzzer import Fuzzer +from typing import TYPE_CHECKING -__all__ = [ - "Fuzzer", -] +if TYPE_CHECKING: + from .fuzzer import Fuzzer + +__all__ = ["Fuzzer"] + + +def __getattr__(name: str): + if name == "Fuzzer": + from .fuzzer import Fuzzer + + return Fuzzer + raise AttributeError(name) diff --git a/graphqler/fuzzer/engine/dengine.py b/graphqler/fuzzer/engine/dengine.py index 164986d5..1fbf22c1 100644 --- a/graphqler/fuzzer/engine/dengine.py +++ b/graphqler/fuzzer/engine/dengine.py @@ -3,6 +3,7 @@ from graphqler.utils.api import API from graphqler.utils.logging_utils import Logger from graphqler.utils.objects_bucket import ObjectsBucket +from graphqler.utils.stats import Stats from .detectors import api_detectors, injection_detectors, misc_detectors, enumeration_detectors from .detectors.detector import Detector @@ -13,23 +14,27 @@ class DEngine: -- used to detect vulnerabilities in the API """ - def __init__(self, api: API): - """The intiialization of the DEngine - - Args: - api (API): The API object - """ + def __init__(self, api: API, stats: Stats, objects_bucket: ObjectsBucket): + """Initialize the detector engine for one fuzzing run.""" self.api = api + self.stats = stats + self.objects_bucket = objects_bucket self.logger = Logger().get_detector_logger() - self.nodes_ran: dict[str, dict[str, bool]] = {} # {node_name: {detection_name: True/False}} + self.nodes_ran: dict[str, dict[str, bool]] = {} def run_detections_on_api(self): """Run detections on the API - - Uses API as the key for nodes_ran marking + - Uses API as the key for nodes_ran marking """ for api_detector in api_detectors: - node = Node(graphql_type='misc', name=self.api.url, body={}) # Create a dummy node object for the API - detector = api_detector(api=self.api, node=node, objects_bucket=ObjectsBucket(self.api), graphql_type="") + node = Node(graphql_type="misc", name=self.api.url, body={}) # Create a dummy node object for the API + detector = api_detector( + api=self.api, + node=node, + objects_bucket=self.objects_bucket, + graphql_type="", + stats=self.stats, + ) if not self.__should_run_detection(detector, self.api.url): continue try: @@ -65,7 +70,7 @@ def __run_misc_detections(self, node: Node, objects_bucket: ObjectsBucket, graph graphql_type (str): The type of the GraphQL operation """ for misc_detector in misc_detectors: - detector = misc_detector(api=self.api, node=node, objects_bucket=objects_bucket, graphql_type=graphql_type) + detector = misc_detector(api=self.api, node=node, objects_bucket=objects_bucket, graphql_type=graphql_type, stats=self.stats) if not self.__should_run_detection(detector, node.name): continue try: @@ -84,7 +89,7 @@ def __run_injection_detections(self, node: Node, objects_bucket: ObjectsBucket, graphql_type (str): The type of the GraphQL operation """ for injection_detector in injection_detectors: - detector = injection_detector(api=self.api, node=node, objects_bucket=objects_bucket, graphql_type=graphql_type) + detector = injection_detector(api=self.api, node=node, objects_bucket=objects_bucket, graphql_type=graphql_type, stats=self.stats) if not self.__should_run_detection(detector, node.name): continue try: @@ -103,7 +108,7 @@ def __run_enumeration_detections(self, node: Node, objects_bucket: ObjectsBucket graphql_type (str): The type of the GraphQL operation """ for enum_detector in enumeration_detectors: - detector = enum_detector(api=self.api, node=node, objects_bucket=objects_bucket, graphql_type=graphql_type) + detector = enum_detector(api=self.api, node=node, objects_bucket=objects_bucket, graphql_type=graphql_type, stats=self.stats) if not self.__should_run_detection(detector, node.name): continue try: diff --git a/graphqler/fuzzer/engine/detectors/__init__.py b/graphqler/fuzzer/engine/detectors/__init__.py index f01a219a..a21e6ec9 100644 --- a/graphqler/fuzzer/engine/detectors/__init__.py +++ b/graphqler/fuzzer/engine/detectors/__init__.py @@ -10,6 +10,7 @@ from .field_fuzzing.id_enumeration_detector import IDEnumerationDetector from .idor_chain_detector import IDORChainDetector as IDORChainDetector from .uaf_chain_detector import UAFChainDetector as UAFChainDetector +from .authorization_differential_detector import AuthorizationDifferentialDetector as AuthorizationDifferentialDetector from .introspection.introspection_detector import IntrospectionDetector from .field_suggestion.field_suggestion_detector import FieldSuggestionsDetector @@ -24,7 +25,7 @@ SQLInjectionDetector, NoSQLInjectionDetector, TimeSQLInjectionDetector, - PathInjectionDetector + PathInjectionDetector, ] misc_detectors = [ @@ -39,7 +40,4 @@ IDEnumerationDetector, ] -api_detectors = [ - IntrospectionDetector, - FieldSuggestionsDetector -] +api_detectors = [IntrospectionDetector, FieldSuggestionsDetector] diff --git a/graphqler/fuzzer/engine/detectors/authorization_differential_detector.py b/graphqler/fuzzer/engine/detectors/authorization_differential_detector.py new file mode 100644 index 00000000..df2b060c --- /dev/null +++ b/graphqler/fuzzer/engine/detectors/authorization_differential_detector.py @@ -0,0 +1,97 @@ +"""Compare one private operation across authentication profiles.""" + +from __future__ import annotations + +from typing import Any + +from graphqler.fuzzer.engine.types import Result +from graphqler.fuzzer.engine.types.profile import RuntimeProfile +from graphqler.utils import detection_writer +from graphqler.utils.stats import Stats + + +class AuthorizationDifferentialDetector: + """Report private data returned to anonymous or alternate identities. + + Classification is deliberately performed by the caller. This detector only + compares responses for an operation already classified as user-scoped. + """ + + DETECTION_NAME = "AUTHORIZATION_DIFFERENTIAL" + + @staticmethod + def _operation_data(response: Any, operation_name: str) -> Any: + if isinstance(response, list): + values = [] + for event in response: + value = AuthorizationDifferentialDetector._operation_data(event, operation_name) + if value is not None: + values.append(value) + return values or None + if not isinstance(response, dict): + return None + data = response.get("data") + if isinstance(data, dict): + return data.get(operation_name) + return None + + @staticmethod + def _field_paths(value: Any, prefix: str = "") -> list[str]: + if isinstance(value, dict): + paths: list[str] = [] + for key, nested in value.items(): + child = f"{prefix}.{key}" if prefix else key + paths.extend(AuthorizationDifferentialDetector._field_paths(nested, child)) + return paths + if isinstance(value, list): + paths: list[str] = [] + for nested in value: + paths.extend(AuthorizationDifferentialDetector._field_paths(nested, prefix)) + return paths + return [prefix] if prefix and value is not None else [] + + def detect( + self, + operation_name: str, + primary_result: Result, + profile_results: list[tuple[RuntimeProfile, Result]], + stats: Stats, + ) -> None: + """Compare successful alternate-profile responses with the primary response.""" + primary_data = self._operation_data(primary_result.graphql_response, operation_name) + if primary_data is None: + return + + for profile, result in profile_results: + alternate_data = self._operation_data(result.graphql_response, operation_name) + if not result.success or alternate_data is None: + continue + exact_match = alternate_data == primary_data + anonymous = not profile.auth_token and not profile.headers + access_kind = "anonymous" if anonymous else f"profile '{profile.name}'" + exposed_fields = sorted(set(self._field_paths(alternate_data))) + fields_evidence = ", ".join(exposed_fields[:12]) or "" + confirmed = exact_match and anonymous + if exact_match: + evidence = f"Private operation returned the same non-null data to {access_kind} as to the primary profile; exposed fields: {fields_evidence}" + else: + evidence = f"Private operation returned non-null data to {access_kind}; data differed from the primary response; exposed fields: {fields_evidence}" + payload = result.payload if isinstance(result.payload, str) else "" + stats.add_vulnerability( + self.DETECTION_NAME, + operation_name, + is_vulnerable=confirmed, + potentially_vulnerable=True, + payload=payload, + evidence=evidence, + ) + detection_writer.write_from_detector( + vuln_name=self.DETECTION_NAME, + node_name=operation_name, + is_vulnerable=confirmed, + potentially_vulnerable=True, + payload=payload, + graphql_response=result.graphql_response, + status_code=result.status_code, + evidence=evidence, + ) diff --git a/graphqler/fuzzer/engine/detectors/detector.py b/graphqler/fuzzer/engine/detectors/detector.py index 71c905a4..c4f000b3 100644 --- a/graphqler/fuzzer/engine/detectors/detector.py +++ b/graphqler/fuzzer/engine/detectors/detector.py @@ -47,12 +47,13 @@ def materializer(self) -> Type[Materializer]: """Materializer class to be used for payload generation""" pass - def __init__(self, api: API, node: Node, objects_bucket: ObjectsBucket, graphql_type: str): + def __init__(self, api: API, node: Node, objects_bucket: ObjectsBucket, graphql_type: str, stats: Stats | None = None): self.api = api self.node = node self.name = node.name self.objects_bucket = objects_bucket self.graphql_type = graphql_type + self.stats = stats or Stats() self.detector_logger = Logger().get_detector_logger() self.fuzzer_logger = Logger().get_fuzzer_logger() self.payload = "" @@ -76,17 +77,17 @@ def detect(self) -> tuple[bool, bool]: payload=self.payload, status_code=request_response.status_code, graphql_response=graphql_response, - raw_response_text=request_response.text + raw_response_text=request_response.text, ) - Stats().add_http_status_code(self.name, request_response.status_code) - Stats().update_stats_from_result(self.node, result) + self.stats.add_http_status_code(self.name, request_response.status_code) + self.stats.update_stats_from_result(self.node, result) self.detector_logger.info(f"[{request_response.status_code}]Response: {request_response.text}") self.fuzzer_logger.info(f"[{request_response.status_code}]Response: {graphql_response}") self._parse_response(graphql_response, request_response) evidence = self._get_evidence(graphql_response, request_response) - Stats().add_vulnerability( + self.stats.add_vulnerability( self.DETECTION_NAME, self.name, self.confirmed_vulnerable, @@ -108,11 +109,7 @@ def detect(self) -> tuple[bool, bool]: def get_payload(self) -> str: """Gets the materialized payload to be sent to the API""" - materializer_instance = self.materializer( - api=self.api, - fail_on_hard_dependency_not_met=False, - max_depth=3 - ) + materializer_instance = self.materializer(api=self.api, fail_on_hard_dependency_not_met=False, max_depth=3) payload, used_objects = materializer_instance.get_payload(self.name, self.objects_bucket, self.graphql_type) assert isinstance(payload, str) return payload diff --git a/graphqler/fuzzer/engine/detectors/field_fuzzing/field_charset_fuzzing_detector.py b/graphqler/fuzzer/engine/detectors/field_fuzzing/field_charset_fuzzing_detector.py index 8e15ed28..bc0b8ce0 100644 --- a/graphqler/fuzzer/engine/detectors/field_fuzzing/field_charset_fuzzing_detector.py +++ b/graphqler/fuzzer/engine/detectors/field_fuzzing/field_charset_fuzzing_detector.py @@ -4,7 +4,7 @@ iterates every character in a configurable charset over String input fields and flags the node as potentially vulnerable to blind data extraction when the response length varies significantly between characters. - + This is like time-based blind SQLi but with response length as the oracle instead of time. Detection logic @@ -25,7 +25,6 @@ from graphqler import config from graphqler.utils import plugins_handler from graphqler.utils.api import API -from graphqler.utils.stats import Stats from graphqler.fuzzer.engine.materializers.getter import Getter from graphqler.fuzzer.engine.materializers.regular_payload_materializer import RegularPayloadMaterializer from graphqler.fuzzer.engine.detectors.detector import Detector @@ -34,6 +33,7 @@ # ── Helpers ────────────────────────────────────────────────────────────────── + def collect_string_inputs(inputs: dict) -> list[str]: """Return field names whose resolved scalar type is String.""" result = [] @@ -45,6 +45,7 @@ def collect_string_inputs(inputs: dict) -> list[str]: # ── Custom getter / materializer ───────────────────────────────────────────── + class _FixedStringGetter(Getter): """Returns a fixed value for a specific field; falls back to default for all others.""" @@ -69,6 +70,7 @@ def __init__(self, api: API, target_field: str = "", value: str = "", max_depth: # ── Detector ────────────────────────────────────────────────────────────────── + class FieldCharsetFuzzingDetector(Detector): """Detect blind field enumeration by charset-fuzzing String inputs. @@ -113,12 +115,8 @@ def detect(self) -> tuple[bool, bool]: self.potentially_vulnerable = bool(enumerable_fields) last_payload = self._build_payload(string_fields[0], config.FIELD_CHARSET[0]) if string_fields else "" - evidence = ( - f"response length varies across charset for field(s): {enumerable_fields}" - if enumerable_fields - else "" - ) - Stats().add_vulnerability( + evidence = f"response length varies across charset for field(s): {enumerable_fields}" if enumerable_fields else "" + self.stats.add_vulnerability( self.DETECTION_NAME, self.name, self.confirmed_vulnerable, @@ -151,10 +149,8 @@ def _get_response_length(self, field_name: str, value: str) -> int: if not payload: return 0 try: - _, request_response = plugins_handler.get_request_utils().send_graphql_request( - self.api.url, payload - ) - Stats().add_http_status_code(self.name, request_response.status_code) + _, request_response = plugins_handler.get_request_utils().send_graphql_request(self.api.url, payload) + self.stats.add_http_status_code(self.name, request_response.status_code) return len(request_response.text) except (ConnectionError, TimeoutError, OSError, AttributeError): return 0 diff --git a/graphqler/fuzzer/engine/detectors/field_fuzzing/id_enumeration_detector.py b/graphqler/fuzzer/engine/detectors/field_fuzzing/id_enumeration_detector.py index 3332da56..5f168547 100644 --- a/graphqler/fuzzer/engine/detectors/field_fuzzing/id_enumeration_detector.py +++ b/graphqler/fuzzer/engine/detectors/field_fuzzing/id_enumeration_detector.py @@ -33,7 +33,6 @@ from graphqler.utils import plugins_handler from graphqler.utils.api import API from graphqler.utils.objects_bucket import ObjectsBucket -from graphqler.utils.stats import Stats from graphqler.utils.response_utils import is_non_empty_result from graphqler.fuzzer.engine.materializers.getter import Getter from graphqler.fuzzer.engine.materializers.regular_payload_materializer import RegularPayloadMaterializer @@ -44,6 +43,7 @@ # ── Helpers ────────────────────────────────────────────────────────────────── + def collect_id_inputs(inputs: dict) -> list[str]: """Return field names whose resolved scalar type is Int or ID.""" result = [] @@ -56,6 +56,7 @@ def collect_id_inputs(inputs: dict) -> list[str]: # ── Custom getter / materializer ───────────────────────────────────────────── + class _FixedIntGetter(Getter): """Returns a fixed integer value for a specific field; falls back to default for all others.""" @@ -86,6 +87,7 @@ def __init__(self, api: API, target_field: str = "", value: int = 0, max_depth: # ── Detector ────────────────────────────────────────────────────────────────── + class IDEnumerationDetector(Detector): """Detect IDOR / ID enumeration by probing sequential integer IDs. @@ -124,9 +126,7 @@ def detect(self) -> tuple[bool, bool]: # Scope guard: skip catalogue / public endpoints to avoid false positives. if config.ID_ENUMERATION_SCOPE_HEURISTIC: return_type_name, return_type_fields = self._get_return_type_info() - scope = EndpointPrivacyClassifier().classify( - self.name, return_type_name, return_type_fields - ) + scope = EndpointPrivacyClassifier().classify(self.name, return_type_name, return_type_fields) if scope != "private": return (False, False) @@ -138,13 +138,12 @@ def detect(self) -> tuple[bool, bool]: self.potentially_vulnerable = success_count >= config.ID_ENUMERATION_SUCCESS_THRESHOLD evidence = ( - f"{success_count}/{config.ID_ENUMERATION_COUNT} IDs (1..{config.ID_ENUMERATION_COUNT}) " - f"returned non-null data for field '{target_field}' — possible IDOR" + f"{success_count}/{config.ID_ENUMERATION_COUNT} IDs (1..{config.ID_ENUMERATION_COUNT}) returned non-null data for field '{target_field}' — possible IDOR" if self.potentially_vulnerable else "" ) last_payload = payloads_used[-1] if payloads_used else "" - Stats().add_vulnerability( + self.stats.add_vulnerability( self.DETECTION_NAME, self.name, self.confirmed_vulnerable, @@ -210,19 +209,13 @@ def _probe_ids(self, field_name: str) -> tuple[int, list[str]]: payloads_used.append(payload) try: - graphql_response, request_response = plugins_handler.get_request_utils().send_graphql_request( - self.api.url, payload - ) - Stats().add_http_status_code(self.name, request_response.status_code) + graphql_response, request_response = plugins_handler.get_request_utils().send_graphql_request(self.api.url, payload) + self.stats.add_http_status_code(self.name, request_response.status_code) if request_response.status_code == 200 and isinstance(graphql_response.get("data"), dict): data = graphql_response["data"] field_result = data.get(self.name) - is_hit = ( - is_non_empty_result(field_result) - if self.name in data - else any(is_non_empty_result(v) for v in data.values()) - ) + is_hit = is_non_empty_result(field_result) if self.name in data else any(is_non_empty_result(v) for v in data.values()) if is_hit: success_count += 1 except Exception as e: diff --git a/graphqler/fuzzer/engine/detectors/field_suggestion/field_suggestion_detector.py b/graphqler/fuzzer/engine/detectors/field_suggestion/field_suggestion_detector.py index eeeebcd7..b8109283 100644 --- a/graphqler/fuzzer/engine/detectors/field_suggestion/field_suggestion_detector.py +++ b/graphqler/fuzzer/engine/detectors/field_suggestion/field_suggestion_detector.py @@ -4,7 +4,6 @@ import requests from graphqler.utils import plugins_handler -from graphqler.utils.stats import Stats from .field_suggestion_materializer import FieldSuggestionMaterializer from ..detector import Detector @@ -44,19 +43,15 @@ def detect(self) -> tuple[bool, bool]: misspelled = query_name + "abc" payload = f"query {{\n {misspelled} {{\n id\n }}\n}}" - graphql_response, request_response = ( - plugins_handler.get_request_utils().send_graphql_request( - self.api.url, payload - ) - ) - Stats().add_http_status_code(self.name, request_response.status_code) + graphql_response, request_response = plugins_handler.get_request_utils().send_graphql_request(self.api.url, payload) + self.stats.add_http_status_code(self.name, request_response.status_code) if self._is_vulnerable(graphql_response, request_response): self.payload = payload self.confirmed_vulnerable = True self.potentially_vulnerable = True evidence = self._get_evidence(graphql_response, request_response) - Stats().add_vulnerability( + self.stats.add_vulnerability( self.DETECTION_NAME, self.name, self.confirmed_vulnerable, @@ -64,17 +59,11 @@ def detect(self) -> tuple[bool, bool]: payload=payload, evidence=evidence, ) - self.detector_logger.info( - f"Detector {self.DETECTION_NAME} finished detecting - " - f"is_vulnerable: True - potentially_vulnerable: True" - ) + self.detector_logger.info(f"Detector {self.DETECTION_NAME} finished detecting - is_vulnerable: True - potentially_vulnerable: True") return (True, True) - self.detector_logger.info( - f"Detector {self.DETECTION_NAME} finished detecting - " - f"is_vulnerable: False - potentially_vulnerable: False" - ) - Stats().add_vulnerability( + self.detector_logger.info(f"Detector {self.DETECTION_NAME} finished detecting - is_vulnerable: False - potentially_vulnerable: False") + self.stats.add_vulnerability( self.DETECTION_NAME, self.name, False, diff --git a/graphqler/fuzzer/engine/detectors/nosql_injection/nosql_injection_detector.py b/graphqler/fuzzer/engine/detectors/nosql_injection/nosql_injection_detector.py index 84ef0efd..99a36099 100644 --- a/graphqler/fuzzer/engine/detectors/nosql_injection/nosql_injection_detector.py +++ b/graphqler/fuzzer/engine/detectors/nosql_injection/nosql_injection_detector.py @@ -4,7 +4,6 @@ from graphqler import config from graphqler.utils import plugins_handler -from graphqler.utils.stats import Stats from graphqler.fuzzer.engine.types import ResultEnum, Result from graphqler.fuzzer.engine.materializers.regular_payload_materializer import RegularPayloadMaterializer @@ -22,7 +21,7 @@ '"{$exists: true}"', '"{$nin: []}"', "\"' || '1'=='1\"", - "\"; sleep(5000); var dummy=\"", + '"; sleep(5000); var dummy="', ] # Error messages commonly emitted by NoSQL databases (MongoDB, etc.) @@ -32,7 +31,7 @@ "mongo", "bson", "objectid", - "e11000", # MongoDB duplicate key error + "e11000", # MongoDB duplicate key error "bad query", "not valid json", "$where", @@ -79,9 +78,7 @@ def detect(self) -> tuple[bool, bool]: try: benign_mat = RegularPayloadMaterializer(self.api, fail_on_hard_dependency_not_met=False) benign_payload, _ = benign_mat.get_payload(self.name, self.objects_bucket, self.graphql_type) - baseline_gql, _ = plugins_handler.get_request_utils().send_graphql_request( - self.api.url, benign_payload - ) + baseline_gql, _ = plugins_handler.get_request_utils().send_graphql_request(self.api.url, benign_payload) if baseline_gql and isinstance(baseline_gql.get("data"), dict): self.baseline_has_data = any(v is not None for v in baseline_gql["data"].values()) else: @@ -91,9 +88,7 @@ def detect(self) -> tuple[bool, bool]: # ── Step 2: injection payload (standard flow) ───────────────────────── self.payload = self.get_payload() - graphql_response, request_response = plugins_handler.get_request_utils().send_graphql_request( - self.api.url, self.payload - ) + graphql_response, request_response = plugins_handler.get_request_utils().send_graphql_request(self.api.url, self.payload) result = Result( result_enum=ResultEnum.GENERAL_SUCCESS, @@ -102,12 +97,12 @@ def detect(self) -> tuple[bool, bool]: graphql_response=graphql_response, raw_response_text=request_response.text, ) - Stats().add_http_status_code(self.name, request_response.status_code) - Stats().update_stats_from_result(self.node, result) + self.stats.add_http_status_code(self.name, request_response.status_code) + self.stats.update_stats_from_result(self.node, result) self._parse_response(graphql_response, request_response) evidence = self._get_evidence(graphql_response, request_response) - Stats().add_vulnerability( + self.stats.add_vulnerability( self.DETECTION_NAME, self.name, self.confirmed_vulnerable, @@ -128,9 +123,7 @@ def _is_potentially_vulnerable(self, graphql_response: dict, request_response: r return False if not any(kw in self.payload for kw in NOSQL_INJECTION_STRINGS): return False - injection_has_data = isinstance(graphql_response.get("data"), dict) and any( - v is not None for v in graphql_response["data"].values() - ) + injection_has_data = isinstance(graphql_response.get("data"), dict) and any(v is not None for v in graphql_response["data"].values()) # Only flag when the operator payload produces data that the benign # baseline did NOT — a strong signal that the operator bypassed a filter. return injection_has_data and not self.baseline_has_data @@ -141,10 +134,7 @@ def _get_evidence(self, graphql_response: dict, request_response: requests.Respo if pattern in response_text_lower: return f"matched NoSQL error pattern: '{pattern}'" if self._is_potentially_vulnerable(graphql_response, request_response): - evidence = ( - "NoSQL operator payload returned data when benign baseline returned none " - "(potential filter/auth bypass)" - ) + evidence = "NoSQL operator payload returned data when benign baseline returned none (potential filter/auth bypass)" if config.NOSQLI_BLIND_EXTRACTION: extracted = BlindNoSQLExtractor(self.api.url, self.payload).extract() if extracted: diff --git a/graphqler/fuzzer/engine/detectors/query_deny_bypass/query_deny_bypass_detector.py b/graphqler/fuzzer/engine/detectors/query_deny_bypass/query_deny_bypass_detector.py index 51a81476..8c885d40 100644 --- a/graphqler/fuzzer/engine/detectors/query_deny_bypass/query_deny_bypass_detector.py +++ b/graphqler/fuzzer/engine/detectors/query_deny_bypass/query_deny_bypass_detector.py @@ -9,7 +9,6 @@ from graphqler.fuzzer.engine.materializers.utils.materialization_utils import prettify_graphql_payload from graphqler.fuzzer.engine.materializers.regular_payload_materializer import RegularPayloadMaterializer from graphqler.utils.objects_bucket import ObjectsBucket -from graphqler.utils.stats import Stats from graphqler.utils import plugins_handler @@ -111,10 +110,9 @@ def detect(self) -> tuple[bool, bool]: self.fuzzer_logger.debug(f"[{aliased_request_response.status_code}]Aliased Response: {aliased_graphql_response}") self.detector_logger.info(f"[{aliased_request_response.status_code}]Aliased Response: {aliased_request_response.text}") - if (("400" in non_aliased_request_response.text and 'errors' in non_aliased_graphql_response) - or non_aliased_request_response.status_code == 400): - if (aliased_request_response.status_code == 200 and 'data' in aliased_graphql_response and aliased_graphql_response['data']): - if 'errors' in aliased_graphql_response and aliased_graphql_response['errors'] and len(aliased_graphql_response['errors']) != 0: + if ("400" in non_aliased_request_response.text and "errors" in non_aliased_graphql_response) or non_aliased_request_response.status_code == 400: + if aliased_request_response.status_code == 200 and "data" in aliased_graphql_response and aliased_graphql_response["data"]: + if "errors" in aliased_graphql_response and aliased_graphql_response["errors"] and len(aliased_graphql_response["errors"]) != 0: self.potentially_vulnerable = True self.confirmed_vulnerable = False else: @@ -124,33 +122,31 @@ def detect(self) -> tuple[bool, bool]: self.potentially_vulnerable = False self.confirmed_vulnerable = False - non_aliased_result = Result(ResultEnum.GENERAL_SUCCESS, - payload=non_aliased_payload, - status_code=non_aliased_request_response.status_code, - graphql_response=non_aliased_graphql_response, - raw_response_text=non_aliased_request_response.text) - aliased_result = Result(ResultEnum.GENERAL_SUCCESS, - payload=aliased_payload, - status_code=aliased_request_response.status_code, - graphql_response=aliased_graphql_response, - raw_response_text=aliased_request_response.text) - - Stats().add_http_status_code(self.name, non_aliased_request_response.status_code) - Stats().add_http_status_code(self.name, aliased_request_response.status_code) - Stats().update_stats_from_result(self.node, non_aliased_result) - Stats().update_stats_from_result(self.node, aliased_result) + non_aliased_result = Result( + ResultEnum.GENERAL_SUCCESS, + payload=non_aliased_payload, + status_code=non_aliased_request_response.status_code, + graphql_response=non_aliased_graphql_response, + raw_response_text=non_aliased_request_response.text, + ) + aliased_result = Result( + ResultEnum.GENERAL_SUCCESS, + payload=aliased_payload, + status_code=aliased_request_response.status_code, + graphql_response=aliased_graphql_response, + raw_response_text=aliased_request_response.text, + ) + + self.stats.add_http_status_code(self.name, non_aliased_request_response.status_code) + self.stats.add_http_status_code(self.name, aliased_request_response.status_code) + self.stats.update_stats_from_result(self.node, non_aliased_result) + self.stats.update_stats_from_result(self.node, aliased_result) evidence = "" if self.confirmed_vulnerable: - evidence = ( - "query deny bypass confirmed: non-aliased request blocked (400/errors), " - "aliased request succeeded (200 with data and no errors)" - ) + evidence = "query deny bypass confirmed: non-aliased request blocked (400/errors), aliased request succeeded (200 with data and no errors)" elif self.potentially_vulnerable: - evidence = ( - "query deny bypass potential: non-aliased request blocked, " - "aliased request returned data but also contained errors" - ) - Stats().add_vulnerability( + evidence = "query deny bypass potential: non-aliased request blocked, aliased request returned data but also contained errors" + self.stats.add_vulnerability( self.DETECTION_NAME, self.name, self.confirmed_vulnerable, diff --git a/graphqler/fuzzer/engine/detectors/sql_injection/sql_injection_detector.py b/graphqler/fuzzer/engine/detectors/sql_injection/sql_injection_detector.py index 2d2d9db8..3b23d85f 100644 --- a/graphqler/fuzzer/engine/detectors/sql_injection/sql_injection_detector.py +++ b/graphqler/fuzzer/engine/detectors/sql_injection/sql_injection_detector.py @@ -4,7 +4,6 @@ import random from graphqler.utils.api import API -from graphqler.utils.stats import Stats from graphqler.utils import plugins_handler, detection_writer from graphqler.fuzzer.engine.types import ResultEnum, Result @@ -17,12 +16,12 @@ # SQLite-specific: reference a non-existent table inside a subquery so the error # is raised within the *first* (and only) statement that sqlite3's db.all() executes. # These are placed first to ensure they are always tried. - "\"' AND 1=(SELECT 1 FROM nonexistent_sqli_table_xyzzy)--\"", - "\"' OR 1=(SELECT 1 FROM nonexistent_sqli_table_xyzzy)--\"", - "\"' AND (SELECT COUNT(*) FROM nonexistent_sqli_table_xyzzy)>0--\"", + '"\' AND 1=(SELECT 1 FROM nonexistent_sqli_table_xyzzy)--"', + '"\' OR 1=(SELECT 1 FROM nonexistent_sqli_table_xyzzy)--"', + '"\' AND (SELECT COUNT(*) FROM nonexistent_sqli_table_xyzzy)>0--"', # Generic '"aaa \' OR 1=1--"', - '"\' OR \'1\'=\'1"', + "\"' OR '1'='1\"", '"1; DROP TABLE users--"', '"1\' UNION SELECT null,null,null--"', '"1\' AND SLEEP(3)--"', @@ -36,7 +35,7 @@ '"1\'; SELECT pg_sleep(3)--"', '"1\' AND 1=(SELECT 1 FROM pg_user LIMIT 1)--"', # MSSQL-specific - '"1\'; WAITFOR DELAY \'0:0:3\'--"', + "\"1'; WAITFOR DELAY '0:0:3'--\"", '"1\' AND 1=@@version--"', ] @@ -102,7 +101,7 @@ def __init__(self, injection_string: str | None = None): @override def get_random_string(self, input_name: str) -> str: - if input_name in ['filter', 'search', 'query', 'name', 'username', 'password', 'email', 'id', 'text', 'message', 'input', 'value']: + if input_name in ["filter", "search", "query", "name", "username", "password", "email", "id", "text", "message", "input", "value"]: if self._injection_string is not None: return self._injection_string return random.choice(SQL_INJECTION_STRINGS) @@ -168,8 +167,8 @@ def detect(self) -> tuple[bool, bool]: graphql_response=graphql_response, raw_response_text=request_response.text, ) - Stats().add_http_status_code(self.name, request_response.status_code) - Stats().update_stats_from_result(self.node, result) + self.stats.add_http_status_code(self.name, request_response.status_code) + self.stats.update_stats_from_result(self.node, result) self.detector_logger.info(f"[{request_response.status_code}] Response: {request_response.text}") self.fuzzer_logger.info(f"[{request_response.status_code}] Response: {graphql_response}") @@ -182,7 +181,7 @@ def detect(self) -> tuple[bool, bool]: break evidence = self._get_evidence(last_graphql_response, last_request_response) - Stats().add_vulnerability( + self.stats.add_vulnerability( self.DETECTION_NAME, self.name, self.confirmed_vulnerable, diff --git a/graphqler/fuzzer/engine/detectors/time_sql_injection/time_sql_injection_detector.py b/graphqler/fuzzer/engine/detectors/time_sql_injection/time_sql_injection_detector.py index 7821a5a4..9f2d934c 100644 --- a/graphqler/fuzzer/engine/detectors/time_sql_injection/time_sql_injection_detector.py +++ b/graphqler/fuzzer/engine/detectors/time_sql_injection/time_sql_injection_detector.py @@ -6,7 +6,6 @@ from graphqler.utils import plugins_handler from graphqler.fuzzer.engine.types import ResultEnum, Result from graphqler.fuzzer.engine.materializers.regular_payload_materializer import RegularPayloadMaterializer -from graphqler.utils.stats import Stats from graphqler import config from ..detector import Detector @@ -74,9 +73,7 @@ def detect(self) -> tuple[bool, bool]: self.detector_logger.info(f"[Detector] Time-based SQLi payload:\n{self.payload}") start = time.monotonic() - graphql_response, request_response = plugins_handler.get_request_utils().send_graphql_request( - self.api.url, self.payload - ) + graphql_response, request_response = plugins_handler.get_request_utils().send_graphql_request(self.api.url, self.payload) self.elapsed_time = time.monotonic() - start # Delta removes naturally-slow-endpoint noise @@ -89,23 +86,19 @@ def detect(self) -> tuple[bool, bool]: graphql_response=graphql_response, raw_response_text=request_response.text, ) - Stats().add_http_status_code(self.name, request_response.status_code) - Stats().update_stats_from_result(self.node, result) + self.stats.add_http_status_code(self.name, request_response.status_code) + self.stats.update_stats_from_result(self.node, result) self.detector_logger.info( - f"[{request_response.status_code}] elapsed={self.elapsed_time:.2f}s " - f"baseline={self.baseline_time:.2f}s delta={self.time_delta:.2f}s " - f"Response: {request_response.text}" + f"[{request_response.status_code}] elapsed={self.elapsed_time:.2f}s baseline={self.baseline_time:.2f}s delta={self.time_delta:.2f}s Response: {request_response.text}" ) self.fuzzer_logger.info( - f"[{request_response.status_code}] elapsed={self.elapsed_time:.2f}s " - f"baseline={self.baseline_time:.2f}s delta={self.time_delta:.2f}s " - f"Response: {graphql_response}" + f"[{request_response.status_code}] elapsed={self.elapsed_time:.2f}s baseline={self.baseline_time:.2f}s delta={self.time_delta:.2f}s Response: {graphql_response}" ) self._parse_response(graphql_response, request_response) evidence = self._get_evidence(graphql_response, request_response) - Stats().add_vulnerability( + self.stats.add_vulnerability( self.DETECTION_NAME, self.name, self.confirmed_vulnerable, diff --git a/graphqler/fuzzer/engine/fengine.py b/graphqler/fuzzer/engine/fengine.py index 960dc244..c2f8d4e2 100644 --- a/graphqler/fuzzer/engine/fengine.py +++ b/graphqler/fuzzer/engine/fengine.py @@ -10,10 +10,9 @@ from graphqler.utils.api import API from graphqler.utils.logging_utils import Logger from graphqler.utils.objects_bucket import ObjectsBucket +from graphqler.utils.stats import Stats from graphqler.utils.parser_utils import get_output_type from graphqler.utils import plugins_handler -from graphqler.utils.singleton import singleton -from graphqler.utils.stats import Stats from graphqler.utils import request_utils as _request_utils from .exceptions import HardDependencyNotMetException @@ -24,15 +23,11 @@ from .utils import check_is_data_empty -@singleton class FEngine(object): - def __init__(self, api: API): - """The intiialization of the FEngine - - Args: - api (API): The API object - """ + def __init__(self, api: API, stats: Stats | None = None): + """Initialize the execution engine for one fuzzing run.""" self.api = api + self.stats = stats or Stats() self.logger = Logger().get_fuzzer_logger() def run_minimal_payload(self, name: str, objects_bucket: ObjectsBucket, graphql_type: str, check_hard_depends_on: bool = True) -> tuple[dict, Result]: @@ -71,6 +66,30 @@ def run_minimal_payload_with_profile(self, name: str, objects_bucket: ObjectsBuc materializer = GeneralPayloadMaterializer(self.api, fail_on_hard_dependency_not_met=False) return self.__run_payload_with_profile(name, objects_bucket, materializer, graphql_type, profile) + def run_payload_with_profile(self, name: str, payload: str, profile: RuntimeProfile) -> tuple[dict, Result]: + """Send an already-materialized payload under a specific profile.""" + result = Result() + result.payload = payload + try: + graphql_response, request_response = _request_utils.send_graphql_request_with_headers( + self.api.url, + payload, + profile.get_headers(), + ) + result.status_code = request_response.status_code + result.graphql_response = graphql_response + result.raw_response_text = request_response.text + if not graphql_response or result.has_errors or not result.has_data or result.data.get(name) is None: + result.result_enum = ResultEnum.EXTERNAL_FAILURE + else: + result.result_enum = ResultEnum.HAS_DATA_SUCCESS + return graphql_response, result + except Exception as exc: + self.logger.info(f"[{profile.name}/{name}] Exception: {exc}") + self.logger.debug(traceback.format_exc()) + result.result_enum = ResultEnum.INTERNAL_FAILURE + return {}, result + def run_minimal_payload_with_auth(self, name: str, objects_bucket: ObjectsBucket, graphql_type: str, auth_override: str) -> tuple[dict, "Result"]: """Backward-compatible wrapper for run_minimal_payload_with_profile.""" profile = RuntimeProfile(name="legacy_override", auth_token=auth_override) @@ -112,34 +131,38 @@ def run_dos_payloads(self, name: str, objects_bucket: ObjectsBucket, graphql_typ return results def run_subscription_payload(self, name: str, objects_bucket: ObjectsBucket) -> tuple[list[dict], Result]: - """Executes a GraphQL subscription over a WebSocket connection and returns collected events. + """Execute a materialized subscription using the configured request headers.""" + materializer = SubscriptionMaterializer(self.api, fail_on_hard_dependency_not_met=False) + try: + payload_str, _used_objects = materializer.get_payload(name, objects_bucket, "Subscription") + except Exception as exc: + self.logger.warning(f"Subscription {name} failed during materialization: {exc}") + return [], Result(ResultEnum.EXTERNAL_FAILURE) + headers = plugins_handler.get_request_utils().get_headers() + return self._run_subscription_payload(name, payload_str, headers) - Args: - name (str): Subscription name - objects_bucket (ObjectsBucket): Shared objects bucket + def run_subscription_with_profile(self, name: str, payload: str, profile: RuntimeProfile) -> tuple[list[dict], Result]: + """Execute an already-materialized subscription under a specific profile.""" + return self._run_subscription_payload(name, payload, profile.get_headers()) - Returns: - tuple[list[dict], Result]: List of event payloads received and a Result - """ + def _run_subscription_payload(self, name: str, payload: str, headers: dict[str, str]) -> tuple[list[dict], Result]: from graphqler.utils.websocket_utils import send_graphql_subscription + result = Result() + result.payload = payload try: - materializer = SubscriptionMaterializer(self.api, fail_on_hard_dependency_not_met=False) - payload_str, _used_objects = materializer.get_payload(name, objects_bucket, "Subscription") - graphql_payload = {"query": payload_str} - request_utils = plugins_handler.get_request_utils() events = send_graphql_subscription( url=self.api.url, - payload=graphql_payload, - headers=request_utils.get_headers(), + payload={"query": payload}, + headers=headers, ) - if events: - return events, Result(ResultEnum.GENERAL_SUCCESS) - else: - return [], Result(ResultEnum.EXTERNAL_FAILURE) - except Exception as e: - self.logger.warning(f"Subscription {name} failed: {e}") - return [], Result(ResultEnum.EXTERNAL_FAILURE) + result.graphql_response = events + result.result_enum = ResultEnum.GENERAL_SUCCESS if events else ResultEnum.EXTERNAL_FAILURE + return events, result + except Exception as exc: + self.logger.warning(f"Subscription {name} failed: {exc}") + result.result_enum = ResultEnum.INTERNAL_FAILURE + return [], result def __run_payload(self, name: str, objects_bucket: ObjectsBucket, materializer: Materializer, graphql_type: str) -> tuple[dict, Result]: """Runs the payload (either Query or Mutation), and returns a new objects bucket @@ -187,7 +210,7 @@ def __run_payload_with_profile(self, name: str, objects_bucket: ObjectsBucket, m payload_string, _ = materializer.get_payload(name, objects_bucket, graphql_type) result.payload = payload_string self.logger.info(f"[{profile.name}/{name}] Sending payload with profile '{profile.name}':\n {payload_string}") - + # Use full profile headers (Authorization + any extra profile-specific headers) graphql_response, request_response = _request_utils.send_graphql_request_with_headers(self.api.url, payload_string, profile.get_headers()) result.status_code = request_response.status_code @@ -211,7 +234,6 @@ def __run_payload_with_profile(self, name: str, objects_bucket: ObjectsBucket, m result.result_enum = ResultEnum.INTERNAL_FAILURE return ({}, result) - def __run_mutation(self, endpoint_name: str, objects_bucket: ObjectsBucket, materializer: Materializer) -> tuple[dict, Result]: """Runs the mutation, and returns a new objects bucket. Performs a few things: 1. Materializes the mutation with its parameters (resolving any dependencies from the object_bucket) @@ -245,7 +267,7 @@ def __run_mutation(self, endpoint_name: str, objects_bucket: ObjectsBucket, mate # Stats tracking stuff, results self.logger.info(f"Request Response code: {status_code}") - Stats().add_http_status_code(endpoint_name, status_code) + self.stats.add_http_status_code(endpoint_name, status_code) result.status_code = status_code result.graphql_response = graphql_response result.raw_response_text = request_response.text @@ -339,7 +361,7 @@ def __run_query(self, endpoint_name: str, objects_bucket: ObjectsBucket, materia # Stats tracking stuff self.logger.info(f"Request Response code: {status_code}") - Stats().add_http_status_code(endpoint_name, status_code) + self.stats.add_http_status_code(endpoint_name, status_code) result.status_code = status_code result.graphql_response = graphql_response result.raw_response_text = request_response.text diff --git a/graphqler/fuzzer/engine/types/result.py b/graphqler/fuzzer/engine/types/result.py index ebb0a1b2..b3b6453c 100644 --- a/graphqler/fuzzer/engine/types/result.py +++ b/graphqler/fuzzer/engine/types/result.py @@ -13,14 +13,16 @@ class ResultEnum(Enum): class Result: - def __init__(self, - result_enum: Optional[ResultEnum] = None, - payload: Optional[str] | Optional[list[str]] | dict = None, - errors: Optional[list] = None, - data: Optional[dict] = None, - status_code: Optional[int] = None, - graphql_response: Optional[dict] = None, - raw_response_text: Optional[str] = None): + def __init__( + self, + result_enum: Optional[ResultEnum] = None, + payload: str | list[str] | dict | None = None, + errors: Optional[list] = None, + data: Optional[dict] = None, + status_code: Optional[int] = None, + graphql_response: dict | list[dict] | None = None, + raw_response_text: Optional[str] = None, + ): """Initializes the result object""" self._result_enum = result_enum self._payload = payload @@ -53,15 +55,34 @@ def __hash__(self) -> int: Implement hashing for Result objects. This allows Result objects to be used in sets and as dictionary keys. """ - return hash(( - self._result_enum, - str(self._payload), - str(self._errors), - str(self._data), - self._status_code, - str(self._graphql_response), - self._raw_response_text - )) + return hash((self._result_enum, str(self._payload), str(self._errors), str(self._data), self._status_code, str(self._graphql_response), self._raw_response_text)) + + def to_dict(self) -> dict: + """Return a JSON-compatible representation.""" + return { + "result_type": self._result_enum.name if self._result_enum is not None else None, + "payload": self._payload, + "errors": self._errors, + "data": self._data, + "status_code": self._status_code, + "graphql_response": self._graphql_response, + "raw_response_text": self._raw_response_text, + } + + @classmethod + def from_dict(cls, data: dict) -> "Result": + """Restore a result from :meth:`to_dict` output.""" + result_name = data.get("result_type") + result_enum = ResultEnum[result_name] if result_name is not None else None + return cls( + result_enum=result_enum, + payload=data.get("payload"), + errors=data.get("errors"), + data=data.get("data"), + status_code=data.get("status_code"), + graphql_response=data.get("graphql_response"), + raw_response_text=data.get("raw_response_text"), + ) def __str__(self) -> str: """Returns a string representation of the result""" @@ -156,7 +177,7 @@ def status_code(self, status_code): self._status_code = status_code @property - def graphql_response(self) -> dict: + def graphql_response(self) -> dict | list[dict]: """Gets the graphql response""" if self._graphql_response is None: return {} @@ -166,17 +187,17 @@ def graphql_response(self) -> dict: def graphql_response(self, graphql_response): """Sets graphql response""" self._graphql_response = graphql_response - if graphql_response is not None: - if 'errors' in graphql_response: - self._errors = graphql_response['errors'] - if 'data' in graphql_response: - self._data = graphql_response['data'] + if isinstance(graphql_response, dict): + if "errors" in graphql_response: + self._errors = graphql_response["errors"] + if "data" in graphql_response: + self._data = graphql_response["data"] @property def raw_response_text(self) -> str: """Gets the raw response text""" if self._raw_response_text is None: - return '' + return "" return self._raw_response_text @raw_response_text.setter diff --git a/graphqler/fuzzer/fuzzer.py b/graphqler/fuzzer/fuzzer.py index cb527336..1dbce3b1 100644 --- a/graphqler/fuzzer/fuzzer.py +++ b/graphqler/fuzzer/fuzzer.py @@ -20,92 +20,94 @@ from graphqler.chains import Chain, ChainGenerator, ChainStep from graphqler.graph import GraphGenerator, Node from graphqler.utils.api import API +from graphqler.utils.artifact_manifest import validate_manifest from graphqler.utils.logging_utils import Logger from graphqler.utils.objects_bucket import ObjectsBucket +from graphqler.utils.run_context import RunContext from graphqler.utils.stats import Stats from .engine.fengine import FEngine from .engine.dengine import DEngine from .engine.types import Result, ResultEnum from .engine.types.profile import RuntimeProfile -from .engine.detectors import IDORChainDetector, UAFChainDetector +from .engine.detectors import AuthorizationDifferentialDetector, IDORChainDetector, UAFChainDetector +from .engine.detectors.field_fuzzing.endpoint_classifier import EndpointPrivacyClassifier from .reporters import LLMReporter class Fuzzer(object): - def __init__(self, save_path: str, url: str, objects_bucket: typing.Optional[ObjectsBucket] = None): - """Initializes the fuzzer, reading information from the compiled files - - Args: - save_path (str): Save directory path - url (str): URL for graphql introspection query to hit - """ + def __init__( + self, + save_path: str, + url: str, + objects_bucket: typing.Optional[ObjectsBucket] = None, + stats: typing.Optional[Stats] = None, + settings: config.RunSettings | None = None, + ): + """Initialize a fuzzer and its isolated run state.""" self.save_path = save_path self.url = url - self.logger = Logger().get_fuzzer_logger() - self.stats = Stats() - self.api = API(url, save_path) - - self.dependency_graph = GraphGenerator(save_path).get_dependency_graph() - # Reset the FEngine singleton so it binds to the current API. When multiple - # Fuzzer instances are created sequentially (e.g. in run_all_experiments.py), - # the stale singleton would otherwise keep the first API's queries/mutations, - # causing KeyErrors for every operation in the subsequent API. - FEngine.reset() # ty: ignore[unresolved-attribute] - self.fengine = FEngine(self.api) - self.dengine = DEngine(self.api) - self.idor_detector = IDORChainDetector() - self.uaf_detector = UAFChainDetector() - - # Initialize runtime profiles - self.profiles: dict[str, RuntimeProfile] = { - "primary": RuntimeProfile(name="primary", auth_token=config.AUTHORIZATION), - "secondary": RuntimeProfile(name="secondary", auth_token=config.IDOR_SECONDARY_AUTH), - # post_delete uses the primary auth token — UAF tests same-user access after deletion - "post_delete": RuntimeProfile(name="post_delete", auth_token=config.AUTHORIZATION), - } - # Add any other profiles defined in config.PROFILES - for name, profile_data in getattr(config, "PROFILES", {}).items(): - if isinstance(profile_data, dict): - self.profiles[name] = RuntimeProfile( - name=name, - auth_token=profile_data.get("auth_token"), - headers=profile_data.get("headers", {}), - variables=profile_data.get("variables", {}) - ) - elif isinstance(profile_data, str): - self.profiles[name] = RuntimeProfile(name=name, auth_token=profile_data) - - if objects_bucket: - self.objects_bucket = objects_bucket - else: - self.objects_bucket = ObjectsBucket(self.api) + self.settings = settings or config.snapshot() + + with config.activate(self.settings): + validate_manifest(save_path, "chains", self.settings, expected_endpoint=url) + self.logger = Logger().get_fuzzer_logger() + run_stats = stats or Stats() + run_stats.set_file_paths(save_path, reset=not self.settings.RESUME) + if self.settings.RESUME: + run_stats.load() + self.api = API(url, save_path) + self.dependency_graph = GraphGenerator(save_path).get_dependency_graph() + run_bucket = objects_bucket or ObjectsBucket(self.api) + if self.settings.RESUME: + run_bucket.load() + self.context = RunContext(Path(save_path), self.settings, run_stats, run_bucket) + self.stats = self.context.stats + self.objects_bucket = self.context.objects_bucket + self.fengine = FEngine(self.api, self.stats) + self.dengine = DEngine(self.api, self.stats, self.objects_bucket) + self.idor_detector = IDORChainDetector() + self.uaf_detector = UAFChainDetector() + self.authorization_detector = AuthorizationDifferentialDetector() + + self.profiles: dict[str, RuntimeProfile] = { + "primary": RuntimeProfile(name="primary", auth_token=self.settings.AUTHORIZATION), + "secondary": RuntimeProfile(name="secondary", auth_token=self.settings.IDOR_SECONDARY_AUTH), + "post_delete": RuntimeProfile(name="post_delete", auth_token=self.settings.AUTHORIZATION), + } + for name, profile_data in self.settings.PROFILES.items(): + if isinstance(profile_data, dict): + self.profiles[name] = RuntimeProfile( + name=name, + auth_token=profile_data.get("auth_token"), + headers=profile_data.get("headers", {}), + variables=profile_data.get("variables", {}), + ) + elif isinstance(profile_data, str): + self.profiles[name] = RuntimeProfile(name=name, auth_token=profile_data) - # Load pre-generated chains produced during compilation - self.chains: list[Chain] = ChainGenerator().load_from_yaml(save_path, self.dependency_graph) + self.chains = ChainGenerator().load_from_yaml(save_path, self.dependency_graph) - # Stats about the run self.stats.number_of_queries = self.api.get_num_queries() self.stats.number_of_mutations = self.api.get_num_mutations() self.stats.number_of_objects = self.api.get_num_objects() - - # Optional TUI callbacks — None by default so CLI mode has zero overhead. - # Set these before calling run() / run_chain() when using the TUI. self.on_chain_start: typing.Optional[typing.Callable[[Chain], None]] = None self.on_chain_done: typing.Optional[typing.Callable[[Chain, list], None]] = None + blocked_keys = set(self.stats.dep_retry_nodes) + self._dep_blocked_nodes: set[Node] = { + node for node in self.dependency_graph.nodes if f"{node.graphql_type}|{node.name}" in blocked_keys + } + self._authorization_tested_nodes: set[Node] = set() - # Nodes that failed due to unmet hard dependencies during chain execution; - # re-run as standalone in the dep_retry phase after islands. - self._dep_blocked_nodes: set[Node] = set() - + @config.use_settings def run(self): """Main function to run the fuzzer""" queue = multiprocessing.Queue() if config.DEBUG: - p = threading.Thread(target=self.__run_fuzz, args=(queue,)) + p = threading.Thread(target=self._run_fuzz_scoped, args=(queue,)) p.daemon = True else: - p = multiprocessing.Process(target=self.__run_fuzz, args=(queue,)) + p = multiprocessing.Process(target=self._run_fuzz_scoped, args=(queue,)) p.start() p.join(config.MAX_TIME) @@ -119,6 +121,11 @@ def run(self): if not queue.empty(): _ = queue.get() + def _run_fuzz_scoped(self, queue: multiprocessing.Queue) -> None: + with config.activate(self.settings): + self.__run_fuzz(queue) + + @config.use_settings def run_chain(self, chain: Chain) -> None: """Execute a single chain (public API for use by the TUI chain explorer). @@ -127,6 +134,7 @@ def run_chain(self, chain: Chain) -> None: """ self.__run_chain(chain) + @config.use_settings def run_single(self, node_name: str): """Runs a single node @@ -147,6 +155,7 @@ def run_single(self, node_name: str): self.stats.save_eval_summary() self.objects_bucket.save() + @config.use_settings def run_idor_only(self): """Run only the IDOR chain phase, skipping regular fuzzing. @@ -155,10 +164,10 @@ def run_idor_only(self): """ queue = multiprocessing.Queue() if config.DEBUG: - p = threading.Thread(target=self.__run_idor_steps, args=(queue,)) + p = threading.Thread(target=self._run_idor_scoped, args=(queue,)) p.daemon = True else: - p = multiprocessing.Process(target=self.__run_idor_steps, args=(queue,)) + p = multiprocessing.Process(target=self._run_idor_scoped, args=(queue,)) p.start() p.join(config.MAX_TIME) @@ -172,6 +181,10 @@ def run_idor_only(self): if not queue.empty(): _ = queue.get() + def _run_idor_scoped(self, queue: multiprocessing.Queue) -> None: + with config.activate(self.settings): + self.__run_idor_steps(queue) + def __run_idor_steps(self, queue: multiprocessing.Queue): """Run only IDOR chains (no regular fuzzing, no island nodes, no API-level detections).""" self.stats.start_time = time.time() @@ -206,10 +219,15 @@ def __run_fuzz(self, queue: multiprocessing.Queue): Args: queue (multiprocessing.Queue): Queue for communicating back to the parent process """ + if config.RESUME and self.stats.phase == "completed": + self.logger.info("Run checkpoint is already complete; nothing to resume") + return + resume_phase = self.stats.phase if config.RESUME else "chains" self.stats.start_time = time.time() # Single background thread that refreshes the progress line for the entire run stop_progress = threading.Event() + def _refresh_progress(): while not stop_progress.is_set(): self.stats.print_running_stats() @@ -225,18 +243,21 @@ def _refresh_progress(): elif self.chains: max_iter = max(1, config.MAX_FUZZING_ITERATIONS) self.stats.chains_total = len(self.chains) - self.stats.total_iterations = max_iter - self.logger.info(f"Running {len(self.chains)} pre-generated chains for up to {max_iter} iteration(s)") - for iteration in range(max_iter): + start_iteration = self.stats.current_iteration - 1 if config.RESUME else 0 + for iteration in range(start_iteration, max_iter): if time.time() - self.stats.start_time >= config.MAX_TIME: self.logger.info(f"MAX_TIME reached during iteration {iteration + 1} — stopping chain loop early") break + resume_index = self.stats.chains_completed if config.RESUME and iteration == start_iteration else 0 self.stats.current_iteration = iteration + 1 - self.stats.chains_completed = 0 - self.logger.info(f"Chain iteration {iteration + 1}/{max_iter}") - for chain in self.chains: + self.stats.chains_completed = resume_index + self.logger.info(f"Chain iteration {iteration + 1}/{max_iter}, starting at chain {resume_index + 1}") + for chain_index, chain in enumerate(self.chains): + if chain_index < resume_index: + continue self.__run_chain(chain) - self.stats.chains_completed += 1 + self.stats.chains_completed = chain_index + 1 + self.stats.checkpoint() self.logger.info("Completed all chain iterations") chained_nodes: set[Node] = {node for chain in self.chains for node in chain.nodes} @@ -261,26 +282,32 @@ def _refresh_progress(): uncovered_nodes = list(self.dependency_graph.nodes) if uncovered_nodes: - self.logger.info(f"Running {len(uncovered_nodes)} uncovered node(s)") self.stats.phase = "islands" self.stats.islands_total = len(uncovered_nodes) - self.stats.islands_completed = 0 - self.__run_nodes(uncovered_nodes) + if resume_phase in {"dep_retry", "detections"}: + island_start = len(uncovered_nodes) + elif resume_phase == "islands": + island_start = self.stats.islands_completed + else: + island_start = 0 + self.stats.islands_completed = island_start + self.__run_nodes(uncovered_nodes[island_start:]) # Dep-retry phase: re-run nodes that failed every chain attempt due to unmet hard # dependencies, now using the globally shared objects_bucket (populated by islands # and any successful chain steps) and bypassing hard-dep checks so they get a # genuine attempt with whatever objects are available (or random fallbacks). - dep_retry_nodes = [ - node for node in self._dep_blocked_nodes - if f"{node.graphql_type}|{node.name}" not in self.stats.successful_nodes - ] + dep_retry_nodes = sorted( + (node for node in self._dep_blocked_nodes if f"{node.graphql_type}|{node.name}" not in self.stats.successful_nodes), + key=lambda node: (node.graphql_type, node.name), + ) if dep_retry_nodes: self.logger.info(f"Dep-retry phase: retrying {len(dep_retry_nodes)} node(s) that always had unmet hard dependencies") self.stats.phase = "dep_retry" self.stats.dep_retry_total = len(dep_retry_nodes) - self.stats.dep_retry_completed = 0 - for node in dep_retry_nodes: + retry_start = self.stats.dep_retry_completed if config.RESUME else 0 + self.stats.dep_retry_completed = retry_start + for node in dep_retry_nodes[retry_start:]: self.logger.info(f"[dep_retry] Running node: {node}") node_start = time.time() _response, result = self.fengine.run_minimal_payload(node.name, self.objects_bucket, node.graphql_type, check_hard_depends_on=False) @@ -289,6 +316,7 @@ def _refresh_progress(): self.fengine.run_maximal_payload(node.name, self.objects_bucket, node.graphql_type, check_hard_depends_on=False) self.__detect_vulnerabilities_on_node(node, self.objects_bucket) self.stats.dep_retry_completed += 1 + self.stats.checkpoint() # Detections self.stats.phase = "detections" @@ -299,6 +327,7 @@ def _refresh_progress(): # LLM report (opt-in via config.LLM_ENABLE_REPORTER) if config.LLM_ENABLE_REPORTER: LLMReporter(self.save_path, self.url).generate() + self.stats.phase = "completed" finally: stop_progress.set() progress_thread.join() @@ -327,8 +356,7 @@ def __run_chain(self, chain: Chain): Args: chain (Chain): The chain to execute. """ - bucket_cls = typing.cast(typing.Any, getattr(ObjectsBucket, "__wrapped__", ObjectsBucket)) - fresh_bucket: ObjectsBucket = bucket_cls(self.api) + fresh_bucket = ObjectsBucket(self.api) results: list[tuple[ChainStep, Result]] = [] pre_delete_snapshot: typing.Optional[ObjectsBucket] = None @@ -391,8 +419,7 @@ def __run_chain(self, chain: Chain): continue if step.profile_name != "primary" and not profile.auth_token: self.logger.warning( - f"Profile '{step.profile_name}' has no auth token configured — aborting chain " - f"(set IDOR_SECONDARY_AUTH in your config to enable IDOR chain testing)" + f"Profile '{step.profile_name}' has no auth token configured — aborting chain (set IDOR_SECONDARY_AUTH in your config to enable IDOR chain testing)" ) break @@ -409,9 +436,7 @@ def __run_chain(self, chain: Chain): print(f"[UAF-DEBUG] token: {repr(profile.auth_token)}") self.logger.info(f"[post_delete][test] Running node with post-delete profile: {node}") - _response, result = self.fengine.run_minimal_payload_with_profile( - node.name, bucket_for_step, node.graphql_type, profile - ) + _response, result = self.fengine.run_minimal_payload_with_profile(node.name, bucket_for_step, node.graphql_type, profile) if config.DEBUG: node_data = result.data.get(node.name) if result.data else None @@ -421,9 +446,7 @@ def __run_chain(self, chain: Chain): elif step.profile_name != "primary": # Multi-profile test phase (e.g. secondary / IDOR) self.logger.info(f"[{step.profile_name}][test] Running node with profile '{step.profile_name}': {node}") - _response, result = self.fengine.run_minimal_payload_with_profile( - node.name, fresh_bucket, node.graphql_type, profile - ) + _response, result = self.fengine.run_minimal_payload_with_profile(node.name, fresh_bucket, node.graphql_type, profile) results.append((step, result)) else: # Regular primary phase — snapshot bucket before DELETE so UAF post_delete step can use it @@ -439,7 +462,7 @@ def __run_chain(self, chain: Chain): self.stats.record_node_timing(node, time.time() - node_start) self.stats.update_stats_from_result(node, result) if result.result_enum == ResultEnum.HARD_DEPENDENCY_NOT_MET: - self._dep_blocked_nodes.add(node) + self.__mark_dep_blocked(node) if i == last_primary_index: self.__fuzz(node, visit_path, objects_bucket=fresh_bucket) self.__detect_vulnerabilities_on_node(node, fresh_bucket) @@ -448,9 +471,9 @@ def __run_chain(self, chain: Chain): self.logger.info(f"[chain] Node {node} failed — stopping chain execution early") # All subsequent primary non-Object steps were skipped because this node # failed; mark them as dep-blocked so the dep_retry phase can attempt them. - for future_step in chain.steps[i + 1:]: + for future_step in chain.steps[i + 1 :]: if future_step.profile_name == "primary" and future_step.node.graphql_type != "Object": - self._dep_blocked_nodes.add(future_step.node) + self.__mark_dep_blocked(future_step.node) break # Post-execution analysis @@ -459,12 +482,22 @@ def __run_chain(self, chain: Chain): if self.on_chain_done: self.on_chain_done(chain, results) finally: + # Preserve run-wide observations for island retries, reports, and callers + # while keeping each chain's dependency inputs isolated. + self.objects_bucket.merge(fresh_bucket) # Always remove the per-chain handler so the FD is released and logs # don't bleed into subsequent chains even if an exception occurred. self.logger.info(f"=== Chain end: {chain_path_str} ===") fuzzer_logger.removeHandler(chain_file_handler) chain_file_handler.close() + def __mark_dep_blocked(self, node: Node) -> None: + """Record a hard-dependency failure in resumable run state.""" + self._dep_blocked_nodes.add(node) + key = f"{node.graphql_type}|{node.name}" + if key not in self.stats.dep_retry_nodes: + self.stats.dep_retry_nodes.append(key) + def __run_nodes(self, nodes: list[Node]): """Runs the nodes given in the list @@ -476,6 +509,8 @@ def __run_nodes(self, nodes: list[Node]): """ for node in nodes: if node.name in config.SKIP_NODES: + self.stats.islands_completed += 1 + self.stats.checkpoint() continue self.logger.info(f"[island] Running node: {node}") node_start = time.time() @@ -485,6 +520,7 @@ def __run_nodes(self, nodes: list[Node]): self.__fuzz(node, [node]) self.__detect_vulnerabilities_on_node(node, self.objects_bucket) self.stats.islands_completed += 1 + self.stats.checkpoint() def __evaluate(self, node: Node, visit_path: list[Node], objects_bucket: typing.Optional[ObjectsBucket] = None) -> tuple[list[list[Node]], Result]: """Evaluates the node @@ -502,13 +538,19 @@ def __evaluate(self, node: Node, visit_path: list[Node], objects_bucket: typing. if node.graphql_type == "Query": _response, result = self.fengine.run_minimal_payload(node.name, objects_bucket, "Query") + if result.success: + self.__run_authorization_differential(node, result) return [], result elif node.graphql_type == "Mutation": _response, result = self.fengine.run_minimal_payload(node.name, objects_bucket, "Mutation") + if result.success: + self.__run_authorization_differential(node, result) return [], result elif node.graphql_type == "Subscription": if not config.SKIP_SUBSCRIPTIONS: _events, result = self.fengine.run_subscription_payload(node.name, objects_bucket) + if result.success: + self.__run_authorization_differential(node, result) return [], result return [], Result(ResultEnum.GENERAL_SUCCESS) elif node.graphql_type == "Object": @@ -516,6 +558,56 @@ def __evaluate(self, node: Node, visit_path: list[Node], objects_bucket: typing. else: raise Exception(f"Unknown GraphQL type: {node.graphql_type}") + def __run_authorization_differential(self, node: Node, primary_result: Result) -> None: + """Replay one private operation under anonymous and alternate profiles.""" + if not config.AUTHORIZATION_DIFFERENTIAL or node in self._authorization_tested_nodes: + return + if node.graphql_type == "Query": + operation = self.api.queries.get(node.name, {}) + elif node.graphql_type == "Mutation": + operation = self.api.mutations.get(node.name, {}) + elif node.graphql_type == "Subscription": + operation = self.api.subscriptions.get(node.name, {}) + else: + return + + output = operation.get("output", {}) + type_node = output + while isinstance(type_node, dict) and type_node and type_node.get("kind") != "OBJECT": + type_node = type_node.get("ofType") + type_name = "" + if isinstance(type_node, dict): + type_name = type_node.get("name") or type_node.get("type") or "" + object_definition = self.api.objects.get(type_name, {}) + fields = [field["name"] for field in object_definition.get("fields", []) if "name" in field] + if EndpointPrivacyClassifier().classify(node.name, type_name, fields) != "private": + return + + primary_profile = self.profiles["primary"] + candidates: list[RuntimeProfile] = [] + if primary_profile.get_headers(): + candidates.append(RuntimeProfile(name="anonymous")) + for name, profile in self.profiles.items(): + if name in {"primary", "post_delete"} or not profile.get_headers(): + continue + if profile.get_headers() != primary_profile.get_headers(): + candidates.append(profile) + if not candidates: + return + payload = primary_result.payload + if not isinstance(payload, str): + return + + self._authorization_tested_nodes.add(node) + profile_results: list[tuple[RuntimeProfile, Result]] = [] + for profile in candidates: + if node.graphql_type == "Subscription": + _response, result = self.fengine.run_subscription_with_profile(node.name, payload, profile) + else: + _response, result = self.fengine.run_payload_with_profile(node.name, payload, profile) + profile_results.append((profile, result)) + self.authorization_detector.detect(node.name, primary_result, profile_results, self.stats) + def __fuzz(self, node: Node, visit_path: list[Node], objects_bucket: typing.Optional[ObjectsBucket] = None): """Fuzzes the node @@ -542,4 +634,4 @@ def __detect_vulnerabilities_on_node(self, node: Node, objects_bucket: ObjectsBu """ if node.graphql_type == "Query" or node.graphql_type == "Mutation": self.dengine.run_detections_on_graphql_object(node, objects_bucket, node.graphql_type) - # Subscription detection is a future enhancement + # Subscription authorization is covered by differential replay; payload mutation detectors are HTTP-only. diff --git a/graphqler/graph/graph_generator.py b/graphqler/graph/graph_generator.py index a7ce56bb..4839a094 100644 --- a/graphqler/graph/graph_generator.py +++ b/graphqler/graph/graph_generator.py @@ -1,11 +1,8 @@ -"""GraphGenerator: Creates a networkx graph and stores it in a pickle file for use later on during fuzzing -The linker does the following: -- Serialize all the objects (Objects, Queries, Mutations, InputObjects, Enums) -- Generate a graph of object dependencies -- Attach queries to the object node -- Attach mutations related to the object node - -!Note!: We decide to not link object-objects together here as it is not relevant for graph traversal +"""Build and reload the dependency graph represented by compiled YAML artifacts. + +The linker creates nodes for schema objects and operations, attaches queries and +mutations to their related object nodes, and intentionally omits object-to-object +links that do not contribute to operation traversal. """ from pathlib import Path @@ -207,7 +204,6 @@ def create_object_query_edges(self, object_nodes: dict, query_nodes: dict): inner_object_node = object_nodes[produces] self.dependency_graph.add_edge(query_node, inner_object_node, weight=100) - def create_object_subscription_edges(self, object_nodes: dict, subscription_nodes: dict): """Updates the dependency graph with edges between objects and subscriptions. 3 cases: Case 1: S -> O | When object(O) is produced by subscription(S), means O has S in its "associatedSubscriptions", weight 100 diff --git a/graphqler/tui/screens/fuzz_screen.py b/graphqler/tui/screens/fuzz_screen.py index 14a395eb..c9661dc5 100644 --- a/graphqler/tui/screens/fuzz_screen.py +++ b/graphqler/tui/screens/fuzz_screen.py @@ -32,6 +32,7 @@ def __init__(self, mode: str = "fuzz", **kwargs): self._mode = mode self._fuzz_running = False self._start_time: float | None = None + self._stats = None def compose(self) -> ComposeResult: mode_label = {"fuzz": "Fuzz", "run": "Run (Compile + Fuzz)", "idor": "IDOR Fuzz"}.get(self._mode, self._mode.title()) @@ -100,6 +101,7 @@ def _run_fuzz(self, mode: str, url: str, path: str) -> None: get_or_create_directory(path) stats = Stats() stats.set_file_paths(path) + self._stats = stats if mode in ("run", "compile"): compiler = Compiler(path, url) @@ -108,7 +110,7 @@ def _run_fuzz(self, mode: str, url: str, path: str) -> None: graph_gen.draw_dependency_graph() compiler.run_chain_generation_and_save() - fuzzer = Fuzzer(path, url) + fuzzer = Fuzzer(path, url, stats=stats) if mode == "idor": fuzzer.run_idor_only() @@ -127,11 +129,10 @@ def _tick_stats(self) -> None: if not self._fuzz_running: return try: - from graphqler.utils.stats import Stats - - stats = Stats() + if self._stats is None: + return panel = self.query_one("#fuzz-stats", StatsPanel) - panel.update_from_stats(stats, self._start_time) + panel.update_from_stats(self._stats, self._start_time) except Exception: pass @@ -140,11 +141,9 @@ def _on_done(self, success: bool, message: str) -> None: self._set_status(message, error=not success) try: self.query_one("#btn-start", Button).disabled = False - # Final stats refresh - from graphqler.utils.stats import Stats - - panel = self.query_one("#fuzz-stats", StatsPanel) - panel.update_from_stats(Stats(), self._start_time) + if self._stats is not None: + panel = self.query_one("#fuzz-stats", StatsPanel) + panel.update_from_stats(self._stats, self._start_time) except Exception: pass diff --git a/graphqler/utils/artifact_manifest.py b/graphqler/utils/artifact_manifest.py new file mode 100644 index 00000000..fff90e38 --- /dev/null +++ b/graphqler/utils/artifact_manifest.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import hashlib +import importlib.metadata +from pathlib import Path +from typing import Any + +from graphqler import config +from graphqler.utils.file_utils import atomic_write_json, read_json_file + + +class ArtifactValidationError(ValueError): + """Compiled artifacts are missing, corrupt, or incompatible.""" + + +_PHASE_ORDER = {"graph": 1, "chains": 2} + + +def _package_version() -> str: + try: + return importlib.metadata.version("GraphQLer") + except importlib.metadata.PackageNotFoundError: + return "development" + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with open(path, "rb") as file_handle: + for chunk in iter(lambda: file_handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _artifact_paths(output_path: Path, settings: config.RunSettings) -> list[Path]: + candidates = [ + output_path / settings.INTROSPECTION_RESULT_FILE_NAME, + output_path / settings.COMPILED_OBJECTS_FILE_NAME, + output_path / settings.COMPILED_QUERIES_FILE_NAME, + output_path / settings.COMPILED_MUTATIONS_FILE_NAME, + output_path / settings.COMPILED_SUBSCRIPTIONS_FILE_NAME, + ] + chains_dir = output_path / settings.CHAINS_DIR_NAME + if chains_dir.exists(): + candidates.extend(path for path in chains_dir.rglob("*") if path.is_file()) + return sorted((path for path in candidates if path.exists()), key=lambda path: str(path)) + + +def write_manifest(output_path: str | Path, endpoint: str, phase: str, settings: config.RunSettings) -> Path: + """Write a versioned manifest and hashes for compiled artifacts.""" + if phase not in _PHASE_ORDER: + raise ValueError(f"Unknown artifact phase: {phase}") + root = Path(output_path) + artifacts = { + str(path.relative_to(root)): { + "sha256": _sha256(path), + "size": path.stat().st_size, + } + for path in _artifact_paths(root, settings) + } + manifest = { + "format": "graphqler.compiled_artifacts", + "schema_version": settings.ARTIFACT_SCHEMA_VERSION, + "graphqler_version": _package_version(), + "endpoint": endpoint, + "phase": phase, + "settings": { + "disable_mutations": settings.DISABLE_MUTATIONS, + "use_llm": settings.USE_LLM, + "llm_use_for_compilation": settings.LLM_USE_FOR_COMPILATION, + }, + "artifacts": artifacts, + } + manifest_path = root / settings.ARTIFACT_MANIFEST_FILE_NAME + atomic_write_json(manifest, manifest_path) + return manifest_path + + +def validate_manifest( + output_path: str | Path, + required_phase: str, + settings: config.RunSettings, + expected_endpoint: str | None = None, +) -> dict[str, Any]: + """Validate manifest compatibility, phase, endpoint, and artifact hashes.""" + if required_phase not in _PHASE_ORDER: + raise ValueError(f"Unknown artifact phase: {required_phase}") + root = Path(output_path) + manifest_path = root / settings.ARTIFACT_MANIFEST_FILE_NAME + if not manifest_path.exists(): + raise ArtifactValidationError(f"Missing {settings.ARTIFACT_MANIFEST_FILE_NAME} in {root}; re-run compile with this GraphQLer version") + try: + manifest = read_json_file(manifest_path) + except Exception as exc: + raise ArtifactValidationError(f"Unable to read artifact manifest: {manifest_path}") from exc + + if manifest.get("format") != "graphqler.compiled_artifacts": + raise ArtifactValidationError(f"Unsupported artifact manifest format: {manifest_path}") + if manifest.get("schema_version") != settings.ARTIFACT_SCHEMA_VERSION: + raise ArtifactValidationError(f"Artifact schema {manifest.get('schema_version')} is incompatible with required schema {settings.ARTIFACT_SCHEMA_VERSION}") + actual_phase = manifest.get("phase") + if actual_phase not in _PHASE_ORDER or _PHASE_ORDER[actual_phase] < _PHASE_ORDER[required_phase]: + raise ArtifactValidationError(f"Artifacts are at phase {actual_phase!r}; required phase is {required_phase!r}") + if expected_endpoint is not None and manifest.get("endpoint") != expected_endpoint: + raise ArtifactValidationError(f"Artifacts were compiled for {manifest.get('endpoint')!r}, not {expected_endpoint!r}") + + for relative_path, metadata in manifest.get("artifacts", {}).items(): + artifact_path = root / relative_path + if not artifact_path.is_file(): + raise ArtifactValidationError(f"Compiled artifact is missing: {artifact_path}") + if artifact_path.stat().st_size != metadata.get("size") or _sha256(artifact_path) != metadata.get("sha256"): + raise ArtifactValidationError(f"Compiled artifact failed integrity validation: {artifact_path}") + return manifest diff --git a/graphqler/utils/cli_utils.py b/graphqler/utils/cli_utils.py index 1f66bbac..a023e8ce 100644 --- a/graphqler/utils/cli_utils.py +++ b/graphqler/utils/cli_utils.py @@ -1,5 +1,6 @@ from graphqler import config from pathlib import Path +from graphqler.utils.artifact_manifest import ArtifactValidationError, validate_manifest def set_auth_token_constant(auth_argument: str) -> None: @@ -14,6 +15,7 @@ def set_auth_token_constant(auth_argument: str) -> None: else: config.AUTHORIZATION = f"Bearer {auth_argument}" from graphqler.utils.request_utils import reset_session + reset_session() @@ -30,25 +32,17 @@ def set_idor_auth_token_constant(auth_argument: str) -> None: else: config.IDOR_SECONDARY_AUTH = f"Bearer {auth_argument}" from graphqler.utils.request_utils import reset_session + reset_session() def is_compiled(path: str | Path) -> bool: - """Checks if the compiled directory exists - - Args: - path (str): The path to the compiled directory - - Returns: - bool: True if the compiled directory exists, False otherwise - """ + """Return whether a compatible, complete artifact set exists.""" if path is None: return False - path = Path(path) - return ( - (path / config.COMPILED_DIR_NAME).exists() - and (path / config.COMPILED_OBJECTS_FILE_NAME).exists() - and (path / config.COMPILED_QUERIES_FILE_NAME).exists() - and (path / config.COMPILED_MUTATIONS_FILE_NAME).exists() - and (path / config.INTROSPECTION_RESULT_FILE_NAME).exists() - ) + settings = config.snapshot() + try: + validate_manifest(path, "chains", settings) + except (ArtifactValidationError, OSError): + return False + return True diff --git a/graphqler/utils/file_utils.py b/graphqler/utils/file_utils.py index 7fe532b1..6620e5a5 100644 --- a/graphqler/utils/file_utils.py +++ b/graphqler/utils/file_utils.py @@ -2,6 +2,9 @@ import yaml import json import shutil +import os +import tempfile +from typing import Any def initialize_file(file_path: str | Path): @@ -97,6 +100,35 @@ def write_dict_to_yaml(contents: dict, output_file: str | Path): yaml_file.write(yaml_data) +def atomic_write_json(contents: Any, output_file: str | Path) -> None: + """Atomically replace a JSON file with fully-written contents.""" + output_path = Path(output_file) + output_path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + dir=output_path.parent, + prefix=f".{output_path.name}.", + suffix=".tmp", + ) + try: + with os.fdopen(descriptor, "w") as file_handle: + json.dump(contents, file_handle, indent=2, sort_keys=True) + file_handle.flush() + os.fsync(file_handle.fileno()) + os.replace(temporary_name, output_path) + except BaseException: + try: + os.unlink(temporary_name) + except FileNotFoundError: + pass + raise + + +def read_json_file(input_file: str | Path) -> Any: + """Read and decode a JSON file.""" + with open(input_file) as file_handle: + return json.load(file_handle) + + def read_yaml_to_dict(read_path: Path) -> dict: """Reads yaml file to dict diff --git a/graphqler/utils/logging_utils.py b/graphqler/utils/logging_utils.py index bb05bfe3..c5793a99 100644 --- a/graphqler/utils/logging_utils.py +++ b/graphqler/utils/logging_utils.py @@ -1,22 +1,15 @@ import logging from graphqler import config from pathlib import Path -from graphqler.utils.singleton import singleton from graphqler.utils.file_utils import initialize_file -@singleton class Logger: - fuzzer_logger = None - compiler_logger = None - detector_logger = None - idor_logger = None - fuzzer_log_path = Path(config.OUTPUT_DIRECTORY) / Path(config.FUZZER_LOG_FILE_PATH) - compiler_log_path = Path(config.OUTPUT_DIRECTORY) / Path(config.COMPILER_LOG_FILE_PATH) - detector_log_path = Path(config.OUTPUT_DIRECTORY) / Path(config.DETECTOR_LOG_FILE_PATH) - idor_log_path = Path(config.OUTPUT_DIRECTORY) / Path(config.IDOR_LOG_FILE_PATH) - def __init__(self): + self.fuzzer_logger: logging.Logger | None = None + self.compiler_logger: logging.Logger | None = None + self.detector_logger: logging.Logger | None = None + self.idor_logger: logging.Logger | None = None self.fuzzer_log_path = Path(config.OUTPUT_DIRECTORY) / Path(config.FUZZER_LOG_FILE_PATH) self.compiler_log_path = Path(config.OUTPUT_DIRECTORY) / Path(config.COMPILER_LOG_FILE_PATH) self.detector_log_path = Path(config.OUTPUT_DIRECTORY) / Path(config.DETECTOR_LOG_FILE_PATH) diff --git a/graphqler/utils/mcp_utils/server.py b/graphqler/utils/mcp_utils/server.py index b17b6536..117204a4 100644 --- a/graphqler/utils/mcp_utils/server.py +++ b/graphqler/utils/mcp_utils/server.py @@ -20,10 +20,7 @@ try: from fastmcp import FastMCP except ImportError as exc: # pragma: no cover - raise ImportError( - "The 'mcp' package is required to run the GraphQLer MCP server. " - "Install it with: pip install GraphQLer[mcp]" - ) from exc + raise ImportError("The 'mcp' package is required to run the GraphQLer MCP server. Install it with: pip install GraphQLer[mcp]") from exc from graphqler import config from graphqler.utils.cli_utils import is_compiled @@ -45,8 +42,8 @@ ), ) -# Global lock to serialise tool execution: compile/fuzz mutate global config state, -# so concurrent HTTP/SSE requests must not run simultaneously. +# stdout capture and Python's logging registry are process-global, so MCP tool +# execution remains serialized even though run configuration is context-local. _pipeline_lock = threading.Lock() @@ -55,51 +52,17 @@ # --------------------------------------------------------------------------- -def _reset_singletons() -> None: - """Clear cached singleton instances so each tool call starts with a clean state.""" - from graphqler.utils.stats import Stats - from graphqler.utils.objects_bucket import ObjectsBucket - - getattr(Stats, "reset")() - getattr(ObjectsBucket, "reset")() - - # Best-effort reset of additional singletons used by compile/fuzz pipeline. - try: - from graphqler.utils.logging_utils import Logger - - reset_fn = getattr(Logger, "reset", None) - if callable(reset_fn): - reset_fn() - except ImportError: - pass - - try: - from graphqler.fuzzer.engine.fengine import FEngine - - reset_fn = getattr(FEngine, "reset", None) - if callable(reset_fn): - reset_fn() - except ImportError: - pass - - -def _set_output_directory(path: str) -> None: - """Set config.OUTPUT_DIRECTORY and keep derived config paths in sync.""" - config.OUTPUT_DIRECTORY = path - if hasattr(config, "PLUGINS_PATH"): - config.PLUGINS_PATH = f"{path}/plugins" - - -def _apply_auth(auth: str | None) -> None: - """Apply an auth token to the global config, clearing any previous value when absent.""" - if auth: - from graphqler.utils.cli_utils import set_auth_token_constant - - set_auth_token_constant(auth) - else: - # Ensure no stale authorization token is reused across MCP tool calls. - if hasattr(config, "AUTHORIZATION"): - config.AUTHORIZATION = None +def _run_settings(path: str, auth: str | None) -> config.RunSettings: + authorization = auth + if authorization and " " not in authorization: + authorization = f"Bearer {authorization}" + return config.snapshot( + { + "OUTPUT_DIRECTORY": path, + "PLUGINS_PATH": f"{path}/plugins", + "AUTHORIZATION": authorization, + } + ) def _capture(fn, *args, **kwargs): @@ -134,16 +97,12 @@ def compile( from graphqler.graph import GraphGenerator from graphqler.__main__ import run_compile_mode - with _pipeline_lock: - _reset_singletons() - _set_output_directory(path) - _apply_auth(auth) - + settings = _run_settings(path, auth) + with _pipeline_lock, config.activate(settings): from graphqler.utils.file_utils import get_or_create_directory get_or_create_directory(path) - - compiler = Compiler(path, url) + compiler = Compiler(path, url, settings=settings) stdout, _, error = _capture(run_compile_mode, compiler, path, url) if error: @@ -177,28 +136,21 @@ def fuzz( from graphqler.__main__ import run_fuzz_mode if not is_compiled(path): - return ( - f"The path '{path}' does not contain compiled artifacts. " - "Please run compile() first." - ) - - with _pipeline_lock: - _reset_singletons() - _set_output_directory(path) - _apply_auth(auth) + return f"The path '{path}' does not contain compiled artifacts. Please run compile() first." + settings = _run_settings(path, auth) + with _pipeline_lock, config.activate(settings): stats = Stats() stats.set_file_paths(path) - fuzzer = Fuzzer(path, url) + fuzzer = Fuzzer(path, url, stats=stats, settings=settings) stdout, _, error = _capture(run_fuzz_mode, fuzzer, path, url) if error: return f"Fuzzing failed:\n{error}\n\nOutput:\n{stdout}" - # Build summary from stats — fuzzer.run() uses multiprocessing so stats are written to - # disk by the child process; load them back into the parent-process singleton here. - stats_obj = Stats().load() + # Fuzzer runs in a child process, so reload its final state into the parent object. + stats_obj = stats.load() lines = [ "Fuzzing complete.", f"Successes: {stats_obj.number_of_successes}", diff --git a/graphqler/utils/objects_bucket.py b/graphqler/utils/objects_bucket.py index e8933804..91a00edb 100644 --- a/graphqler/utils/objects_bucket.py +++ b/graphqler/utils/objects_bucket.py @@ -1,32 +1,22 @@ -"""Class for an objects bucket to contain the history of all objects in the system under test -What does an objects bucket track? -- For each type of object, track any values that were associated to that object to be used later -- For each kind of scalar, track any values seen to be used later - -TODO: Implement the following: -The class should have two functionalities -1. Given the graphql data response, parse the data and put objects in the bucket -2. Be able to return random scalars / objects from the bucket -3. Be able to return objects from the bucket if given a type and the object name +"""Run-scoped store of object and scalar values observed from GraphQL responses. + +Values are reused to satisfy dependencies in later requests. Checkpoints use a +versioned JSON representation containing primitives only. """ +import json import copy import pathlib import pprint import random from typing import Self -import cloudpickle as pickle - from graphqler import config from graphqler.utils.api import API -from graphqler.utils.file_utils import get_or_create_file +from graphqler.utils.file_utils import atomic_write_json, get_or_create_file, read_json_file from graphqler.utils.parser_utils import get_base_oftype, get_output_type_from_details -from .singleton import singleton - -@singleton class ObjectsBucket: def __init__(self, api: API): self.api = api @@ -38,7 +28,7 @@ def __init__(self, api: API): self.scalars: dict[str, dict] = {} # File paths - self.pickle_save_path = pathlib.Path(config.OUTPUT_DIRECTORY) / config.SERIALIZED_DIR_NAME / config.OBJECTS_BUCKET_PICKLE_FILE_NAME + self.state_save_path = pathlib.Path(config.OUTPUT_DIRECTORY) / config.SERIALIZED_DIR_NAME / config.OBJECTS_BUCKET_STATE_FILE_NAME self.text_save_path = pathlib.Path(config.OUTPUT_DIRECTORY) / config.OBJECTS_BUCKET_TEXT_FILE_NAME def __str__(self): @@ -52,20 +42,22 @@ def __str__(self): return built_str - # ------------------- Pickle ------------------- - def __getstate__(self): - # Return a dictionary of the attributes to pickle - return self.__dict__ - - def __setstate__(self, state): - # Restore the state from the pickled attributes - self.__dict__.update(state) - + # ------------------- Persistence ------------------- def save(self): - """Saves the objects bucket as a pickle file and as a text file""" - self.pickle_save_path = get_or_create_file(self.pickle_save_path) - with open(self.pickle_save_path, "wb") as file: - pickle.dump(self, file) + """Save the bucket as versioned JSON and a human-readable text file.""" + state = { + "format": "graphqler.objects_bucket", + "version": 1, + "objects": self.objects, + "scalars": { + name: { + **details, + "values": list(details.get("values", set())), + } + for name, details in self.scalars.items() + }, + } + atomic_write_json(state, self.state_save_path) self.text_save_path = get_or_create_file(self.text_save_path) with open(self.text_save_path, "w") as file: @@ -74,17 +66,26 @@ def save(self): file.write(str(self)) def load(self) -> Self: - """Loads the objects bucket from a pickle file. If the file doesn't exist, does nothing. - """ - if self.pickle_save_path.exists(): - try: - with open(self.pickle_save_path, "rb") as file: - loaded_bucket = pickle.load(file) - self.__dict__ = loaded_bucket.__dict__ - except (EOFError, Exception): - # File may be empty or corrupt (e.g. child process killed mid-write); skip load. - pass + """Load a versioned JSON bucket state if it exists.""" + if not self.state_save_path.exists(): + return self + try: + state = read_json_file(self.state_save_path) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"Unable to read objects bucket state: {self.state_save_path}") from exc + + if state.get("format") != "graphqler.objects_bucket" or state.get("version") != 1: + raise ValueError(f"Unsupported objects bucket state format: {self.state_save_path}") + + self.objects = state.get("objects", {}) + self.scalars = { + name: { + **details, + "values": set(details.get("values", [])), + } + for name, details in state.get("scalars", {}).items() + } return self # ------------------- GETTERS ------------------- @@ -338,20 +339,29 @@ def put_scalar_in_bucket(self, name: str, type: str, data: str | int | float | b self.scalars[name] = {"type": type, "values": {data}} self.scalars[name]["values"].add(data) + def merge(self, other: "ObjectsBucket") -> None: + """Merge observations from another bucket without sharing mutable state.""" + for object_name, objects in other.objects.items(): + for object_info in objects: + self.put_object_in_bucket(object_name, copy.deepcopy(object_info)) + for scalar_name, details in other.scalars.items(): + if scalar_name not in self.scalars: + self.scalars[scalar_name] = { + **copy.deepcopy(details), + "values": set(details.get("values", set())), + } + else: + self.scalars[scalar_name]["values"].update(details.get("values", set())) + # ------------------- CLONE ------------------- def clone(self) -> "ObjectsBucket": - """Creates an independent copy of this bucket, bypassing the singleton. - - Uses ``type(self)`` which resolves to the inner (unwrapped) class, so the - new instance is allocated directly without going through the singleton - ``getInstance`` wrapper. - """ + """Create an independent copy of this bucket.""" real_cls = type(self) new_bucket = real_cls.__new__(real_cls) new_bucket.api = self.api new_bucket.objects = copy.deepcopy(self.objects) new_bucket.scalars = copy.deepcopy(self.scalars) - new_bucket.pickle_save_path = self.pickle_save_path + new_bucket.state_save_path = self.state_save_path new_bucket.text_save_path = self.text_save_path return new_bucket diff --git a/graphqler/utils/run_context.py b/graphqler/utils/run_context.py new file mode 100644 index 00000000..7468d906 --- /dev/null +++ b/graphqler/utils/run_context.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from graphqler.config import RunSettings +from graphqler.utils.objects_bucket import ObjectsBucket +from graphqler.utils.stats import Stats + + +@dataclass +class RunContext: + """Mutable state and immutable settings owned by one fuzzing run.""" + + output_path: Path + settings: RunSettings + stats: Stats + objects_bucket: ObjectsBucket diff --git a/graphqler/utils/singleton.py b/graphqler/utils/singleton.py deleted file mode 100644 index e9e403c6..00000000 --- a/graphqler/utils/singleton.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import Any, Generic, TypeVar - -_T = TypeVar("_T") - - -class _SingletonCallable(Generic[_T]): - """Callable wrapper returned by the @singleton decorator. - - Exposes the original class as ``__wrapped__`` and provides a ``reset()`` - helper that clears the cached instance (useful for test isolation). - """ - - def __init__(self, cls: type[_T]) -> None: - self.__wrapped__: type[_T] = cls - self._instances: dict[type[_T], _T] = {} - - def __call__(self, *args: Any, **kwargs: Any) -> _T: - if self.__wrapped__ not in self._instances: - self._instances[self.__wrapped__] = self.__wrapped__(*args, **kwargs) - return self._instances[self.__wrapped__] - - def reset(self) -> None: - """Clear the cached instance so the next call creates a fresh one.""" - self._instances.pop(self.__wrapped__, None) - - -def singleton(myClass: type[_T]) -> _SingletonCallable[_T]: - return _SingletonCallable(myClass) diff --git a/graphqler/utils/stats.py b/graphqler/utils/stats.py index 9eb6165e..36341613 100644 --- a/graphqler/utils/stats.py +++ b/graphqler/utils/stats.py @@ -1,6 +1,7 @@ import json -import cloudpickle as pickle +import os import pprint +import re import shutil import sys import time @@ -8,18 +9,13 @@ from typing import Self from graphqler import config -from graphqler.fuzzer.engine.types import Result +from graphqler.fuzzer.engine.types import Result, ResultEnum from graphqler.graph import Node -from graphqler.fuzzer.engine.types import ResultEnum -from .file_utils import initialize_file, intialize_file_if_not_exists, recreate_path, get_or_create_file -from .singleton import singleton -import os -import re +from .file_utils import atomic_write_json, initialize_file, read_json_file, recreate_path -@singleton -class Stats : +class Stats: ### PUT THE STATS YOU WANT HERE file_path = "/tmp/stats.txt" # This gets overriden by the set_file_path function endpoint_results_dir = "/tmp/endpoint_results" @@ -28,7 +24,7 @@ class Stats : http_status_codes: dict[str, dict[str, int]] = {} successful_nodes: dict[str, int] = {} failed_nodes: dict[str, int] = {} - results: dict[str, set[Result]] = {} # Mapping of query/mutation to results for that node + results: dict[str, set[Result]] = {} # Mapping of query/mutation to results for that node unique_responses: dict[str, list[str]] = {} # Mapping of response to endpoints (query/mutation) number_of_queries: int = 0 number_of_mutations: int = 0 @@ -50,6 +46,7 @@ class Stats : islands_completed: int = 0 dep_retry_total: int = 0 dep_retry_completed: int = 0 + dep_retry_nodes: list[str] = [] # Detection stats is_introspection_available: bool = False @@ -78,11 +75,49 @@ def __init__(self): self.islands_completed = 0 self.dep_retry_total = 0 self.dep_retry_completed = 0 - self.pickle_save_path = Path(config.OUTPUT_DIRECTORY) / config.SERIALIZED_DIR_NAME / config.STATS_PICKLE_FILE_NAME + self.dep_retry_nodes = [] + self.state_save_path = Path(config.OUTPUT_DIRECTORY) / config.SERIALIZED_DIR_NAME / config.STATS_STATE_FILE_NAME + self._last_checkpoint = time.monotonic() def load(self) -> Self: - """Loads the stats from the pickle file""" - self.__load_pickle() + """Load a versioned JSON stats snapshot.""" + if not self.state_save_path.exists(): + return self + try: + state = read_json_file(self.state_save_path) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"Unable to read stats state: {self.state_save_path}") from exc + if state.get("format") != "graphqler.stats" or state.get("version") != 1: + raise ValueError(f"Unsupported stats state format: {self.state_save_path}") + + for name in ( + "start_time", + "http_status_codes", + "successful_nodes", + "failed_nodes", + "unique_responses", + "number_of_queries", + "number_of_mutations", + "number_of_objects", + "number_of_successes", + "number_of_failures", + "vulnerabilities", + "node_timings", + "is_introspection_available", + "chains_total", + "chains_completed", + "current_iteration", + "total_iterations", + "phase", + "islands_total", + "islands_completed", + "dep_retry_total", + "dep_retry_completed", + "dep_retry_nodes", + ): + if name in state: + setattr(self, name, state[name]) + self.results = {name: {Result.from_dict(result) for result in results} for name, results in state.get("results", {}).items()} return self def add_successful_node(self, node: Node): @@ -97,7 +132,6 @@ def add_successful_node(self, node: Node): self.successful_nodes[key_name] += 1 else: self.successful_nodes[key_name] = 1 - self.save() def add_failed_node(self, node: Node): """Adds a new failed node to the internal failed stats @@ -111,7 +145,6 @@ def add_failed_node(self, node: Node): self.failed_nodes[key_name] += 1 else: self.failed_nodes[key_name] = 1 - self.save() def add_http_status_code(self, payload_name: str, status_code: int | None): """Adds the http status code to stats @@ -130,34 +163,27 @@ def add_http_status_code(self, payload_name: str, status_code: int | None): self.http_status_codes[status_code_str][payload_name] = 1 else: self.http_status_codes[status_code_str] = {payload_name: 1} - self.save() - - def set_file_paths(self, working_dir: str): - """ - - Args: - working_dir (str): _description_ - """ - # Do the stats file first - initialize_file(Path(working_dir) / config.STATS_FILE_NAME) - self.file_path = Path(working_dir) / config.STATS_FILE_NAME - # JSON report path (machine-readable) + def set_file_paths(self, working_dir: str, reset: bool = True) -> None: + """Configure report/state paths, optionally preserving an interrupted run.""" + root = Path(working_dir) + root.mkdir(parents=True, exist_ok=True) + self.file_path = root / config.STATS_FILE_NAME json_file_name = config.STATS_FILE_NAME.replace(".txt", ".json") if config.STATS_FILE_NAME.endswith(".txt") else config.STATS_FILE_NAME + ".json" - initialize_file(Path(working_dir) / json_file_name) - self.json_file_path = Path(working_dir) / json_file_name - - # Eval/ablation directory (written only when ablation flags are active) - self.eval_dir = Path(working_dir) / config.EVAL_DIR_NAME - - # Do the endpoint results directory - self.endpoint_results_dir = Path(working_dir) / config.ENDPOINT_RESULTS_DIR_NAME - if config.SAVE_ENDPOINT_RESULTS: - recreate_path(self.endpoint_results_dir) - - # Do the unique responses file - self.unique_responses_file_path = Path(working_dir) / config.UNIQUE_RESPONSES_FILE_NAME - initialize_file(self.unique_responses_file_path) + self.json_file_path = root / json_file_name + self.eval_dir = root / config.EVAL_DIR_NAME + self.endpoint_results_dir = root / config.ENDPOINT_RESULTS_DIR_NAME + self.unique_responses_file_path = root / config.UNIQUE_RESPONSES_FILE_NAME + self.state_save_path = root / config.SERIALIZED_DIR_NAME / config.STATS_STATE_FILE_NAME + + if reset: + initialize_file(self.file_path) + initialize_file(self.json_file_path) + initialize_file(self.unique_responses_file_path) + if config.SAVE_ENDPOINT_RESULTS: + recreate_path(self.endpoint_results_dir) + elif config.SAVE_ENDPOINT_RESULTS: + self.endpoint_results_dir.mkdir(parents=True, exist_ok=True) def print_running_stats(self): """Print a single-line progress update that overwrites itself each second.""" @@ -168,15 +194,9 @@ def print_running_stats(self): if self.phase == "detections": progress = f"[Detections] {counts} | {elapsed_str} elapsed" elif self.phase == "dep_retry": - progress = ( - f"[Dep-Retry {self.dep_retry_completed}/{self.dep_retry_total}] " - f"{counts} | {elapsed_str} elapsed" - ) + progress = f"[Dep-Retry {self.dep_retry_completed}/{self.dep_retry_total}] {counts} | {elapsed_str} elapsed" elif self.phase == "islands": - progress = ( - f"[Islands {self.islands_completed}/{self.islands_total}] " - f"{counts} | {elapsed_str} elapsed" - ) + progress = f"[Islands {self.islands_completed}/{self.islands_total}] {counts} | {elapsed_str} elapsed" elif self.chains_total > 0: overall_done = (self.current_iteration - 1) * self.chains_total + self.chains_completed overall_total = self.total_iterations * self.chains_total @@ -187,10 +207,7 @@ def print_running_stats(self): else: eta_str = "--:--:--" progress = ( - f"[Iter {self.current_iteration}/{self.total_iterations} | " - f"Chain {self.chains_completed}/{self.chains_total}] " - f"{counts} | " - f"{elapsed_str} elapsed | ETA {eta_str}" + f"[Iter {self.current_iteration}/{self.total_iterations} | Chain {self.chains_completed}/{self.chains_total}] {counts} | {elapsed_str} elapsed | ETA {eta_str}" ) else: progress = f"{counts} | {elapsed_str} elapsed" @@ -326,6 +343,7 @@ def update_stats_from_result(self, node, result: Result) -> None: self.unique_responses[str(result.graphql_response)].append(node.name) else: self.unique_responses[str(result.graphql_response)] = [node.name] + self.maybe_checkpoint() def get_number_of_successful_mutations_and_queries(self) -> tuple[int, int]: """Returns the number of successful mutations and queries""" @@ -370,8 +388,7 @@ def print_results(self): print("---------------------------------------------------------") def save(self): - """Saves the stats into the stats text file - """ + """Saves the stats into the stats text file""" covered, total, coverage_frac = self.get_coverage_rate() failed, _, negative_frac = self.get_negative_coverage_rate() with open(self.file_path, "w") as f: @@ -401,9 +418,7 @@ def save(self): self.save_endpoint_results() self.save_unique_response() self.save_json() - - # Saves the pickle file as well - self.__save_pickle() + self.checkpoint() def save_json(self): """Saves a machine-readable JSON report alongside the text stats file""" @@ -429,8 +444,7 @@ def save_json(self): "vulnerabilities": self.vulnerabilities, "node_timings": self.node_timings, } - with open(json_path, "w") as f: - json.dump(report, f, indent=4) + atomic_write_json(report, json_path) def save_eval_summary(self): """Saves an ablation/evaluation summary to the ``eval/`` directory. @@ -444,11 +458,7 @@ def save_eval_summary(self): if eval_dir is None: return - is_ablation = ( - not config.USE_OBJECTS_BUCKET - or not config.USE_DEPENDENCY_GRAPH - or config.MAX_FUZZING_ITERATIONS != 1 - ) + is_ablation = not config.USE_OBJECTS_BUCKET or not config.USE_DEPENDENCY_GRAPH or config.MAX_FUZZING_ITERATIONS != 1 if not is_ablation: return @@ -476,10 +486,7 @@ def save_eval_summary(self): "number_of_failures": self.number_of_failures, "operation_coverage": {"covered": covered, "total": total, "rate": round(coverage_frac, 4)}, "negative_coverage": {"failed": failed, "total": total, "rate": round(negative_frac, 4)}, - "vulnerabilities_found": { - vuln: {node: info.get("is_vulnerable", False) for node, info in nodes.items()} - for vuln, nodes in self.vulnerabilities.items() - }, + "vulnerabilities_found": {vuln: {node: info.get("is_vulnerable", False) for node, info in nodes.items()} for vuln, nodes in self.vulnerabilities.items()}, }, } @@ -491,7 +498,7 @@ def save_eval_summary(self): # Also write a human-readable summary summary_file = eval_dir / "ablation_summary.txt" with open(summary_file, "a") as f: - f.write(f"\n{'='*60}\n") + f.write(f"\n{'=' * 60}\n") f.write(f"Run at: {entry['timestamp']}\n") f.write(f" USE_OBJECTS_BUCKET : {config.USE_OBJECTS_BUCKET}\n") f.write(f" USE_DEPENDENCY_GRAPH : {config.USE_DEPENDENCY_GRAPH}\n") @@ -508,60 +515,71 @@ def save_eval_summary(self): f.write(f" Vulnerabilities: {list(self.vulnerabilities.keys())}\n") def save_endpoint_results(self): - """Reads the results, for each node in the node name -> results, create a directory for the - result type, then a file for the response code, and append the payload and the response to the file. - """ - unique_results = {} - # Filter out for only unique results - for node_name, results in self.results.items(): - # If the node name has slashes, replace them with underscores - node_name = node_name.replace("/", "_") - + """Rewrite deterministic, de-duplicated result files for each endpoint.""" + recreate_path(Path(self.endpoint_results_dir)) + unique_results: dict[Path, dict[str, object]] = {} + for raw_node_name, results in self.results.items(): + node_name = raw_node_name.replace("/", "_") if os.name == "nt": - # Replace characters that are invalid in Windows filenames node_name = re.sub(r'[\\/:*?"<>|]', "_", node_name) for result in results: result_type = "success" if result.success else "failure" - result_file_path = Path(self.endpoint_results_dir) / node_name / result_type / f"{result.status_code}" - - payload_string = str(result.payload) - if result_file_path not in unique_results: - unique_results[result_file_path] = {payload_string: result.graphql_response} - else: - if payload_string not in unique_results[result_file_path]: - unique_results[result_file_path][payload_string] = result.graphql_response - - # Write the unique results to the file - for result_file_path, payloads in unique_results.items(): - intialize_file_if_not_exists(result_file_path) - for payload, response in payloads.items(): - with open(result_file_path, "a") as f: - f.write("------------------Payload:-------------------\n") - f.write(f"{payload}\n") - f.write("------------------Response:-------------------\n") - f.write(f"{response}\n") + result_file_path = Path(self.endpoint_results_dir) / node_name / result_type / str(result.status_code) + unique_results.setdefault(result_file_path, {})[str(result.payload)] = result.graphql_response + + for result_file_path, payloads in sorted(unique_results.items(), key=lambda item: str(item[0])): + result_file_path.parent.mkdir(parents=True, exist_ok=True) + with open(result_file_path, "w") as file_handle: + for payload, response in sorted(payloads.items()): + file_handle.write("------------------Payload:-------------------\n") + file_handle.write(f"{payload}\n") + file_handle.write("------------------Response:-------------------\n") + file_handle.write(f"{response}\n") def save_unique_response(self): - """Saves the unique responses to a file""" - with open(Path(self.unique_responses_file_path), "w") as f: + """Save unique responses and the operations that returned them.""" + with open(Path(self.unique_responses_file_path), "w") as file_handle: for response, endpoints in self.unique_responses.items(): - f.write(f"Response: {response}\n") - f.write(f"Endpoints: {endpoints}\n") - - def __save_pickle(self): - """Saves the stats to a pickle file""" - self.pickle_save_path = get_or_create_file(self.pickle_save_path) - with open(self.pickle_save_path, "wb") as file: - pickle.dump(self, file) - - def __load_pickle(self): - """Loads the stats from a pickle file""" - if self.pickle_save_path.exists(): - try: - with open(self.pickle_save_path, "rb") as file: - loaded_stats = pickle.load(file) - self.__dict__.update(loaded_stats.__dict__) - except (EOFError, Exception): - # File may be empty or corrupt (e.g. child process killed mid-write); skip load. - pass + file_handle.write(f"Response: {response}\n") + file_handle.write(f"Endpoints: {endpoints}\n") + + def _state(self) -> dict: + return { + "format": "graphqler.stats", + "version": 1, + "start_time": self.start_time, + "http_status_codes": self.http_status_codes, + "successful_nodes": self.successful_nodes, + "failed_nodes": self.failed_nodes, + "results": {name: [result.to_dict() for result in results] for name, results in self.results.items()}, + "unique_responses": self.unique_responses, + "number_of_queries": self.number_of_queries, + "number_of_mutations": self.number_of_mutations, + "number_of_objects": self.number_of_objects, + "number_of_successes": self.number_of_successes, + "number_of_failures": self.number_of_failures, + "vulnerabilities": self.vulnerabilities, + "node_timings": self.node_timings, + "is_introspection_available": self.is_introspection_available, + "chains_total": self.chains_total, + "chains_completed": self.chains_completed, + "current_iteration": self.current_iteration, + "total_iterations": self.total_iterations, + "phase": self.phase, + "islands_total": self.islands_total, + "islands_completed": self.islands_completed, + "dep_retry_total": self.dep_retry_total, + "dep_retry_completed": self.dep_retry_completed, + "dep_retry_nodes": self.dep_retry_nodes, + } + + def checkpoint(self) -> None: + """Atomically persist a compact run snapshot.""" + atomic_write_json(self._state(), self.state_save_path) + self._last_checkpoint = time.monotonic() + + def maybe_checkpoint(self, interval_seconds: float = 5.0) -> None: + """Persist at most once per interval while a run is active.""" + if time.monotonic() - self._last_checkpoint >= interval_seconds: + self.checkpoint() diff --git a/graphqler/utils/websocket_utils.py b/graphqler/utils/websocket_utils.py index 1a660ee5..b36abcc8 100644 --- a/graphqler/utils/websocket_utils.py +++ b/graphqler/utils/websocket_utils.py @@ -15,10 +15,10 @@ logger = logging.getLogger(__name__) -async def _send_graphql_ws(websocket, payload: dict, timeout: float) -> list[dict]: +async def _send_graphql_ws(websocket, payload: dict, timeout: float, connection_payload: Optional[dict] = None) -> list[dict]: """graphql-ws protocol handler (modern standard).""" - # 1. Send connection_init - await websocket.send(json.dumps({"type": "connection_init", "payload": {}})) + # Send credentials in connection_init as required by many GraphQL WS servers. + await websocket.send(json.dumps({"type": "connection_init", "payload": connection_payload or {}})) # 2. Wait for connection_ack ack_raw = await asyncio.wait_for(websocket.recv(), timeout=timeout) @@ -51,10 +51,10 @@ async def _send_graphql_ws(websocket, payload: dict, timeout: float) -> list[dic return events -async def _send_subscriptions_transport_ws(websocket, payload: dict, timeout: float) -> list[dict]: +async def _send_subscriptions_transport_ws(websocket, payload: dict, timeout: float, connection_payload: Optional[dict] = None) -> list[dict]: """subscriptions-transport-ws (legacy Apollo) protocol handler.""" - # 1. Send connection_init - await websocket.send(json.dumps({"type": "connection_init", "payload": {}})) + # Legacy Apollo servers commonly read authentication from this payload. + await websocket.send(json.dumps({"type": "connection_init", "payload": connection_payload or {}})) # 2. Wait for connection_ack ack_raw = await asyncio.wait_for(websocket.recv(), timeout=timeout) @@ -99,9 +99,9 @@ async def _run_subscription(url: str, payload: dict, timeout: float, protocol: s async with websockets.connect(ws_url, subprotocols=cast(Any, [protocol]), additional_headers=extra_headers) as websocket: if protocol in ("graphql-ws", "graphql-transport-ws"): - return await _send_graphql_ws(websocket, payload, timeout) + return await _send_graphql_ws(websocket, payload, timeout, extra_headers) else: - return await _send_subscriptions_transport_ws(websocket, payload, timeout) + return await _send_subscriptions_transport_ws(websocket, payload, timeout, extra_headers) def send_graphql_subscription( diff --git a/pyproject.toml b/pyproject.toml index 2b7a7202..59c47fc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,6 @@ dependencies = [ "pyyaml>=6.0.3", "levenshtein>=0.27.3", "clairvoyance>=2.5.5", - "cloudpickle>=3.1.2,<4", "matplotlib>=3.8.0", "networkx>=3.6.1", "litellm>=1.67.0", diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index b2f07557..7e5932f1 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,27 +1 @@ -import pytest - - -@pytest.fixture(autouse=True, scope="function") -def reset_singletons(): - """Reset singleton instances before and after each test method. - - When pytest-xdist uses fewer workers than test files, multiple test - modules run sequentially in the same worker process. Singletons - (Stats, ObjectsBucket, FEngine) would otherwise retain stale file - paths and state from a previous test, causing FileNotFoundErrors and - empty-bucket assertion failures. - - Function scope is required because module-scoped fixtures are not - reliably triggered between unittest.TestCase test files. - """ - from graphqler.utils.stats import Stats - from graphqler.utils.objects_bucket import ObjectsBucket - from graphqler.fuzzer.engine.fengine import FEngine - - Stats.reset() # ty: ignore[unresolved-attribute] - ObjectsBucket.reset() # ty: ignore[unresolved-attribute] - FEngine.reset() # ty: ignore[unresolved-attribute] - yield - Stats.reset() # ty: ignore[unresolved-attribute] - ObjectsBucket.reset() # ty: ignore[unresolved-attribute] - FEngine.reset() # ty: ignore[unresolved-attribute] +"""Shared end-to-end test configuration.""" diff --git a/tests/integration/test_cli_modes.py b/tests/integration/test_cli_modes.py index d9db7520..f04847f8 100644 --- a/tests/integration/test_cli_modes.py +++ b/tests/integration/test_cli_modes.py @@ -1,9 +1,8 @@ """Integration tests for GraphQLer CLI modes. -Each test invokes ``python -m graphqler`` as a subprocess so that: -- singletons (Stats, FEngine, ObjectsBucket) are fully isolated per test -- config module state cannot leak between tests -- the real CLI argument-parsing / dispatch path is exercised end-to-end +Each test invokes ``python -m graphqler`` as a subprocess so that run-scoped +configuration and state cannot leak between tests and the real CLI +argument-parsing / dispatch path is exercised end-to-end. All tests share a single food-delivery-api server started in ``setUpClass``. Each test gets its own output directory that is deleted in ``tearDown``. @@ -13,6 +12,12 @@ import shutil import subprocess import unittest +import threading +import time + +import requests + +from graphqler.utils.websocket_utils import send_graphql_subscription from tests.e2e.utils.run_api import run_node_project, wait_for_server @@ -139,7 +144,8 @@ def test_compile_graph_mode_does_not_create_chains(self): if os.path.isdir(chains_dir): chain_files = [f for f in os.listdir(chains_dir) if f.endswith(".yml")] self.assertEqual( - len(chain_files), 0, + len(chain_files), + 0, "compile-graph should not generate chain YAML files", ) @@ -152,11 +158,11 @@ def test_compile_chains_after_compile_graph_creates_chains(self): self.assertEqual(result.returncode, 0, f"compile-chains failed:\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}") self._assert_chains_exist() - def test_compile_chains_on_empty_directory_exits_cleanly(self): - """compile-chains with no prior graph handles an empty graph without crashing.""" + def test_compile_chains_on_empty_directory_reports_missing_manifest(self): + """compile-chains rejects incomplete output instead of loading an empty graph.""" result = self._compile_chains() - # Should exit 0 (it prints a warning and returns early, not sys.exit(1)) - self.assertEqual(result.returncode, 0, f"compile-chains crashed:\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}") + self.assertNotEqual(result.returncode, 0) + self.assertIn("Missing manifest.json", result.stderr + result.stdout) # ── full compile mode ───────────────────────────────────────────────────── @@ -266,6 +272,18 @@ def test_compile_mode_is_idempotent(self): self._assert_compiled_graph() self._assert_chains_exist() + def test_fuzz_rejects_tampered_compiled_artifact(self): + compile_result = self._compile() + self.assertEqual(compile_result.returncode, 0, compile_result.stderr) + queries_path = os.path.join(self.path, "compiled", "compiled_queries.yml") + with open(queries_path, "a") as file: + file.write("\n# tampered\n") + + fuzz_result = self._fuzz() + + self.assertNotEqual(fuzz_result.returncode, 0) + self.assertIn("integrity validation", fuzz_result.stderr + fuzz_result.stdout) + # ───────────────────────────────────────────────────────────────────────────── # Subscription support tests (user-wallet-api) @@ -351,6 +369,36 @@ def test_fuzz_with_subscriptions_flag_completes_without_error(self): fuzz_result = _run_cli_sub(self.path, "fuzz", extra_args=["--subscriptions"]) self.assertEqual(fuzz_result.returncode, 0, f"fuzz --subscriptions failed:\nSTDOUT: {fuzz_result.stdout}\nSTDERR: {fuzz_result.stderr}") + def test_subscription_receives_and_parses_published_event(self): + """The real graphql-ws transport must receive a published event, not only connect.""" + observed: list[dict] = [] + + def subscribe(): + observed.extend( + send_graphql_subscription( + SUB_URL, + {"query": "subscription { onTransactionCreated { id amount } }"}, + timeout=5, + protocol="graphql-transport-ws", + ) + ) + + subscriber = threading.Thread(target=subscribe) + subscriber.start() + time.sleep(0.5) + response = requests.post( + SUB_URL, + json={"query": ('mutation { createTransaction(amount: 12.5, payerID: "payer-a", walletID: "wallet-a", currencyID: "currency-a") { id } }')}, + timeout=10, + ) + subscriber.join(timeout=7) + + self.assertEqual(response.status_code, 200, response.text) + self.assertFalse(subscriber.is_alive(), "subscription listener did not finish") + self.assertTrue(observed, "subscription connected but received no event") + event = observed[0]["data"]["onTransactionCreated"] + self.assertEqual(event["amount"], 12.5) + def test_fuzz_without_subscriptions_flag_still_works(self): """Without --subscriptions, subscription nodes are skipped and fuzzing should succeed.""" compile_result = _run_cli_sub(self.path, "compile") diff --git a/tests/unit/fuzzer/engine/test_authorization_differential_detector.py b/tests/unit/fuzzer/engine/test_authorization_differential_detector.py new file mode 100644 index 00000000..e0cb4874 --- /dev/null +++ b/tests/unit/fuzzer/engine/test_authorization_differential_detector.py @@ -0,0 +1,92 @@ +from unittest.mock import patch + +from graphqler.fuzzer.engine.detectors.authorization_differential_detector import AuthorizationDifferentialDetector +from graphqler.fuzzer.engine.types import Result, ResultEnum +from graphqler.fuzzer.engine.types.profile import RuntimeProfile +from graphqler.utils.stats import Stats + + +def _result(value, payload="query { viewer { id email } }"): + return Result( + ResultEnum.HAS_DATA_SUCCESS, + payload=payload, + status_code=200, + graphql_response={"data": {"viewer": value}}, + ) + + +def test_anonymous_exact_match_is_confirmed(): + stats = Stats() + primary = _result({"id": "1", "email": "a@example.test"}) + anonymous = _result({"id": "1", "email": "a@example.test"}) + + with patch("graphqler.fuzzer.engine.detectors.authorization_differential_detector.detection_writer.write_from_detector"): + AuthorizationDifferentialDetector().detect( + "viewer", + primary, + [(RuntimeProfile(name="anonymous"), anonymous)], + stats, + ) + + finding = stats.vulnerabilities["AUTHORIZATION_DIFFERENTIAL"]["viewer"] + assert finding["is_vulnerable"] is True + assert "anonymous" in finding["evidence"] + + +def test_alternate_profile_access_is_potential_not_confirmed(): + stats = Stats() + primary = _result({"id": "1", "email": "a@example.test"}) + alternate = _result({"id": "1", "email": "a@example.test"}) + + with patch("graphqler.fuzzer.engine.detectors.authorization_differential_detector.detection_writer.write_from_detector"): + AuthorizationDifferentialDetector().detect( + "viewer", + primary, + [(RuntimeProfile(name="user-b", auth_token="token-b"), alternate)], + stats, + ) + + finding = stats.vulnerabilities["AUTHORIZATION_DIFFERENTIAL"]["viewer"] + assert finding["is_vulnerable"] is False + assert finding["potentially_vulnerable"] is True + assert "user-b" in finding["evidence"] + + +def test_denied_profile_does_not_create_finding(): + stats = Stats() + primary = _result({"id": "1"}) + denied = Result(ResultEnum.EXTERNAL_FAILURE, graphql_response={"errors": [{"message": "denied"}]}) + + with patch("graphqler.fuzzer.engine.detectors.authorization_differential_detector.detection_writer.write_from_detector"): + AuthorizationDifferentialDetector().detect( + "viewer", + primary, + [(RuntimeProfile(name="anonymous"), denied)], + stats, + ) + + assert "AUTHORIZATION_DIFFERENTIAL" not in stats.vulnerabilities + + +def test_subscription_events_are_compared(): + stats = Stats() + primary = Result( + ResultEnum.GENERAL_SUCCESS, + payload="subscription { privateUpdates { id } }", + graphql_response=[{"data": {"privateUpdates": {"id": "1"}}}], + ) + anonymous = Result( + ResultEnum.GENERAL_SUCCESS, + payload=primary.payload, + graphql_response=[{"data": {"privateUpdates": {"id": "1"}}}], + ) + + with patch("graphqler.fuzzer.engine.detectors.authorization_differential_detector.detection_writer.write_from_detector"): + AuthorizationDifferentialDetector().detect( + "privateUpdates", + primary, + [(RuntimeProfile(name="anonymous"), anonymous)], + stats, + ) + + assert stats.vulnerabilities["AUTHORIZATION_DIFFERENTIAL"]["privateUpdates"]["is_vulnerable"] is True diff --git a/tests/unit/fuzzer/engine/test_profile_execution.py b/tests/unit/fuzzer/engine/test_profile_execution.py new file mode 100644 index 00000000..6c53d12a --- /dev/null +++ b/tests/unit/fuzzer/engine/test_profile_execution.py @@ -0,0 +1,54 @@ +from unittest.mock import MagicMock, patch + +from graphqler.fuzzer.engine.fengine import FEngine +from graphqler.fuzzer.engine.types.profile import RuntimeProfile +from graphqler.utils.stats import Stats + + +def test_exact_query_payload_uses_profile_headers(): + api = MagicMock(url="https://example.test/graphql") + response = MagicMock(status_code=200, text='{"data":{"viewer":{"id":"1"}}}') + graphql_response = {"data": {"viewer": {"id": "1"}}} + profile = RuntimeProfile(name="user-b", auth_token="token-b", headers={"X-Tenant": "b"}) + + with patch( + "graphqler.fuzzer.engine.fengine._request_utils.send_graphql_request_with_headers", + return_value=(graphql_response, response), + ) as send: + returned, result = FEngine(api, Stats()).run_payload_with_profile( + "viewer", + "query { viewer { id } }", + profile, + ) + + assert returned == graphql_response + assert result.success is True + assert result.payload == "query { viewer { id } }" + send.assert_called_once_with( + api.url, + "query { viewer { id } }", + {"X-Tenant": "b", "Authorization": "Bearer token-b"}, + ) + + +def test_exact_subscription_payload_uses_profile_headers_and_records_events(): + api = MagicMock(url="https://example.test/graphql") + profile = RuntimeProfile(name="anonymous", headers={"X-Tenant": "public"}) + events = [{"data": {"privateUpdates": {"id": "1"}}}] + + with patch("graphqler.utils.websocket_utils.send_graphql_subscription", return_value=events) as send: + returned, result = FEngine(api, Stats()).run_subscription_with_profile( + "privateUpdates", + "subscription { privateUpdates { id } }", + profile, + ) + + assert returned == events + assert result.success is True + assert result.payload == "subscription { privateUpdates { id } }" + assert result.graphql_response == events + send.assert_called_once_with( + url=api.url, + payload={"query": "subscription { privateUpdates { id } }"}, + headers={"X-Tenant": "public"}, + ) diff --git a/tests/unit/fuzzer/fengine/test_field_fuzzing_detectors.py b/tests/unit/fuzzer/fengine/test_field_fuzzing_detectors.py index 49490dea..58737052 100644 --- a/tests/unit/fuzzer/fengine/test_field_fuzzing_detectors.py +++ b/tests/unit/fuzzer/fengine/test_field_fuzzing_detectors.py @@ -148,10 +148,8 @@ def test_skips_when_no_string_inputs(self): self.assertFalse(confirmed) self.assertFalse(potential) - @patch("graphqler.fuzzer.engine.detectors.field_fuzzing.field_charset_fuzzing_detector.Stats") @patch("graphqler.fuzzer.engine.detectors.field_fuzzing.field_charset_fuzzing_detector.plugins_handler") - def test_flags_potential_on_high_variance(self, mock_ph, mock_stats): - mock_stats.return_value = MagicMock() + def test_flags_potential_on_high_variance(self, mock_ph): # 'a' → short response, 'b' → very long, 'c' → short def fake_send(url, payload): resp = MagicMock() @@ -176,10 +174,8 @@ def fake_send(url, payload): self.assertFalse(confirmed, "charset fuzzing should never confirm") self.assertTrue(potential, "high response variance should flag as potential") - @patch("graphqler.fuzzer.engine.detectors.field_fuzzing.field_charset_fuzzing_detector.Stats") @patch("graphqler.fuzzer.engine.detectors.field_fuzzing.field_charset_fuzzing_detector.plugins_handler") - def test_no_flag_on_uniform_responses(self, mock_ph, mock_stats): - mock_stats.return_value = MagicMock() + def test_no_flag_on_uniform_responses(self, mock_ph): def fake_send(url, payload): resp = MagicMock() resp.text = "x" * 150 @@ -230,10 +226,8 @@ def test_skips_when_no_id_inputs(self): self.assertFalse(confirmed) self.assertFalse(potential) - @patch("graphqler.fuzzer.engine.detectors.field_fuzzing.id_enumeration_detector.Stats") @patch("graphqler.fuzzer.engine.detectors.field_fuzzing.id_enumeration_detector.plugins_handler") - def test_flags_potential_on_multiple_id_hits(self, mock_ph, mock_stats): - mock_stats.return_value = MagicMock() + def test_flags_potential_on_multiple_id_hits(self, mock_ph): # IDs 1, 2, 3 return data; 4, 5 do not def fake_send(url, payload): resp = MagicMock() @@ -253,20 +247,16 @@ def fake_send(url, payload): self.assertFalse(confirmed, "ID enumeration should never confirm") self.assertTrue(potential, "3 IDs returning data >= threshold of 2 should flag") - @patch("graphqler.fuzzer.engine.detectors.field_fuzzing.id_enumeration_detector.Stats") @patch("graphqler.fuzzer.engine.detectors.field_fuzzing.id_enumeration_detector.plugins_handler") - def test_no_flag_when_single_id_returns_data(self, mock_ph, mock_stats): - mock_stats.return_value = MagicMock() + def test_no_flag_when_single_id_returns_data(self, mock_ph): det = _make_detector(IDEnumerationDetector, INT_INPUT) det._probe_ids = lambda f: (1, ['query { searchItems(id: 1) { id } }']) _, potential = det.detect() self.assertFalse(potential, "only 1 ID hit < threshold of 2 should NOT flag") - @patch("graphqler.fuzzer.engine.detectors.field_fuzzing.id_enumeration_detector.Stats") @patch("graphqler.fuzzer.engine.detectors.field_fuzzing.id_enumeration_detector.plugins_handler") - def test_no_flag_when_no_ids_return_data(self, mock_ph, mock_stats): - mock_stats.return_value = MagicMock() + def test_no_flag_when_no_ids_return_data(self, mock_ph): det = _make_detector(IDEnumerationDetector, INT_INPUT) det._probe_ids = lambda f: (0, []) diff --git a/tests/unit/fuzzer/fengine/test_time_sql_injection_detector.py b/tests/unit/fuzzer/fengine/test_time_sql_injection_detector.py index c53a33b2..d0e4d237 100644 --- a/tests/unit/fuzzer/fengine/test_time_sql_injection_detector.py +++ b/tests/unit/fuzzer/fengine/test_time_sql_injection_detector.py @@ -142,8 +142,7 @@ def test_evidence_empty_when_fast(self): # --- full detect() with mocked request --- @patch("graphqler.fuzzer.engine.detectors.time_sql_injection.time_sql_injection_detector.plugins_handler") - @patch("graphqler.fuzzer.engine.detectors.time_sql_injection.time_sql_injection_detector.Stats") - def test_detect_flags_confirmed_on_slow_response(self, mock_stats, mock_plugins): + def test_detect_flags_confirmed_on_slow_response(self, mock_plugins): api = MagicMock() api.url = "http://localhost/graphql" node = MagicMock() @@ -159,7 +158,6 @@ def slow_request(url, payload): return ({"data": {"searchUser": None}}, fast_response) mock_plugins.get_request_utils.return_value.send_graphql_request.side_effect = slow_request - mock_stats.return_value = MagicMock() detector = TimeSQLInjectionDetector(api=api, node=node, objects_bucket=objects_bucket, graphql_type="Query") diff --git a/tests/unit/fuzzer/test_authorization_differential.py b/tests/unit/fuzzer/test_authorization_differential.py new file mode 100644 index 00000000..427b70a4 --- /dev/null +++ b/tests/unit/fuzzer/test_authorization_differential.py @@ -0,0 +1,107 @@ +from unittest.mock import MagicMock, patch + +from graphqler import config +from graphqler.fuzzer.engine.types import Result, ResultEnum +from graphqler.fuzzer.engine.types.profile import RuntimeProfile +from graphqler.fuzzer.fuzzer import Fuzzer +from graphqler.graph.node import Node +from graphqler.utils.stats import Stats + + +def _fuzzer(): + fuzzer = Fuzzer.__new__(Fuzzer) + fuzzer.api = MagicMock() + fuzzer.api.queries = { + "viewer": { + "output": {"kind": "OBJECT", "name": "User", "ofType": None}, + } + } + fuzzer.api.mutations = { + "updateProfile": { + "output": {"kind": "OBJECT", "name": "User", "ofType": None}, + } + } + fuzzer.api.subscriptions = {} + fuzzer.api.objects = {"User": {"fields": [{"name": "id"}, {"name": "email"}]}} + fuzzer.profiles = { + "primary": RuntimeProfile(name="primary", auth_token="token-a"), + "secondary": RuntimeProfile(name="secondary", auth_token="token-b"), + "post_delete": RuntimeProfile(name="post_delete", auth_token="token-a"), + } + fuzzer.fengine = MagicMock() + alternate = Result( + ResultEnum.HAS_DATA_SUCCESS, + payload="query { viewer { id email } }", + graphql_response={"data": {"viewer": {"id": "1"}}}, + ) + fuzzer.fengine.run_payload_with_profile.return_value = (alternate.graphql_response, alternate) + fuzzer.authorization_detector = MagicMock() + fuzzer.stats = Stats() + fuzzer._authorization_tested_nodes = set() + return fuzzer + + +def test_private_query_replays_exact_payload_for_anonymous_and_alternate_profiles(): + fuzzer = _fuzzer() + node = Node("Query", "viewer", {}) + primary = Result( + ResultEnum.HAS_DATA_SUCCESS, + payload="query { viewer { id email } }", + graphql_response={"data": {"viewer": {"id": "1", "email": "a@example.test"}}}, + ) + settings = config.snapshot({"AUTHORIZATION_DIFFERENTIAL": True}) + + with ( + config.activate(settings), + patch( + "graphqler.fuzzer.fuzzer.EndpointPrivacyClassifier.classify", + return_value="private", + ), + ): + fuzzer._Fuzzer__run_authorization_differential(node, primary) + + calls = fuzzer.fengine.run_payload_with_profile.call_args_list + assert [call.args[2].name for call in calls] == ["anonymous", "secondary"] + assert all(call.args[1] == primary.payload for call in calls) + fuzzer.authorization_detector.detect.assert_called_once() + + +def test_public_query_is_not_replayed(): + fuzzer = _fuzzer() + node = Node("Query", "viewer", {}) + primary = Result(ResultEnum.HAS_DATA_SUCCESS, payload="query { viewer { id } }") + settings = config.snapshot({"AUTHORIZATION_DIFFERENTIAL": True}) + + with ( + config.activate(settings), + patch( + "graphqler.fuzzer.fuzzer.EndpointPrivacyClassifier.classify", + return_value="public", + ), + ): + fuzzer._Fuzzer__run_authorization_differential(node, primary) + + fuzzer.fengine.run_payload_with_profile.assert_not_called() + + +def test_private_mutation_is_replayed_under_each_profile(): + fuzzer = _fuzzer() + node = Node("Mutation", "updateProfile", {}) + primary = Result( + ResultEnum.HAS_DATA_SUCCESS, + payload='mutation { updateProfile(name: "new") { id email } }', + graphql_response={"data": {"updateProfile": {"id": "1", "email": "a@example.test"}}}, + ) + settings = config.snapshot({"AUTHORIZATION_DIFFERENTIAL": True}) + + with ( + config.activate(settings), + patch( + "graphqler.fuzzer.fuzzer.EndpointPrivacyClassifier.classify", + return_value="private", + ), + ): + fuzzer._Fuzzer__run_authorization_differential(node, primary) + + assert fuzzer.fengine.run_payload_with_profile.call_count == 2 + assert all(call.args[1] == primary.payload for call in fuzzer.fengine.run_payload_with_profile.call_args_list) diff --git a/tests/unit/fuzzer/test_dep_retry_phase.py b/tests/unit/fuzzer/test_dep_retry_phase.py index 23f2960a..353b63db 100644 --- a/tests/unit/fuzzer/test_dep_retry_phase.py +++ b/tests/unit/fuzzer/test_dep_retry_phase.py @@ -45,11 +45,10 @@ class TestFEngineHardDepResult: into ResultEnum.HARD_DEPENDENCY_NOT_MET (not INTERNAL_FAILURE).""" def _make_fengine(self): - """Create a FEngine instance with a mocked API, resetting the singleton first.""" + """Create an execution engine with a mocked API.""" from graphqler.fuzzer.engine.fengine import FEngine from graphqler.utils.api import API - FEngine.reset() # ty: ignore[unresolved-attribute] api = MagicMock(spec=API) api.url = "http://example.com/graphql" return FEngine(api) @@ -108,9 +107,6 @@ class TestDepRetryTracking: def _make_fuzzer(self): """Build a Fuzzer with all heavy dependencies mocked out.""" from graphqler.fuzzer.fuzzer import Fuzzer - from graphqler.fuzzer.engine.fengine import FEngine - - FEngine.reset() # ty: ignore[unresolved-attribute] with ( patch("graphqler.fuzzer.fuzzer.API"), @@ -134,10 +130,10 @@ def test_downstream_nodes_added_when_chain_breaks(self): """When a chain stops early, all subsequent primary non-Object nodes must be dep-blocked.""" fuzzer = self._make_fuzzer() - node2 = _make_node("character", "Query") # fails - node3 = _make_node("charactersByIds", "Query") # never reached - node4 = _make_node("episode", "Query") # never reached - obj_node = _make_node("Character", "Object") # should be excluded + node2 = _make_node("character", "Query") # fails + node3 = _make_node("charactersByIds", "Query") # never reached + node4 = _make_node("episode", "Query") # never reached + obj_node = _make_node("Character", "Object") # should be excluded # Build fake chain steps matching the structure in fuzzer.py def _make_step(node, profile="primary"): @@ -148,10 +144,10 @@ def _make_step(node, profile="primary"): chain_steps = [ _make_step(_make_node("characters", "Query")), # step 0 — succeeds - _make_step(node2), # step 1 — fails - _make_step(node3), # step 2 — skipped - _make_step(node4), # step 3 — skipped - _make_step(obj_node), # step 4 — Object, must be excluded + _make_step(node2), # step 1 — fails + _make_step(node3), # step 2 — skipped + _make_step(node4), # step 3 — skipped + _make_step(obj_node), # step 4 — Object, must be excluded ] # Simulate the break logic from __run_chain (i=1, failure at node2) @@ -159,7 +155,7 @@ def _make_step(node, profile="primary"): fail_result = Result(ResultEnum.HARD_DEPENDENCY_NOT_MET) if fail_result.result_enum == ResultEnum.HARD_DEPENDENCY_NOT_MET: fuzzer._dep_blocked_nodes.add(node2) - for future_step in chain_steps[i + 1:]: + for future_step in chain_steps[i + 1 :]: if future_step.profile_name == "primary" and future_step.node.graphql_type != "Object": fuzzer._dep_blocked_nodes.add(future_step.node) @@ -168,7 +164,6 @@ def _make_step(node, profile="primary"): assert node4 in fuzzer._dep_blocked_nodes assert obj_node not in fuzzer._dep_blocked_nodes - """A node that returns HARD_DEPENDENCY_NOT_MET should be in _dep_blocked_nodes.""" fuzzer = self._make_fuzzer() node = _make_node("charactersByIds") @@ -193,20 +188,13 @@ def test_dep_retry_calls_run_minimal_with_no_dep_check(self): fuzzer.fengine.run_maximal_payload.return_value = ({}, success_result) # Run the dep_retry logic (extracted from __run_fuzz) - dep_retry_nodes = [ - n for n in fuzzer._dep_blocked_nodes - if f"{n.graphql_type}|{n.name}" not in fuzzer.stats.successful_nodes - ] + dep_retry_nodes = [n for n in fuzzer._dep_blocked_nodes if f"{n.graphql_type}|{n.name}" not in fuzzer.stats.successful_nodes] for n in dep_retry_nodes: fuzzer.fengine.run_minimal_payload(n.name, fuzzer.objects_bucket, n.graphql_type, check_hard_depends_on=False) fuzzer.fengine.run_maximal_payload(n.name, fuzzer.objects_bucket, n.graphql_type, check_hard_depends_on=False) - fuzzer.fengine.run_minimal_payload.assert_called_once_with( - "charactersByIds", fuzzer.objects_bucket, "Query", check_hard_depends_on=False - ) - fuzzer.fengine.run_maximal_payload.assert_called_once_with( - "charactersByIds", fuzzer.objects_bucket, "Query", check_hard_depends_on=False - ) + fuzzer.fengine.run_minimal_payload.assert_called_once_with("charactersByIds", fuzzer.objects_bucket, "Query", check_hard_depends_on=False) + fuzzer.fengine.run_maximal_payload.assert_called_once_with("charactersByIds", fuzzer.objects_bucket, "Query", check_hard_depends_on=False) def test_dep_retry_skips_already_successful_nodes(self): """Nodes that already succeeded during chains/islands must NOT be retried.""" @@ -216,10 +204,7 @@ def test_dep_retry_skips_already_successful_nodes(self): # Mark as already successful fuzzer.stats.successful_nodes = {"Query|charactersByIds": 1} - dep_retry_nodes = [ - n for n in fuzzer._dep_blocked_nodes - if f"{n.graphql_type}|{n.name}" not in fuzzer.stats.successful_nodes - ] + dep_retry_nodes = [n for n in fuzzer._dep_blocked_nodes if f"{n.graphql_type}|{n.name}" not in fuzzer.stats.successful_nodes] assert dep_retry_nodes == [] def test_dep_retry_empty_when_no_hard_dep_failures(self): @@ -227,19 +212,14 @@ def test_dep_retry_empty_when_no_hard_dep_failures(self): fuzzer = self._make_fuzzer() fuzzer._dep_blocked_nodes = set() - dep_retry_nodes = [ - n for n in fuzzer._dep_blocked_nodes - if f"{n.graphql_type}|{n.name}" not in fuzzer.stats.successful_nodes - ] + dep_retry_nodes = [n for n in fuzzer._dep_blocked_nodes if f"{n.graphql_type}|{n.name}" not in fuzzer.stats.successful_nodes] assert dep_retry_nodes == [] fuzzer.fengine.run_minimal_payload.assert_not_called() def test_dep_blocked_nodes_initialized_empty(self): """Fuzzer._dep_blocked_nodes must start as an empty set.""" from graphqler.fuzzer.fuzzer import Fuzzer - from graphqler.fuzzer.engine.fengine import FEngine - FEngine.reset() # ty: ignore[unresolved-attribute] with ( patch("graphqler.fuzzer.fuzzer.API"), patch("graphqler.fuzzer.fuzzer.GraphGenerator"), @@ -248,6 +228,7 @@ def test_dep_blocked_nodes_initialized_empty(self): patch("graphqler.fuzzer.fuzzer.FEngine"), patch("graphqler.fuzzer.fuzzer.ObjectsBucket"), patch("graphqler.fuzzer.fuzzer.Stats"), + patch("graphqler.fuzzer.fuzzer.validate_manifest"), ): fuzzer = Fuzzer(save_path="/tmp/fake", url="http://example.com/graphql") diff --git a/tests/unit/fuzzer/test_resume.py b/tests/unit/fuzzer/test_resume.py new file mode 100644 index 00000000..34247f91 --- /dev/null +++ b/tests/unit/fuzzer/test_resume.py @@ -0,0 +1,64 @@ +from multiprocessing import Queue +from unittest.mock import MagicMock + +from graphqler import config +from graphqler.fuzzer.fuzzer import Fuzzer +from graphqler.utils.stats import Stats + + +def test_resume_skips_completed_chains(tmp_path): + settings = config.snapshot( + { + "RESUME": True, + "DEBUG": True, + "MAX_FUZZING_ITERATIONS": 1, + "SKIP_INJECTION_ATTACKS": True, + "SKIP_MISC_ATTACKS": True, + "SKIP_DOS_ATTACKS": True, + "SKIP_ENUMERATION_ATTACKS": True, + "LLM_ENABLE_REPORTER": False, + } + ) + stats = Stats() + stats.set_file_paths(str(tmp_path)) + stats.phase = "chains" + stats.current_iteration = 1 + stats.chains_completed = 1 + + first_chain = MagicMock(nodes=[]) + second_chain = MagicMock(nodes=[]) + fuzzer = Fuzzer.__new__(Fuzzer) + fuzzer.settings = settings + fuzzer.stats = stats + fuzzer.chains = [first_chain, second_chain] + fuzzer.dependency_graph = MagicMock(nodes=[]) + fuzzer.logger = MagicMock() + fuzzer.objects_bucket = MagicMock() + fuzzer.dengine = MagicMock() + fuzzer.fengine = MagicMock() + fuzzer._dep_blocked_nodes = set() + fuzzer.save_path = str(tmp_path) + fuzzer.url = "https://example.test/graphql" + fuzzer._Fuzzer__run_chain = MagicMock() + + with config.activate(settings): + fuzzer._Fuzzer__run_fuzz(Queue()) + + fuzzer._Fuzzer__run_chain.assert_called_once_with(second_chain) + assert stats.phase == "completed" + assert stats.chains_completed == 2 + + +def test_resume_of_completed_run_is_noop(tmp_path): + settings = config.snapshot({"RESUME": True, "DEBUG": True}) + fuzzer = Fuzzer.__new__(Fuzzer) + fuzzer.settings = settings + fuzzer.stats = Stats() + fuzzer.stats.set_file_paths(str(tmp_path)) + fuzzer.stats.phase = "completed" + fuzzer.logger = MagicMock() + + with config.activate(settings): + fuzzer._Fuzzer__run_fuzz(Queue()) + + fuzzer.logger.info.assert_called_once_with("Run checkpoint is already complete; nothing to resume") diff --git a/tests/unit/mcp/test_server.py b/tests/unit/mcp/test_server.py index 32e46ee1..ec6847f6 100644 --- a/tests/unit/mcp/test_server.py +++ b/tests/unit/mcp/test_server.py @@ -17,6 +17,7 @@ def _make_compiled_path(tmp_dir: str) -> None: """Create the minimal directory/file layout that is_compiled() expects.""" from graphqler import config + from graphqler.utils.artifact_manifest import write_manifest compiled = os.path.join(tmp_dir, config.COMPILED_DIR_NAME) extracted = os.path.join(tmp_dir, config.EXTRACTED_DIR_NAME) @@ -28,12 +29,14 @@ def _make_compiled_path(tmp_dir: str) -> None: config.COMPILED_OBJECTS_FILE_NAME, config.COMPILED_QUERIES_FILE_NAME, config.COMPILED_MUTATIONS_FILE_NAME, + config.COMPILED_SUBSCRIPTIONS_FILE_NAME, config.INTROSPECTION_RESULT_FILE_NAME, ]: full = os.path.join(tmp_dir, fname) os.makedirs(os.path.dirname(full), exist_ok=True) with open(full, "w") as f: f.write("") + write_manifest(tmp_dir, "http://example.com/graphql", "chains", config.snapshot()) # --------------------------------------------------------------------------- diff --git a/tests/unit/utils/test_artifact_manifest.py b/tests/unit/utils/test_artifact_manifest.py new file mode 100644 index 00000000..b43077b5 --- /dev/null +++ b/tests/unit/utils/test_artifact_manifest.py @@ -0,0 +1,76 @@ +import json + +import pytest + +from graphqler import config +from graphqler.utils.artifact_manifest import ArtifactValidationError, validate_manifest, write_manifest + + +def _compiled_files(root, settings): + files = [ + settings.INTROSPECTION_RESULT_FILE_NAME, + settings.COMPILED_OBJECTS_FILE_NAME, + settings.COMPILED_QUERIES_FILE_NAME, + settings.COMPILED_MUTATIONS_FILE_NAME, + settings.COMPILED_SUBSCRIPTIONS_FILE_NAME, + ] + for index, relative_path in enumerate(files): + path = root / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"artifact-{index}") + + +def test_manifest_round_trip_validates_hashes_and_endpoint(tmp_path): + settings = config.snapshot() + _compiled_files(tmp_path, settings) + write_manifest(tmp_path, "https://example.test/graphql", "chains", settings) + + manifest = validate_manifest( + tmp_path, + "chains", + settings, + expected_endpoint="https://example.test/graphql", + ) + + assert manifest["schema_version"] == settings.ARTIFACT_SCHEMA_VERSION + assert manifest["phase"] == "chains" + assert len(manifest["artifacts"]) == 5 + + +def test_manifest_rejects_tampered_artifact(tmp_path): + settings = config.snapshot() + _compiled_files(tmp_path, settings) + write_manifest(tmp_path, "https://example.test/graphql", "chains", settings) + (tmp_path / settings.COMPILED_QUERIES_FILE_NAME).write_text("tampered") + + with pytest.raises(ArtifactValidationError, match="integrity validation"): + validate_manifest(tmp_path, "chains", settings) + + +def test_manifest_rejects_wrong_endpoint_and_incomplete_phase(tmp_path): + settings = config.snapshot() + _compiled_files(tmp_path, settings) + write_manifest(tmp_path, "https://first.test/graphql", "graph", settings) + + with pytest.raises(ArtifactValidationError, match="required phase"): + validate_manifest(tmp_path, "chains", settings) + with pytest.raises(ArtifactValidationError, match="not 'https://second.test/graphql'"): + validate_manifest(tmp_path, "graph", settings, expected_endpoint="https://second.test/graphql") + + +def test_manifest_rejects_incompatible_schema(tmp_path): + settings = config.snapshot() + manifest_path = tmp_path / settings.ARTIFACT_MANIFEST_FILE_NAME + manifest_path.write_text( + json.dumps( + { + "format": "graphqler.compiled_artifacts", + "schema_version": 999, + "phase": "chains", + "artifacts": {}, + } + ) + ) + + with pytest.raises(ArtifactValidationError, match="incompatible"): + validate_manifest(tmp_path, "chains", settings) diff --git a/tests/unit/utils/test_objects_bucket_connection.py b/tests/unit/utils/test_objects_bucket_connection.py index 3e0e4b82..c562804d 100644 --- a/tests/unit/utils/test_objects_bucket_connection.py +++ b/tests/unit/utils/test_objects_bucket_connection.py @@ -51,13 +51,7 @@ def _build_bucket(connection_fields=None): } } - # Access the underlying class to bypass the singleton decorator for isolated testing. - # ObjectsBucket.__wrapped__ is the original undecorated class set by the @singleton decorator. - real_cls = ObjectsBucket.__wrapped__ # type: ignore - bucket = real_cls.__new__(real_cls) - bucket.api = api - bucket.objects = {} - bucket.scalars = {} + bucket = ObjectsBucket(api) return bucket @@ -193,7 +187,6 @@ def test_auto_discovered_non_standard_list_field(self): bucket._unpack_connection_wrapper("CountryConnection", data) assert {"id": "20", "name": "Brazil"} in bucket.objects.get("Country", []) - """A connection field value that is not a list is silently ignored.""" bucket = _build_bucket() data = {"items": "not-a-list"} diff --git a/tests/unit/utils/test_run_context.py b/tests/unit/utils/test_run_context.py new file mode 100644 index 00000000..cd8a893b --- /dev/null +++ b/tests/unit/utils/test_run_context.py @@ -0,0 +1,62 @@ +from concurrent.futures import ThreadPoolExecutor +from unittest.mock import MagicMock + +from graphqler import config +from graphqler.fuzzer.engine.fengine import FEngine +from graphqler.utils.logging_utils import Logger +from graphqler.utils.objects_bucket import ObjectsBucket +from graphqler.utils.stats import Stats + + +def test_runtime_settings_are_isolated_between_threads(): + default_output = config.OUTPUT_DIRECTORY + + def active_output(path: str) -> str: + settings = config.snapshot({"OUTPUT_DIRECTORY": path}) + with config.activate(settings): + return config.OUTPUT_DIRECTORY + + with ThreadPoolExecutor(max_workers=2) as executor: + outputs = list(executor.map(active_output, ["output-a", "output-b"])) + + assert outputs == ["output-a", "output-b"] + assert config.OUTPUT_DIRECTORY == default_output + + +def test_snapshot_rejects_unknown_configuration_key(): + try: + config.snapshot({"DOES_NOT_EXIST": True}) + except KeyError as exc: + assert "DOES_NOT_EXIST" in str(exc) + else: + raise AssertionError("unknown configuration key was accepted") + + +def test_mutable_run_services_are_not_singletons(): + api = MagicMock() + + first_stats = Stats() + second_stats = Stats() + first_bucket = ObjectsBucket(api) + second_bucket = ObjectsBucket(api) + first_engine = FEngine(api, first_stats) + second_engine = FEngine(api, second_stats) + + assert first_stats is not second_stats + assert first_bucket is not second_bucket + assert first_engine is not second_engine + assert first_engine.stats is first_stats + assert second_engine.stats is second_stats + + +def test_logger_paths_follow_active_run_settings(tmp_path): + first = config.snapshot({"OUTPUT_DIRECTORY": str(tmp_path / "first")}) + second = config.snapshot({"OUTPUT_DIRECTORY": str(tmp_path / "second")}) + + with config.activate(first): + first_logger = Logger() + with config.activate(second): + second_logger = Logger() + + assert first_logger.fuzzer_log_path.parent.parent == tmp_path / "first" + assert second_logger.fuzzer_log_path.parent.parent == tmp_path / "second" diff --git a/tests/unit/utils/test_state_persistence.py b/tests/unit/utils/test_state_persistence.py new file mode 100644 index 00000000..b7e75909 --- /dev/null +++ b/tests/unit/utils/test_state_persistence.py @@ -0,0 +1,113 @@ +import json +from unittest.mock import MagicMock + +import pytest + +from graphqler import config +from graphqler.fuzzer.engine.types import Result, ResultEnum +from graphqler.graph.node import Node +from graphqler.utils.objects_bucket import ObjectsBucket +from graphqler.utils.stats import Stats + + +def _successful_result() -> Result: + return Result( + ResultEnum.HAS_DATA_SUCCESS, + payload="query { viewer { id } }", + status_code=200, + graphql_response={"data": {"viewer": {"id": "1"}}}, + raw_response_text='{"data":{"viewer":{"id":"1"}}}', + ) + + +def test_stats_mutation_stays_in_memory_until_checkpoint(tmp_path): + stats = Stats() + stats.set_file_paths(str(tmp_path)) + node = Node(graphql_type="Query", name="viewer", body={}) + + stats.add_http_status_code(node.name, 200) + stats.update_stats_from_result(node, _successful_result()) + + assert not stats.state_save_path.exists() + assert stats.number_of_successes == 1 + + +def test_stats_save_is_idempotent_and_round_trips(tmp_path): + stats = Stats() + stats.set_file_paths(str(tmp_path)) + node = Node(graphql_type="Query", name="viewer", body={}) + result = _successful_result() + stats.add_http_status_code(node.name, 200) + stats.update_stats_from_result(node, result) + stats.dep_retry_nodes = ["Query|viewer"] + + stats.save() + endpoint_file = tmp_path / "endpoint_results" / "viewer" / "success" / "200" + first_output = endpoint_file.read_text() + stats.save() + + restored = Stats() + restored.state_save_path = tmp_path / config.SERIALIZED_DIR_NAME / config.STATS_STATE_FILE_NAME + restored.load() + + assert endpoint_file.read_text() == first_output + assert first_output.count("Payload:") == 1 + assert restored.results == {"viewer": {result}} + assert restored.number_of_successes == 1 + assert restored.http_status_codes == {"200": {"viewer": 1}} + assert restored.dep_retry_nodes == ["Query|viewer"] + + +def test_stats_rejects_unknown_state_version(tmp_path): + state_path = tmp_path / "stats.json" + state_path.write_text(json.dumps({"format": "graphqler.stats", "version": 999})) + stats = Stats() + stats.state_save_path = state_path + + with pytest.raises(ValueError, match="Unsupported stats state format"): + stats.load() + + +def test_objects_bucket_round_trips_json_state(tmp_path, monkeypatch): + monkeypatch.setattr(config, "OUTPUT_DIRECTORY", str(tmp_path)) + api = MagicMock() + bucket = ObjectsBucket(api) + bucket.objects = {"User": [{"id": "1", "name": "Ada"}]} + bucket.scalars = {"id": {"type": "ID", "values": {"1", "2"}}} + + bucket.save() + restored = ObjectsBucket(api).load() + + assert restored.objects == bucket.objects + assert restored.scalars == bucket.scalars + state = json.loads((tmp_path / "serialized" / "objects_bucket.json").read_text()) + assert state["format"] == "graphqler.objects_bucket" + assert state["version"] == 1 + + +def test_objects_bucket_merge_deduplicates_and_copies_values(tmp_path, monkeypatch): + monkeypatch.setattr(config, "OUTPUT_DIRECTORY", str(tmp_path)) + api = MagicMock() + target = ObjectsBucket(api) + target.objects = {"User": [{"id": "1"}]} + target.scalars = {"id": {"type": "ID", "values": {"1"}}} + source = ObjectsBucket(api) + source.objects = {"User": [{"id": "1"}, {"id": "2"}]} + source.scalars = {"id": {"type": "ID", "values": {"1", "2"}}} + + target.merge(source) + source.objects["User"][0]["id"] = "changed" + source.scalars["id"]["values"].add("changed") + + assert target.objects == {"User": [{"id": "1"}, {"id": "2"}]} + assert target.scalars == {"id": {"type": "ID", "values": {"1", "2"}}} + + +def test_objects_bucket_rejects_executable_or_invalid_state(tmp_path, monkeypatch): + monkeypatch.setattr(config, "OUTPUT_DIRECTORY", str(tmp_path)) + state_path = tmp_path / "serialized" / "objects_bucket.json" + state_path.parent.mkdir() + state_path.write_text("not json") + + with pytest.raises(ValueError, match="Unable to read objects bucket state"): + ObjectsBucket(MagicMock()).load() diff --git a/tests/unit/utils/test_websocket_utils.py b/tests/unit/utils/test_websocket_utils.py new file mode 100644 index 00000000..2d7100f8 --- /dev/null +++ b/tests/unit/utils/test_websocket_utils.py @@ -0,0 +1,52 @@ +import asyncio +import json +from unittest.mock import AsyncMock + +from graphqler.utils.websocket_utils import _send_graphql_ws, _send_subscriptions_transport_ws + + +def test_modern_protocol_sends_auth_in_connection_init(): + websocket = AsyncMock() + websocket.recv.side_effect = [ + json.dumps({"type": "connection_ack"}), + json.dumps({"type": "next", "payload": {"data": {"privateUpdates": {"id": "1"}}}}), + json.dumps({"type": "complete"}), + ] + + events = asyncio.run( + _send_graphql_ws( + websocket, + {"query": "subscription { privateUpdates { id } }"}, + 1, + {"Authorization": "Bearer token-a", "X-Tenant": "a"}, + ) + ) + + first_message = json.loads(websocket.send.await_args_list[0].args[0]) + assert first_message == { + "type": "connection_init", + "payload": {"Authorization": "Bearer token-a", "X-Tenant": "a"}, + } + assert events == [{"data": {"privateUpdates": {"id": "1"}}}] + + +def test_legacy_protocol_sends_auth_in_connection_init(): + websocket = AsyncMock() + websocket.recv.side_effect = [ + json.dumps({"type": "connection_ack"}), + json.dumps({"type": "data", "payload": {"data": {"privateUpdates": {"id": "1"}}}}), + json.dumps({"type": "complete"}), + ] + + events = asyncio.run( + _send_subscriptions_transport_ws( + websocket, + {"query": "subscription { privateUpdates { id } }"}, + 1, + {"Authorization": "Bearer token-b"}, + ) + ) + + first_message = json.loads(websocket.send.await_args_list[0].args[0]) + assert first_message["payload"]["Authorization"] == "Bearer token-b" + assert events == [{"data": {"privateUpdates": {"id": "1"}}}] diff --git a/uv.lock b/uv.lock index 6c65b9af..95ec7fa1 100644 --- a/uv.lock +++ b/uv.lock @@ -347,15 +347,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941, upload-time = "2023-08-17T17:29:10.08Z" }, ] -[[package]] -name = "cloudpickle" -version = "3.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -704,7 +695,6 @@ version = "2.3.8" source = { editable = "." } dependencies = [ { name = "clairvoyance" }, - { name = "cloudpickle" }, { name = "graphql-core" }, { name = "levenshtein" }, { name = "litellm" }, @@ -741,7 +731,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "clairvoyance", specifier = ">=2.5.5" }, - { name = "cloudpickle", specifier = ">=3.1.2,<4" }, { name = "fastmcp", marker = "extra == 'mcp'", specifier = ">=3.1.1" }, { name = "graphql-core", specifier = ">=3.2.8" }, { name = "levenshtein", specifier = ">=0.27.3" },