@hulumi/drift
v1.5.0
Published
Local-first drift classifier and live posture validator — distinguishes provider-API churn from console break-glass from genuine IaC drift, then renders read-only live posture findings as JSON, Markdown, and SARIF. Verdict logic mirrors HulumiDrift.tla Ha
Readme
@hulumi/drift
Local-first drift classifier for Pulumi stacks. Distinguishes
provider-API churn from console break-glass from genuine IaC drift via
four pluggable adapters whose composition mirrors HardenedVerdict in
HulumiDrift.tla
(upstream planning corpus) exactly.
Quick-start
import {
DriftClassifier,
AutomationApiAdapter,
CloudTrailAdapter,
ProviderVersionAdapter,
GitLogAdapter,
} from "@hulumi/drift";
import { simpleGit } from "simple-git";
const classifier = new DriftClassifier({
adapters: {
automationApi: new AutomationApiAdapter({ preview: runPulumiPreview }),
cloudTrail: new CloudTrailAdapter({ lookup: cloudTrailLookup }),
providerVersion: new ProviderVersionAdapter({ fetcher: pinnedVsLatest }),
gitLog: new GitLogAdapter({ git: simpleGit(), paths: ["pulumi/**/*.ts"] }),
},
probe: cloudTrailDeliveryProbe,
});
const verdict = await classifier.classify(
"urn:pulumi:dev::project::stack",
"urn:pulumi:dev::project::aws:s3/bucket:Bucket::my-bucket",
);
console.log(verdict.source, verdict.confidence);S3 resource-token handling accepts both the current aws:s3/bucket:Bucket
token and the legacy aws:s3/bucketV2:BucketV2 token so old Pulumi state
remains classifiable during SecureBucket migration.
Live posture validator
@hulumi/[email protected] also ships the read-only hulumi validate live CLI. It turns
bounded provider adapters into deterministic JSON, Markdown, and SARIF posture
artifacts for AWS organization guardrails, Pulumi state backend posture, EKS
foundation posture, and GitHub environment/runner governance.
hulumi validate live \
--config hulumi-live-validator.json \
--format json,markdown,sarif \
--out-dir .hulumi-artifacts/live-validatorThe CLI is advisory-only: it reads configured posture facts, emits findings, and never mutates cloud or GitHub state.
Verdict matrix
| # | Snapshot | Source | Confidence |
| --- | -------------------------------------------- | ----------------- | ---------- |
| 1 | !mutated | None | none |
| 2 | mutated && eventDelivered | ConsoleBreakGlass | high |
| 3 | mutated && eventInTransit | Unknown | low |
| 4 | mutated && providerDrift && !event* | ProviderApiChurn | medium |
| 5 | mutated && !provider drift && !event* | Unknown | low |
| 6 | mutated && eventDelivered && providerDrift | Mixed | high |
Row 4's medium ceiling is TLA+-proven (SafetyRealistic invariant).
Security guarantees
- S2 — cache files written with
0o600; ownership-mismatched files are treated as absent. Seetests/cache-permissions.test.ts. - S3 — URNs validated via
urn-sanitize.tsbefore reaching git;simple-gitargv-based call form. Nochild_process.exec. Seetests/shell-injection.test.ts. - S7 — cache TTL is the rate-limit; within TTL repeat calls return
cached verdict. See
tests/rate-limit.test.ts. - E1 — probe wraps
p-timeout+AbortSignal; on timeout returnsUnknown / lowwithprobeFailedAt. Seetests/probe-timeout.test.ts. - E4 — CloudTrail principal filter requires the FULL
hulumi:iac-role=truetag; bareiac-role=trueis rejected. Seetests/namespace-rejection.test.ts. - E4 retry —
CloudTrailAdaptercan retry transient lookup failures with bounded attempts and accumulated delay. Seetests/cloudtrail-retry-budget.test.ts. - E5 —
GitLogAdapter.available()isfalseon shallow clones; classifier degrades toUnknown / lowwith remediation. Seetests/shallow-clone.test.ts.
Guarded reconciliation and sweeping
DriftClassifier remains classify-only and non-destructive. Cleanup and
state-reconciliation decisions live behind the separate
OrphanReconciler / OrphanSweeper surface.
The first supported sweep primitive is versioned S3 cleanup for strongly-owned failed-run buckets:
import { OrphanReconciler, S3SweeperExecutor } from "@hulumi/drift";
const reconciler = new OrphanReconciler({
executors: {
drainS3BucketVersions: new S3SweeperExecutor({
expectedPrefix: "af-e2e-abc123",
deleteBucket: true,
}),
},
});
const plan = reconciler.plan({
mode: "sweep-only",
scope: {
stackName: "sandbox-abc123",
resourcePrefix: "af-e2e-abc123",
regions: ["us-east-1"],
minAgeMinutes: 15,
ownershipMinSignals: 2,
},
targets: [
{
inState: false,
existsInCloud: true,
identity: {
provider: "aws",
type: "aws:s3/bucket:Bucket",
physicalId: "af-e2e-abc123-logs",
region: "us-east-1",
},
ownership: [
{ signal: "name-prefix", subject: "af-e2e-abc123-logs", confidence: "high" },
{ signal: "tag", subject: "hulumi:component=AccountFoundation", confidence: "high" },
],
},
],
});
await reconciler.execute(plan, {
confirmToken: plan.confirmToken,
allow: ["deleteCloudResource"],
});The guarded path is deliberately narrow:
check-onlyandplanmodes cannot execute.- Execute requires the plan confirmation token.
- Cloud-only resources require an explicit prefix and at least two ownership signals by default.
- Shared singleton resources are retained unless explicitly enabled.
- Plan artifacts redact account IDs, bucket names, ARNs, backend URLs, and evidence subjects before upload.
- The S3 executor uses AWS SDK calls only, drains object versions/delete markers in batches of 1000, aborts multipart uploads, and refuses bucket names outside the configured prefix.
- Broad execute-mode changes must link to the checked reconciler model in
HulumiReconciler.tlaand keep the verified invariants inHulumiReconciler-verified.mdcurrent.
For read-only discovery, feed known Pulumi state and explicitly scoped
cloud inventory into discoverReconcileTargets() before planning:
import { discoverReconcileTargets, OrphanReconciler } from "@hulumi/drift";
const discovered = discoverReconcileTargets({
scope: { resourcePrefix: "af-e2e-abc123", regions: ["us-east-1"] },
pulumiState: await stack.exportStack(),
cloudResources: [
{
provider: "aws",
type: "aws:s3/bucket:Bucket",
physicalId: "af-e2e-abc123-logs",
region: "us-east-1",
tags: { "hulumi:component": "AccountFoundation" },
},
],
});
const plan = new OrphanReconciler().plan({
mode: "plan",
scope: { resourcePrefix: "af-e2e-abc123", regions: ["us-east-1"] },
targets: discovered.targets,
});