Skip to content

[DO NOT MERGE] Prototype runtime IAST taint tracking for Go - #858

Draft
eliottness wants to merge 18 commits into
mainfrom
eliottness/iast-testing
Draft

[DO NOT MERGE] Prototype runtime IAST taint tracking for Go#858
eliottness wants to merge 18 commits into
mainfrom
eliottness/iast-testing

Conversation

@eliottness

Copy link
Copy Markdown
Contributor

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:

  1. An Orchestrion source-rewriting implementation that preserves ordinary Go
    values while storing taint ranges in a runtime registry.
  2. A patched Go compiler/runtime experiment that carries one-byte shadow labels
    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 []rune types.

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

  • Adds a typed value-operation join point for language-level operations such as
    string concatenation, slices, conversions, indexing, append, copy, and
    clear.
  • Adds statement replacement and insertion advice.
  • Matches advice against the original AST before applying mutations, preventing
    newly inserted nodes from being rematched during the same traversal.
  • Adds type-aware context queries for constants, builtins, and addressability.
  • Rejects scalar-capture patterns that would evaluate effectful expressions twice
    or accumulate deferred cleanup inside for/range loops.
  • Rejects insert-statements-after when the statement occupies a field rather
    than a list, avoiding dstutil.Cursor.InsertAfter panics.

Runtime tracking

runtime/taint propagates byte ranges through:

  • strings, byte slices, rune slices, named types, and generic types;
  • concatenation, explicit slices, conversions, indexing, append, copy, clear,
    and byte assignment;
  • strings.Clone, Replace, ReplaceAll, Join, Repeat, ToUpper,
    ToLower, and Map;
  • fmt.Sprintf and filepath.Join conservatively;
  • strings.Builder and bytes.Buffer operations.

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.patch and is gated by
-d=taint=1.

Compiler behavior

  • Associates tracked string SSA values with runtime uint8 labels.
  • Propagates labels through assignments, aliases, control-flow phis, memory
    loads/stores, and closure environments.
  • Uses authenticated per-goroutine transitions for static calls, function values,
    interface dispatch, recursion, panic/recover, deferred results, and
    address-taken parameters.
  • Supports exactly one explicit string parameter and one string result per
    interprocedural transition in this prototype.
  • Excludes runtime support packages from recursive instrumentation.

Runtime behavior

  • Uses dense atomic arena shadow bytes for heap and managed stack addresses.
  • Copies stack shadows during growth/shrink and clears them before stack reuse.
  • Clears dead heap labels during sweep before allocator reuse.
  • Preserves labels through buffered and unbuffered chan string, blocked
    senders/receivers, close paths, select cases, timers, and sudog reuse.
  • Preserves labels through Swiss-map assignment, lookup, overwrite, range,
    delete, clear, clone, and growth.
  • Avoids proportional metadata allocation for zero-sized and non-string channels.
  • Keeps buffered string-channel buffers and label storage visible to the GC.

Consolidated test suite

The minimal fixture programs remain independently runnable under
experiments/go-shadow/fixture. A table-driven subprocess suite consolidates the
full behavior matrix:

TAINT_GO=/path/to/go-taint-shadow/bin/go \
  go test -count=1 ./experiments/go-shadow/suite

The suite skips when TAINT_GO is not configured, so normal repository test and
vet commands remain usable.

Coverage includes:

  • disabled and clean paths;
  • local copies and SSA phis;
  • static calls, function values, interface dispatch, recursion, panic/recover,
    dynamic defer, receiver isolation, unnamed parameters, and address-taken
    parameters;
  • buffered, unbuffered, GC-stressed, closed, selected, and race-enabled channels;
  • map literals, assignments, lifecycle operations, reflection-based slot reuse,
    growth, and race mode;
  • closure environments, exact-address heap reuse, and race-enabled reuse;
  • zero-sized-channel allocation behavior.

Validation performed

The following passed locally:

go test -race -count=1 ./runtime/taint/...
go test -count=1 ./internal/injector/aspect/... ./internal/injector/config/...
go vet ./...
go run ./_tools/headercheck/header_check.go
go build ./...
go test -count=1 -run='^$' ./...
TAINT_GO=/path/to/go-taint-shadow/bin/go \
  go test -count=1 ./experiments/go-shadow/suite

The isolated Go toolchain also passed:

GOMAXPROCS=1 ./make.bash
../bin/go test -count=1 runtime internal/runtime/maps \
  cmd/compile/internal/typecheck

Full 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

  • No implicit/control-flow taint.
  • Scalar propagation is intentionally limited when preserving single evaluation
    cannot be proven.
  • No arbitrary scalar transport through calls, maps, or channels.
  • No unsafe aliases, cgo, assembly, plugins, go:linkname, prebuilt code, or
    uninstrumented dependencies.
  • Registry bounds trade false negatives after saturation for bounded memory.

Patched compiler/runtime prototype

  • Exactly one explicit string parameter and one string result are supported by
    the current call protocol.
  • Globals, foreign memory, and some aggregate/conversion forms remain clean.
  • Arena shadow memory and runtime structure overhead are currently unconditional,
    including when taint compilation is disabled.
  • Validation was performed on darwin/arm64; 32-bit execution was not tested.
  • The patch targets an isolated Go worktree and must not be applied to a primary
    development checkout.

Review guidance

Please focus review on:

  1. Whether the injector primitives are general enough without exposing unsafe AST
    mutation behavior.
  2. Registry identity/lifetime tradeoffs and the bounded-retention policy.
  3. The authenticated call-transition protocol and panic cleanup.
  4. Stack-shadow copying and heap-shadow sweep timing.
  5. Channel/select atomicity and Swiss-map metadata movement.
  6. Which prototype, if either, provides a viable path toward a production design.

Again, this PR is intentionally a draft and must not be merged.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant