@4cloudguru/terraform-drift-contract
v1.4.1
Published
Shared parser for Terraform/OpenTofu plan JSON → TSM drift counts + summary. One source of truth for the GitHub Action, the Azure DevOps task, and the backend's driftingest semantics.
Readme
@4cloudguru/terraform-drift-contract
The single source of truth for parsing a Terraform/OpenTofu plan JSON
(terraform show -json / tofu show -json) into Terraform State Manager (TSM)
drift counts + a changed-resource summary.
One tiny package, consumed (and bundled) by every drift implementation so they cannot diverge:
terraform-drift-report— the GitHub Action- the Azure DevOps
TerraformDriftReporttask (azure-pipelines-terraform, initiative 6) - mirrored by the backend's Go
internal/services/driftingestand the jq in the dispatched CI templates. This package —src/summarize.tsplus__tests__/— is the authority those two are diffed against, and the diffing is done by the shared conformance corpus, which all three implementations run and compare byte-for-byte. See cross-implementation status.
Install
npm install @4cloudguru/terraform-drift-contract// package.json of a consumer
"dependencies": {
"@4cloudguru/terraform-drift-contract": "^1.1.0"
}Published to the public npm registry with provenance,
so npm audit signatures verifies the tarball back to the workflow run that
built it. No registry auth is needed to install it.
Consumer status. Both consumers are on the scoped npm package at
^1.1.0— the GitHub Action (terraform-drift-report) and the Azure DevOpsTerraformDriftReporttask — so the v1.1.0 redaction fixes reach both. They previously pinned the pre-transfer git URLgithub:sethbacon/terraform-drift-contract#v1.0.0, which is why an earlier revision of this note said fixes did not reach them.dist/stays committed so the git form keeps working for anything not yet migrated.If you must use the git form, pin a commit SHA, not a tag. A git tag is mutable and the tags here are unsigned and unprotected, so
github:4cloudguru/terraform-drift-contract#v1.1.0can be repointed at different code with no npm publish, no provenance and no signature to check against — in a package two CI systems execute.#<full-40-char-sha>is immutable. The npm package above is the supported channel and the only one that carries provenance.
API
import { summarize, moduleCallsPlan, type Plan, type Result } from '@4cloudguru/terraform-drift-contract'
// `Plan` is a compile-time DESCRIPTION of the document, not a runtime check —
// TypeScript interfaces are erased, so every field is `unknown` at run time no
// matter what the annotation says. That is fine here, and deliberately so: the
// plan is attacker-influenceable on a fork PR, and `summarize()` normalises
// every field it reads at the loop head rather than trusting the declared type.
// No separate validator is exported, because a second notion of "valid" would
// be one more thing the four implementations have to agree on.
const plan: Plan = JSON.parse(fs.readFileSync('plan.json', 'utf8'))
const r: Result = summarize(plan)
// r = { added, changed, destroyed, drift_added, drift_changed, drift_destroyed,
// drifted, summary: [{ address, actions }], drift_summary: [{ address, actions }],
// unparseable, unmasked, truncated, omitted_entries, omitted_attrs }
// Bounds are defaulted, not mandatory. Both defaults are declared in the shared
// conformance corpus and asserted by every implementation.
summarize(plan, { maxEntries: 500, maxAttrsPerEntry: 50 })Do not read drifted: false as "verified clean" on its own. unparseable
is what separates a clean plan from a document that was never a plan — a
truncated terraform show -json, the wrong file, a broken pipeline step. All of
those used to produce the identical drifted: false.
Where this output goes, and why that matters. Consumers POST summary (and
attrs) to a TSM callback endpoint over the network, write it to a JSON report
file on the runner, and echo part of it into the CI log. An unmasked attrs
value — up to 300 code points of plaintext whenever the sensitivity mirrors are
absent, or whenever the secret sits below an unmarked top-level key — is
therefore transmitted, persisted and displayed, not merely held in memory. Read
the masking semantics below with that in mind.
fmt and isSens are exported alongside summarize/moduleCallsPlan and are
part of the public contract: fmt(v) is the 300-code-point formatter described
below and isSens(mirror, key) is the masking predicate. They are exported so a
porter can check a mirror implementation against them value-by-value, and the
publish workflow asserts all four are present on the CJS entry point. The same
truncation and masking caveats apply to them as to attrs.
Semantics (the authority the other implementations mirror)
added/changed/destroyed= resources whose actions contain create / update / delete (a replacement["delete","create"]counts as both added and destroyed; counts are not mutually exclusive — usesummary.lengthfor a distinct resource count);summary= every change whose actions are not exactly["no-op"]or["read"], as{address, actions, attrs?};attrs(in-place updates/replaces only) = the top-level keys whose value differs, each{name, before, after}with values run throughfmt()(300 code-point truncation, U+2026 marker) and masked to the literal"(sensitive)"when eitherbefore_sensitiveorafter_sensitivemarks them (terraform-jsondoes not pre-mask — masking happens here, beforefmt(), so a marked secret never reaches the formatter). The union landed in v1.1.0 and is now matched by the other implementations — see cross-implementation status. Two preconditions, both real: masking is applied per top-level changed key and is driven entirely bybefore_sensitive/after_sensitive. A secret nested under an unmarked top-level key is serialised whole, and a plan that omits the sensitivity mirrors gets no masking at all for that resource. Do not treat this as a redaction guarantee for plans of unknown or untrusted provenance — see SECURITY.md;drifted=(added + changed + destroyed) > 0(a pure replace haschanged == 0butdrifted == true; do not infer "no drift" fromchanged == 0);unparseable= the document had noresource_changesarray, so it was not a plan. Distinct fromdrifted, and the field a CI gate should fail loud on;unmasked= at least one non-skipped in-place change carried neither sensitivity mirror, so nothing was masked for it (see the masking preconditions above). Deliberately shape-based and slightly over-broad: it is the definition all three implementations can compute identically, and over-warning is the right direction for a redaction signal. A present-but-falsemirror is metadata and does not set it;truncated/omitted_entries/omitted_attrs= a bound was reached, and by how much, so a consumer can tell "no more drift" from "we stopped looking". The counts are never capped — capping them would turn a payload bound into a missed detection — sodriftedstays truthful when the summary does not;drift_added/drift_changed/drift_destroyed/drift_summary= the same rules asadded/changed/destroyed/summaryabove (skip, count, attrs, masking, bounds — the identical per-item logic, not a second implementation), computed from the optionalresource_driftarray instead ofresource_changes.resource_driftis infra drift — hand-edits or other out-of-band changes — as distinct from the unapplied config changes inresource_changes. Zero /[]whenresource_driftis absent or not an array. Purely additive: never affectsdrifted,summary, or the three original counts, andunmasked/truncated/omitted_entries/omitted_attrsreport only theresource_changespath — see SECURITY.md.
Module provenance (moduleCallsPlan)
Optional, orthogonal to the summary, and not part of the count/skip semantics
above: the plan field of the drift callback, carrying which modules a root
module calls.
The plan's configuration block carries no terraform sensitivity metadata
at all — before_sensitive/after_sensitive exist only inside
resource_changes — so anything forwarded from it is unredacted by
construction. module_calls entries carry expressions (the constant_value
of every literal argument), the full recursive module subtree (its own
resources' expressions and its variables' defaults, sensitive ones included)
and the raw source (which can embed a PAT). The subtree is therefore
projected, never forwarded verbatim:
- per call, only
sourceandversion_constraint— exactly the two fields the backend'sdriftingest.Configurationreads, so nothing a consumer uses is lost.expressions,moduleand every other member are dropped; sourcehas its credentials scrubbed: URL userinfo (https://token@host/…,https://user:pass@host/…) becomes(redacted)@, and of the query parameters onlyrefsurvives — every other value (sshkey=,X-Amz-Signature=,token=) is redacted;- every emitted string is capped by
fmt()at 300 code points, and at most 100 module calls are emitted (sorted by name); an overflow setsconfiguration.root_module.module_calls_truncated: true.
Cross-implementation status
The two redaction behaviours this package introduced ahead of the others are now
matched by both — the Go driftingest and the jq in the backend's dispatched
CI templates:
| Behaviour | Status |
| --- | --- |
| An attribute marked sensitive on one side only (config-derived marks apply to the planned value only, so this is routine) → both sides "(sensitive)" | reconciled with Go in backend#374; the jq path has no attrs to mask |
| module_calls provenance projected + credential-scrubbed + capped (above) | both jq templates project identically as of backend#374 |
| Byte-level serialized form — code-point key order, U+2028/U+2029 escaped, </>/& raw, -0 | reconciled by the corpus (#17, #18) |
| jq skips ["read"], and drifted comes from the counts | reconciled in the backend (#20, #21) |
Neither behaviour changes counting or skip semantics, and neither changes the symmetric case (both mirrors marking the key, or neither): those stay byte-identical. When neither mirror is present the change is emitted unmasked — that is the shape of a plan with no sensitivity metadata at all, and masking it would mask every attribute of every such plan; this is asserted in the class test so it cannot change silently.
The serialized byte form is reconciled too, as of the corpus: keys sort by code
point everywhere, U+2028/U+2029 are escaped, </>/& stay raw (the Go mirror
serialises with SetEscapeHTML(false)), and negative zero emits -0. The jq
SUMMARY now skips ["read"] and takes drifted from the counts rather than
from terraform plan -detailed-exitcode.
What still differs is stated in the corpus, per vector: Go rejects a
malformed document at its unmarshal boundary where this package tolerates it
(fail-closed, a 422 rather than a wrong answer), and Go marshals a nil action
list as null where this package emits []. Neither is a redaction gap. See
SECURITY.md for the detail and for the cross-implementation
obligation.
Note. Earlier revisions of this README named a Python
drift_summary.pyas the file these semantics must match. No such file exists anywhere in the suite. The authority is this package:src/summarize.tsand its test vectors.
Contract
__tests__/ is this package's vector set, and this package is the authority.
If the semantics change here, update the mirrors in the same change — every
consumer pulls from here.
__tests__/fixtures/ are this package's own unit fixtures. The shared set —
the one the mirrors run — is conformance/vectors.json, and it
is the artifact a disagreement gets settled with. Each implementation pins the
same SHA-256 of that file and the same digest over its own rendered results, so
a divergence reddens both repositories without either CI job needing to run the
other language. Differences that are real and deliberate are stated per
vector; a difference with no entry there is a regression. See
conformance/README.md for the procedure when the
semantics change.
An earlier revision of this section said the fixtures were "vendored from the backend's
driftingesttests", and the correction that replaced it said nothing verified parity automatically. Both are superseded by the corpus (#22).
Development
npm install
npm test # vitest contract tests + the conformance corpus
npm run test:coverage # the same run, gated on the thresholds in vitest.config.mts
npm run lint # tsc --noEmit
npm run build # tsc → dist/ (commit the result)CI runs test:coverage, not test. Every gap this suite has had was an
untested branch — the fail-open masking path, the asymmetric key path, the
prototype-chain read — so the thresholds sit just under the current numbers and
only ever move up.
dist/ is committed and CI fails if it is stale, so a source change that is not
rebuilt in the same commit does not merge.
Releasing
There is no manual publish step and no long-lived registry token.
- Land conventional commits on
main. PR titles are validated by theConventional PR Titlecheck and PRs are squash-merged, so the PR title becomes the commit subject that release-please reads. - release-please opens a release PR (
.github/workflows/release-please.yml) computing the next version from the commit history —fix:→ patch,feat:→ minor,BREAKING CHANGE:→ major. It maintainsCHANGELOG.md, thepackage.jsonversion and.release-please-manifest.json. Per the suite convention, one merged commit announces at most one breaking change. - Merging the release PR tags
vX.Y.Zand publishes a GitHub Release. - The release publishes the package (
.github/workflows/publish.yml): an unprivileged job builds, tests, audits and packs the tarball, then a separate job — gated on thereleaseenvironment's required reviewer and av*tag policy — publishes it.
Publishing uses npm trusted publishing: the registry credential is minted
from the workflow's OIDC token and matched against the trusted publisher
configured on npmjs for this package. There is no NPM_TOKEN anywhere, and the
publishing job holds no repository secrets. publishConfig.provenance plus
id-token: write produce the provenance attestation, and the published tarball
is additionally attested with its CycloneDX SBOM.
The CHANGELOG is load-bearing: this package's semantics are mirrored by a Go and a jq implementation, and the release notes are how those learn that a divergence exists.
