[DO NOT MERGE] Prototype runtime IAST taint tracking for Go - #858
Draft
eliottness wants to merge 18 commits into
Draft
[DO NOT MERGE] Prototype runtime IAST taint tracking for Go#858eliottness wants to merge 18 commits into
eliottness wants to merge 18 commits into
Conversation
The research report left 110 of 155 comparison cases marked "Not proven", meaning no fixture established either a win or a loss. This resolves all of them that are measurable, replacing inference with executed evidence. Both suites are restructured first so cases are additive and isolated: - Orchestrion E2E moves from one monolithic test to a discovery harness in testdata/e2e. Each case is a self-contained file registering a Case with its own expectations, asserted in-process via taint.SetReporter. One build and one run cover the whole suite. The legacy assertions are preserved exactly (same 35 sink values) and tightened from "ranges non-empty" to exact ranges. - The patched-Go suite moves from a hardcoded slice to fixture/*/cases.json discovery, with duplicate-name and orphan-fixture guards. All 31 original cases round-trip unchanged. Adds build-variant coverage the shared harness cannot express: a disabled-instrumentation build (cases 1, 119) and a -race build re-running every case (cases 98, 99, 120). Results, all measured rather than asserted: Orchestrion 54 ->103 win, 54 -> 5 not proven, 0 false positives Patched Go 44 -> 49 win, 72 -> 8 not proven, 30 -> 89 loss The patched prototype propagates through runtime plumbing (channels, goroutines under -race, no capacity cap) but loses taint on every string operation tested, including filepath.Join, path.Clean, fmt.Sprintf, bufio.ReadString and database/sql Scan. Root cause is that labels do not survive cross-package calls into stdlib and []byte is not a tracked type. One correctness defect found: reflective map overwrite leaves stale shadow state in the patched runtime, so a clean value reports as tainted (fixture reflectmapcleanoverwriisolat). Recorded as a regression test. The 8 remaining not-proven cases each carry an explicit reason in the ledger: 4 target APIs that do not exist, 2 are umbrella rows deferring to concrete rows, and 2 need other-architecture build lanes. Suites: 35 assertions -> 174 subtests, and 31 -> 97 subtests.
Taint aspects need to weave calls that do not appear as a plain selector call. A captured method expression (`grow := (*bytes.Buffer).Grow`) and a bound method value both reach the callee through a value, so `method-call` never matches them and the taint integration silently lost propagation at those sites. `method-expression` matches a selector only when it is NOT the `Fun` of its parent `CallExpr`, so an immediate direct call still belongs to `method-call` alone and cannot be double-woven. `method-value` covers the bound form. The golden fixtures prove the distinction non-vacuously: the captured expression is wrapped while an adjacent direct call on the same method is left byte-for-byte unchanged.
…annels Adds 20 value operations so a `byte` or `rune` scalar keeps its provenance when it crosses a direct call, a map entry or a channel, rather than being dropped at the boundary. Scoping is what makes this safe. A scalar container is only marked after its declaration owner's whole body is proven compatible, and every store, load, send and receive requires that exact marker, so a container captured by a closure cannot have an inner operation rewritten underneath it. The scalar-container-scope golden asserts zero instrumentation calls for the mixed and captured cases. Fixes a schema/source drift this exposed: `internal/injector/config/schema.json` still listed only the original 23 operations, so `orchestrion.yml` referenced 20 names the schema rejected and `TestSchemaValidatesTaintIntegration` failed. The operations now live in one place, `allValueOperations`, which the unmarshaler validates against instead of a hand-maintained switch, and `Test_SchemaEnumListsEveryValueOperation` compares that list to the schema enum in both directions so neither side can grow alone again.
…der/sql sinks Extends the runtime along four axes: - Request generations. `StartRequest` opens a generation and retains exactly one prior one, so a value that escapes into a goroutine keeps exact provenance across a single rollover. A second rollover evicts the oldest generation to bound metadata and latches conservative `unknown` reporting for range-free values. The trade is deliberate and one-directional: it can over-taint a clean sink, never under-taint an escaped one, and the tests pin both halves. - Scalar transfer for `byte` and `rune` across call, map and channel boundaries. - `bufio.Reader`/`strings.Reader` and `database/sql` row-scan propagation. - Source identity: distinct source IDs so adjacent ranges from different sources are reported separately instead of coalescing, plus registry saturation flags. Fixes a false-negative defect in scalar provenance. Scalars were keyed on `uintptr`, which goes stale the moment Go copies a goroutine stack, so taint was silently lost — under `-race` it disappeared in 3 of 4 runs with no signal at all. Keying on GC-visible `unsafe.Pointer` identity fixes it; `scalar_transfer_test.go` covers the stack-growth path.
Grows the end-to-end matrix from 86 to 114 cases, so the suite now runs 232 subtests: every case instrumented, every case again under `-race`, two instrumentation-disabled builds, and two exact-range cases past 65,536 registry entries. The comparator is the point of this lane. Each case asserts the exact sink value, the exact byte-range coordinates and the COMPLETE report multiset, and the unmatched-report loop means an extra report fails the case just as a missing one does. An empty expectation is a real negative assertion rather than a no-op, which is what lets the clean half of a fixture prove silence in the same run as its tainted half. New coverage includes captured method expressions and bound method values, scalar transitions through calls/maps/channels, reflection-based map delete and overwrite in isolation, `database/sql` driver bytes, `regexp` fresh output, and multi-result returns.
Refreshes the durable diff of the isolated Go 1.26.1 worktree. The toolchain built from this patch self-identifies as `iast-taint-shadow-v28` via `go tool compile -V=full`, and the file is kept byte-identical to that live worktree so the compiler and runtime work can be rebuilt from the repository alone — it is the only surviving copy. Adds since the previous revision: - Control-flow taint. A synthetic PC-taint SSA value joins condition labels into assignments, captured before condition-init lowering and restored at if, loop, switch and select postdominators. Reports are deliberately header-only with no ranges, because control-flow influence has no byte provenance to report. - Byte and rune scalar transitions across calls, maps and channels. - Byte-exact range reporting, which walks the per-byte shadow bitmask the design already carried and coalesces dirty runs; distinct source IDs keep spans from different sources from merging. - Per-symbol admissions for stdlib functions that compute fresh bytes rather than copying them: `strconv.appendQuotedWith`, `net/url.escape`, `base64.(*Encoding).EncodeToString`, `path.Clean` and its `lazybuf.append`. These are unexported internals with no compatibility promise, so each is a maintenance liability that the win count does not show. - Recovery of a local `os.Getenv` alias, guarded so a reassigned alias is declined rather than treated as a source. Also emits the compiler's own build ID into `-V=full`. Without it the Go build cache never invalidated on a compiler rebuild, so a warm cache silently served objects from the previous compiler and fixtures failed for reasons unrelated to taint.
The Lane B suite could report success while proving nothing, in three separate ways. Each now fails instead. An unidentified toolchain. `TAINT_GO` was only stat'd, so pointing it at a stock `go` produced zero shadow labels — and every zero-report fixture passed. The suite now runs `go tool compile -V=full` first and refuses to continue unless the output contains `iast-taint-shadow-v28`. A vanishing fixture. `selectchannel` shipped a `main.go` and an empty `[]` manifest, so its select-over-channels program had been silently absent from every run while still looking like coverage on disk. Discovery now rejects an empty manifest, the runner refuses a zero-case suite, and the fixture is restored with dirty, clean and instrumentation-off cases. Manifests that quietly weaken themselves. `dirtyReports` must now be declared explicitly, because a positive case that omitted it decayed into a zero-report negative case that passes for the wrong reason; six manifests were relying on that default. Overriding `TAINT_PATH` through `env` is rejected outright, since it replaces the source the harness just set and leaves the declared `taintPath` dead — `stringstolower` did exactly that, and now declares its real source. Adds `TestFixtureInventory`, which runs on the stock toolchain and is therefore the only Lane B check a plain `go test ./...` executes. It cannot substitute for running the fixtures, but it does fail on all of the rot above, and it reports the directory and case counts so a shrinking suite is visible.
Audit of the ledger against the code it describes found the summary drifted from reality and four verdicts were not supported by their own evidence. Verdicts corrected, all downgrades: - 104, Orchestrion Win to Loss. The case is "GC sweep clears heap shadow" and the test passes only when no sweep occurs, because the registry pins the backing array; a real sweep fails it for a missing report. It proves taint survives forced GC and says nothing about sweep clearing. The finding it was hiding is now stated in `runtime/taint/README.md`: string owners are strong references, so tracked storage is never reclaimed until the process exits. - 132, Orchestrion Win to Partial. The cell's own closing sentence records a real false negative — a tainted replacement with a clean source reports nothing. - 65 and 73, Win to Partial. Both name several call forms or methods and their fixtures exercise one: `(*bytes.Buffer).Grow` as a method expression only, and `ReadString` of the four Buffer read methods. - 108, Orchestrion Loss to Partial. `StartRequest` now provides the API the original loss called impossible. Not a Win: eviction latches process-wide saturation, measured as a clean literal reporting `unknown` on 4 of 4 opens afterwards, which `lifecycle_test.go` now pins. Counts corrected against the current source: 232 Orchestrion subtests, not 174; 170 patched-Go subtests from 115 fixture directories, not 97; 161 measured cells across 124 case rows, not 113; five fixtures assert exact ranges and two assert the full standard, not four and one. Two claims narrowed. Case 124 was read as proving the whole patched-Go column merely under-asserts precision it already has; it proves that for the one path it tests, while 107 positive cases still assert nothing but a report count, so the two columns' Win totals are not commensurable and the ledger now says so. The race-flakiness hazard is marked resolved with its root cause and a closing measurement, rather than left in the present tense where it invalidated the column it appeared in. Also fixes stale cross-references that contradicted other rows — case 157's ranges do not merge, and cases 49, 131 and 133 are no longer lost — adds a provenance warning that 149 of 310 cells were never measured though 90 of them still read as "Verified", and removes a pointer to a note that was never written. Adds `experiments/go-shadow/report`, which renders the ledger as one self-contained HTML page and recomputes every tally from the matrix rows, so a stale summary surfaces as a mismatch instead of being republished.
111 of the 115 fixture programs already carried it; these four did not.
The matrix is 155 rows and the two columns agree on 130 of them, so the 25 cases that actually distinguish the prototypes were buried. Agreement mostly means the capability is easy or impossible for both; disagreement is where the comparison carries information. Adds a "Differences only" toggle that keeps just the rows whose two verdicts differ, and marks those rows with an accent on the ID cell so they remain findable with the filter off. The readout now always reports the differing count alongside the visible total, and the filter composes with the verdict chips, the measured filter and the search box. `Differs` is derived from the parsed verdicts rather than declared in the ledger, so it cannot fall out of sync with the matrix.
Both harnesses could only answer pass or fail. A passing case proves that observation and expectation agree, but the ledger quotes evidence, and only the observation can be quoted - so half its cells carried the source report's wording instead of a measurement, purely because the numbers were never recoverable. Adds an opt-in observation dump to each lane, with no change to any assertion: - `IAST_E2E_OBSERVED=<file>` makes the Orchestrion e2e binary write one JSON record per case with the sink, value, byte ranges and source IDs it actually produced. The driver parses stdout strictly and rejects any non-CASE line, so this writes to a file rather than the console. - `TAINT_OBSERVED=<file>` makes the patched-Go suite record each fixture's arguments, expected and observed report counts, any range lines, and full output, captured from the same invocation it asserts on so the record cannot drift from the assertion. Both are inert when the variable is unset, and the e2e sink never fails a run over a dump error.
`writeJSONReport` is the reporter a real deployment runs, and it had no test. Every other sink test installs its own reporter through `SetReporter`, so the redaction path was never executed and the ledger's claim for it rested entirely on the source report. Asserts both halves of the guarantee across five cases: the emitted JSON carries `[REDACTED]` and does not contain the tainted value when `ORCHESTRION_TAINT_INCLUDE_VALUE` is unset, empty, `0`, or `true` - only the exact string `1` opts in. Also checks the sink name and exact ranges survive redaction, so the placeholder cannot hide a mangled report.
The ledger's own provenance warning said half the matrix had never been measured: 149 of 310 cells had no `✎`, and 90 of those still read "Verified…" because the wording came from the source report. Almost all of them were already covered by tests that ran green on every commit - the results were simply never recorded. Now 255 of 310 cells are measured, across 142 of 155 rows and 113 rows in both columns. Stale "Verified" phrasing is down from 90 cells to 9. No verdict changed; this replaces inherited prose with observations. Where the evidence came from: - 38 Orchestrion cells from the e2e binary's own output via `IAST_E2E_OBSERVED`, each quoting the exact value, byte ranges and source ID observed at the sink. Case 40 is a good illustration - `strings.ReplaceAll` was observed at ranges=[12,13)[14,16)[17,18), three disjoint spans, which is the byte precision the column claims. - 49 Patched Go cells from the fixture suite via `TAINT_OBSERVED`, quoting observed versus expected report counts plus each fixture's own clean and instrumentation-off controls by name. - 7 Orchestrion cells against named unit tests, including case 130, which had no test until this change. Five Orchestrion cases assert silence rather than a report, so each now names the positive sibling in the same binary that makes that silence non-vacuous - case 72's clean alias overwrite against case 71's dirty one, and so on. Two things this deliberately does not paper over. Cells resting only on a report count now say so and carry an explicit evidence grade, except where the fixture is self-controlling enough that the count discriminates - `mapcases` drives nine sinks of which exactly four may report, so a leak through delete, clear or clean overwrite would move the number. And where a fixture has no paired control at all, the cell states that gap instead of implying one. The header now also accounts for the 55 cells still unmeasured: 30 cannot be measured without new engineering or new build lanes, and 25 need new fixtures.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
DO NOT MERGE
This is a research PR for discussion and review. It is not production-ready and
must not be merged.
Summary
This PR explores runtime IAST taint tracking for Go with one concrete source,
os.Getenv, and one sink,os.Open.It contains two complementary prototypes:
values while storing taint ranges in a runtime registry.
through SSA, calls, memory, maps, channels, closures, stack relocation, and GC
object reuse.
The objective is to evaluate achievable coverage, runtime invariants, compiler
integration points, and operational costs before selecting a production design.
Motivation
Go values do not carry application-defined metadata, so transparent IAST must
either rewrite source operations or add a parallel metadata representation below
the language surface. This work tests both approaches against explicit data flow
without changing user-visible
string,[]byte, or[]runetypes.All source, propagation, and sink decisions remain runtime decisions. The
compiler and injector select where tracking executes, but they do not decide at
compile time whether a concrete value is tainted.
Orchestrion prototype
Injector primitives
value-operationjoin point for language-level operations such asstring concatenation, slices, conversions, indexing,
append,copy, andclear.newly inserted nodes from being rematched during the same traversal.
or accumulate deferred cleanup inside
for/rangeloops.insert-statements-afterwhen the statement occupies a field ratherthan a list, avoiding
dstutil.Cursor.InsertAfterpanics.Runtime tracking
runtime/taintpropagates byte ranges through:and byte assignment;
strings.Clone,Replace,ReplaceAll,Join,Repeat,ToUpper,ToLower, andMap;fmt.Sprintfandfilepath.Joinconservatively;strings.Builderandbytes.Bufferoperations.The registry is bounded to 65,536 retained occurrences per storage category.
When a category reaches its limit, additional occurrences remain clean rather
than allowing request-driven unbounded growth. Clean builders are not retained.
The default JSON sink reporter redacts the raw value. Controlled debugging can
opt in with
ORCHESTRION_TAINT_INCLUDE_VALUE=1, or install a custom reporter.Patched Go compiler/runtime prototype
The complete patch is stored in
experiments/go-shadow/go-taint-shadow.patchand is gated by-d=taint=1.Compiler behavior
uint8labels.loads/stores, and closure environments.
interface dispatch, recursion, panic/recover, deferred results, and
address-taken parameters.
interprocedural transition in this prototype.
Runtime behavior
chan string, blockedsenders/receivers, close paths, select cases, timers, and
sudogreuse.delete, clear, clone, and growth.
Consolidated test suite
The minimal fixture programs remain independently runnable under
experiments/go-shadow/fixture. A table-driven subprocess suite consolidates thefull behavior matrix:
TAINT_GO=/path/to/go-taint-shadow/bin/go \ go test -count=1 ./experiments/go-shadow/suiteThe suite skips when
TAINT_GOis not configured, so normal repository test andvet commands remain usable.
Coverage includes:
dynamic defer, receiver isolation, unnamed parameters, and address-taken
parameters;
growth, and race mode;
Validation performed
The following passed locally:
The isolated Go toolchain also passed:
GOMAXPROCS=1 ./make.bash ../bin/go test -count=1 runtime internal/runtime/maps \ cmd/compile/internal/typecheckFull repository tests that dynamically resolve external integration modules were
blocked locally by timeouts to the internal module proxy. No failure was observed
in the changed packages.
The final patched-runtime implementation received a dedicated Oracle audit, and
the complete PR received goal, hands-on QA, code-quality, security, and repository
context reviews after blocker remediation.
Known boundaries
Orchestrion prototype
cannot be proven.
go:linkname, prebuilt code, oruninstrumented dependencies.
Patched compiler/runtime prototype
the current call protocol.
including when taint compilation is disabled.
development checkout.
Review guidance
Please focus review on:
mutation behavior.
Again, this PR is intentionally a draft and must not be merged.