{
"README.md": "# MockForge discussion and CVE tools\n\nRequires Python 3.11 or later; no pip packages or Git checkout needed.\nRun these commands from this directory:\n\n```bash\npython3 scripts/export_github_issue.py --repo SaaSy-Solutions/mockforge --issue 79 --output-dir issue-79-export\npython3 scripts/cve_pack.py --help\npython3 scripts/cve_pack.py tips CVE-2026-109618\npython3 scripts/cve_pack.py server examples/cve-packs/CVE-2025-29927/profile.json > mock.json\n```\n\nSee book/src/user-guide/cve-packs.md for the LB recorder setup, run versus probe, and limitations. A CVE ID creates a template, not a vulnerable application or exploit.\n",
"book/src/user-guide/cve-packs.md": "# Per-CVE Rule Packs\n\nA per-CVE rule pack tests a load-balancer or WAF rule using a profile you author\nfrom an advisory. It sends the specified request signature and benign controls,\nthen checks the expected responses.\n\n`new CVE-2026-109618` writes an unfinished template with that ID. It does not\nlook up the CVE, generate an exploit, or build a vulnerable PHP server. `server`\nturns a completed profile into an OpenAPI mock with canned responses. Neither\ncommand reproduces PHP2Shell or Log4Shell. The template is rejected by `server`,\n`run`, and `probe` until you replace its placeholders.\n\nFor a completed example, use\n`examples/cve-packs/CVE-2025-29927/profile.json`. To reproduce vulnerable\napplication behavior, you need a separate application lab with the affected\nversion and observations specific to the advisory. See the\n[CVE reference and application requirements](issue-79-reference.md#cve-reference-and-required-application-behavior).\n\n## Install the tools from crates.io\n\nInstall MockForge 0.3.238 or later, then export the bundled scripts into a new\ndirectory:\n\n```bash\ncargo install mockforge-cli --version 0.3.238 --locked\nmockforge tools export --output-dir mockforge-tools\ncd mockforge-tools\npython3 scripts/cve_pack.py --help\n```\n\nThe export includes the discussion exporter, CVE scripts, their Python modules,\nthe Next.js profile, and this guide. It needs Python 3.11 or later and no pip\npackages. `VERSION` records the CLI version that supplied the files. Export\ninto a new directory after upgrading; existing directories are never overwritten.\n\n## What it is and is not\n\nThis is a **defensive rule-regression harness**, not an exploit tool.\n\n- **Server leg** is a MockForge mock of the guarded route (`cve_pack.py server`).\n MockForge serves a fixed canned response; none of the vulnerable code runs,\n so a load balancer in front of it sees a realistic origin that is inherently\n inert.\n- **Client leg** sends the rule's **signature** — the header and query-parameter\n *names* the rule matches on — with **inert marker values**. The profile schema\n only accepts marker values matching `[A-Za-z0-9_.:-]`, so a profile cannot\n carry a command, a template expression, markup, a traversal or any payload,\n and there is no request-body field at all.\n- The harness sends only `GET`, one correlation token per request, no retries\n and no redirects, to a **loopback** target by default.\n\nIt never provisions a vulnerable application, executes a payload, or sends a\ncallback. Reports always mark `runtime_patch_evidence` as `unverified`.\n\n## The profile\n\nA profile (`examples/cve-packs/<cve-id>/profile.json`) names the route, the\nsignature tokens, benign controls, and the expected rule outcome:\n\n```json\n{\n \"schema_version\": 1,\n \"cve\": \"CVE-2025-29927\",\n \"title\": \"Next.js middleware authorization bypass via x-middleware-subrequest\",\n \"references\": [\"https://nextjs.org/blog/cve-2025-29927\"],\n \"route\": \"/dashboard\",\n \"signature\": {\n \"method\": \"GET\",\n \"query\": {},\n \"headers\": { \"x-middleware-subrequest\": \"middleware:middleware:middleware:middleware:middleware\" }\n },\n \"expect\": { \"blocked_status\": 403, \"origin\": \"absent\" },\n \"controls\": [\n { \"id\": \"benign\", \"query\": {}, \"headers\": {}, \"expect\": { \"status\": 200, \"origin\": \"seen\" } }\n ]\n}\n```\n\n- `signature.headers` / `signature.query` are name → marker. Use the **exact\n name** the rule keys on; keep the value an inert marker. The repeated\n `middleware:` token above is inert (letters and colons only) yet still matches\n a rule that looks for the chained value.\n- `expect.blocked_status` is the 4xx/5xx the rule returns; `origin: \"absent\"`\n asserts the request must **not** reach the origin.\n- Each control is benign traffic that **should** pass through (`200`, `seen`).\n\n## Workflow\n\n1. **Scaffold** a profile from the advisory:\n\n ```bash\n python3 scripts/cve_pack.py new CVE-2025-29927 --output CVE-2025-29927.profile.json\n ```\n\n Replace the placeholder title, reference URL, route, and signature using the\n vendor advisory. Set `expect.blocked_status` to your rule's response. Keep\n values as markers. A CVE ID alone does not supply any of these fields.\n\n2. **(Optional) run the realistic origin** for manual exploration:\n\n ```bash\n python3 scripts/cve_pack.py server CVE-2025-29927.profile.json > mock.json\n mockforge serve --spec mock.json --http-port 18080\n ```\n\n3. **Test your load balancer or WAF with `run`.** Configure the LB's upstream\n to `127.0.0.1:18082` on the machine running this command. `run` starts its own\n recorder there for the duration of the test. The recorder listens on loopback,\n so run the LB on the same host or provide a tunnel from your remote LB to\n that recorder. Preserve the correlation query parameter and header.\n\n Point `--target` at the LB's listener. The optional MockForge server on port\n 18080 is for manual exploration; it is not the recorder used by `run`:\n\n ```text\n run client -> LB listener :8080 -> run's recorder 127.0.0.1:18082\n ```\n\n ```bash\n python3 scripts/cve_pack.py run CVE-2025-29927.profile.json \\\n --target http://127.0.0.1:8080 --report report.json\n ```\n\n The harness sends each benign control and the signature request (flanked by a\n control beacon to corroborate origin reachability), then prints a verdict per\n case and writes `report.json`. It exits non-zero unless every case passes:\n the signature **blocked and not forwarded**, and every control **allowed and\n observed at the origin**.\n\n A non-loopback `--target` requires `--allow-external-target`, which affirms\n you are authorized to send this traffic to that exact host.\n\n## Reading the result\n\nFor each case the report gives `rule_behavior` (`pass`, `fail`, `inconclusive`),\nthe observed `status`, `origin_seen`, and the bounded `origin_observations`. The\nrun exits `0` only if all cases `pass`.\n\n- **`pass` on the signature** means the rule returned the expected block status\n and the request did not reach the origin.\n- **`fail`** means the status did not match, or a \"blocked\" response still\n forwarded the request to the origin.\n- **`inconclusive`** means the evidence could not establish blocking — a network\n error, missing correlation, or controls that could not corroborate origin\n reachability (for example an LB that denies everything).\n\nWhat the harness proves is scenario delivery and rule behavior at the transport\nlayer. It is not a load-balancer byte capture, and it is not proof of CVE\nprotection or remediation.\n\n## Remediation tips\n\nFor any CVE, `cve_pack.py tips <CVE>` aggregates actionable guidance from every\nfeed, deduplicated: the version to **upgrade to** (OSV per-package fixes, with\nNVD CPE as a fallback), whether it is **actively exploited** (CISA KEV) and\n**ransomware-linked**, its **EPSS** exploitation probability, the CWE classes,\nand the authoritative references.\n\n```bash\npython3 scripts/cve_pack.py tips CVE-2025-29927\n# \"advice\": \"EPSS 99%; upgrade to next 13.5.9, next 14.2.25, next 15.2.3\"\n```\n\nEach feed is queried independently. A connection reset or timeout produces\n`complete: false` and a `feed_errors` entry identifying the URL and error type.\nResults from other feeds remain in the JSON; the command exits `1` for partial\nresults and `0` when all reads succeed. Retry later or check network access to\nthe listed URLs. `--timeout 30` changes the per-request timeout.\n\nUnavailable KEV data yields `actively_exploited: null`, not `false`. No version\nin the returned data means no version was found; it does not mean no fix exists.\nCheck the vendor advisory for the version appropriate to your application.\nFeed lookups do not inspect your installed software or prove a WAF mitigates it.\n\n## Monitoring new CVEs\n\n`cve_pack_monitor.py` keeps the pack set current. It is **CISA KEV-driven** (the\nactively-exploited catalog), triages each entry to the CWE classes a\nrequest-facing rule can act on, enriches the most urgent ones with tips, and\nwrites a candidate pack per CVE. Every CVE is keyed once across all feeds, and\nCVEs you already have a profile for are skipped.\n\n```bash\npython3 scripts/cve_pack_monitor.py \\\n --out-dir candidate-packs \\\n --report monitor-report.json \\\n --known-dir examples/cve-packs \\\n --since 2026-10-01 --limit 25\n```\n\nEach candidate lands in `candidate-packs/<CVE>/` as a `profile.json` (signature\n`TODO`) plus a `tips.json`. It reads feeds only — it sends no traffic and opens\nno pull request. Review a candidate, fill the signature from the advisory, and\nrun it.\n\n### Automatic signature drafts\n\nPass `--drafter-cmd` to have a command propose each signature. The command\nreads a JSON context (CVE, title, references, CWEs, bounded advisory text) on\nstdin and prints a JSON proposal (`route`, `signature`, `expect`, `controls`).\nIt is provider-agnostic — wire any LLM CLI behind it — so there is no built-in\nmodel or cost.\n\n```bash\npython3 scripts/cve_pack_monitor.py --out-dir candidate-packs \\\n --drafter-cmd 'my-llm-drafter' --since 2026-10-01\n```\n\n**The schema is the trust boundary, not the drafter.** Advisory text is\nuntrusted input and a drafter may be an LLM, so every proposal is rebuilt with\nthe feed-authoritative CVE id, title and references and then validated. Because\na profile admits only inert marker values and has no request body, a\nprompt-injected or careless draft **cannot** produce a runnable payload: it is\nwritten as `profile.needs-review.json` with the reason and flagged, never run.\nAlways review a drafted signature before trusting it.\n\n## Choose `run` or `probe`\n\nUse `run` when you can route your LB to the recorder, and want evidence about\nwhich requests reached the origin. Use `probe` for a target you are authorized\nto test when you cannot replace or instrument its origin. `probe` requires no\nrecorder and compares the signature response with benign controls. It works\nwith your own site too; origin access is the distinction, not site ownership.\n\n```bash\npython3 scripts/cve_pack.py probe CVE-2025-29927.profile.json \\\n --target https://example.com --report probe.json --allow-external-target\n```\n\n- **blocked** — the signature got an access-denied status (401/403/406/429/451/501)\n while a benign control was allowed.\n- **not-blocked** — the signature passed (a 2xx/3xx).\n- **inconclusive** — no benign control was allowed (no baseline), or the\n signature got an ambiguous status such as 404/400/5xx. A missing route is\n never reported as a block.\n\n`probe` observes only the front end's response; it does not correlate origin\nreachability, so it is weaker evidence than `run`. Judge applicability with\n`tips` — a WAF that doesn't block a signature only matters if the CVE applies to\nwhat's behind it.\n\n## Limitations\n\n- **Name/presence signatures, not payload content.** A profile's values are\n inert markers, so the harness tests rules that key on a header/parameter\n **name or presence** (e.g. the Next.js `x-middleware-subrequest` header). It\n does **not** carry the literal payload of content-matching CVEs (Log4Shell\n `${jndi:…}`, reflected XSS, SQLi), so it cannot exercise a WAF rule that\n matches on that payload string — by design, the schema forbids payloads.\n- **`probe` is status-only.** It cannot tell a rule block from an origin that\n happens to deny; it is a quick front-end check, not proof. Use `run` with an\n owned origin for correlated evidence.\n- Neither mode proves a target is vulnerable or patched; that stays\n `unverified`. Use `tips` for the upgrade path.\n",
"book/src/user-guide/issue-79-reference.md": "# Issue #79: offline discussion and CVE traffic labs\n\nThis is a companion to [Srikanth's latest question in issue #79](https://github.com/SaaSy-Solutions/mockforge/issues/79#issuecomment-6367266823).\nIt covers saving the conversation, the requested CVEs, and what a MockForge\ntraffic test can establish. Advisory references were checked on **2026-10-24**.\n\n## Save every comment and search offline\n\nWith Python 3.11+ and MockForge 0.3.238 or later:\n\n```bash\ncargo install mockforge-cli --version 0.3.238 --locked\nmockforge tools export --output-dir mockforge-tools\ncd mockforge-tools\n```\n\nThe following command works in that directory or in a source checkout:\n\n```bash\npython3 scripts/export_github_issue.py --repo SaaSy-Solutions/mockforge \\\n --issue 79 --output-dir issue-79-export\n```\n\nThe command reads the issue body and **all comment pages**, including threads\nlonger than GitHub's first page. It produces:\n\n| File | Use |\n| --- | --- |\n| `discussion.html` | Open locally, search with Ctrl+F / Cmd+F, print and choose **Save as PDF**. |\n| `discussion.md` | Searchable text containing all bodies, code blocks and logs. |\n| `snapshot.json` | Original API records, author handles, creation/edit times and source URLs. |\n\nHTML displays bodies as escaped Markdown source. It keeps tables and long logs\ncomplete without running HTML or scripts from a comment, fetching images, or\nrequiring an internet connection to search. PDF creation is a browser action;\nthe exporter itself does not install a PDF converter. For an offline re-render:\n\n```bash\npython3 scripts/export_github_issue.py --input issue-79-export/snapshot.json \\\n --repo SaaSy-Solutions/mockforge --issue 79 --output-dir issue-79-another-copy\n```\n\nUse a new output directory each time. A fetch failure or inconsistent comment\ncount fails instead of silently creating a partial discussion. The export is a\npoint-in-time copy, not an atomic GitHub backup: later edits and deletions are\nnot recoverable. Reactions, timeline events, linked PR discussions, and binary\nattachment contents are outside the export; their original URLs remain in the\ncomment text. If `gh` is installed, the exporter uses its authentication. Otherwise,\nit reads the GitHub REST API directly, with no extra Python packages. Public\nissues need no token unless GitHub rate-limits the request. Set `GH_TOKEN` or\n`GITHUB_TOKEN` for authenticated REST access, or install\n[GitHub CLI](https://cli.github.com/) and run `gh auth login`. Keep exports of\nprivate repositories private.\n\n## CVE reference and required application behavior\n\n**Text4Shell is CVE-2022-42889.** The question says `CVE-2022-42289`; this guide\nuses Apache's Text4Shell identifier and does not treat the two IDs as aliases.\n\nMockForge is an HTTP client and mock server. A mock response of `200` or `403`\ndoes not reproduce memory corruption, a Java lookup, framework deserialization,\nfile placement, or an authorization decision in another runtime. These are the\napplication behaviors a genuine reproduction must exercise:\n\n| CVE / requested name | Mechanism and real client/server requirement | Primary reference |\n| --- | --- | --- |\n| **CVE-2026-111497 / Node2Shell** | Malformed Brotli input can overrun the bundled decoder. A Node application must actually decompress attacker-controlled bytes with the affected runtime. Test both directions when relevant: upload to a decoding server, or a compressed HTTP response consumed by a decoding client. A `Content-Encoding: br` header alone proves nothing. | [Node.js security release](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases) |\n| **CVE-2026-109618 / PHP2Shell** | PHP request initialization/cleanup can mishandle partly initialized data after an out-of-memory failure. Reproduction depends on the PHP build, SAPI, configuration and request processing; a large POST to a mock does not demonstrate it. | [PHP advisory, October 21](https://www.php.net/archive/2026.php), [PHP's technical advisory](https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w) |\n| **CVE-2026-109619 / PHP2Shell** | Request-owned state can outlive its freed memory and be reused by a later request in the same worker. Preserve and observe worker identity across the sequence. Independent requests to a stateless mock cannot reproduce the lifecycle. | [PHP technical advisory](https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp) |\n| **CVE-2021-44228 / Log4Shell** | Attacker-controlled text reaches vulnerable `log4j-core` lookup processing, potentially leading to remote code execution through JNDI. An HTTP sender needs an application that logs the chosen field; a lab-owned lookup observer can show the callback. Merely echoing the string is not lookup execution. | [Apache Logging security, CVE-2021-44228](https://logging.apache.org/security.html#CVE-2021-44228) |\n| **CVE-2022-26134 / Confluence** | Unauthenticated OGNL injection in Confluence Server/Data Center can cause code execution. The server must be the relevant Confluence application and version. A path containing expression syntax against a mock only checks delivery. | [Atlassian advisory](https://confluence.atlassian.com/doc/confluence-security-advisory-2022-06-02-1130377146.html) |\n| **CVE-2022-42889 / Text4Shell** | Commons Text interpolation can evaluate dangerous lookups when an application passes untrusted text to the affected interpolator. Test that application code path and its lookup configuration; using the library elsewhere is insufficient. | [Apache Commons Text security](https://commons.apache.org/proper/commons-text/security.html) |\n| **CVE-2025-55182 / React2Shell** | Unsafe decoding of React Server Function payloads in affected React Server Components packages permits unauthenticated code execution. Use a real RSC endpoint and matching framework build. A client-only React page or generic JSON POST is not a reproduction. | [React advisory](https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components) |\n| **CVE-2025-29927 / Next.js** | A forged internal `x-middleware-subrequest` header can skip middleware in affected self-hosted deployments. Compare unauthorized access to a protected route, with and without that header, in vulnerable and patched applications. This is an authorization bypass, not itself a general RCE. | [Next.js/Vercel postmortem](https://nextjs.org/blog/cve-2025-29927) |\n| **CVE-2023-50164 / Struts** | Manipulated upload parameters can enable path traversal and, under some conditions, code execution. A real upload action and observable file destination are needed. Use a harmless text marker to establish placement; an upload accepted by a mock establishes neither traversal nor execution. | [Apache Struts S2-066](https://cwiki.apache.org/confluence/display/WW/S2-066) |\n| **CVE-2025-27636 / Camel** | Improper incoming header filtering can allow mixed-case internal headers to influence route behavior. The advisory describes specific component/bean conditions. Use a real affected route and a harmless observable bean method, not a generic HTTP echo. | [Apache Camel advisory](https://camel.apache.org/security/CVE-2025-27636.html) |\n| **CVE-2025-29891 / Camel** | The related filtering issue can also enter through request parameters. Test header and query/form entry points separately and observe the actual affected component. This is not evidence that every Camel route is exploitable. | [Apache Camel advisory](https://camel.apache.org/security/CVE-2025-29891.html) |\n\nThe vendor advisories are the sources for these descriptions. No exploit code\nwas copied into this example. Follow the current vendor advisory for affected\nversions and patches; these older CVE-specific fixes are not a recommendation\nto deploy an old release. PHP's advisory also states that a WAF is not a complete\nsubstitute for updating the runtime.\n\n## Separate three kinds of evidence\n\n1. **Transport:** the intended method, URI, headers and body reached the receiver.\n2. **Load-balancer policy:** the configured rule matched or blocked the intended\n traffic, while ordinary controls still worked. Record the rule ID/action and\n correlate LB logs with receiver observations.\n3. **Application behavior:** an affected application exhibits the CVE behavior\n and a patched application does not under equivalent conditions. Use a harmless\n observable outcome, such as a canary method or temporary text-file placement.\n\nA request blocked with `403` establishes only the policy outcome when the LB log\nconfirms its source. A backend can return `403` too. A `200`, timeout, or process\ncrash alone establishes neither a successful exploit nor a working mitigation.\n\n## Runnable starter: transport canaries\n\nThe repository includes:\n\n- [`receiver.json`](https://github.com/SaaSy-Solutions/mockforge/blob/main/examples/cve-lab/receiver.json): an OpenAPI receiver with a fixed `mockforge-cve-lab` response.\n- [`traffic.yaml`](https://github.com/SaaSy-Solutions/mockforge/blob/main/examples/cve-lab/traffic.yaml): a normal control, a Next.js internal-header probe, and separate Camel header/parameter probes.\n\nThese four cases are **transport canaries**, not a full CVE PoC suite. The\nreceiver does not implement Next.js middleware or Camel bean dispatch. The\ncanary method name is `mockforgeCanary`; there are no shell commands, uploaded\nprograms, or external callbacks. They do not cover the other CVEs in the table.\n\nStart the receiver on a lab host. In the following local example the port is\n3000; place the LB virtual service in front of this receiver for the LB run.\n\n```bash\nMOCKFORGE_RESPONSE_TEMPLATE_EXPAND=false mockforge serve \\\n --spec examples/cve-lab/receiver.json --http-port 3000\n```\n\nInstall [k6](https://grafana.com/docs/k6/latest/set-up/install-k6/) on PATH, then\ncheck the generated script before running a short test. The CLI checks for k6\neven with `--generate-only`. `--wafbench-verbatim`\nis essential: without it the normal payload-pool mode can extract one value\nand inject it into unrelated spec endpoints, losing the request relationship.\nDo not add `--security-test` or generic fuzzing to this transport check.\n\n```bash\nmockforge bench --target http://127.0.0.1:3000 \\\n --wafbench-dir examples/cve-lab/traffic.yaml --wafbench-verbatim \\\n --wafbench-cycle-all --scenario constant --vus 1 --duration 4s \\\n --generate-only --output cve-generated\n\n# A real HTTP client sends to a real listening mock server.\nmockforge bench --target http://127.0.0.1:3000 \\\n --wafbench-dir examples/cve-lab/traffic.yaml --wafbench-verbatim \\\n --wafbench-cycle-all --scenario constant --vus 1 --duration 4s \\\n --output cve-direct\n```\n\nThe starter returns `200` for all four requests when sent directly to the mock.\nFor an LB run, point `--target` at the lab virtual service and confirm the\nspecific headers and query strings in LB logs or a lab packet capture.\n\n**Expected-status limitation:** the traffic file's `expected` field classifies\ncases in the traffic breakdown. In the current verbatim benchmark path, those\nstatuses are not carried into the generated request templates; the ordinary\nbenchmark still checks for a 2xx response and a nonempty body. Changing a probe\nto `expected: 403` does not make a 403 response pass, and an intentional LB block\ncan appear as a benchmark error. Use custom conformance checks with\n`expected_status: 403` (and a separate successful normal control), or independently\nassert the observed status and correlate it with the LB rule log. Do not use the\ntraffic-breakdown classification as a security-regression verdict. The CLI may also issue a\nmock-server discovery probe before the test; count it separately from the four cases.\n\n**Header-name casing matters for Camel.** In the tested k6 2.3.0 HTTP/1.1 run,\n`cAmelBeanMethodName` arrived as `Camelbeanmethodname`. That does not preserve the\nmixed-case prefix used to bypass the vulnerable filter. The query parameter's\nspelling was preserved. These canaries therefore establish header/value and\nquery delivery only, not a Camel mixed-case bypass. To test exact HTTP/1.1 header\nspelling, use a client that preserves it and verify the wire bytes; HTTP/2\nlowercases field names. Do not infer such coverage from the generated script.\n\nThe YAML does not enable or disable LB rules. `--wafbench-cycle-all` cycles cases; it does not assert that\neach case ran exactly once. Keep the case list small and confirm actual counts.\n\n## Real application lab flow\n\nFor Next.js, Camel, Confluence, Struts, PHP, Log4j and Commons Text:\n\n```text\nMockForge bench / protocol-appropriate client\n -> lab LB virtual service and security policy\n -> affected application OR patched application\n -> lab-owned observation (route result, worker log, canary, lookup event)\n```\n\nFor a Brotli response consumed by a Node client, reverse the relevant roles:\n\n```text\nreal Node client with decompression enabled\n -> lab LB / proxy\n -> controlled response server returning the exact compressed bytes\n```\n\nOpenAPI response examples and generic string payload injection are not a claim\nof arbitrary binary response replay. Preserve exact compressed bytes and their\nhashes with a protocol-appropriate fixture server when testing this direction.\nCapture both sides if the LB decompresses, recompresses, strips internal headers,\nchanges multipart boundaries or routes to different workers.\n\nFor each CVE, record the application version, configuration, route, request\nfixture hash, expected harmless outcome and patched control before interpreting\na result. Run direct-to-application first, then through the LB with the same\nfixture and a separate ordinary request. For PHP worker-state behavior, ordinary\nHTTP keep-alive does not guarantee the same PHP worker; observe the worker.\nFor Struts, verify actual file placement. For JNDI/interpolator behavior,\nobserve the lookup rather than inferring it from an HTTP status.\n\nThis starter does not provision vulnerable applications, generate malformed\nBrotli streams, reproduce PHP worker corruption or implement React Flight\nexploit payloads. Those require version-specific application harnesses and\nseparate validation. A load-balancer rule verdict for one canary must not be\nreported as coverage for all variants of a CVE. Use dedicated lab targets and\nbounded rates; do not expose deliberately vulnerable application fixtures as\npublic services.\n\nFor rate interpretation and `--repeat-until` pass boundaries, see\n[Load Testing](load-testing.md). The rate-label change from this conversation\nlanded in [PR #1380](https://github.com/SaaSy-Solutions/mockforge/pull/1380).\n",
"book/src/user-guide/security-rule-test-packs.md": "# Security Rule Test Packs (preview)\n\nTest a small set of security rules with safe, fixed HTTP canaries and similar\nbenign requests. The Python standard-library client sends serial GET requests through\nan explicitly supplied load balancer to a harmless recording origin. It reports\nthe expected and observed status, origin observations, and rule verdict per case.\nIt does not start Next.js, Camel, or any vulnerable application.\n\n## Run against an authorized load balancer\n\nUse Python 3.10 or newer. Configure your own load balancer to forward\n`/security-rule-probe` and its query string to `http://127.0.0.1:18081` on the\nmachine running the script. The script owns that loopback recording origin only\nfor the run. A remote load balancer needs a separately authorized forwarding path\nto this origin; the script does not create tunnels or discover hosts. Ensure the\nload balancer preserves both correlation tokens (query and header) and request path. Disable retries,\ncaching and asynchronous forwarding for this dedicated test route.\n\n```bash\npython3 scripts/security_rule_test_pack.py \\\n --pack examples/security-rule-packs/nextjs-camel.json \\\n --target http://127.0.0.1:18080 \\\n --report security-rule-report.json\n```\n\nThe load balancer must already be running; no policy is installed or modified.\nConfigure expected rejections as appropriate for your policy (the example uses\n403), then review the JSON pack. `status` is an exact assertion, not benchmark\nmetadata. The WAFBench verbatim path currently discards expected-status metadata\nand uses generic 2xx checks; this runner is independent of that path.\n\n`--allow-external-target` affirms authorization for the exact non-loopback target\nyou supply. Without it only loopback IP literals are accepted. Credentials, paths,\nqueries and fragments in target URLs are rejected. HTTPS uses certificate\nverification. There are no redirects, retries, callbacks, command payloads,\nruntime exploits, or automatic probes of discovered hosts. Only the five fixed\nsupported cases are sent, with one benign control before and after each: **15\nrequests maximum**, each with a default two-second socket timeout (maximum five), a\n4 KiB response read limit, and a default 0.1-second observation window (0.01–1).\nThe recorder returns fixed JSON, stores correlated headers/path in memory, and\nis closed on exit. Keep the dedicated route isolated; report headers are test\nobservations, so do not route real user traffic or credentials through it.\n\n## Cases and evidence\n\n| Case | Intended check | Default expectation |\n| --- | --- | --- |\n| normal-control | Ordinary GET | 200, origin seen |\n| nextjs-benign | Ordinary client note containing `middleware` | 200, origin seen |\n| nextjs-header | Synthetic `x-middleware-subrequest` marker | 403, origin absent within window |\n| camel-benign | Ordinary `beanMethodName` query parameter | 200, origin seen |\n| camel-parameter | Mixed-case `cAmelBeanMethodName=mockforgeCanary` query | 403, origin absent within window |\n| camel-header | Mixed-case internal header | Unsupported; no request sent |\n\nSources: [Next.js CVE-2025-29927 postmortem](https://nextjs.org/blog/cve-2025-29927),\n[Camel CVE-2025-27636](https://camel.apache.org/security/CVE-2025-27636.html),\n[Camel CVE-2025-29891](https://camel.apache.org/security/CVE-2025-29891.html).\nThese scenarios follow the safe transport canaries from draft PR1403. This\nfeature is independent of that PR and does not include its exporter or guide.\n\nThe known k6 header normalization prevents claiming mixed-case wire fidelity.\nThis first runner deliberately leaves the header case unsupported too; it does\nnot infer wire fidelity from a requested header spelling. Recorded receiver\nheader pairs are exposed for supported cases, but are not a capture of bytes at\nthe load balancer. Query observations check the exact path, and supported probe\nheaders must match at the origin when the case is allowed.\n\nThe JSON report separates `scenario_delivery`, `rule_behavior`, and\n`runtime_patch_evidence`. Every runtime/patch result is `unverified`. A pass\nmeans the explicit status and origin assertion agree during the stated window,\nwith a unique matching query/header correlation ID, an exact path/header check for observed traffic,\nand successful correlated benign controls immediately before and after the case.\nAn absent observation is only evidence within that window, under the forwarding\nassumptions above. It cannot exclude later delivery, arbitrary policy rewrites,\nor selective correlation removal. It does not prove request bytes reached the\nload balancer, application protection, remediation, authorization enforcement,\nbean invocation, or CVE exploitability. No runtime or patch is tested.\n\n`fail` identifies an observed assertion mismatch, including false positives on\nbenign traffic or a denial that still reaches the origin. Network errors,\nmissing expected observations, malformed correlation, and failed flanking\ncontrols produce `inconclusive` unless an explicit status mismatch is already\nobserved. They never prove blocking. `unsupported` is never a pass.\n\nExit codes: **0** only when every selected case passes, **1** for any failure,\ninconclusive or unsupported case, **2** for invalid configuration/startup/output\nerrors. Consequently the full example pack intentionally returns 1 even when\nits five supported cases pass. To gate only supported cases, copy the JSON pack\nand explicitly remove the `camel-header` entry; all five other cases are\nmandatory. The schema rejects unknown fields/IDs, duplicate IDs, invalid status\ntypes, and controls changed to expect rejection. This is a closed set of safe\nrequests, not a user-supplied payload or broad CVE catalog.\n\n## Local verification\n\n```bash\npython3 scripts/tests/test_security_rule_test_pack.py\nruff check scripts/security_rule*.py scripts/tests/test_security_rule_test_pack.py\nruff format --check scripts/security_rule*.py scripts/tests/test_security_rule_test_pack.py\n```\n\nThe tests own real loopback proxy/recorder servers and exercise the CLI and JSON\nreport. They do not validate a production load balancer, WAF implementation,\nNext.js/Camel runtime, TLS termination, or mixed-case header bytes. The dedicated\nCI workflow runs these offline tests without downloads or external targets.\n\nFactual description suitable for later marketing review: “Preview safe security\nrule canaries with benign controls, exact response assertions, and correlated\norigin observations.” Publication and claims of CVE protection require separate\nreview and application/runtime evidence.\n",
"examples/cve-lab/receiver.json": "{\n \"openapi\": \"3.0.3\",\n \"info\": {\"title\": \"CVE lab transport receiver\", \"version\": \"1.0.0\"},\n \"paths\": {\n \"/cve-lab/probe\": {\n \"get\": {\n \"operationId\": \"cveLabProbe\",\n \"responses\": {\n \"200\": {\n \"description\": \"Transport received; no vulnerable runtime behavior is simulated\",\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"type\": \"object\",\n \"required\": [\"receiver\"],\n \"properties\": {\"receiver\": {\"type\": \"string\", \"enum\": [\"mockforge-cve-lab\"]}}\n },\n \"example\": {\"receiver\": \"mockforge-cve-lab\"}\n }\n }\n }\n }\n }\n }\n }\n}\n",
"examples/cve-lab/traffic.yaml": "# Transport canaries only. Direct receiver expects 200 for every case.\n# expected is traffic metadata; use custom conformance for LB status assertions.\n# See the guide: verbatim benchmarks currently apply generic 2xx checks.\n- title: normal-control\n request:\n method: GET\n uri: /cve-lab/probe?case=normal-control\n headers:\n X-Mockforge-Lab-Case: normal-control\n expected: 200\n\n- title: nextjs-internal-header-canary\n request:\n method: GET\n uri: /cve-lab/probe?case=nextjs-header\n headers:\n X-Mockforge-Lab-Case: nextjs-header\n x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware\n expected: 200\n\n- title: camel-header-canary\n request:\n method: GET\n uri: /cve-lab/probe?case=camel-header\n headers:\n X-Mockforge-Lab-Case: camel-header\n cAmelBeanMethodName: mockforgeCanary\n expected: 200\n\n- title: camel-parameter-canary\n request:\n method: GET\n uri: /cve-lab/probe?case=camel-parameter&cAmelBeanMethodName=mockforgeCanary\n headers:\n X-Mockforge-Lab-Case: camel-parameter\n expected: 200\n",
"examples/cve-packs/CVE-2025-29927/profile.json": "{\n \"schema_version\": 1,\n \"cve\": \"CVE-2025-29927\",\n \"title\": \"Next.js middleware authorization bypass via x-middleware-subrequest\",\n \"references\": [\n \"https://nextjs.org/blog/cve-2025-29927\",\n \"https://github.com/advisories/GHSA-f82v-jwr5-mffw\"\n ],\n \"route\": \"/dashboard\",\n \"signature\": {\n \"method\": \"GET\",\n \"query\": {},\n \"headers\": {\n \"x-middleware-subrequest\": \"middleware:middleware:middleware:middleware:middleware\"\n }\n },\n \"expect\": { \"blocked_status\": 403, \"origin\": \"absent\" },\n \"controls\": [\n { \"id\": \"benign\", \"query\": {}, \"headers\": {}, \"expect\": { \"status\": 200, \"origin\": \"seen\" } },\n {\n \"id\": \"benign-note\",\n \"query\": {},\n \"headers\": { \"x-client-note\": \"middleware\" },\n \"expect\": { \"status\": 200, \"origin\": \"seen\" }\n }\n ]\n}\n",
"examples/security-rule-packs/nextjs-camel.json": "{\n \"schema_version\": 1,\n \"cases\": [\n {\n \"id\": \"normal-control\",\n \"status\": 200,\n \"origin\": \"seen\"\n },\n {\n \"id\": \"nextjs-benign\",\n \"status\": 200,\n \"origin\": \"seen\"\n },\n {\n \"id\": \"nextjs-header\",\n \"status\": 403,\n \"origin\": \"absent\"\n },\n {\n \"id\": \"camel-benign\",\n \"status\": 200,\n \"origin\": \"seen\"\n },\n {\n \"id\": \"camel-parameter\",\n \"status\": 403,\n \"origin\": \"absent\"\n },\n {\n \"id\": \"camel-header\",\n \"status\": 403,\n \"origin\": \"absent\"\n }\n ]\n}\n",
"scripts/cve_pack.py": "\"\"\"Per-CVE WAF/LB rule test from a declarative profile; Python 3.10+, stdlib.\n\nSubcommands:\n new <CVE-ID> write a profile skeleton to fill from the advisory\n server <profile> print a MockForge mock of the guarded route (stdout)\n run <profile> send the signature + benign controls through a supplied\n load balancer to an owned recorder and assert the rule\n blocked the signature and allowed the controls\n\nThe request set is bounded and inert: only GET, only the profile's marker\ntokens (see cve_pack_profile), a correlation token, no bodies, no retries, no\nredirects. It establishes rule behavior and bounded origin correlation, never\nthat a target is actually vulnerable or patched (that stays unverified).\n\"\"\"\n\nimport argparse\nimport http.client\nimport json\nimport sys\nimport uuid\nfrom functools import partial\nfrom pathlib import Path\n\nfrom cve_pack_evidence import observe, verdict\nfrom cve_pack_profile import SCHEMA_VERSION, cases, load\nfrom cve_pack_server import to_openapi\nfrom security_rule_pack_transport import recorder, request, target_url\n\nSKELETON = {\n \"schema_version\": SCHEMA_VERSION,\n \"cve\": \"CVE-0000-00000\",\n \"title\": \"One-line description of the guarded weakness\",\n \"references\": [\"https://vendor.example/advisory\"],\n \"route\": \"/the/guarded/route\",\n \"signature\": {\n \"method\": \"GET\",\n \"query\": {},\n \"headers\": {\"x-example-signature-header\": \"marker\"},\n },\n \"expect\": {\"blocked_status\": 403, \"origin\": \"absent\"},\n \"controls\": [\n {\n \"id\": \"benign\",\n \"query\": {},\n \"headers\": {},\n \"expect\": {\"status\": 200, \"origin\": \"seen\"},\n }\n ],\n}\n\n\ndef run(profile, url, observations, lock, timeout=2, window=0.1):\n all_cases = list(cases(profile))\n beacon_path, beacon_headers = None, None\n for cid, path, headers, expect, kind in all_cases:\n if kind == \"control\" and expect[\"origin\"] == \"seen\":\n beacon_path, beacon_headers = path, headers\n break\n run_id = uuid.uuid4().hex\n deliver = partial(\n observe,\n run_id=run_id,\n url=url,\n observations=observations,\n lock=lock,\n timeout=timeout,\n window=window,\n send=request,\n )\n results = []\n for cid, path, headers, expect, kind in all_cases:\n before = deliver(beacon_path, beacon_headers)\n status, error, records, valid, token = deliver(path, headers)\n after = deliver(beacon_path, beacon_headers)\n healthy = all(s == 200 and e is None and v for s, e, _, v, _ in (before, after))\n result = {\n \"id\": cid,\n \"kind\": kind,\n \"expected\": expect,\n \"correlation_id\": token,\n \"status\": status,\n \"origin_seen\": bool(records),\n \"scenario_delivery\": \"origin-observed\" if valid else \"unverified\",\n \"runtime_patch_evidence\": \"unverified\",\n \"origin_observations\": records,\n }\n result.update(verdict(expect, status, error, records, valid, healthy))\n results.append(result)\n return {\n \"schema_version\": SCHEMA_VERSION,\n \"cve\": profile[\"cve\"],\n \"run_id\": run_id,\n \"target\": url.geturl(),\n \"timeout_seconds\": timeout,\n \"observation_window_seconds\": window,\n \"request_count\": 3 * len(all_cases),\n \"scope\": \"synthetic transport/rule behavior only; no application protection or patch proof\",\n \"results\": results,\n }\n\n\ndef _run(args):\n profile = load(args.profile)\n url = target_url(args.target, args.allow_external_target)\n if not 1 <= args.origin_port <= 65535 or not 0 < args.timeout <= 5:\n raise ValueError(\"origin port must be 1..65535; timeout must be >0 and <=5\")\n if not 0.01 <= args.observation_window <= 1:\n raise ValueError(\"observation window must be 0.01..1 seconds\")\n with recorder(args.origin_port) as (_, observations, lock):\n report = run(\n profile, url, observations, lock, args.timeout, args.observation_window\n )\n Path(args.report).write_text(json.dumps(report, indent=2) + \"\\n\")\n for result in report[\"results\"]:\n print(\n f\"{result['id']} [{result['kind']}]: {result['rule_behavior']} ({result['reason']})\"\n )\n return 0 if all(r[\"rule_behavior\"] == \"pass\" for r in report[\"results\"]) else 1\n\n\n# Access-denied codes a WAF/CDN returns to block a request (vs 404/400/5xx,\n# which are ambiguous: a missing route or a generic error, not a rule block).\nDENY_CODES = {401, 403, 406, 429, 451, 501}\n\n\ndef _send(url, path, headers, timeout):\n try:\n return request(url, path, headers, timeout), None\n except (OSError, http.client.HTTPException) as exc:\n return None, type(exc).__name__\n\n\ndef probe(profile, url, timeout=5):\n \"\"\"Send the signature + controls straight at a site and judge the signature\n RELATIVE to the benign controls. Use when you do NOT control the origin\n (a CDN/WAF in front of a third party), so recorder correlation is\n impossible: this reports what the front end did, not origin reachability.\n\n 'blocked' means the signature got an access-denied status while a benign\n control was allowed. A 404/400/5xx is 'inconclusive', not a block, so a\n missing route is never mistaken for a rule firing.\"\"\"\n results = []\n control_ok = False\n for cid, path, headers, expect, kind in cases(profile):\n if kind != \"control\":\n continue\n status, error = _send(url, path, headers, timeout)\n allowed = status is not None and status < 400\n control_ok = control_ok or allowed\n results.append(\n {\n \"id\": cid,\n \"kind\": \"control\",\n \"status\": status,\n \"front_end\": \"allowed\" if allowed else \"unexpected\",\n \"reason\": error or f\"front end returned {status}\",\n }\n )\n sid, spath, sheaders, _, _ = next(c for c in cases(profile) if c[4] == \"signature\")\n status, error = _send(url, spath, sheaders, timeout)\n if error:\n verdict, reason = \"inconclusive\", error\n elif not control_ok:\n verdict, reason = (\n \"inconclusive\",\n \"no benign control was allowed; cannot baseline\",\n )\n elif status in DENY_CODES:\n verdict, reason = \"blocked\", f\"signature denied ({status}) while benign allowed\"\n elif status < 400:\n verdict, reason = \"not-blocked\", f\"signature passed ({status})\"\n else:\n verdict, reason = \"inconclusive\", f\"ambiguous status {status} (not a deny code)\"\n results.insert(\n 0,\n {\n \"id\": sid,\n \"kind\": \"signature\",\n \"status\": status,\n \"front_end\": verdict,\n \"reason\": reason,\n },\n )\n return {\n \"schema_version\": SCHEMA_VERSION,\n \"cve\": profile[\"cve\"],\n \"target\": url.geturl(),\n \"mode\": \"probe\",\n \"scope\": \"front-end response only; origin reachability not correlated, no patch proof\",\n \"results\": results,\n }\n\n\ndef _probe(args):\n profile = load(args.profile)\n url = target_url(args.target, args.allow_external_target)\n if not 0 < args.timeout <= 10:\n raise ValueError(\"timeout must be >0 and <=10\")\n report = probe(profile, url, args.timeout)\n Path(args.report).write_text(json.dumps(report, indent=2) + \"\\n\")\n for r in report[\"results\"]:\n print(f\"{r['id']} [{r['kind']}]: {r['front_end']} ({r['reason']})\")\n sig = next(r for r in report[\"results\"] if r[\"kind\"] == \"signature\")\n controls_ok = all(\n r[\"front_end\"] == \"allowed\" for r in report[\"results\"] if r[\"kind\"] == \"control\"\n )\n return 0 if sig[\"front_end\"] == \"blocked\" and controls_ok else 1\n\n\ndef _server(args):\n # Pass the raw profile; to_openapi validates it (load() would normalize first).\n print(json.dumps(to_openapi(json.loads(Path(args.profile).read_text())), indent=2))\n return 0\n\n\ndef _new(args):\n if not cve_ok(args.cve):\n raise ValueError(\"cve must look like CVE-YYYY-NNNN\")\n out = Path(args.output) if args.output else Path(f\"{args.cve}.profile.json\")\n if out.exists() and not args.force:\n raise ValueError(f\"{out} exists; pass --force to overwrite\")\n skeleton = dict(SKELETON, cve=args.cve)\n out.write_text(json.dumps(skeleton, indent=2) + \"\\n\")\n print(\n f\"Wrote unfinished template to {out}. No CVE lookup or simulation was performed. \"\n \"Replace the placeholder title, reference, route and signature using the advisory before server/run/probe.\"\n )\n return 0\n\n\ndef cve_ok(value):\n from cve_pack_profile import CVE_ID\n\n return bool(CVE_ID.match(value))\n\n\ndef _tips(args):\n # Lazy import so the core run/new/server path needs no feed access.\n from cve_pack_enrich import tips\n from cve_pack_feeds import Fetcher\n\n if not cve_ok(args.cve):\n raise ValueError(\"cve must look like CVE-YYYY-NNNN\")\n if not 0 < args.timeout <= 120:\n raise ValueError(\"timeout must be >0 and <=120\")\n report = tips(args.cve, fetcher=Fetcher(timeout=args.timeout))\n print(json.dumps(report, indent=2))\n if not report[\"complete\"]:\n print(\n \"Some feeds are unavailable; partial results are above. Retry later or check access to the URLs in feed_errors.\",\n file=sys.stderr,\n )\n return 0 if report[\"complete\"] else 1\n\n\ndef main(argv=None):\n parser = argparse.ArgumentParser(description=__doc__)\n sub = parser.add_subparsers(dest=\"cmd\", required=True)\n\n p_new = sub.add_parser(\n \"new\", help=\"write an unfinished template; does not look up or simulate a CVE\"\n )\n p_new.add_argument(\"cve\")\n p_new.add_argument(\"--output\")\n p_new.add_argument(\"--force\", action=\"store_true\")\n p_new.set_defaults(func=_new)\n\n p_srv = sub.add_parser(\"server\", help=\"print a MockForge mock of the route\")\n p_srv.add_argument(\"profile\")\n p_srv.set_defaults(func=_server)\n\n p_run = sub.add_parser(\"run\", help=\"test a supplied LB against the profile\")\n p_run.add_argument(\"profile\")\n p_run.add_argument(\"--target\", required=True, help=\"the load balancer origin\")\n p_run.add_argument(\"--report\", required=True)\n p_run.add_argument(\"--allow-external-target\", action=\"store_true\")\n p_run.add_argument(\"--origin-port\", type=int, default=18082)\n p_run.add_argument(\"--timeout\", type=float, default=2)\n p_run.add_argument(\"--observation-window\", type=float, default=0.1)\n p_run.set_defaults(func=_run)\n\n p_probe = sub.add_parser(\n \"probe\",\n help=\"send the signature at an authorized target without an origin recorder and \"\n \"report the front end's response; no origin correlation\",\n )\n p_probe.add_argument(\"profile\")\n p_probe.add_argument(\"--target\", required=True, help=\"the site/CDN/WAF URL\")\n p_probe.add_argument(\"--report\", required=True)\n p_probe.add_argument(\"--allow-external-target\", action=\"store_true\")\n p_probe.add_argument(\"--timeout\", type=float, default=5)\n p_probe.set_defaults(func=_probe)\n\n p_tips = sub.add_parser(\n \"tips\", help=\"print remediation tips for a CVE (upgrade target, urgency)\"\n )\n p_tips.add_argument(\"cve\")\n p_tips.add_argument(\"--timeout\", type=float, default=20)\n p_tips.set_defaults(func=_tips)\n\n args = parser.parse_args(argv)\n try:\n return args.func(args)\n except (ValueError, OSError) as exc:\n parser.error(str(exc))\n\n\nif __name__ == \"__main__\":\n raise SystemExit(main())\n",
"scripts/cve_pack_draft.py": "\"\"\"Draft a profile's rule signature automatically, with the schema as the\ntrust boundary.\n\nA drafter proposes the variable parts of a profile (route, signature tokens,\nexpected block status, controls) from a CVE's advisory. The advisory text is\nuntrusted input and a drafter may be an LLM, so nothing it returns is trusted:\nevery draft is rebuilt with the authoritative cve/title/references from the\nfeeds and then passed through cve_pack_profile.validate(). Because the schema\nadmits only inert marker values and has no body field, a draft can never yield\na runnable payload — a prompt-injected or careless draft is rejected and\nflagged for review, never silently accepted.\n\"\"\"\n\nimport json\nimport subprocess\n\nfrom cve_pack_profile import ProfileError, validate\n\nPROPOSAL_KEYS = {\"route\", \"signature\", \"expect\", \"controls\"}\nMAX_ADVISORY_CHARS = 20000\n\n\nclass StubDrafter:\n \"\"\"Returns a fixed proposal; for tests and offline demos.\"\"\"\n\n def __init__(self, proposal):\n self._proposal = proposal\n\n def draft(self, context):\n return dict(self._proposal)\n\n\nclass CommandDrafter:\n \"\"\"Runs a configured command, provider-agnostic. The command receives the\n context as JSON on stdin and must print a JSON proposal on stdout. No\n network, model or key is assumed here — wire any LLM CLI behind it.\"\"\"\n\n def __init__(self, command, timeout=120):\n self.command = command\n self.timeout = timeout\n\n def draft(self, context):\n result = subprocess.run(\n self.command,\n shell=True,\n input=json.dumps(context),\n capture_output=True,\n text=True,\n timeout=self.timeout,\n )\n if result.returncode != 0:\n raise RuntimeError(f\"drafter command failed: {result.stderr[:200]}\")\n return json.loads(result.stdout)\n\n\ndef context_for(cve, *, title, references, cwes, advisory_text):\n \"\"\"The untrusted-input bundle a drafter sees. Advisory text is bounded.\"\"\"\n return {\n \"cve\": cve,\n \"title\": title,\n \"references\": references,\n \"cwes\": cwes,\n \"advisory_text\": (advisory_text or \"\")[:MAX_ADVISORY_CHARS],\n \"instructions\": (\n \"Propose ONLY route, signature (method GET; header/query NAMES the \"\n \"rule matches, with short inert marker VALUES using [A-Za-z0-9_.:-]; \"\n \"no payloads, no request body), expect.blocked_status (4xx/5xx) with \"\n \"origin 'absent', and 1-3 benign controls expecting 200 and origin \"\n \"'seen'. Values must be inert markers, never working exploit strings.\"\n ),\n }\n\n\ndef draft_profile(cve, *, title, references, cwes, advisory_text, drafter):\n \"\"\"Return (profile, None) when the draft validates, else (None, reason).\n\n The feed-authoritative cve/title/references always win over the drafter's,\n so a drafter cannot fabricate identity or inject extra reference URLs.\"\"\"\n try:\n proposal = drafter.draft(\n context_for(\n cve,\n title=title,\n references=references,\n cwes=cwes,\n advisory_text=advisory_text,\n )\n )\n except (subprocess.SubprocessError, OSError, ValueError, RuntimeError) as exc:\n return None, f\"drafter error: {type(exc).__name__}: {exc}\"\n if not isinstance(proposal, dict) or not set(proposal) <= PROPOSAL_KEYS:\n return None, \"drafter must propose only route, signature, expect, controls\"\n candidate = {\n \"schema_version\": 1,\n \"cve\": cve,\n \"title\": title,\n \"references\": references,\n \"route\": proposal.get(\"route\"),\n \"signature\": proposal.get(\"signature\"),\n \"expect\": proposal.get(\"expect\"),\n \"controls\": proposal.get(\"controls\"),\n }\n try:\n return validate(candidate), None\n except ProfileError as exc:\n return None, f\"schema rejected the draft: {exc}\"\n",
"scripts/cve_pack_enrich.py": "\"\"\"Aggregate actionable 'tips' for a CVE across every feed, deduped.\n\nWhen the monitor flags a CVE, or `cve_pack run` finds a rule gap, these tips\ntell the user what to do: the version to upgrade to, how urgent it is (actively\nexploited, exploitation probability), and the authoritative remediation action.\nNothing here sends traffic; it only reads public feeds.\n\"\"\"\n\nimport http.client\nimport urllib.error\n\nfrom cve_pack_feeds import (\n epss,\n kev_catalog,\n nvd_cve,\n nvd_cwes,\n nvd_fixed_versions,\n osv_fixed_versions,\n)\n\n\ndef kev_index(fetcher):\n \"\"\"{cveID: entry} for fast membership, plus the catalog version/date.\"\"\"\n catalog = kev_catalog(fetcher)\n index = {v[\"cveID\"]: v for v in catalog[\"vulnerabilities\"] if v.get(\"cveID\")}\n return index, {\"version\": catalog[\"version\"], \"released\": catalog[\"released\"]}\n\n\ndef _dedupe(items, key):\n seen, out = set(), []\n for item in items:\n k = key(item)\n if k not in seen:\n seen.add(k)\n out.append(item)\n return out\n\n\ndef _kev_tip(entry):\n if not entry:\n return None\n notes = entry.get(\"notes\", \"\") or \"\"\n refs = [\n u.strip()\n for u in notes.replace(\",\", \";\").split(\";\")\n if u.strip().startswith(\"http\")\n ]\n return {\n \"listed\": True,\n \"date_added\": entry.get(\"dateAdded\"),\n \"required_action\": entry.get(\"requiredAction\"),\n \"due_date\": entry.get(\"dueDate\"),\n \"known_ransomware\": entry.get(\"knownRansomwareCampaignUse\") == \"Known\",\n \"references\": refs,\n }\n\n\nclass FeedResults:\n \"\"\"Record failed reads, including optional OSV aliases, without losing other feeds.\"\"\"\n\n def __init__(self, fetcher):\n self.fetcher = fetcher\n self.errors = []\n\n def get_json(self, url):\n try:\n return self.fetcher.get_json(url)\n except (OSError, ValueError, http.client.HTTPException) as error:\n # OSV uses 404 for an identifier absent from its catalog.\n if (\n isinstance(error, urllib.error.HTTPError)\n and error.code == 404\n and url.startswith(\"https://api.osv.dev/v1/vulns/\")\n ):\n return {}\n self.errors.append(\n {\n \"url\": url,\n \"error\": type(error).__name__,\n \"http_status\": getattr(error, \"code\", None),\n }\n )\n raise OSError(f\"Feed unavailable: {url}\") from error\n\n\ndef tips(cve, *, fetcher, kev=None):\n \"\"\"Aggregate tips for one CVE. `kev` is an optional (index, meta) from\n kev_index(); pass it when enriching many CVEs to avoid refetching KEV.\"\"\"\n fetcher = FeedResults(fetcher)\n if kev is None:\n try:\n kev = kev_index(fetcher)\n except (OSError, ValueError):\n kev = None\n kev_entry = (kev[0] if kev else {}).get(cve)\n kev_tip = _kev_tip(kev_entry)\n\n try:\n cve_obj = nvd_cve(fetcher, cve)\n except (OSError, ValueError):\n cve_obj = None\n cwes = nvd_cwes(cve_obj)\n osv_entries, osv_refs = osv_fixed_versions(fetcher, cve)\n osv_entries = _dedupe(\n osv_entries, key=lambda e: (e[\"ecosystem\"], e[\"package\"], e[\"fixed\"])\n )\n # OSV ecosystem fixes name the actual package; NVD CPE adds bundled-product\n # noise (firmware that ships the library), kept only as a capped fallback.\n cpe_entries = _dedupe(\n nvd_fixed_versions(cve_obj), key=lambda e: (e[\"package\"], e[\"fixed\"])\n )\n fixed = osv_entries + cpe_entries[:20]\n score, percentile = epss(fetcher, cve)\n\n references = _dedupe(\n [{\"url\": u} for u in osv_refs]\n + [\n {\"url\": r.get(\"url\")}\n for r in (cve_obj or {}).get(\"references\", [])\n if r.get(\"url\")\n ]\n + [{\"url\": u} for u in (kev_tip[\"references\"] if kev_tip else [])],\n key=lambda r: r[\"url\"],\n )\n\n return {\n \"cve\": cve,\n \"complete\": not fetcher.errors,\n \"feed_errors\": fetcher.errors,\n \"actively_exploited\": bool(kev_tip) if kev is not None else None,\n \"known_ransomware\": bool(kev_tip and kev_tip[\"known_ransomware\"])\n if kev is not None\n else None,\n \"epss\": None if score is None else {\"score\": score, \"percentile\": percentile},\n \"cwes\": cwes,\n \"fixed_versions\": fixed,\n \"kev\": kev_tip,\n \"references\": [r[\"url\"] for r in references][:15],\n # Prefer the named-package (OSV) fixes for the one-line advice; CPE\n # firmware fallback is kept in fixed_versions but not dumped here.\n \"advice\": _advice(kev_tip, osv_entries or cpe_entries, score),\n }\n\n\ndef _advice(kev_tip, fixed, score):\n bits = []\n if kev_tip:\n bits.append(\"actively exploited (CISA KEV)\")\n if kev_tip[\"known_ransomware\"]:\n bits.append(\"used in ransomware\")\n if kev_tip.get(\"due_date\"):\n bits.append(f\"CISA remediation due {kev_tip['due_date']}\")\n if score is not None:\n bits.append(f\"EPSS {score:.0%}\")\n if fixed:\n where = \", \".join(sorted({f\"{e['package']} {e['fixed']}\" for e in fixed})[:4])\n bits.append(f\"upgrade to {where}\")\n else:\n bits.append(\n \"no fixed version found in available feed data; consult the vendor advisory\"\n )\n return \"; \".join(bits)\n",
"scripts/cve_pack_evidence.py": "\"\"\"Send one profile case through the supplied load balancer and correlate it\nat the owned recorder. Reuses the vetted transport and classifier from the\nSecurity Rule Test Packs (#1404); the only generalization is that the case's\npath and headers come from a declarative profile instead of a fixed table.\n\"\"\"\n\nimport http.client\nimport time\nimport uuid\n\nfrom cve_pack_profile import CORRELATION_HEADER, CORRELATION_KEY\nfrom security_rule_pack_evidence import classify # reused unchanged\nfrom security_rule_pack_transport import request # bounded GET, no retries\n\n\ndef observe(\n path, headers, *, run_id, url, observations, lock, timeout, window, send=request\n):\n \"\"\"Send `path`+`headers` (plus a one-shot correlation token) through `url`\n and report (status, error, records, valid, token). `valid` means exactly\n one correlated observation arrived with the intended path and markers.\"\"\"\n token = run_id + \"-\" + uuid.uuid4().hex\n sep = \"&\" if \"?\" in path else \"?\"\n sent_path = path + sep + CORRELATION_KEY + \"=\" + token\n sent_headers = {**headers, CORRELATION_HEADER: token}\n try:\n status = send(url, sent_path, sent_headers, timeout)\n error = None\n except (OSError, http.client.HTTPException) as exc:\n status, error = None, type(exc).__name__\n time.sleep(window)\n with lock:\n records = list(observations.get(token, []))\n valid = len(records) == 1 and records[0][\"path\"] == sent_path\n if valid:\n valid = all(\n [v for k, v in records[0][\"headers\"] if k.lower() == name.lower()]\n == [value]\n for name, value in sent_headers.items()\n )\n return status, error, records, valid, token\n\n\ndef verdict(expect, status, error, records, valid, healthy):\n \"\"\"Thin adapter: classify() expects a case dict of {status, origin}.\"\"\"\n return classify(\n {\"status\": expect[\"status\"], \"origin\": expect[\"origin\"]},\n status,\n error,\n records,\n valid,\n healthy,\n )\n",
"scripts/cve_pack_feeds.py": "\"\"\"Read-only clients for the public vulnerability feeds the monitor uses.\n\nAll network access goes through a Fetcher, so tests inject a fixed mapping and\nnever touch the network. Live use is GET-only, bounded, with a short timeout.\n\nFeeds (deduped by the enrich layer):\n- CISA KEV the actively-exploited catalog (primary trigger)\n- NVD API 2.0 CWE weaknesses and affected/fixed version ranges (CPE)\n- OSV per-package 'fixed in' version, following GHSA aliases\n- EPSS (FIRST) probability a CVE will be exploited (prioritization)\n\"\"\"\n\nimport json\nimport urllib.request\n\nKEV_URL = \"https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json\"\nNVD_URL = \"https://services.nvd.nist.gov/rest/json/cves/2.0\"\nOSV_VULN_URL = \"https://api.osv.dev/v1/vulns\"\nEPSS_URL = \"https://api.first.org/data/v1/epss\"\nUSER_AGENT = \"mockforge-cve-pack-monitor (+https://mockforge.dev)\"\nMAX_BYTES = 8 * 1024 * 1024\n\n\nclass Fetcher:\n \"\"\"Bounded GET -> parsed JSON. Pass `responses` (a {url_prefix: object}\n mapping, values may be Exceptions) to stub the network in tests.\"\"\"\n\n def __init__(self, timeout=15, responses=None):\n self.timeout = timeout\n self.responses = responses\n\n def get_json(self, url):\n if self.responses is not None:\n for prefix, value in self.responses.items():\n if url.startswith(prefix):\n if isinstance(value, Exception):\n raise value\n return value\n raise KeyError(f\"no stubbed response for {url}\")\n request = urllib.request.Request(url, headers={\"User-Agent\": USER_AGENT})\n with urllib.request.urlopen(request, timeout=self.timeout) as response:\n return json.loads(response.read(MAX_BYTES))\n\n\ndef kev_catalog(fetcher):\n \"\"\"The KEV catalog as {version, released, vulnerabilities: [...]}.\"\"\"\n doc = fetcher.get_json(KEV_URL)\n return {\n \"version\": doc.get(\"catalogVersion\"),\n \"released\": doc.get(\"dateReleased\"),\n \"vulnerabilities\": doc.get(\"vulnerabilities\", []),\n }\n\n\ndef nvd_cve(fetcher, cve):\n \"\"\"The NVD `cve` object for one id, or None.\"\"\"\n doc = fetcher.get_json(f\"{NVD_URL}?cveId={cve}\")\n items = doc.get(\"vulnerabilities\") or []\n return items[0][\"cve\"] if items else None\n\n\ndef nvd_cwes(cve_obj):\n out = []\n for weakness in (cve_obj or {}).get(\"weaknesses\", []):\n for desc in weakness.get(\"description\", []):\n value = desc.get(\"value\", \"\")\n if value.startswith(\"CWE-\") and value not in out:\n out.append(value)\n return out\n\n\ndef nvd_fixed_versions(cve_obj):\n \"\"\"Fixed versions implied by CPE `versionEndExcluding` across configs.\"\"\"\n out = []\n for config in (cve_obj or {}).get(\"configurations\", []):\n for node in config.get(\"nodes\", []):\n for match in node.get(\"cpeMatch\", []):\n end = match.get(\"versionEndExcluding\")\n if not end:\n continue\n parts = match.get(\"criteria\", \"\").split(\":\")\n product = parts[4] if len(parts) > 4 else \"?\"\n out.append({\"ecosystem\": \"cpe\", \"package\": product, \"fixed\": end})\n return out\n\n\ndef _affected_fixed(vuln):\n out = []\n for affected in (vuln or {}).get(\"affected\", []):\n pkg = affected.get(\"package\", {})\n for rng in affected.get(\"ranges\", []):\n # Skip GIT ranges: their 'fixed' is a commit hash, not a version.\n if rng.get(\"type\") == \"GIT\":\n continue\n for event in rng.get(\"events\", []):\n if \"fixed\" in event and pkg.get(\"name\"):\n out.append(\n {\n \"ecosystem\": pkg.get(\"ecosystem\", \"?\"),\n \"package\": pkg[\"name\"],\n \"fixed\": event[\"fixed\"],\n }\n )\n return out\n\n\ndef osv_fixed_versions(fetcher, cve):\n \"\"\"Per-package 'fixed in' versions from OSV, following GHSA aliases.\n\n Returns (entries, references). The CVE record and each aliased GHSA record\n are merged; the enrich layer dedupes against the other feeds.\"\"\"\n try:\n root = fetcher.get_json(f\"{OSV_VULN_URL}/{cve}\")\n except (OSError, KeyError, ValueError):\n return [], []\n entries = _affected_fixed(root)\n references = [r.get(\"url\") for r in root.get(\"references\", []) if r.get(\"url\")]\n for alias in root.get(\"aliases\", []):\n if not alias.startswith(\"GHSA-\"):\n continue\n try:\n record = fetcher.get_json(f\"{OSV_VULN_URL}/{alias}\")\n except (OSError, KeyError, ValueError):\n continue\n entries.extend(_affected_fixed(record))\n references.extend(\n r.get(\"url\") for r in record.get(\"references\", []) if r.get(\"url\")\n )\n return entries, references\n\n\ndef epss(fetcher, cve):\n \"\"\"(score, percentile) floats for a CVE, or (None, None).\"\"\"\n try:\n doc = fetcher.get_json(f\"{EPSS_URL}?cve={cve}\")\n except (OSError, KeyError, ValueError):\n return None, None\n data = doc.get(\"data\") or []\n if not data:\n return None, None\n try:\n return float(data[0][\"epss\"]), float(data[0][\"percentile\"])\n except (KeyError, ValueError, TypeError):\n return None, None\n",
"scripts/cve_pack_monitor.py": "\"\"\"Monitor public vulnerability feeds and emit candidate CVE rule packs.\n\nPrimary trigger is CISA KEV (actively exploited). Each candidate is enriched\nwith tips from NVD, OSV and EPSS (deduped), triaged to web-/WAF-relevant CWEs,\nand — when a drafter command is configured — given a schema-validated signature\ndraft. Without a drafter it emits an enriched stub to fill. CVEs already covered\nby a profile are skipped, and every CVE is keyed once across all sources.\n\nThe monitor only reads feeds and writes candidate files; it sends no traffic and\nopens no pull request. Review a candidate, then run it with cve_pack.py.\n\"\"\"\n\nimport argparse\nimport datetime\nimport json\nfrom pathlib import Path\n\nfrom cve_pack_draft import CommandDrafter, draft_profile\nfrom cve_pack_enrich import kev_index, tips\nfrom cve_pack_feeds import Fetcher\nfrom cve_pack_profile import SCHEMA_VERSION\nfrom cve_pack_triage import rank, web_relevant\n\nSKELETON_SIGNATURE = {\n \"method\": \"GET\",\n \"query\": {},\n \"headers\": {\"x-REPLACE-with-the-rule-signature-header\": \"marker\"},\n}\n\n\ndef existing_cves(dirs):\n \"\"\"CVE ids already covered by a profile.json under any of `dirs`.\"\"\"\n covered = set()\n for d in dirs:\n for path in Path(d).rglob(\"profile.json\"):\n try:\n covered.add(json.loads(path.read_text())[\"cve\"])\n except (OSError, ValueError, KeyError):\n continue\n return covered\n\n\ndef _advisory_text(kev_entry):\n parts = [\n kev_entry.get(\"vulnerabilityName\", \"\"),\n kev_entry.get(\"shortDescription\", \"\"),\n kev_entry.get(\"requiredAction\", \"\"),\n ]\n return \"\\n\".join(p for p in parts if p)\n\n\ndef _stub(cve, title, references):\n return {\n \"schema_version\": SCHEMA_VERSION,\n \"cve\": cve,\n \"title\": title[:200] or cve,\n \"references\": references[:10] or [\"https://nvd.nist.gov/vuln/detail/\" + cve],\n \"route\": \"/REPLACE/with/the/guarded/route\",\n \"signature\": dict(SKELETON_SIGNATURE),\n \"expect\": {\"blocked_status\": 403, \"origin\": \"absent\"},\n \"controls\": [\n {\n \"id\": \"benign\",\n \"query\": {},\n \"headers\": {},\n \"expect\": {\"status\": 200, \"origin\": \"seen\"},\n }\n ],\n }\n\n\ndef candidates(fetcher, *, since=None, limit=50, drafter=None, known=()):\n \"\"\"Enriched candidate dicts for KEV CVEs, most-urgent first.\n\n Triage and ordering use the cheap per-entry KEV fields (its own `cwes`, the\n ransomware flag, dateAdded), so only the top `limit` web-relevant CVEs are\n enriched with per-CVE NVD/OSV/EPSS calls. Entries without KEV CWEs are not\n triaged in and would need a hand-authored profile.\"\"\"\n index, meta = kev_index(fetcher)\n covered = set(known)\n pool = []\n for cve, entry in index.items():\n if cve in covered:\n continue\n if since and (entry.get(\"dateAdded\") or \"\") < since:\n continue\n if not web_relevant(entry.get(\"cwes\")):\n continue\n pool.append((cve, entry))\n # Cheap ordering: ransomware-linked first, then most recently added.\n pool.sort(\n key=lambda r: (\n r[1].get(\"knownRansomwareCampaignUse\") == \"Known\",\n r[1].get(\"dateAdded\", \"\"),\n ),\n reverse=True,\n )\n\n out = []\n for cve, entry in pool[:limit]:\n t = tips(cve, fetcher=fetcher, kev=(index, meta))\n title = entry.get(\"vulnerabilityName\") or entry.get(\"shortDescription\") or cve\n references = t[\"references\"]\n candidate = {\n \"cve\": cve,\n \"title\": title[:200],\n \"rank\": round(rank(t), 3),\n \"tips\": t,\n \"status\": \"stub\",\n \"reason\": None,\n \"profile\": _stub(cve, title, references),\n }\n if drafter is not None:\n profile, reason = draft_profile(\n cve,\n title=title[:200],\n references=references[:10]\n or [\"https://nvd.nist.gov/vuln/detail/\" + cve],\n cwes=t[\"cwes\"],\n advisory_text=_advisory_text(entry),\n drafter=drafter,\n )\n if profile is not None:\n candidate.update(status=\"drafted\", profile=profile)\n else:\n candidate.update(status=\"needs-review\", reason=reason)\n out.append(candidate)\n if len(out) >= limit:\n break\n return {\"kev_version\": meta[\"version\"], \"generated\": _now(), \"candidates\": out}\n\n\ndef _now():\n return datetime.datetime.now(datetime.timezone.utc).strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n\n\ndef write_candidates(result, out_dir):\n \"\"\"Write each candidate's profile and tips under out_dir/<cve>/.\"\"\"\n base = Path(out_dir)\n for c in result[\"candidates\"]:\n d = base / c[\"cve\"]\n d.mkdir(parents=True, exist_ok=True)\n name = (\n \"profile.json\"\n if c[\"status\"] in (\"drafted\", \"stub\")\n else \"profile.needs-review.json\"\n )\n (d / name).write_text(json.dumps(c[\"profile\"], indent=2) + \"\\n\")\n (d / \"tips.json\").write_text(json.dumps(c[\"tips\"], indent=2) + \"\\n\")\n\n\ndef main(argv=None):\n parser = argparse.ArgumentParser(description=__doc__)\n parser.add_argument(\n \"--out-dir\", required=True, help=\"where candidate packs are written\"\n )\n parser.add_argument(\"--report\", help=\"write the run report JSON here\")\n parser.add_argument(\"--since\", help=\"only KEV entries dateAdded >= YYYY-MM-DD\")\n parser.add_argument(\"--limit\", type=int, default=50)\n parser.add_argument(\n \"--known-dir\",\n action=\"append\",\n default=[],\n help=\"profiles already covered (repeatable); these CVEs are skipped\",\n )\n parser.add_argument(\n \"--drafter-cmd\",\n help=\"command that reads a JSON context on stdin and prints a JSON \"\n \"signature proposal; its output is schema-validated before use\",\n )\n parser.add_argument(\"--timeout\", type=float, default=20)\n args = parser.parse_args(argv)\n try:\n if not 0 < args.timeout <= 60 or not 1 <= args.limit <= 500:\n raise ValueError(\"timeout must be 0..60s; limit must be 1..500\")\n fetcher = Fetcher(timeout=args.timeout)\n drafter = CommandDrafter(args.drafter_cmd) if args.drafter_cmd else None\n known = existing_cves(args.known_dir) if args.known_dir else set()\n result = candidates(\n fetcher, since=args.since, limit=args.limit, drafter=drafter, known=known\n )\n write_candidates(result, args.out_dir)\n if args.report:\n summary = {\n \"kev_version\": result[\"kev_version\"],\n \"generated\": result[\"generated\"],\n \"candidates\": [\n {k: c[k] for k in (\"cve\", \"title\", \"rank\", \"status\", \"reason\")}\n | {\"advice\": c[\"tips\"][\"advice\"]}\n for c in result[\"candidates\"]\n ],\n }\n Path(args.report).write_text(json.dumps(summary, indent=2) + \"\\n\")\n except (ValueError, OSError) as exc:\n parser.error(str(exc))\n for c in result[\"candidates\"]:\n print(f\"{c['cve']} [{c['status']}] rank={c['rank']}: {c['tips']['advice']}\")\n return 0\n\n\nif __name__ == \"__main__\":\n raise SystemExit(main())\n",
"scripts/cve_pack_profile.py": "\"\"\"Declarative CVE rule-test profile: strict schema that admits only inert\nmarkers, so a profile can describe a WAF/LB rule's signature but can never\ncarry a working payload.\n\nA profile names the route a rule guards and the signature tokens the rule\nmatches on (header and query-parameter *names*, with harmless marker\n*values*), plus benign controls and the expected rule outcome. It is consumed\nby cve_pack.py to drive the same canary -> supplied-LB -> owned-recorder flow\nthat security_rule_test_pack.py uses, and by cve_pack_server.py to emit a\nMockForge mock of the guarded route.\n\nSafety is structural, not advisory:\n- values are constrained to `[A-Za-z0-9_.:-]`, so they cannot contain spaces,\n quotes, slashes, angle brackets, `;`, `|`, `&`, `$`, `{`, `}`, `(`, `)`,\n `=`, `%` or backslashes. Command/template/markup/traversal payloads cannot be\n expressed.\n- there is no request-body field anywhere; the schema rejects unknown keys.\n- only GET is allowed, mirroring the bounded transport.\n\"\"\"\n\nimport json\nimport re\nfrom pathlib import Path\n\nSCHEMA_VERSION = 1\nCVE_ID = re.compile(r\"^CVE-\\d{4}-\\d{4,}$\")\nROUTE = re.compile(r\"^/[A-Za-z0-9/_.\\-]{0,200}$\")\nHEADER_NAME = re.compile(r\"^[A-Za-z0-9\\-]{1,64}$\")\nQUERY_KEY = re.compile(r\"^[A-Za-z0-9_.\\-]{1,64}$\")\nCONTROL_ID = re.compile(r\"^[a-z0-9][a-z0-9\\-]{0,39}$\")\n# The one structural guarantee: marker values are inert tokens, never payloads.\nMARKER = re.compile(r\"^[A-Za-z0-9_.:\\-]{1,128}$\")\nMAX_CONTROLS = 5\nMAX_TOKENS = 8\nCORRELATION_KEY = \"__mockforge_correlation\"\nCORRELATION_HEADER = \"X-Mockforge-Correlation\"\n\n\nclass ProfileError(ValueError):\n \"\"\"A profile that is malformed or would carry anything but inert markers.\"\"\"\n\n\ndef _tokens(value, kind, keyre):\n if not isinstance(value, dict):\n raise ProfileError(f\"{kind} must be an object of name -> marker value\")\n if len(value) > MAX_TOKENS:\n raise ProfileError(f\"{kind} allows at most {MAX_TOKENS} entries\")\n out = {}\n for key, marker in value.items():\n if not isinstance(key, str) or not keyre.match(key):\n raise ProfileError(f\"{kind} name {key!r} is not an allowed identifier\")\n if key.lower() in (CORRELATION_KEY, CORRELATION_HEADER.lower()):\n raise ProfileError(f\"{kind} name {key!r} is reserved for correlation\")\n if not isinstance(marker, str) or not MARKER.match(marker):\n raise ProfileError(\n f\"{kind} value for {key!r} must be an inert marker matching \"\n f\"[A-Za-z0-9_.:-]{{1,128}} (no payloads, spaces or metacharacters)\"\n )\n out[key] = marker\n return out\n\n\ndef _signature(sig):\n if not isinstance(sig, dict) or set(sig) - {\"method\", \"query\", \"headers\"}:\n raise ProfileError(\"signature allows only method, query and headers\")\n if sig.get(\"method\", \"GET\") != \"GET\":\n raise ProfileError(\"only GET signatures are supported\")\n query = _tokens(sig.get(\"query\", {}), \"signature.query\", QUERY_KEY)\n headers = _tokens(sig.get(\"headers\", {}), \"signature.headers\", HEADER_NAME)\n if not query and not headers:\n raise ProfileError(\"signature needs at least one query or header token\")\n return {\"method\": \"GET\", \"query\": query, \"headers\": headers}\n\n\ndef _expect(expect, *, blocked):\n allowed = {\"blocked_status\", \"origin\"} if blocked else {\"status\", \"origin\"}\n if not isinstance(expect, dict) or set(expect) != allowed:\n raise ProfileError(f\"expect requires exactly {sorted(allowed)}\")\n status = expect[\"blocked_status\"] if blocked else expect[\"status\"]\n if type(status) is not int:\n raise ProfileError(\"expect status must be an integer\")\n if blocked:\n if not 400 <= status <= 599:\n raise ProfileError(\"a blocked_status must be 400..599\")\n if expect[\"origin\"] != \"absent\":\n raise ProfileError(\"a blocked signature must expect origin 'absent'\")\n else:\n if not 200 <= status <= 399:\n raise ProfileError(\"a control status must be 200..399\")\n if expect[\"origin\"] != \"seen\":\n raise ProfileError(\"a benign control must expect origin 'seen'\")\n return {\"status\": status, \"origin\": expect[\"origin\"]}\n\n\ndef _control(control):\n if not isinstance(control, dict) or set(control) - {\n \"id\",\n \"query\",\n \"headers\",\n \"expect\",\n }:\n raise ProfileError(\"control allows only id, query, headers and expect\")\n cid = control.get(\"id\")\n if not isinstance(cid, str) or not CONTROL_ID.match(cid):\n raise ProfileError(\"control id must be a short lower-case slug\")\n return {\n \"id\": cid,\n \"query\": _tokens(control.get(\"query\", {}), \"control.query\", QUERY_KEY),\n \"headers\": _tokens(control.get(\"headers\", {}), \"control.headers\", HEADER_NAME),\n \"expect\": _expect(control.get(\"expect\"), blocked=False),\n }\n\n\ndef validate(profile):\n \"\"\"Return a normalized profile or raise ProfileError. Pure, no I/O.\"\"\"\n if not isinstance(profile, dict):\n raise ProfileError(\"profile must be a JSON object\")\n allowed = {\n \"schema_version\",\n \"cve\",\n \"title\",\n \"references\",\n \"route\",\n \"signature\",\n \"expect\",\n \"controls\",\n }\n if set(profile) != allowed:\n raise ProfileError(f\"profile requires exactly {sorted(allowed)}\")\n if profile[\"schema_version\"] != SCHEMA_VERSION:\n raise ProfileError(\"unsupported schema_version\")\n if not isinstance(profile[\"cve\"], str) or not CVE_ID.match(profile[\"cve\"]):\n raise ProfileError(\"cve must look like CVE-YYYY-NNNN\")\n if not isinstance(profile[\"title\"], str) or not 1 <= len(profile[\"title\"]) <= 200:\n raise ProfileError(\"title must be 1..200 characters\")\n refs = profile[\"references\"]\n if not isinstance(refs, list) or not 1 <= len(refs) <= 10:\n raise ProfileError(\"references must list 1..10 vendor/advisory URLs\")\n for ref in refs:\n if not isinstance(ref, str) or not ref.startswith(\"https://\"):\n raise ProfileError(\"each reference must be an https URL\")\n if not isinstance(profile[\"route\"], str) or not ROUTE.match(profile[\"route\"]):\n raise ProfileError(\"route must be an absolute path with no query or '..'\")\n if \"..\" in profile[\"route\"]:\n raise ProfileError(\"route must not contain '..'\")\n signature = _signature(profile[\"signature\"])\n if (\n profile[\"title\"] == \"One-line description of the guarded weakness\"\n or \"https://vendor.example/advisory\" in refs\n or profile[\"route\"] == \"/the/guarded/route\"\n or \"x-example-signature-header\" in signature[\"headers\"]\n ):\n raise ProfileError(\n \"unfinished template: replace title, references, route and signature from the advisory; new does not simulate a CVE\"\n )\n controls = profile[\"controls\"]\n if not isinstance(controls, list) or not 1 <= len(controls) <= MAX_CONTROLS:\n raise ProfileError(f\"controls must list 1..{MAX_CONTROLS} benign cases\")\n seen = set()\n norm_controls = []\n for control in controls:\n c = _control(control)\n if c[\"id\"] in seen:\n raise ProfileError(\"duplicate control id\")\n seen.add(c[\"id\"])\n norm_controls.append(c)\n return {\n \"schema_version\": SCHEMA_VERSION,\n \"cve\": profile[\"cve\"],\n \"title\": profile[\"title\"],\n \"references\": list(refs),\n \"route\": profile[\"route\"],\n \"signature\": signature,\n \"expect\": _expect(profile[\"expect\"], blocked=True),\n \"controls\": norm_controls,\n }\n\n\ndef load(path):\n return validate(json.loads(Path(path).read_text()))\n\n\ndef _case_path(route, query):\n if not query:\n return route\n pairs = \"&\".join(f\"{k}={v}\" for k, v in query.items())\n return route + (\"&\" if \"?\" in route else \"?\") + pairs\n\n\ndef cases(profile):\n \"\"\"Yield (id, path, headers, expect, kind) for the signature then controls.\n\n kind is 'signature' or 'control'. Paths and headers hold only the profile's\n inert markers; cve_pack.py adds the correlation token at send time.\n \"\"\"\n sig = profile[\"signature\"]\n yield (\n profile[\"cve\"],\n _case_path(profile[\"route\"], sig[\"query\"]),\n dict(sig[\"headers\"]),\n profile[\"expect\"],\n \"signature\",\n )\n for control in profile[\"controls\"]:\n yield (\n control[\"id\"],\n _case_path(profile[\"route\"], control[\"query\"]),\n dict(control[\"headers\"]),\n control[\"expect\"],\n \"control\",\n )\n",
"scripts/cve_pack_server.py": "\"\"\"Turn a CVE profile into a MockForge mock of the guarded route.\n\nThe mock makes `mockforge serve` answer the profile's route with a small,\nfixed 200 body, so a load balancer in front of it sees a target that *looks*\nlike the guarded application. MockForge serves canned responses only: none of\nthe vulnerable code runs, and the spec carries no payload. This is a\nconvenience for manually exploring a rule against a realistic origin; the\nautomated `cve_pack.py run` uses the owned correlation recorder instead.\n\"\"\"\n\nfrom cve_pack_profile import validate\n\n\ndef to_openapi(profile):\n \"\"\"Return an OpenAPI 3.0.3 mock document for the profile's route.\"\"\"\n profile = validate(profile)\n body = {\"receiver\": \"mockforge-cve-pack\", \"cve\": profile[\"cve\"]}\n return {\n \"openapi\": \"3.0.3\",\n \"info\": {\n \"title\": f\"{profile['cve']} rule-test mock\",\n \"version\": \"1.0.0\",\n \"description\": (\n \"Mocks the guarded route so a load balancer sees a realistic \"\n \"origin. Canned response only; no vulnerable behavior is run.\"\n ),\n },\n \"paths\": {\n profile[\"route\"]: {\n \"get\": {\n \"operationId\": \"cvePackGuardedRoute\",\n \"responses\": {\n \"200\": {\n \"description\": \"Mock origin reached; no exploit simulated\",\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"type\": \"object\",\n \"required\": [\"receiver\", \"cve\"],\n \"properties\": {\n \"receiver\": {\"type\": \"string\"},\n \"cve\": {\"type\": \"string\"},\n },\n },\n \"example\": body,\n }\n },\n }\n },\n }\n }\n },\n }\n",
"scripts/cve_pack_triage.py": "\"\"\"Decide which CVEs a load-balancer / WAF rule can act on, and rank them.\n\nA per-CVE rule pack only makes sense for weaknesses that show up in the HTTP\nrequest a front end sees: injection, traversal, SSRF, auth/access bypass,\ndeserialization, XXE, open redirect, request smuggling, malicious upload.\nCWE classes outside that (memory safety, local privilege, crypto, hardware)\nare filtered out.\n\"\"\"\n\n# CWE classes a request-facing rule can plausibly match on.\nWEB_CWES = {\n # Injection\n \"CWE-74\",\n \"CWE-77\",\n \"CWE-78\",\n \"CWE-79\",\n \"CWE-88\",\n \"CWE-89\",\n \"CWE-90\",\n \"CWE-91\",\n \"CWE-93\",\n \"CWE-94\",\n \"CWE-95\",\n \"CWE-96\",\n \"CWE-97\",\n \"CWE-98\",\n \"CWE-113\",\n \"CWE-116\",\n \"CWE-564\",\n \"CWE-643\",\n \"CWE-652\",\n \"CWE-917\",\n # Path traversal / file inclusion\n \"CWE-22\",\n \"CWE-23\",\n \"CWE-24\",\n \"CWE-36\",\n \"CWE-73\",\n \"CWE-434\",\n # SSRF / redirect / smuggling\n \"CWE-918\",\n \"CWE-601\",\n \"CWE-444\",\n # Auth / access control bypass\n \"CWE-285\",\n \"CWE-287\",\n \"CWE-288\",\n \"CWE-290\",\n \"CWE-302\",\n \"CWE-306\",\n \"CWE-639\",\n \"CWE-862\",\n \"CWE-863\",\n \"CWE-1390\",\n # Deserialization / XML\n \"CWE-502\",\n \"CWE-611\",\n \"CWE-827\",\n}\n\n\ndef web_relevant(cwes):\n \"\"\"True if any CWE is one a request-facing rule can match.\"\"\"\n return any(c in WEB_CWES for c in (cwes or []))\n\n\ndef rank(tips):\n \"\"\"A sort key (higher first) for monitor prioritization: actively\n exploited and ransomware-linked first, then by exploitation probability.\"\"\"\n score = 0.0\n if tips.get(\"actively_exploited\"):\n score += 100\n if tips.get(\"known_ransomware\"):\n score += 20\n epss = tips.get(\"epss\")\n if epss:\n score += 50 * epss[\"score\"]\n return score\n",
"scripts/export_github_issue.py": "#!/usr/bin/env python3\n\"\"\"Export an issue and every paginated comment to offline HTML, Markdown and JSON.\"\"\"\n\nimport argparse\nimport html\nimport json\nimport os\nimport urllib.error\nimport urllib.request\nimport re\nimport subprocess\nimport sys\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\n\ndef github_json(endpoint, *, paginate=False):\n command = [\"gh\", \"api\", \"--hostname\", \"github.com\"]\n if paginate:\n command += [\"--paginate\", \"--slurp\"]\n command += [endpoint]\n try:\n result = subprocess.run(\n command, check=True, capture_output=True, text=True, timeout=120\n )\n except FileNotFoundError:\n return github_http_json(endpoint, paginate=paginate)\n return json.loads(result.stdout)\n\n\ndef github_http_json(endpoint, *, paginate=False):\n \"\"\"Use the public REST API when GitHub CLI is unavailable.\"\"\"\n headers = {\n \"Accept\": \"application/vnd.github+json\",\n \"User-Agent\": \"mockforge-issue-export\",\n }\n token = os.environ.get(\"GH_TOKEN\") or os.environ.get(\"GITHUB_TOKEN\")\n if token:\n headers[\"Authorization\"] = f\"Bearer {token}\"\n pages = []\n page = 1\n while True:\n suffix = f\"&page={page}\" if paginate else \"\"\n request = urllib.request.Request(\n f\"https://api.github.com/{endpoint}{suffix}\", headers=headers\n )\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n data = json.load(response)\n except urllib.error.HTTPError as error:\n raise ValueError(\n f\"GitHub API returned HTTP {error.code}. For rate limits or private repositories, \"\n \"set GH_TOKEN or GITHUB_TOKEN, or install gh and run gh auth login.\"\n ) from None\n if not paginate:\n return data\n if not isinstance(data, list):\n raise ValueError(\"GitHub comments response must be a list\")\n pages.append(data)\n if len(data) < 100:\n return pages\n page += 1\n\n\ndef fetch_snapshot(repository, number):\n \"\"\"Read only from the explicitly selected repository on github.com.\"\"\"\n endpoint = f\"repos/{repository}/issues/{number}\"\n issue = github_json(endpoint)\n pages = github_json(f\"{endpoint}/comments?per_page=100\", paginate=True)\n comments = [comment for page in pages for comment in page]\n return {\n \"exported_at\": datetime.now(timezone.utc).isoformat(),\n \"repository\": repository,\n \"issue\": issue,\n \"comments\": comments,\n }\n\n\ndef validate_snapshot(snapshot, repository, number):\n issue = snapshot[\"issue\"]\n expected = f\"https://github.com/{repository}/issues/{number}\"\n if snapshot[\"repository\"] != repository or issue[\"html_url\"] != expected:\n raise ValueError(\"Snapshot does not match the selected repository/issue\")\n if issue[\"number\"] != number or \"pull_request\" in issue:\n raise ValueError(\"Expected an issue, not a pull request\")\n comments = snapshot[\"comments\"]\n if len(comments) != issue[\"comments\"]:\n raise ValueError(\"Comment count changed or export is incomplete; fetch again\")\n ids = [comment[\"id\"] for comment in comments]\n if len(set(ids)) != len(ids):\n raise ValueError(\"Duplicate comment IDs; fetch again\")\n for comment in comments:\n if comment[\"html_url\"] != f\"{expected}#issuecomment-{comment['id']}\":\n raise ValueError(\"Comment URL does not belong to the selected issue\")\n return sorted(comments, key=lambda c: (c[\"created_at\"], c[\"id\"]))\n\n\ndef author(item):\n return (item.get(\"user\") or {}).get(\"login\") or \"[deleted account]\"\n\n\ndef render(snapshot, comments):\n issue = snapshot[\"issue\"]\n title = f\"{snapshot['repository']} #{issue['number']}: {issue['title']}\"\n note = (\n f\"Exported {snapshot['exported_at']}. {len(comments)} comments. \"\n f\"Issue state: {issue['state']}. \"\n \"Snapshot of the issue body and all available issue comments. \"\n \"GitHub edits after export, deleted comments, timeline events, reactions, \"\n \"linked PR discussions and attachment contents are not included. \"\n \"Attachment URLs remain in the text; no external content is downloaded. \"\n \"Fetching multiple pages is not an atomic snapshot.\"\n )\n escape = html.escape\n html_parts = [\n '<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">',\n '<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">',\n '<meta http-equiv=\"Content-Security-Policy\" '\n \"content=\\\"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'\\\">\",\n f\"<title>{escape(title)}</title>\",\n \"<style>body{max-width:72rem;margin:2rem auto;padding:0 1rem;\"\n \"font:16px/1.5 system-ui;color:#17202a;background:#fff}\"\n \"pre{white-space:pre-wrap;overflow-wrap:anywhere;font:14px/1.5 monospace}\"\n \"article{border-top:1px solid #bbb;padding:1rem 0}\"\n \"a{color:#174ea6}nav{line-height:2}\"\n \"@media print{body{max-width:none;margin:0}nav{display:none}\"\n \"h2{break-after:avoid}pre{font-size:10pt}}</style></head><body>\",\n f\"<h1>{escape(title)}</h1><p>{escape(note)}</p>\",\n \"<p>Search with Ctrl+F (Cmd+F on macOS). Print and choose Save as PDF \"\n \"for a searchable PDF. Bodies are shown as plain Markdown source so \"\n 'code, logs and tables remain complete.</p><nav><a href=\"#issue\">Issue</a> ',\n ]\n html_parts.extend(\n f'<a href=\"#comment-{c[\"id\"]}\">{i}</a> ' for i, c in enumerate(comments, 1)\n )\n html_parts.append(\"</nav>\")\n markdown = [f\"# {escape(title)}\\n\\n{escape(note)}\\n\\nSource: {issue['html_url']}\\n\"]\n for index, item in enumerate([issue, *comments]):\n heading = \"Issue\" if index == 0 else f\"Comment {index}\"\n anchor = \"issue\" if index == 0 else f\"comment-{item['id']}\"\n metadata = (\n f\"{author(item)} | created {item['created_at']} | \"\n f\"updated {item['updated_at']}\"\n )\n body = item.get(\"body\") or \"\"\n html_parts.append(\n f'<article id=\"{anchor}\"><h2>{heading}</h2>'\n f\"<p>{escape(metadata)} | \"\n f'<a href=\"{escape(item[\"html_url\"], quote=True)}\">View on GitHub</a></p>'\n f\"<pre>{escape(body)}</pre></article>\"\n )\n # Use a longer fence than anything in a body. Raw Markdown is retained\n # as text, including HTML which a Markdown viewer might otherwise run.\n runs = re.findall(r\"`+\", body)\n fence = \"`\" * max(3, 1 + max(map(len, runs), default=0))\n markdown.append(\n f\"\\n## {heading}\\n\\n{escape(metadata)}\\n\\n{item['html_url']}\\n\\n\"\n f\"{fence}text\\n{body}\\n{fence}\\n\"\n )\n html_parts.append(\"</body></html>\\n\")\n return \"\\n\".join(html_parts), \"\".join(markdown)\n\n\ndef main(argv=None):\n parser = argparse.ArgumentParser(description=__doc__)\n parser.add_argument(\"--repo\", default=\"SaaSy-Solutions/mockforge\")\n parser.add_argument(\"--issue\", type=int, default=79)\n parser.add_argument(\n \"--output-dir\", type=Path, required=True, help=\"New output directory\"\n )\n parser.add_argument(\n \"--input\", type=Path, help=\"Offline snapshot.json from a previous export\"\n )\n args = parser.parse_args(argv)\n if (\n not re.fullmatch(r\"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\", args.repo)\n or args.issue < 1\n ):\n parser.error(\"Expected --repo OWNER/REPO and a positive --issue number\")\n try:\n snapshot = (\n json.loads(args.input.read_text(encoding=\"utf-8\"))\n if args.input\n else fetch_snapshot(args.repo, args.issue)\n )\n comments = validate_snapshot(snapshot, args.repo, args.issue)\n html_text, markdown = render(snapshot, comments)\n # Never overwrite an earlier snapshot or leave an output on fetch failure.\n args.output_dir.mkdir(parents=True, exist_ok=False)\n (args.output_dir / \"discussion.html\").write_text(html_text, encoding=\"utf-8\")\n (args.output_dir / \"discussion.md\").write_text(markdown, encoding=\"utf-8\")\n (args.output_dir / \"snapshot.json\").write_text(\n json.dumps(snapshot, ensure_ascii=False, indent=2) + \"\\n\", encoding=\"utf-8\"\n )\n except subprocess.TimeoutExpired:\n print(\n \"GitHub read timed out; no export created. Try again later.\",\n file=sys.stderr,\n )\n return 1\n except subprocess.CalledProcessError as error:\n print(\n f\"GitHub read failed (exit {error.returncode}); no export created. Check gh auth status.\",\n file=sys.stderr,\n )\n return 1\n except (OSError, ValueError, KeyError, TypeError) as error:\n print(f\"Export failed: {error}\", file=sys.stderr)\n return 1\n print(\n f\"Exported issue + {len(comments)} comments to {args.output_dir / 'discussion.html'}\"\n )\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n",
"scripts/security_rule_pack_config.py": "\"\"\"Closed safe canary definitions and strict expectation schema validation.\"\"\"\n\nimport json\nfrom pathlib import Path\n\n\nCASES = {\n \"normal-control\": (\"/security-rule-probe\", {}),\n \"nextjs-benign\": (\"/security-rule-probe\", {\"X-Client-Note\": \"middleware\"}),\n \"nextjs-header\": (\n \"/security-rule-probe\",\n {\"x-middleware-subrequest\": \"middleware:\" * 4 + \"middleware\"},\n ),\n \"camel-benign\": (\"/security-rule-probe?beanMethodName=mockforgeCanary\", {}),\n \"camel-parameter\": (\"/security-rule-probe?cAmelBeanMethodName=mockforgeCanary\", {}),\n \"camel-header\": (\n \"/security-rule-probe\",\n {\"cAmelBeanMethodName\": \"mockforgeCanary\"},\n ),\n}\n\n\nCONTROLS = {\"normal-control\", \"nextjs-benign\", \"camel-benign\"}\n\n\ndef load_pack(path):\n pack = json.loads(Path(path).read_text())\n if not isinstance(pack, dict) or set(pack) != {\"schema_version\", \"cases\"}:\n raise ValueError(\"pack requires only schema_version and cases\")\n if type(pack[\"schema_version\"]) is not int or pack[\"schema_version\"] != 1:\n raise ValueError(\"unsupported schema_version\")\n cases = pack[\"cases\"]\n if not isinstance(cases, list) or not 5 <= len(cases) <= len(CASES):\n raise ValueError(\"pack requires five supported cases and optional camel-header\")\n seen = set()\n for case in cases:\n if not isinstance(case, dict) or set(case) != {\"id\", \"status\", \"origin\"}:\n raise ValueError(\"case requires only id, status and origin\")\n name, status, origin = case[\"id\"], case[\"status\"], case[\"origin\"]\n if not isinstance(name, str) or name not in CASES or name in seen:\n raise ValueError(\"unknown or duplicate case id\")\n seen.add(name)\n if type(status) is not int or not 200 <= status <= 599:\n raise ValueError(\"status must be an integer from 200 to 599\")\n if origin not in (\"seen\", \"absent\"):\n raise ValueError(\"origin must be seen or absent\")\n if name in CONTROLS and (status != 200 or origin != \"seen\"):\n raise ValueError(\"benign controls require 200 and origin seen\")\n if origin == \"absent\" and status < 400:\n raise ValueError(\"rejection requires an explicit error status\")\n if not set(CASES) - {\"camel-header\"} <= seen:\n raise ValueError(\"all supported probes and benign controls are required\")\n return cases\n",
"scripts/security_rule_pack_evidence.py": "\"\"\"Correlate receiver observations and conservatively classify rule evidence.\"\"\"\n\nimport http.client\nimport time\nimport uuid\nfrom security_rule_pack_config import CASES\n\n\ndef observe(name, *, run_id, url, observations, lock, timeout, window, send):\n token = run_id + \"-\" + uuid.uuid4().hex\n path, headers = CASES[name]\n path += (\"&\" if \"?\" in path else \"?\") + \"__mockforge_correlation=\" + token\n headers = {**headers, \"X-Mockforge-Correlation\": token}\n try:\n status = send(url, path, headers, timeout)\n error = None\n except (OSError, http.client.HTTPException) as exc:\n status, error = None, type(exc).__name__\n time.sleep(window)\n with lock:\n records = list(observations.get(token, []))\n # Require exact request path, one observation, and matching probe headers.\n valid = len(records) == 1 and records[0][\"path\"] == path\n if valid:\n valid = all(\n [value for key, value in records[0][\"headers\"] if key.lower() == k.lower()]\n == [v]\n for k, v in headers.items()\n )\n return status, error, records, valid, token\n\n\ndef classify(case, status, error, records, valid, healthy):\n \"\"\"Decide only the evidence supported verdict; absent data never proves blocking.\"\"\"\n result = {\"rule_behavior\": \"inconclusive\"}\n if error or (records and not valid):\n result[\"reason\"] = error or \"origin correlation unavailable\"\n elif status != case[\"status\"]:\n result.update(rule_behavior=\"fail\", reason=\"explicit status assertion mismatch\")\n elif not healthy:\n result[\"reason\"] = \"flanking controls could not corroborate origin reachability\"\n elif case[\"origin\"] == \"seen\" and not records:\n result[\"reason\"] = \"expected origin observation missing\"\n elif case[\"origin\"] == \"absent\" and records:\n result.update(\n rule_behavior=\"fail\", reason=\"rejected response still reached origin\"\n )\n else:\n result.update(\n rule_behavior=\"pass\", reason=\"status and bounded origin assertion agree\"\n )\n return result\n",
"scripts/security_rule_pack_transport.py": "\"\"\"Owned loopback recorder, explicit target guard and bounded HTTP transport.\"\"\"\n\nimport http.client\nimport ipaddress\nimport threading\nfrom contextlib import contextmanager\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\nfrom urllib.parse import parse_qs, urlsplit\n\n\ndef target_url(value, allow_external=False):\n url = urlsplit(value)\n if (\n url.scheme not in (\"http\", \"https\")\n or not url.hostname\n or url.username is not None\n or url.password is not None\n or url.path not in (\"\", \"/\")\n or url.query\n or url.fragment\n ):\n raise ValueError(\"target must be an HTTP(S) origin without credentials or path\")\n # Loopback literals avoid DNS rebinding and accidental discovered-host probing.\n try:\n loopback = ipaddress.ip_address(url.hostname).is_loopback\n except ValueError:\n loopback = False\n if not loopback and not allow_external:\n raise ValueError(\"non-loopback targets require --allow-external-target\")\n if url.port is not None and url.port == 0:\n raise ValueError(\"target port must be 1..65535\")\n return url\n\n\n@contextmanager\ndef recorder(port=0):\n \"\"\"Own synchronous recorder; raw header pairs retain receiver representation.\"\"\"\n observations = {}\n lock = threading.Lock()\n\n class Handler(BaseHTTPRequestHandler):\n def do_GET(self):\n query_token = parse_qs(urlsplit(self.path).query).get(\n \"__mockforge_correlation\", [None]\n )[0]\n header_token = self.headers.get(\"X-Mockforge-Correlation\")\n # Observe either surviving token so removal of one cannot fake absence.\n for token in {query_token, header_token}:\n if token and len(token) <= 100:\n with lock:\n observations.setdefault(token, []).append(\n {\n \"path\": self.path,\n \"headers\": list(self.headers.raw_items()),\n }\n )\n body = b'{\"receiver\":\"mockforge-security-rule-recorder\"}'\n self.send_response(200)\n self.send_header(\"Content-Length\", str(len(body)))\n self.end_headers()\n self.wfile.write(body)\n\n def log_message(self, *args):\n pass\n\n class BoundedServer(HTTPServer):\n def get_request(self):\n connection, address = super().get_request()\n connection.settimeout(2)\n return connection, address\n\n server = BoundedServer((\"127.0.0.1\", port), Handler)\n server.timeout = 1\n thread = threading.Thread(target=server.serve_forever, daemon=True)\n thread.start()\n try:\n yield server.server_address[1], observations, lock\n finally:\n server.shutdown()\n server.server_close()\n thread.join(timeout=2)\n\n\ndef request(url, path, headers, timeout):\n cls = (\n http.client.HTTPSConnection\n if url.scheme == \"https\"\n else http.client.HTTPConnection\n )\n connection = cls(url.hostname, url.port, timeout=timeout)\n try:\n connection.request(\"GET\", path, headers=headers)\n response = connection.getresponse()\n # Bound response consumption; no retries or redirects.\n response.read(4096)\n return response.status\n finally:\n connection.close()\n",
"scripts/security_rule_test_pack.py": "\"\"\"Bounded, harmless HTTP security-rule canaries; Python 3.10+, standard library.\"\"\"\n\nimport argparse\nfrom functools import partial\nfrom security_rule_pack_evidence import observe, classify\nfrom security_rule_pack_config import load_pack\nfrom security_rule_pack_transport import recorder, request, target_url\nimport json\nimport uuid\nfrom pathlib import Path\n\n\ndef run(cases, url, observations, lock, timeout=2, window=0.1):\n results = []\n run_id = uuid.uuid4().hex\n\n deliver = partial(\n observe,\n run_id=run_id,\n url=url,\n observations=observations,\n lock=lock,\n timeout=timeout,\n window=window,\n send=request,\n )\n\n for case in cases:\n name = case[\"id\"]\n result = {\n \"id\": name,\n \"expected\": case,\n \"status\": None,\n \"origin_seen\": None,\n \"scenario_delivery\": \"unverified\",\n \"rule_behavior\": \"inconclusive\",\n \"runtime_patch_evidence\": \"unverified\",\n \"origin_observations\": [],\n }\n if name == \"camel-header\":\n result.update(\n rule_behavior=\"unsupported\",\n reason=\"mixed-case wire fidelity not verified\",\n )\n results.append(result)\n continue\n # Corroborate origin reachability on both sides of every case.\n before = deliver(\"normal-control\")\n status, error, records, valid, token = deliver(name)\n after = deliver(\"normal-control\")\n healthy = all(s == 200 and e is None and v for s, e, _, v, _ in (before, after))\n result.update(\n correlation_id=token,\n status=status,\n origin_seen=bool(records),\n origin_observations=records,\n scenario_delivery=\"origin-observed\" if valid else \"unverified\",\n control_before={\n \"status\": before[0],\n \"origin_valid\": before[3],\n \"correlation_id\": before[4],\n \"observations\": before[2],\n },\n control_after={\n \"status\": after[0],\n \"origin_valid\": after[3],\n \"correlation_id\": after[4],\n \"observations\": after[2],\n },\n )\n result.update(classify(case, status, error, records, valid, healthy))\n results.append(result)\n return {\n \"schema_version\": 1,\n \"run_id\": run_id,\n \"target\": url.geturl(),\n \"timeout_seconds\": timeout,\n \"request_count\": 3 * sum(c[\"id\"] != \"camel-header\" for c in cases),\n \"observation_window_seconds\": window,\n \"scope\": \"synthetic transport/rule behavior only; no application protection or patch proof\",\n \"results\": results,\n }\n\n\ndef main(argv=None):\n parser = argparse.ArgumentParser(description=__doc__)\n parser.add_argument(\"--pack\", required=True)\n parser.add_argument(\"--target\", required=True)\n parser.add_argument(\"--report\", required=True)\n parser.add_argument(\n \"--allow-external-target\",\n action=\"store_true\",\n help=\"affirm explicit authorization for this exact supplied target\",\n )\n parser.add_argument(\"--origin-port\", type=int, default=18081)\n parser.add_argument(\"--timeout\", type=float, default=2)\n parser.add_argument(\"--observation-window\", type=float, default=0.1)\n args = parser.parse_args(argv)\n try:\n cases = load_pack(args.pack)\n url = target_url(args.target, args.allow_external_target)\n if not 1 <= args.origin_port <= 65535 or not 0 < args.timeout <= 5:\n raise ValueError(\"origin port must be 1..65535; timeout must be >0 and <=5\")\n if not 0.01 <= args.observation_window <= 1:\n raise ValueError(\"observation window must be 0.01..1 seconds\")\n with recorder(args.origin_port) as (_, observations, lock):\n report = run(\n cases, url, observations, lock, args.timeout, args.observation_window\n )\n Path(args.report).write_text(json.dumps(report, indent=2) + \"\\n\")\n except (ValueError, OSError) as exc:\n parser.error(str(exc))\n for result in report[\"results\"]:\n print(f\"{result['id']}: {result['rule_behavior']} ({result['reason']})\")\n return 0 if all(r[\"rule_behavior\"] == \"pass\" for r in report[\"results\"]) else 1\n\n\nif __name__ == \"__main__\":\n raise SystemExit(main())\n"
}