mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
feat(cpg): add a transaction-owned engine session and route it opt-in (M2 11.1, 11.3)
JVM startup, not analysis, is what a push scan spends its time on: each disposable script invocation costs 13 to 23 seconds while the work takes under a second. The session owns one Joern server per transaction on loopback with an OS-chosen port and no published port. Three properties are load-bearing, and the probe that settled the design demonstrated why each is needed. An HTTP status is not an answer. The server returns success even for a compile error, with the diagnostic in its body, so every request redirects the evaluated code's console output into a fresh private file behind a nonce, and the nonce is verified before the answer is accepted. The engine's body is retained for exactly the case where the receipt is missing, without which that failure explains nothing. A poisoned session answers nothing afterwards, and cancellation kills the process group rather than the launcher, which this project has already run a machine out of memory by neglecting. What the probe established about the engine, after three iterations that each failed loudly: runScript is absent from the server REPL; a shipped @main script cannot be nested in a block expression, so definitions go to the top level unwrapped; and the REPL shadows Console with io.joern.console.Console, which has no withOut, so capture must qualify scala.Console. With that fixed, both mechanisms answer identically to the disposable invocation, 0.8 s against 9.8 s. The backend routes batch and single queries through the session only when session_transport is enabled, which it is not by default. Any failure falls back to the disposable invocation rather than reporting a zero, and each script is defined under its own name because every script declares exec. Equivalence for taint, dominance and configuration is task 11.2 and gates any measured run. 1,391 tests passed, nine skipped. Rollout stays NO-GO. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
co-authored by
Claude Opus 5
parent
cec1c4a428
commit
7e3f12bc00
@@ -289,3 +289,42 @@ development and transfer data: it cannot qualify production Node support, and No
|
||||
cannot qualify PHP or Python. No capability is enabled, no defect is admitted, no alert is
|
||||
emitted and no latency claim is made. Runtime, independent qualification and rollout all remain
|
||||
NO-GO.
|
||||
|
||||
## M1b explanation: the finding as a developer would read it
|
||||
|
||||
Run 2026-09-16 after task 10.10, same pins and budget, with an unreviewed experimental
|
||||
declaration generated by the instrument and bound to the run's own analysis semantics.
|
||||
Evidence: [summary](../../../benchmarks/measurements/2026-09-16-nodegoat-m1b-explained.json)
|
||||
and its [bundle](../../../benchmarks/measurements/2026-09-16-nodegoat-m1b-explained.json.gz).
|
||||
|
||||
Three evaluation findings were rendered on the vulnerable revision, one per eval site. For
|
||||
line 32:
|
||||
|
||||
- **Location** app/routes/contributions.js:32, family injection, novelty new with
|
||||
`operation_absent_from_comparable_base`.
|
||||
- **Witness** `eval(req.body.preTax) -> const preTax = eval(req.body.preTax)`.
|
||||
- **Change attribution** `base:app/routes/contributions.js:32-34`.
|
||||
- **Provenance** `file_scope`: the question named no function, the engine reported `<lambda>2`.
|
||||
- **Consequence** "A request field reaches const preTax = eval(req.body.preTax), where it is
|
||||
evaluated as JavaScript, so an attacker controlling that field can execute arbitrary code in
|
||||
the server process."
|
||||
- **Repair** "Replace the evaluation at const preTax = eval(req.body.preTax) with a numeric
|
||||
conversion such as Number(...) or parseInt(...), then validate the result before use."
|
||||
|
||||
Both sentences come from the declaration's templates through the grounded-template path, not from
|
||||
generated prose. The declaration reports verdict `experimental` and `enabled: false`, the
|
||||
production result still reports `finding_status: none`, and all three artifacts carry zero
|
||||
admitted defects. The rendering path never reaches admission, so no supplied declaration can
|
||||
change that.
|
||||
|
||||
### A defect this surfaced, recorded not repaired
|
||||
|
||||
The operation symbol admission extracts is the text before the first parenthesis, which for
|
||||
`const preTax = eval(req.body.preTax)` is `const preTax = eval`, not `eval`. The demonstration
|
||||
declaration had to enumerate each assignment spelling for its coverage check to pass. A real
|
||||
qualified declaration cannot enumerate every assignment a codebase might write, so admission
|
||||
would reject genuine findings on `operation_semantics_mismatch`. That is recorded as its own task
|
||||
ahead of M4; it is not worked around here.
|
||||
|
||||
M1b is met: the comparison classifies all three revisions through the production rules, and the
|
||||
finding reads as a developer would need it to, with every claim traceable to its evidence.
|
||||
|
||||
@@ -266,7 +266,7 @@ Preserve all completed task evidence below.
|
||||
- _Requirements: 1.1, 2.1, 2.4, 3.4, 6.3, 7.1, 7.3_
|
||||
- _Verified 2026-09-16:_ all three pinned revisions replayed through the production CLI under a named 900-second development budget; expectations frozen on disk first. Raw `<lambda>2` witnesses present at lines 32–34; fixed twin analyzed and quiet (target question answered with zero rows, no deadline reason, three base-only findings); unchanged revision analyzed and recorded `unchanged`. Five vetoes fired and were recorded; the remaining blocker is `operation_correspondence_unresolved` for an operation on a changed line. Zero admitted defects. Evidence: [M1a record](m1a-recorded-vetoes.md) and `benchmarks/measurements/2026-09-16-nodegoat-m1a-recorded-vetoes.json`. Diagnosis only; no capability enabled and no latency claim.
|
||||
|
||||
- [ ] 10. Repair only the vetoes M1a recorded (release milestone M1b)
|
||||
- [x] 10. Repair only the vetoes M1a recorded (release milestone M1b)
|
||||
- [x] 10.1 Establish operation correspondence for an operation on a changed line
|
||||
- M1a measured this as the blocker that survives every other repair: a newly introduced operation sits on a changed line, has no base line anchor, and therefore cannot be classified even when every completeness veto is lifted.
|
||||
- Bind the required base/head correspondence to the specific operation identity rather than to a line anchor. A complete base answer for the counterpart question that contains no operation with the same operation and source establishes absence; a matching operation elsewhere in that answer is movement, not novelty.
|
||||
@@ -341,7 +341,7 @@ Preserve all completed task evidence below.
|
||||
- _Requirements: 1.1, 2.1, 2.4, 3.1, 3.4, 6.3, 7.1, 7.3_
|
||||
- _Verified 2026-09-16 with one named exception:_ all three revisions replayed through the production rules with exact pins and reopened source receipts. Vulnerable: three sites new with `operation_absent_from_comparable_base`, admission rejecting on `capability_unavailable` alone. Fixed twin: analyzed and quiet with three base-only findings. Unchanged: three sites `unchanged` with base locations resolved to the same lines. Every finding carries its question, the engine-reported method and its witness; the head census is the complete 44 files; all 436 base questions complete. Zero admitted defects. The repaired and remaining veto lists are in [the M1b record](m1a-recorded-vetoes.md). **Exception:** the supported security consequence and concrete repair direction are not demonstrated, because both are owned by an evaluated capability declaration and the registry is deliberately empty. M1b's comparison half is verified; its explanation half waits on M4.
|
||||
|
||||
- [ ] 10.10 Render the experimental evaluation finding with its consequence and repair
|
||||
- [x] 10.10 Render the experimental evaluation finding with its consequence and repair
|
||||
- **The remaining half of M1b's exit, named by the 10.9 run.** The comparison is verified, but the milestone also asks for a developer-readable finding carrying a supported security consequence and a concrete repair direction. Both come from an evaluated capability declaration and the registry is deliberately empty, so nothing renders them.
|
||||
- Generate an explicitly unreviewed, experimental declaration for this capability with the existing registry tool, and render the finding through the same grounded-template path the admitted case uses. Keep eligibility disabled: an experimental declaration is not enabled and not PASS, so admission must still reject it and no normal hook alert may be advertised.
|
||||
- Do not write consequence or repair text that no declaration supports. Refusing generic advice without a supported context and operation is the rule admission exists to enforce, and the experimental path must not become a way around it.
|
||||
@@ -349,6 +349,61 @@ Preserve all completed task evidence below.
|
||||
- _Boundary: Result and Hook Adapter, Delta and Actionability Policy, Evaluation harness_
|
||||
- _Depends: 10.9_
|
||||
- _Requirements: 3.1, 3.2, 3.3, 5.1, 5.5, 6.3_
|
||||
- _Verified 2026-09-16:_ 1,386 tests passed, nine skipped; Ruff/format/mypy passed. Three evaluation findings rendered on the vulnerable revision with exact location, witness, change attribution, provenance, consequence and repair, all from the declaration's templates through the grounded-template path. The declaration reports verdict experimental and enabled false, the production result still reports no actionable defect, and all three artifacts carry zero admitted defects. Controls prove an ungrounded template is refused, an uncovered operation symbol is stated, no matching declaration leaves both sentences absent with the reason, and a declaration claiming PASS still admits nothing because the rendering path is outside admission. Evidence: `benchmarks/measurements/2026-09-16-nodegoat-m1b-explained.json`.
|
||||
|
||||
|
||||
- [ ] 11. Fit the same useful analysis into the push budget (release milestone M2)
|
||||
- [ ] 11.1 Give the backend a transaction-owned engine session
|
||||
- The measured cost is JVM startup, not analysis. In the M1b runs each disposable `joern --script` invocation cost 13 to 23 seconds while the work itself took under a second, and the 2026-09-14 lifecycle probe measured one session at 12.8 s to start, 3.6 s to load a graph and 0.22 to 0.47 s per warm request.
|
||||
- Own the session inside the existing backend for the length of one transaction: loopback only on an ephemeral port with no published port, a private working directory, one shared deadline, serialized requests, explicit graph identity and epoch, and kill-and-reap of the whole process group on cancellation, failure or exit.
|
||||
- An HTTP status is not an answer contract. Require a per-request receipt written to a fresh private file and verified before the answer is accepted, the way the probe's repaired instrument did after its first attempt read an empty stdout as a census.
|
||||
- Poison the session on any crash or malformed response so a failed session can never answer a later request, and fall back to the disposable path rather than reporting a zero.
|
||||
- Done when a controlled hanging in-session request is cancelled inside the allowance with no surviving process group, a crashed session answers nothing afterwards, and the disposable path still works unchanged.
|
||||
- _Boundary: contributor-scan runtime, Cache and Engine Boundary_
|
||||
- _Requirements: 4.1, 4.2, 7.3_
|
||||
- [ ] 11.2 Prove every shipped query kind answers identically through both transports
|
||||
- Run taint, dominance, configuration and census over the same graph through the session and through the disposable path and require identical payloads, including empty answers and the distinction between an empty answer and an unavailable one.
|
||||
- Cover a malformed or absent response, a switched graph, a second graph loaded in the same session, and a request arriving after cancellation. Equivalence on a census alone does not establish equivalence for security queries.
|
||||
- Done when every shipped query kind matches byte for byte on a real graph, and every failure shape is reported as unavailable rather than empty.
|
||||
- _Boundary: contributor-scan runtime, engine integration tests_
|
||||
- _Depends: 11.1_
|
||||
- _Requirements: 4.1, 4.2, 6.4, 7.3_
|
||||
- [ ] 11.3 Route the batch path through the session behind an explicit setting
|
||||
- Consume the session from the existing batch and census paths, keeping the disposable path as the default and as the fallback. No capability, ranking decision or evidence semantics changes; only the transport does.
|
||||
- Account for session startup, load, requests and teardown inside the one shared deadline, and record them as separate stage costs so the next decision is made on measurements rather than estimates.
|
||||
- Done when a replay produces the same evidence and admission through either transport under a sufficient budget, with per-stage costs recorded for both.
|
||||
- _Boundary: Cache and Engine Boundary, Push runner_
|
||||
- _Depends: 11.2_
|
||||
- _Requirements: 4.1, 4.3, 6.4, 7.3_
|
||||
- [ ] 11.4 Remove the startup cost from the critical path where the measurement says it pays
|
||||
- With 11.3's stage costs in hand, decide what else to change. The candidates the M1b runs point at are the overlay pass, 15.8 s in a separate invocation, and session startup, 12.8 s, which may overlap the frontend build rather than follow it.
|
||||
- Change only what the measurement implicates, and keep each change's correctness evidence separate from its timing evidence. Do not substitute a faster census for a completed security check.
|
||||
- Done when the recorded stage costs after the change are compared against the same profile before it, with any regression in completed checks reported rather than traded away.
|
||||
- _Boundary: contributor-scan runtime_
|
||||
- _Depends: 11.3_
|
||||
- _Requirements: 4.1, 4.2, 6.2, 6.5_
|
||||
- [ ] 11.5 Rerun the frozen seven-class runtime profile and decide
|
||||
- Rerun the unchanged seven-class profile: identical-tip, function edit, dependency edit, configuration edit, cold, growth and multi-ref, three samples each, retaining every timeout and unresolved check. Identical-tip reuse cannot substitute for warm changed-code work.
|
||||
- Verify the M1 outcome still holds on the same three revisions through the production rules, so speed is not bought with a lost detection.
|
||||
- Apply the existing gates without alteration: representative warm changed-code p95 at most 30 s, cancellation and reporting at most 2 s, supported-check completion at least 95%. A failed gate stays NO-GO and names the dominant remaining cost.
|
||||
- Done when one versioned scorecard reports p50 and p95 with sample counts beside the M1 correctness outcome, and states GO or NO-GO against the unchanged gates.
|
||||
- _Boundary: Evaluation harness, runtime integration_
|
||||
- _Depends: 11.4_
|
||||
- _Requirements: 4.1, 4.2, 6.2, 6.3, 6.5, 7.3_
|
||||
|
||||
- [ ] 12. Prerequisites for independent qualification found during M1a and M1b
|
||||
- [ ] 12.1 Extract the operation symbol from the operation, not from the statement
|
||||
- **Found 2026-09-16 in the M1b explanation run.** Admission takes the text before the first parenthesis as the operation symbol, so `const preTax = eval(req.body.preTax)` yields `const preTax = eval`. The demonstration declaration had to enumerate each assignment spelling for its coverage check to pass.
|
||||
- A qualified declaration cannot enumerate every assignment a codebase might write, so admission would reject genuine findings on `operation_semantics_mismatch`. Take the symbol from the called operation the engine reported, preserving receiver spelling, and leave an unresolved or dynamic spelling ineligible as it is today.
|
||||
- Done when the symbol for an assigned call is the call's own name, a receiver-qualified call keeps its receiver, a dynamic spelling stays ineligible, and a declaration naming the plain symbol covers the assigned form.
|
||||
- _Boundary: Delta and Actionability Policy_
|
||||
- _Requirements: 3.1, 3.2, 3.4_
|
||||
- [ ] 12.2 Supply per-family context producers for dominance and configuration
|
||||
- The M1b exit run left 231 of 436 head questions reporting `change_context_incomplete` from `context_projection_unavailable` and `context_scope_empty`, because those families' queries export no context. They did not block the Node injection case, so M1b correctly left them alone, but their families stay experimental until they can answer.
|
||||
- Give dominance and configuration their own context producers describing the actual scope of the query they accompany. Do not copy taint context into them: dominance evaluates sibling operations across a file and configuration has different scope and obligations.
|
||||
- Done when a dominance and a configuration question complete their change context on a real repository, with missing extents and unsupported reasoning still explicit.
|
||||
- _Boundary: contributor-scan query/context evidence_
|
||||
- _Requirements: 2.1, 2.2, 8.1, 8.2_
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"modules": 116,
|
||||
"modules": 117,
|
||||
"counts": {
|
||||
"load_bearing": 107,
|
||||
"load_bearing": 108,
|
||||
"standalone_capability": 9
|
||||
},
|
||||
"orphaned": [],
|
||||
@@ -200,6 +200,14 @@
|
||||
"cpg.backend"
|
||||
]
|
||||
},
|
||||
{
|
||||
"module": "cpg.session",
|
||||
"classification": "load_bearing",
|
||||
"reason": "reachable from gate, map_gate, pair_gate or the CLI",
|
||||
"importers": [
|
||||
"cpg.backend"
|
||||
]
|
||||
},
|
||||
{
|
||||
"module": "findings",
|
||||
"classification": "load_bearing",
|
||||
@@ -378,6 +386,7 @@
|
||||
"importers": [
|
||||
"cli",
|
||||
"cpg.backend",
|
||||
"cpg.session",
|
||||
"model.candidates",
|
||||
"model.config_value",
|
||||
"model.dominance",
|
||||
@@ -440,6 +449,7 @@
|
||||
"importers": [
|
||||
"cli",
|
||||
"cpg.backend",
|
||||
"cpg.session",
|
||||
"model.partitions",
|
||||
"model.regions",
|
||||
"model.scan",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"experiment": "session-script-execution-v2",
|
||||
"purpose": "Which in-session mechanism runs a shipped @main script with parameters",
|
||||
"scope": "One JavaScript graph, census script; no security-query equivalence and no latency claim",
|
||||
"instrument_note": "v1 of this probe sent two candidates as Scala string literals instead of code and reported NO_MECHANISM_FOUND. It did establish that runScript is absent from the server REPL.",
|
||||
"attempts": [
|
||||
{
|
||||
"mechanism": "main_annotation_kept",
|
||||
"definition_sent": true,
|
||||
"definition_output": "def exec(cpgFile: String): Unit\n",
|
||||
"call_seconds": 2.0279543119249865,
|
||||
"payload": {
|
||||
"files": "19",
|
||||
"methods": "229",
|
||||
"calls": "1683",
|
||||
"file_names": [
|
||||
"assets/js/chart/chart-data-morris.js",
|
||||
"assets/js/tour/redirects-steps.js",
|
||||
"data/allocations-dao.js",
|
||||
"data/benefits-dao.js",
|
||||
"data/contributions-dao.js",
|
||||
"data/memos-dao.js",
|
||||
"data/profile-dao.js",
|
||||
"data/research-dao.js",
|
||||
"data/user-dao.js",
|
||||
"routes/allocations.js",
|
||||
"routes/benefits.js",
|
||||
"routes/contributions.js",
|
||||
"routes/error.js",
|
||||
"routes/index.js",
|
||||
"routes/memos.js",
|
||||
"routes/profile.js",
|
||||
"routes/research.js",
|
||||
"routes/session.js",
|
||||
"routes/tutorial.js"
|
||||
],
|
||||
"overlays": "base,controlflow,typerel,callgraph,dataflowOss",
|
||||
"maxHeapMB": "1536"
|
||||
},
|
||||
"stdout_tail": "llocations.js\",\"routes/benefits.js\",\"routes/contributions.js\",\"routes/error.js\",\"routes/index.js\",\"routes/memos.js\",\"routes/profile.js\",\"routes/research.js\",\"routes/session.js\",\"routes/tutorial.js\"],\"overlays\":\"base,controlflow,typerel,callgraph,dataflowOss\",\"maxHeapMB\":\"1536\"}\n---OUSAST-CPG-END---\n",
|
||||
"payload_matches_disposable": true,
|
||||
"seconds": 3.2417371519841254
|
||||
},
|
||||
{
|
||||
"mechanism": "main_annotation_stripped",
|
||||
"definition_sent": true,
|
||||
"definition_output": "def ousastCensus(cpgFile: String): Unit\n",
|
||||
"call_seconds": 0.7982574889902025,
|
||||
"payload": {
|
||||
"files": "19",
|
||||
"methods": "229",
|
||||
"calls": "1683",
|
||||
"file_names": [
|
||||
"assets/js/chart/chart-data-morris.js",
|
||||
"assets/js/tour/redirects-steps.js",
|
||||
"data/allocations-dao.js",
|
||||
"data/benefits-dao.js",
|
||||
"data/contributions-dao.js",
|
||||
"data/memos-dao.js",
|
||||
"data/profile-dao.js",
|
||||
"data/research-dao.js",
|
||||
"data/user-dao.js",
|
||||
"routes/allocations.js",
|
||||
"routes/benefits.js",
|
||||
"routes/contributions.js",
|
||||
"routes/error.js",
|
||||
"routes/index.js",
|
||||
"routes/memos.js",
|
||||
"routes/profile.js",
|
||||
"routes/research.js",
|
||||
"routes/session.js",
|
||||
"routes/tutorial.js"
|
||||
],
|
||||
"overlays": "base,controlflow,typerel,callgraph,dataflowOss",
|
||||
"maxHeapMB": "1536"
|
||||
},
|
||||
"stdout_tail": "llocations.js\",\"routes/benefits.js\",\"routes/contributions.js\",\"routes/error.js\",\"routes/index.js\",\"routes/memos.js\",\"routes/profile.js\",\"routes/research.js\",\"routes/session.js\",\"routes/tutorial.js\"],\"overlays\":\"base,controlflow,typerel,callgraph,dataflowOss\",\"maxHeapMB\":\"1536\"}\n---OUSAST-CPG-END---\n",
|
||||
"payload_matches_disposable": true,
|
||||
"seconds": 1.0805925760651007
|
||||
}
|
||||
],
|
||||
"verdict": {
|
||||
"status": "PASS",
|
||||
"working": [
|
||||
"main_annotation_kept",
|
||||
"main_annotation_stripped"
|
||||
],
|
||||
"warm_call_seconds": [
|
||||
2.0279543119249865,
|
||||
0.7982574889902025
|
||||
],
|
||||
"disposable_seconds": 9.838564986013807,
|
||||
"note": "Census only. A working mechanism must still be proven equivalent for taint, dominance and configuration before any production switch, which is task 11.2."
|
||||
},
|
||||
"source": {
|
||||
"root": "/cache/nodegoat/c5cb68a7084e/app",
|
||||
"files": 25,
|
||||
"bytes": 318000,
|
||||
"sha256": "459f6fd19c7d4700094f0b834eb5474ff94923014055c22385de9de3332c9d53"
|
||||
},
|
||||
"build": {
|
||||
"exit_code": 0,
|
||||
"graph_bytes": 425453,
|
||||
"stderr": "Picked up JAVA_TOOL_OPTIONS: -Xmx1536m\n"
|
||||
},
|
||||
"disposable": {
|
||||
"seconds": 9.838564986013807,
|
||||
"exit_code": 0,
|
||||
"payload": {
|
||||
"files": "19",
|
||||
"methods": "229",
|
||||
"calls": "1683",
|
||||
"file_names": [
|
||||
"assets/js/chart/chart-data-morris.js",
|
||||
"assets/js/tour/redirects-steps.js",
|
||||
"data/allocations-dao.js",
|
||||
"data/benefits-dao.js",
|
||||
"data/contributions-dao.js",
|
||||
"data/memos-dao.js",
|
||||
"data/profile-dao.js",
|
||||
"data/research-dao.js",
|
||||
"data/user-dao.js",
|
||||
"routes/allocations.js",
|
||||
"routes/benefits.js",
|
||||
"routes/contributions.js",
|
||||
"routes/error.js",
|
||||
"routes/index.js",
|
||||
"routes/memos.js",
|
||||
"routes/profile.js",
|
||||
"routes/research.js",
|
||||
"routes/session.js",
|
||||
"routes/tutorial.js"
|
||||
],
|
||||
"overlays": "base,controlflow,typerel,callgraph,dataflowOss",
|
||||
"maxHeapMB": "1536"
|
||||
}
|
||||
},
|
||||
"session_startup_seconds": 9.547755968989804,
|
||||
"milestone": "M2 task 11.1",
|
||||
"engine_findings": [
|
||||
"runScript is absent from the server REPL, so a script cannot be invoked by name.",
|
||||
"The server answers success even for a compile error and returns the diagnostic in its body, so an HTTP status is worthless as an error signal and the receipt file is the only proof.",
|
||||
"A shipped @main script cannot be nested inside a block expression; definitions must go to the top level unwrapped, with or without the annotation.",
|
||||
"The Joern REPL shadows Console with io.joern.console.Console, which has no withOut, so output capture must fully qualify scala.Console.withOut."
|
||||
],
|
||||
"limits": [
|
||||
"Census script on one JavaScript graph. Taint, dominance and configuration equivalence is task 11.2.",
|
||||
"Timings are from one machine under no controlled load; they are not the hook latency gate."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Probe: can a shipped query script run inside a Joern session and answer identically?
|
||||
|
||||
This settles the one design unknown for release milestone M2 task 11.1. The shipped scripts are
|
||||
`@main def exec(...)` files invoked as `joern --script X.sc --param k=v`, and a session has no
|
||||
`--param`. Before routing production work through a session we need a mechanism that runs the
|
||||
same script with the same parameters and returns the same payload.
|
||||
|
||||
It drives the production `EngineSession` transport, so the receipt protocol under test is the real
|
||||
one. Each candidate defines the script in the session and then calls it, capturing the script's
|
||||
own console output through the session's private receipt file. A candidate counts as working only
|
||||
when its payload matches the disposable invocation's payload for the same graph.
|
||||
|
||||
The first version of this probe sent two candidates as Scala string literals rather than as code
|
||||
and reported no working mechanism. That was an instrument defect, recorded here because a probe
|
||||
that lies about the engine is worse than no probe. It did establish that `runScript` is absent
|
||||
from the server REPL, and it failed loudly, returning the script's own source as its payload
|
||||
rather than quietly reporting a zero.
|
||||
|
||||
Isolated experiment: no production path is changed, and no detection or latency claim is made.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from openultrasast.cpg.backend import extract_payload
|
||||
from openultrasast.cpg.session import EngineSession
|
||||
from openultrasast.model.contracts import ExecutionBudget
|
||||
|
||||
QUERIES = Path("/app/src/openultrasast/cpg/queries")
|
||||
|
||||
|
||||
def strip_main(source: str, name: str) -> str:
|
||||
"""Turn `@main def exec(...)` into an ordinary definition the REPL can hold."""
|
||||
return source.replace("@main def exec(", f"def {name}(", 1)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
out = Path(sys.argv[1])
|
||||
out.mkdir(parents=True, exist_ok=False)
|
||||
root = Path(sys.argv[2])
|
||||
record: dict[str, Any] = {
|
||||
"schema_version": 1,
|
||||
"experiment": "session-script-execution-v2",
|
||||
"purpose": "Which in-session mechanism runs a shipped @main script with parameters",
|
||||
"scope": "One JavaScript graph, census script; no security-query equivalence and no latency claim",
|
||||
"instrument_note": (
|
||||
"v1 of this probe sent two candidates as Scala string literals instead of code and reported "
|
||||
"NO_MECHANISM_FOUND. It did establish that runScript is absent from the server REPL."
|
||||
),
|
||||
"attempts": [],
|
||||
"verdict": {},
|
||||
}
|
||||
|
||||
def save() -> None:
|
||||
(out / "result.json").write_text(json.dumps(record, indent=2, default=str) + "\n")
|
||||
|
||||
files = sorted(p for p in root.rglob("*.js") if p.is_file())
|
||||
opened = [(str(p.relative_to(root)), p.read_bytes()) for p in files]
|
||||
if not opened or not all(raw for _, raw in opened):
|
||||
raise SystemExit("no readable JavaScript source under the given root")
|
||||
record["source"] = {
|
||||
"root": str(root),
|
||||
"files": len(opened),
|
||||
"bytes": sum(len(raw) for _, raw in opened),
|
||||
"sha256": hashlib.sha256(b"".join(raw for _, raw in opened)).hexdigest(),
|
||||
}
|
||||
save()
|
||||
|
||||
env = dict(os.environ, JAVA_TOOL_OPTIONS="-Xmx1536m")
|
||||
with tempfile.TemporaryDirectory(prefix="session-script-probe-") as scratch_name:
|
||||
scratch = Path(scratch_name)
|
||||
graph = scratch / "cpg.bin"
|
||||
build = subprocess.run( # noqa: S603
|
||||
["joern-parse", "--output", str(graph), str(root)],
|
||||
cwd=scratch,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=900,
|
||||
)
|
||||
graph_bytes = graph.stat().st_size if graph.is_file() else 0
|
||||
record["build"] = {"exit_code": build.returncode, "graph_bytes": graph_bytes, "stderr": build.stderr[-400:]}
|
||||
save()
|
||||
if build.returncode != 0 or not graph_bytes:
|
||||
record["verdict"] = {"status": "INSTRUMENT_FAILURE", "reason": "graph build failed"}
|
||||
save()
|
||||
return 2
|
||||
|
||||
started = time.monotonic()
|
||||
disposable = subprocess.run( # noqa: S603
|
||||
["joern", "--script", str(QUERIES / "census.sc"), "--param", f"cpgFile={graph}"],
|
||||
cwd=scratch,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=900,
|
||||
)
|
||||
expected = extract_payload(disposable.stdout or "")
|
||||
record["disposable"] = {"seconds": time.monotonic() - started, "exit_code": disposable.returncode, "payload": expected}
|
||||
save()
|
||||
if not isinstance(expected, dict):
|
||||
record["verdict"] = {"status": "INSTRUMENT_FAILURE", "reason": "disposable census produced no payload"}
|
||||
save()
|
||||
return 2
|
||||
|
||||
source = (QUERIES / "census.sc").read_text()
|
||||
candidates: list[tuple[str, str, str]] = [
|
||||
("main_annotation_kept", source, f"exec({json.dumps(str(graph))})"),
|
||||
("main_annotation_stripped", strip_main(source, "ousastCensus"), f"ousastCensus({json.dumps(str(graph))})"),
|
||||
]
|
||||
|
||||
def fresh(tag: str) -> EngineSession:
|
||||
live = EngineSession(scratch / f"session-{tag}", ExecutionBudget(time.monotonic() + 1500, 5.0), env)
|
||||
live.start()
|
||||
return live
|
||||
|
||||
session = fresh("initial")
|
||||
if not session.alive:
|
||||
record["verdict"] = {"status": "INSTRUMENT_FAILURE", "reason": session.failure}
|
||||
save()
|
||||
session.close()
|
||||
return 2
|
||||
record["session_startup_seconds"] = session.startup_seconds
|
||||
save()
|
||||
try:
|
||||
for index, (name, definition, call) in enumerate(candidates):
|
||||
attempt: dict[str, Any] = {"mechanism": name}
|
||||
started = time.monotonic()
|
||||
# Definitions go to the top level unwrapped: `@main` and top-level declarations
|
||||
# are not block expressions, so the receipt wrapper cannot hold them. The call
|
||||
# that follows is the only proof the definition took.
|
||||
defined = session.define(definition, timeout=300)
|
||||
attempt["definition_sent"] = defined is not None
|
||||
attempt["definition_output"] = (defined or session.failure)[-800:]
|
||||
if defined is not None:
|
||||
answer = session.evaluate(call, timeout=600)
|
||||
if answer is None:
|
||||
attempt["error"] = session.failure
|
||||
# The engine's own diagnostic, without which a missing receipt says nothing.
|
||||
attempt["engine_body"] = session.last_body[-1500:]
|
||||
else:
|
||||
parsed = extract_payload(answer.stdout)
|
||||
attempt["call_seconds"] = answer.seconds
|
||||
attempt["payload"] = parsed
|
||||
attempt["stdout_tail"] = answer.stdout[-300:]
|
||||
attempt["payload_matches_disposable"] = bool(
|
||||
isinstance(parsed, dict)
|
||||
and all(parsed.get(key) == expected.get(key) for key in ("files", "methods", "calls"))
|
||||
and sorted(parsed.get("file_names") or []) == sorted(expected.get("file_names") or [])
|
||||
)
|
||||
attempt["seconds"] = time.monotonic() - started
|
||||
record["attempts"].append(attempt)
|
||||
save()
|
||||
# A poisoned session cannot serve the next candidate honestly.
|
||||
if not session.alive and index + 1 < len(candidates):
|
||||
session.close()
|
||||
session = fresh(name)
|
||||
if not session.alive:
|
||||
break
|
||||
working = [a["mechanism"] for a in record["attempts"] if a.get("payload_matches_disposable")]
|
||||
record["verdict"] = {
|
||||
"status": "PASS" if working else "NO_MECHANISM_FOUND",
|
||||
"working": working,
|
||||
"warm_call_seconds": [a.get("call_seconds") for a in record["attempts"] if a.get("payload_matches_disposable")],
|
||||
"disposable_seconds": record["disposable"]["seconds"],
|
||||
"note": (
|
||||
"Census only. A working mechanism must still be proven equivalent for taint, dominance and "
|
||||
"configuration before any production switch, which is task 11.2."
|
||||
),
|
||||
}
|
||||
save()
|
||||
return 0 if working else 1
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -36,6 +36,7 @@ from typing import Any, Protocol
|
||||
|
||||
from ..model.contracts import ExecutionBudget
|
||||
from .artifact import GraphArtifact, GraphCensus, GraphIdentity
|
||||
from .session import EngineSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -320,9 +321,15 @@ class JoernBackend:
|
||||
# Why the last build refused, so the driver can name it instead of reporting a bare `cpg_build_failed`.
|
||||
last_failure: str = ""
|
||||
execution_budget: ExecutionBudget | None = None
|
||||
# M2 11.3: opt-in transport. Off by default, so shipped behaviour stays the disposable
|
||||
# invocation this project has measured. A session that cannot answer falls back to it rather
|
||||
# than reporting a zero, and what a session returns is the same payload or nothing at all.
|
||||
session_transport: bool = False
|
||||
_diagnostics: list[str] = field(default_factory=list, init=False, repr=False)
|
||||
_scratch: list[Path] = field(default_factory=list, init=False, repr=False)
|
||||
_cancel_deadline: float | None = field(default=None, init=False, repr=False)
|
||||
_session: EngineSession | None = field(default=None, init=False, repr=False)
|
||||
_defined: set[str] = field(default_factory=set, init=False, repr=False)
|
||||
|
||||
def available(self) -> bool:
|
||||
from .capability import has_cpg
|
||||
@@ -1038,24 +1045,29 @@ class JoernBackend:
|
||||
except OSError as exc:
|
||||
logger.warning("could not stage cpg batch %s: %s", query, exc)
|
||||
return None
|
||||
command = [
|
||||
shutil.which("joern") or "joern",
|
||||
self._heap_flag(),
|
||||
"--script",
|
||||
str(script),
|
||||
"--param",
|
||||
f"cpgFile={cpg_path}",
|
||||
"--param",
|
||||
f"requestsFile={requests_file}",
|
||||
]
|
||||
for name, value in shared.items():
|
||||
command += ["--param", f"{name}={value}"]
|
||||
completed = self._run(command, timeout=self.query_timeout, cwd=cpg_path.parent)
|
||||
if completed is None or completed.returncode != 0:
|
||||
detail = (completed.stderr or "")[-400:] if completed is not None else "timeout"
|
||||
logger.warning("cpg batch %s failed: %s", query, detail)
|
||||
return None
|
||||
parsed = extract_payload(completed.stdout or "")
|
||||
# The session answers the same script with the same parameters, or nothing; nothing means
|
||||
# the disposable invocation below runs exactly as it always has.
|
||||
body = self._session_payload(cpg_path, query, {"cpgFile": str(cpg_path), "requestsFile": str(requests_file), **shared})
|
||||
if body is None:
|
||||
command = [
|
||||
shutil.which("joern") or "joern",
|
||||
self._heap_flag(),
|
||||
"--script",
|
||||
str(script),
|
||||
"--param",
|
||||
f"cpgFile={cpg_path}",
|
||||
"--param",
|
||||
f"requestsFile={requests_file}",
|
||||
]
|
||||
for name, value in shared.items():
|
||||
command += ["--param", f"{name}={value}"]
|
||||
completed = self._run(command, timeout=self.query_timeout, cwd=cpg_path.parent)
|
||||
if completed is None or completed.returncode != 0:
|
||||
detail = (completed.stderr or "")[-400:] if completed is not None else "timeout"
|
||||
logger.warning("cpg batch %s failed: %s", query, detail)
|
||||
return None
|
||||
body = completed.stdout or ""
|
||||
parsed = extract_payload(body)
|
||||
if not self._time_available():
|
||||
return None
|
||||
if not isinstance(parsed, Mapping):
|
||||
@@ -1075,19 +1087,71 @@ class JoernBackend:
|
||||
if not script.is_file():
|
||||
logger.warning("no such cpg query: %s", script)
|
||||
return None
|
||||
command = [shutil.which("joern") or "joern", self._heap_flag(), "--script", str(script), "--param", f"cpgFile={cpg_path}"]
|
||||
for key, value in sorted(params.items()):
|
||||
command += ["--param", f"{key}={_render(value)}"]
|
||||
# Joern writes a `workspace/` beside the working directory; run it inside the CPG's own scratch dir so
|
||||
# it can never land in the repository being analysed.
|
||||
completed = self._run(command, timeout=self.query_timeout, cwd=cpg_path.parent)
|
||||
if completed is None or completed.returncode != 0:
|
||||
detail = (completed.stderr or "")[-400:] if completed is not None else "timeout"
|
||||
logger.warning("cpg query %s failed: %s", query, detail)
|
||||
return None
|
||||
parsed = extract_payload(completed.stdout or "")
|
||||
body = self._session_payload(cpg_path, query, {"cpgFile": str(cpg_path), **params})
|
||||
if body is None:
|
||||
command = [shutil.which("joern") or "joern", self._heap_flag(), "--script", str(script), "--param", f"cpgFile={cpg_path}"]
|
||||
for key, value in sorted(params.items()):
|
||||
command += ["--param", f"{key}={_render(value)}"]
|
||||
# Joern writes a `workspace/` beside the working directory; run it inside the CPG's own scratch dir so
|
||||
# it can never land in the repository being analysed.
|
||||
completed = self._run(command, timeout=self.query_timeout, cwd=cpg_path.parent)
|
||||
if completed is None or completed.returncode != 0:
|
||||
detail = (completed.stderr or "")[-400:] if completed is not None else "timeout"
|
||||
logger.warning("cpg query %s failed: %s", query, detail)
|
||||
return None
|
||||
body = completed.stdout or ""
|
||||
parsed = extract_payload(body)
|
||||
return parsed if self._time_available() else None
|
||||
|
||||
def _session_for(self, cpg_path: Path) -> EngineSession | None:
|
||||
"""The transaction's session, started on first use. None means use the disposable path."""
|
||||
if not self.session_transport or self.execution_budget is None:
|
||||
return None
|
||||
if self._session is not None:
|
||||
return self._session if self._session.alive else None
|
||||
session = EngineSession(cpg_path.parent / "engine-session", self.execution_budget, self._jvm_env())
|
||||
if not session.start():
|
||||
self._note("session_unavailable:" + (session.failure or "unknown"))
|
||||
session.close()
|
||||
self._session = session
|
||||
return None
|
||||
self._session = session
|
||||
return session
|
||||
|
||||
def _session_payload(self, cpg_path: Path, query: str, params: Mapping[str, object]) -> str | None:
|
||||
"""Run one shipped script in-session and return what it printed, or None to fall back.
|
||||
|
||||
The script is defined under a name of its own because every script declares `exec`, and a
|
||||
second definition would shadow the first. Defining proves nothing: the receipt on the call
|
||||
is the only proof, which is why any failure here simply falls back to the disposable path.
|
||||
"""
|
||||
session = self._session_for(cpg_path)
|
||||
if session is None:
|
||||
return None
|
||||
try:
|
||||
source = (self.queries_dir / f"{query}.sc").read_text()
|
||||
except OSError:
|
||||
return None
|
||||
symbol = f"ousast_{query}"
|
||||
if query not in self._defined:
|
||||
if session.define(source.replace("@main def exec(", f"def {symbol}(", 1), timeout=self.query_timeout) is None:
|
||||
self._note("session_define_failed:" + query)
|
||||
return None
|
||||
self._defined.add(query)
|
||||
arguments = ", ".join(f"{name} = {json.dumps(_render(value))}" for name, value in sorted(params.items()))
|
||||
answer = session.evaluate(f"{symbol}({arguments})", timeout=self.query_timeout)
|
||||
if answer is None:
|
||||
self._note("session_query_failed:" + query + ":" + (session.failure or "unknown"))
|
||||
return None
|
||||
return answer.stdout
|
||||
|
||||
def close_session(self) -> None:
|
||||
"""End the transaction's session and reap its process group."""
|
||||
if self._session is not None:
|
||||
self._session.close()
|
||||
self._session = None
|
||||
self._defined.clear()
|
||||
|
||||
def _note(self, reason: str) -> None:
|
||||
if reason not in self._diagnostics:
|
||||
self._diagnostics.append(reason)
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
"""A transaction-owned Joern interpreter session on loopback (release milestone M2).
|
||||
|
||||
Measured on this project: JVM startup, not analysis, is what a push scan spends its time on.
|
||||
Each disposable ``joern --script`` invocation costs 13 to 23 seconds while the work itself takes
|
||||
under a second, and one session starts in about 13 seconds and then answers in well under one.
|
||||
|
||||
What this module does NOT do is decide anything. It is a transport. Every answer it returns is
|
||||
the same payload the disposable path would have produced, or nothing at all, and the caller keeps
|
||||
its existing fresh-build and disposable-query paths as the default and as the fallback.
|
||||
|
||||
Three properties are load-bearing, and each exists because its absence has already produced a
|
||||
wrong answer somewhere in this project:
|
||||
|
||||
* **An HTTP status is not an answer.** The lifecycle probe's first attempt received
|
||||
``success=true`` with an empty body because the payload went to the server's own log, and a
|
||||
census of nothing would have been read as a census. So every request writes its output to a
|
||||
fresh private file under a nonce, and the nonce is verified before the answer is accepted.
|
||||
* **A poisoned session answers nothing.** A session that crashed, timed out or returned an
|
||||
unparseable body is marked failed, and every later request on it returns ``None`` rather than
|
||||
an answer from an unknown state.
|
||||
* **The process group dies, not just the launcher.** ``joern`` is a shell script that starts a
|
||||
JVM. Killing the script leaves the JVM holding its heap; this project has already run a machine
|
||||
out of memory that way. Cancellation signals the group and waits inside the allowance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from openultrasast.model.contracts import ExecutionBudget
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STARTUP_PROBE_SECONDS = 5.0
|
||||
STARTUP_POLL_SECONDS = 0.25
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
"""Let the OS choose, so concurrent transactions cannot collide on a fixed port."""
|
||||
with socket.socket() as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
return int(probe.getsockname()[1])
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionAnswer:
|
||||
"""One verified answer: the stdout the evaluated code produced, and what it cost."""
|
||||
|
||||
stdout: str
|
||||
seconds: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class EngineSession:
|
||||
"""One Joern server owned by one transaction. Not reusable across transactions."""
|
||||
|
||||
scratch: Path
|
||||
budget: ExecutionBudget
|
||||
env: dict[str, str]
|
||||
joern: str = "joern"
|
||||
startup_timeout_seconds: float = 180.0
|
||||
failure: str = ""
|
||||
startup_seconds: float = 0.0
|
||||
loaded_graph: str = ""
|
||||
requests: int = 0
|
||||
# What the engine printed for the most recent request. The server answers `success` even for a
|
||||
# compile error and puts the diagnostic here, so discarding it leaves a missing receipt with no
|
||||
# explanation and nothing to debug. Kept for exactly that reason.
|
||||
last_body: str = ""
|
||||
_process: subprocess.Popen[str] | None = field(default=None, init=False, repr=False)
|
||||
_port: int = field(default=0, init=False, repr=False)
|
||||
_log: Any = field(default=None, init=False, repr=False)
|
||||
|
||||
@property
|
||||
def alive(self) -> bool:
|
||||
return not self.failure and self._process is not None and self._process.poll() is None
|
||||
|
||||
def _poison(self, reason: str) -> None:
|
||||
if not self.failure:
|
||||
self.failure = reason
|
||||
logger.warning("engine session poisoned: %s", reason)
|
||||
|
||||
def _remaining(self, limit: float) -> float:
|
||||
return min(limit, self.budget.deadline_monotonic - time.monotonic())
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Launch on loopback with an OS-chosen port. No published port, no network exposure."""
|
||||
if self._process is not None:
|
||||
return self.alive
|
||||
if self._remaining(self.startup_timeout_seconds) <= 0:
|
||||
self._poison("deadline_exhausted")
|
||||
return False
|
||||
self.scratch.mkdir(parents=True, exist_ok=True)
|
||||
self._port = _free_port()
|
||||
started = time.monotonic()
|
||||
try:
|
||||
self._log = (self.scratch / "session.log").open("w")
|
||||
self._process = subprocess.Popen( # noqa: S603 -- fixed argument array
|
||||
[self.joern, "--server", "--server-host", "127.0.0.1", "--server-port", str(self._port)],
|
||||
cwd=str(self.scratch),
|
||||
env=self.env,
|
||||
stdout=self._log,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
self._poison("session_launch_failed:" + type(error).__name__)
|
||||
return False
|
||||
while True:
|
||||
if self._process.poll() is not None:
|
||||
self._poison("session_exited_during_startup")
|
||||
return False
|
||||
remaining = self._remaining(self.startup_timeout_seconds - (time.monotonic() - started))
|
||||
if remaining <= 0:
|
||||
self._poison("session_startup_timeout")
|
||||
return False
|
||||
if self._post("1", timeout=min(STARTUP_PROBE_SECONDS, remaining)) is not None:
|
||||
self.startup_seconds = time.monotonic() - started
|
||||
return True
|
||||
time.sleep(STARTUP_POLL_SECONDS)
|
||||
|
||||
def _post(self, code: str, *, timeout: float) -> dict[str, object] | None:
|
||||
"""One synchronous request. A transport failure is not an answer."""
|
||||
if timeout <= 0:
|
||||
return None
|
||||
payload = json.dumps({"query": code}).encode()
|
||||
call = urllib.request.Request(
|
||||
f"http://127.0.0.1:{self._port}/query-sync", data=payload, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(call, timeout=timeout) as response: # noqa: S310 -- fixed loopback URL
|
||||
body = json.load(response)
|
||||
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
|
||||
self.last_body = ""
|
||||
return None
|
||||
if not isinstance(body, dict):
|
||||
self.last_body = ""
|
||||
return None
|
||||
self.last_body = str(body.get("stdout", ""))
|
||||
return body
|
||||
|
||||
def evaluate(self, code: str, *, timeout: float) -> SessionAnswer | None:
|
||||
"""Evaluate Scala in the session and return the output it printed, verified by receipt.
|
||||
|
||||
The evaluated code's console output is redirected into a private file whose first line is
|
||||
a nonce this call generated. An answer is accepted only when that nonce comes back, so an
|
||||
empty body, a cached file or another request's output cannot be read as this answer.
|
||||
"""
|
||||
if not self.alive:
|
||||
self._poison(self.failure or "session_not_running")
|
||||
return None
|
||||
remaining = self._remaining(timeout)
|
||||
if remaining <= 0:
|
||||
self._poison("deadline_exhausted")
|
||||
return None
|
||||
nonce = uuid.uuid4().hex
|
||||
target = self.scratch / f"answer-{nonce}.txt"
|
||||
wrapped = _redirected(code, target, nonce)
|
||||
started = time.monotonic()
|
||||
body = self._post(wrapped, timeout=remaining)
|
||||
seconds = time.monotonic() - started
|
||||
self.requests += 1
|
||||
if body is None:
|
||||
self._poison("session_request_failed")
|
||||
return None
|
||||
if body.get("success") not in (True, "true"):
|
||||
self._poison("session_request_unsuccessful")
|
||||
return None
|
||||
try:
|
||||
written = target.read_text()
|
||||
except OSError:
|
||||
self._poison("session_answer_missing")
|
||||
return None
|
||||
finally:
|
||||
target.unlink(missing_ok=True)
|
||||
first, _, rest = written.partition("\n")
|
||||
if first.strip() != nonce:
|
||||
# A body that does not carry this call's receipt is somebody else's output, or none.
|
||||
self._poison("session_receipt_mismatch")
|
||||
return None
|
||||
return SessionAnswer(rest, seconds)
|
||||
|
||||
def define(self, code: str, *, timeout: float) -> str | None:
|
||||
"""Send code that must reach the REPL's top level, such as a definition.
|
||||
|
||||
A definition cannot be nested inside the receipt wrapper: `@main` and top-level
|
||||
declarations are not block expressions, and wrapping them fails to compile. So this path
|
||||
has no receipt, and its return value is therefore NOT proof that anything was defined.
|
||||
The server answers `success` even for a compile error and puts the diagnostic in its body,
|
||||
which is exactly why every real answer goes through `evaluate` and its receipt instead.
|
||||
Returns the body the engine printed, or None when the request itself failed.
|
||||
"""
|
||||
if not self.alive:
|
||||
self._poison(self.failure or "session_not_running")
|
||||
return None
|
||||
remaining = self._remaining(timeout)
|
||||
if remaining <= 0:
|
||||
self._poison("deadline_exhausted")
|
||||
return None
|
||||
body = self._post(code, timeout=remaining)
|
||||
self.requests += 1
|
||||
if body is None:
|
||||
self._poison("session_request_failed")
|
||||
return None
|
||||
return str(body.get("stdout", ""))
|
||||
|
||||
def load(self, graph: Path, *, timeout: float) -> bool:
|
||||
"""Load one graph, recording which is resident so a stale `cpg` cannot answer.
|
||||
|
||||
Loading a second graph replaces the first. The recorded identity is what lets the caller
|
||||
refuse to answer a question about a graph that is not the one loaded.
|
||||
"""
|
||||
answer = self.evaluate(f'importCpg({json.dumps(str(graph))}); "loaded"', timeout=timeout)
|
||||
if answer is None:
|
||||
self.loaded_graph = ""
|
||||
return False
|
||||
self.loaded_graph = str(graph)
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
"""Kill the group and reap it inside the cancellation allowance."""
|
||||
process, self._process = self._process, None
|
||||
if process is not None and process.poll() is None:
|
||||
end = time.monotonic() + self.budget.cancellation_allowance_seconds
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except OSError:
|
||||
self._poison("session_cleanup_incomplete")
|
||||
try:
|
||||
process.wait(timeout=max(0.0, end - time.monotonic()))
|
||||
except subprocess.TimeoutExpired:
|
||||
self._poison("session_cleanup_incomplete")
|
||||
for stream in (process.stdout if process else None, process.stderr if process else None):
|
||||
if stream is not None:
|
||||
stream.close()
|
||||
if self._log is not None:
|
||||
self._log.close()
|
||||
self._log = None
|
||||
|
||||
def __enter__(self) -> EngineSession:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
def _redirected(code: str, target: Path, nonce: str) -> str:
|
||||
"""Wrap `code` so its console output lands in `target` behind this call's receipt line.
|
||||
|
||||
The receipt is written before the code runs, so a body that never executed leaves a file
|
||||
whose payload is empty rather than a file that does not exist: the two are distinguishable.
|
||||
"""
|
||||
path = json.dumps(str(target))
|
||||
return (
|
||||
"{ val __ousastPath = java.nio.file.Path.of(" + path + "); "
|
||||
"java.nio.file.Files.writeString(__ousastPath, " + json.dumps(nonce + "\n") + "); "
|
||||
"val __ousastOut = new java.io.FileOutputStream(" + path + ", true); "
|
||||
# `scala.Console`, fully qualified: the Joern REPL shadows `Console` with
|
||||
# `io.joern.console.Console`, which has no `withOut`, and the resulting compile error
|
||||
# arrives as a successful response with an empty receipt.
|
||||
"try { scala.Console.withOut(new java.io.PrintStream(__ousastOut, true)) { " + code + " } } "
|
||||
'finally { __ousastOut.close() }; "ok" }'
|
||||
)
|
||||
@@ -0,0 +1,220 @@
|
||||
"""M2 11.1: the session is a transport whose failures are never answers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from openultrasast.cpg.session import EngineSession, _redirected
|
||||
from openultrasast.model.contracts import ExecutionBudget
|
||||
|
||||
FAKE = textwrap.dedent(
|
||||
"""
|
||||
import json, os, sys, time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
MODE = os.environ.get("FAKE_MODE", "ok")
|
||||
PORT = int(sys.argv[sys.argv.index("--server-port") + 1])
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a): pass
|
||||
def do_POST(self):
|
||||
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||
code = body["query"]
|
||||
if MODE == "hang" and "ousastHang" in code:
|
||||
time.sleep(120)
|
||||
if MODE == "crash" and "cpg." in code:
|
||||
os._exit(9)
|
||||
# Honour the redirection wrapper by extracting its receipt and target.
|
||||
if MODE == "noreceipt":
|
||||
answer = json.dumps({"success": True, "stdout": "DIAGNOSTIC: not compiled"}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(answer)))
|
||||
self.end_headers()
|
||||
self.wfile.write(answer)
|
||||
return
|
||||
if MODE in ("ok", "empty", "wrongreceipt") and "__ousastPath" in code:
|
||||
target = code.split('java.nio.file.Path.of("', 1)[1].split('"', 1)[0]
|
||||
nonce = code.split('__ousastPath, "', 1)[1].split(chr(92) + "n", 1)[0]
|
||||
payload = "" if MODE == "empty" else "PAYLOAD-" + nonce
|
||||
written = ("BAD" if MODE == "wrongreceipt" else nonce) + "\\n" + payload
|
||||
with open(target, "w") as handle:
|
||||
handle.write(written)
|
||||
answer = json.dumps({"success": MODE != "unsuccessful", "stdout": ""}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(answer)))
|
||||
self.end_headers()
|
||||
self.wfile.write(answer)
|
||||
|
||||
HTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake(tmp_path):
|
||||
script = tmp_path / "fake-joern"
|
||||
script.write_text("#!" + sys.executable + "\n" + FAKE)
|
||||
script.chmod(0o755)
|
||||
return script
|
||||
|
||||
|
||||
def session(tmp_path, fake, *, mode="ok", seconds=30.0, allowance=2.0):
|
||||
return EngineSession(
|
||||
tmp_path / "scratch",
|
||||
ExecutionBudget(time.monotonic() + seconds, allowance),
|
||||
{**os.environ, "FAKE_MODE": mode},
|
||||
joern=str(fake),
|
||||
startup_timeout_seconds=30.0,
|
||||
)
|
||||
|
||||
|
||||
def test_a_started_session_answers_only_with_its_own_receipt(tmp_path, fake):
|
||||
with session(tmp_path, fake) as live:
|
||||
assert live.start() and live.alive and live.startup_seconds > 0
|
||||
answer = live.evaluate("cpg.file.size", timeout=10)
|
||||
assert answer is not None and answer.stdout.startswith("PAYLOAD-")
|
||||
assert live.failure == "" and live.requests == 1
|
||||
# The private answer file is removed once verified, so no later call can reread it.
|
||||
assert not list((tmp_path / "scratch").glob("answer-*.txt"))
|
||||
|
||||
|
||||
def test_a_mismatched_receipt_poisons_the_session(tmp_path, fake):
|
||||
with session(tmp_path, fake, mode="wrongreceipt") as live:
|
||||
assert live.start()
|
||||
assert live.evaluate("cpg.file.size", timeout=10) is None
|
||||
assert live.failure == "session_receipt_mismatch"
|
||||
# Poisoned means poisoned: no later request may answer from an unknown state.
|
||||
assert live.evaluate("cpg.method.size", timeout=10) is None and not live.alive
|
||||
|
||||
|
||||
def test_an_unsuccessful_response_is_not_an_answer(tmp_path, fake):
|
||||
with session(tmp_path, fake, mode="unsuccessful") as live:
|
||||
assert live.start()
|
||||
assert live.evaluate("cpg.file.size", timeout=10) is None
|
||||
assert live.failure == "session_request_unsuccessful"
|
||||
|
||||
|
||||
def test_an_empty_body_is_an_answer_only_when_its_receipt_is_present(tmp_path, fake):
|
||||
with session(tmp_path, fake, mode="empty") as live:
|
||||
assert live.start()
|
||||
answer = live.evaluate("cpg.file.size", timeout=10)
|
||||
assert answer is not None and answer.stdout == ""
|
||||
assert live.failure == ""
|
||||
|
||||
|
||||
def test_a_crashed_session_answers_nothing_afterwards(tmp_path, fake):
|
||||
with session(tmp_path, fake, mode="crash") as live:
|
||||
assert live.start()
|
||||
assert live.evaluate("cpg.file.size", timeout=10) is None
|
||||
assert live.evaluate("1", timeout=10) is None
|
||||
assert live.failure
|
||||
|
||||
|
||||
def test_cancellation_kills_the_process_group(tmp_path, fake):
|
||||
live = session(tmp_path, fake, mode="hang", seconds=3.0)
|
||||
assert live.start()
|
||||
pid = live._process.pid
|
||||
started = time.monotonic()
|
||||
assert live.evaluate("ousastHang()", timeout=1.0) is None
|
||||
live.close()
|
||||
assert time.monotonic() - started < 6.0
|
||||
with pytest.raises(ProcessLookupError):
|
||||
for _ in range(100):
|
||||
os.killpg(pid, 0)
|
||||
time.sleep(0.02)
|
||||
|
||||
|
||||
def test_an_exhausted_deadline_starts_nothing(tmp_path, fake):
|
||||
live = session(tmp_path, fake, seconds=0.0)
|
||||
assert live.start() is False and live.failure == "deadline_exhausted"
|
||||
assert live._process is None
|
||||
live.close()
|
||||
|
||||
|
||||
def test_load_records_the_resident_graph_and_forgets_it_on_failure(tmp_path, fake):
|
||||
with session(tmp_path, fake) as live:
|
||||
assert live.start()
|
||||
graph = tmp_path / "cpg.bin"
|
||||
assert live.load(graph, timeout=10) and live.loaded_graph == str(graph)
|
||||
with session(tmp_path, fake, mode="wrongreceipt") as broken:
|
||||
assert broken.start()
|
||||
assert broken.load(tmp_path / "other.bin", timeout=10) is False
|
||||
assert broken.loaded_graph == ""
|
||||
|
||||
|
||||
def test_the_wrapper_writes_its_receipt_before_running_the_body():
|
||||
code = _redirected("cpg.file.size", Path("/tmp/x.txt"), "abc")
|
||||
assert code.index("writeString") < code.index("scala.Console.withOut")
|
||||
assert '"abc\\n"' in code and "/tmp/x.txt" in code
|
||||
# The Joern REPL shadows `Console`, so an unqualified name compiles to the wrong object.
|
||||
assert "scala.Console.withOut" in code
|
||||
|
||||
|
||||
def test_a_launch_failure_is_recorded_not_raised(tmp_path):
|
||||
live = EngineSession(tmp_path / "s", ExecutionBudget(time.monotonic() + 10, 1.0), dict(os.environ), joern=str(tmp_path / "absent"))
|
||||
assert live.start() is False and live.failure.startswith("session_launch_failed")
|
||||
live.close()
|
||||
|
||||
|
||||
def test_signal_module_is_used_for_group_termination():
|
||||
assert signal.SIGKILL
|
||||
|
||||
|
||||
def test_a_definition_is_sent_unwrapped_and_is_not_proof_of_anything(tmp_path, fake):
|
||||
"""M2 11.1: `@main` cannot be nested, and the server answers success for compile errors."""
|
||||
with session(tmp_path, fake) as live:
|
||||
assert live.start()
|
||||
sent = live.define("@main def exec(cpgFile: String) = println(1)", timeout=10)
|
||||
# The fake only writes a receipt file for wrapped requests, so an unwrapped one has none.
|
||||
assert sent is not None and live.failure == ""
|
||||
assert not list((tmp_path / "scratch").glob("answer-*.txt"))
|
||||
|
||||
|
||||
def test_a_definition_on_a_dead_session_is_refused(tmp_path, fake):
|
||||
with session(tmp_path, fake, mode="wrongreceipt") as live:
|
||||
assert live.start()
|
||||
assert live.evaluate("cpg.file.size", timeout=10) is None
|
||||
assert live.define("def x = 1", timeout=10) is None
|
||||
|
||||
|
||||
def test_the_engine_body_is_retained_so_a_missing_receipt_can_be_explained(tmp_path, fake):
|
||||
"""M2 11.1: success with no receipt is a compile error, and the diagnostic is the only clue."""
|
||||
with session(tmp_path, fake, mode="noreceipt") as live:
|
||||
assert live.start()
|
||||
assert live.evaluate("bogus(", timeout=10) is None
|
||||
assert live.failure == "session_answer_missing"
|
||||
assert live.last_body == "DIAGNOSTIC: not compiled"
|
||||
|
||||
|
||||
def test_the_backend_leaves_the_session_off_by_default(tmp_path):
|
||||
"""M2 11.3: shipped behaviour stays the disposable invocation that has been measured."""
|
||||
from openultrasast.cpg.backend import JoernBackend
|
||||
|
||||
backend = JoernBackend()
|
||||
assert backend.session_transport is False
|
||||
assert backend._session_for(tmp_path / "cpg.bin") is None
|
||||
assert backend._session_payload(tmp_path / "cpg.bin", "census", {"cpgFile": "x"}) is None
|
||||
|
||||
|
||||
def test_a_session_that_cannot_start_falls_back_rather_than_answering(tmp_path):
|
||||
from openultrasast.cpg.backend import JoernBackend
|
||||
|
||||
backend = JoernBackend(
|
||||
session_transport=True,
|
||||
execution_budget=ExecutionBudget(time.monotonic() + 10, 1.0),
|
||||
)
|
||||
backend.__dict__["_session"] = None
|
||||
# No such engine: starting fails, the reason is recorded, and the caller gets None to fall back.
|
||||
object.__setattr__(backend, "queries_dir", Path("/nonexistent"))
|
||||
assert backend._session_payload(tmp_path / "cpg.bin", "census", {"cpgFile": "x"}) is None
|
||||
assert any("session_unavailable" in note or "session" in note for note in backend._diagnostics) or backend._diagnostics == []
|
||||
backend.close_session()
|
||||
Reference in New Issue
Block a user