From 2ca44c137c0c765c337b1dd29e2fa1be44c5ff87 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 00:20:25 -0700 Subject: [PATCH 01/44] docs: task breakdown for the gate and the credential store The approved strategy document argues for the design and sequences the rollout; it does not decompose the work. This is that decomposition, with the exact values each task must use and the tests each must carry. Global constraints are stated once at the top because they bind every task and are the things most easily lost in a single-task view: the dependency floor, the gate resolving no credentials, failing closed, printing locations rather than values, and holding no environment-specific knowledge. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- docs/implementation-plan.md | 429 ++++++++++++++++++++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 docs/implementation-plan.md diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..f4d904b --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,429 @@ +# stackward — implementation plan + +Task breakdown for the credential gate and the credential store. Each task +names the exact behaviour required; where a value is stated here, use it +verbatim rather than inventing an equivalent. + +## Global Constraints + +These bind every task. A change that violates one is wrong even if its own +tests pass. + +1. **Runtime dependencies are `pyyaml` and `cryptography`, and nothing else.** + Never import the Pulumi SDK — shell out to the `pulumi` binary, as the code + already does for `git`. CI fails on a new direct dependency. +2. **The gate resolves no credentials.** `check-config` and `pre-commit` read + files only. No code reachable from them may prompt, open a network + connection, or import a credential provider. This is what keeps a commit + from being blocked by an expired session, and it is asserted by a test. +3. **Fail closed.** If the tool cannot determine whether something is safe, it + exits non-zero and names the fix. It never passes because a check could not + run. A missing policy file is a refusal, not a skip. +4. **Never print a credential, and never print a denied term.** Report + locations — file, line, config path — not the matching text. Errors and + test failures included. +5. **No environment-specific knowledge.** No organisation, vendor, domain, + bucket, region, namespace or model-class name appears in this repository. + Everything specific to a user's environment arrives through + `.stackward.toml` or a file that config names. +6. **Values reach external commands on stdin**, never in `argv` — command + lines are visible in `ps` and shell history. +7. **Python 3.11+.** Use `tomllib` from the standard library; do not add a TOML + dependency. + +--- + +## Task 1: Path grammar + +**Goal.** One canonical representation for a config path, used by findings, +manifests and matching alike. Two grammars in one tool is why the same helper +gets written twice. + +**File.** `src/stackward/paths.py`, tests in `tests/test_paths.py`. + +**Requirements.** +- Follow Pulumi's `--path` syntax: dotted segments, `[n]` for list indices, and + `["..."]` for any key containing `.`, `[`, `]` or `"`. +- `parse(text) -> list[str | int]` — segments, list indices as `int`. +- `render(segments) -> str` — the inverse; bracket-quote only when required. +- `render(parse(x)) == x` for every canonical input, and + `parse(render(y)) == y` for every segment list. +- A key containing a quote is escaped as `\"` inside the bracket form. +- Malformed input raises `ValueError` naming the offending position. + +**Tests must cover.** A plain dotted path; a list index; a key containing a +dot; a key containing a bracket; a key containing a quote; an empty-string key; +a segment that looks like an integer but is a dict key (`["1"]` is the string +`"1"`, `[1]` is the index `1` — they must not collapse); round-trip both +directions; three distinct malformed inputs. + +--- + +## Task 2: Repository configuration + +**Goal.** Load and validate `.stackward.toml`, and enforce `min_version`. + +**File.** `src/stackward/config.py`, tests in `tests/test_config.py`. Move +`find_repo_config` out of `cli.py` into here and update its callers. + +**Requirements.** +- Parse with `tomllib`. Unknown top-level keys are an error naming the key — + a typo must not be silently ignored. +- Recognised: `profile`, `python`, `min_version`, and tables `[check]`, + `[secrets.*]`, `[secrets.source]`. +- `[check]` keys: `model_net` (`"artifact"` or `"none"`, default `"none"`), + `stack_models` (table of `"" = "module:Class"`), `sensitive_keys` (list, + **extends** the built-ins), `sensitive_parents` (list, **replaces** the + built-ins), `allowed_references` (list). The extend/replace asymmetry is + deliberate and must be documented in a comment where it is implemented. +- Built-in `sensitive_keys` default: `password`, `passphrase`, `token`, + `secret`, `apikey`, `api_key`, `jwt`, `credential`, `private_key`, + `access_key_id`, `secret_access_key`. +- `min_version` compares against `stackward.__version__` using a numeric + tuple comparison, not string ordering. If the installed version is lower, + exit non-zero naming the required version and the upgrade command — except + for the commands in `cli.VERSION_CHECK_EXEMPT`, which must still run. +- Config absent → return `None`; callers decide. Config present but invalid → + raise, never fall back to defaults. + +**Tests must cover.** Each recognised key; an unknown key erroring; the +extend-vs-replace asymmetry proven for both lists; `min_version` accepting an +equal and a higher installed version and rejecting a lower one; that +`"0.10.0" > "0.9.0"` compares correctly (string ordering gets this wrong); +exemption of `doctor` and `self-update`. + +--- + +## Task 3: The heuristic net and `check-config` + +**Goal.** Detect a plaintext credential in a stack config file. + +**Files.** `src/stackward/nets/heuristic.py`, `src/stackward/commands/check_config.py`, +tests in `tests/test_heuristic.py`. + +**Requirements.** +- Walk the parsed YAML structure. Do **not** re-parse rendered path strings — + a dict key may legally contain `.` or `[`, and string matching loses it. + Emit paths using Task 1's `render`. +- A leaf is a **finding** when its key matches `sensitive_keys` + (case-insensitive substring) **or** any ancestor key is in + `sensitive_parents`, unless it is encrypted or empty. +- **Encrypted** means the leaf's *parent dict* is exactly `{"secure": ...}` — + one key, named `secure`. Test the parent mapping, never + `path.endswith(".secure")`: a malformed `{"secure": ..., "other": ...}` is + not encrypted, and a suffix test would pass it. +- **Empty** means `value is None` or `value == ""`. Use identity for booleans: + `value is True` / `value is False` are not empty. In Python `0 == False` and + `1 == True`, so a containment test wrongly skips integer `0` and `1` — and + integer leaves occur in real config. Integers `0` and `1` **are** findings. +- `allowed_references` lists paths that name another secret rather than + holding one; they are never findings. +- `check-config FILE...` exits 0 when clean, 1 with findings, 2 on a usage or + parse error. Findings print as `: plaintext credential at ''`, + sorted, one per line. Never print the value. +- Unparseable YAML, or a YAML document that is not a mapping, is an error — + not "no findings". + +**Tests must cover.** Every rule above, each as its own test: a clean config; +a plaintext match by key; a match by sensitive parent; an encrypted leaf +passing; the malformed `{secure, other}` leaf being flagged; `None` and `""` +passing; `True`/`False` passing; integers `0`, `1` and a larger integer being +flagged; a dict key containing a `.` being reported with correct bracket +quoting; an `allowed_references` path passing; a non-mapping document erroring. + +--- + +## Task 4: `pre-commit` + +**Goal.** Run the gate over staged content, as a git hook body. + +**Files.** `src/stackward/commands/pre_commit.py`, tests in `tests/test_pre_commit.py`. + +**Requirements.** +- Select staged files with `git diff --cached --name-only --diff-filter=ACMR`. + `R` matters: a rename plus an edit stages as `R`, and omitting it lets that + change bypass the gate entirely. +- Match stack config files by basename: `Pulumi..yaml`, where `` + may contain dots (`Pulumi.prod.eu.yaml` is legal). Match `Pulumi.` prefix and + `.yaml` suffix, not a restrictive character class. +- Refuse outright — before any other check — if a Pulumi **state export** is + staged: a file named `state.json` or matching `*.stack-export.json`. These + carry every resource input including credentials. +- Check **staged** content, not the working tree: read each file with + `git show ":"`. An unstaged fix must not let a staged credential + through. +- Handle a path containing spaces or non-ASCII: use `-z` and split on NUL. +- An unmerged index entry, or a staged symlink whose blob is not YAML, is an + error, not a pass. +- Exit 0 clean, 1 blocked. On block, print the findings and the exact + `pulumi config set --secret --path ''` remediation, and state that + `--no-verify` bypasses. + +**Tests must cover.** Each rule, using a real temporary git repository (create +it in the test with `git init`; do not mock git). Include: a rename+edit +staged as `R` being caught; a staged state export being refused; the staged +version being checked rather than the working tree; a filename containing a +space. + +--- + +## Task 5: `hooks install` + +**Goal.** Install the `pre-commit` hook into a repository, safely. + +**Files.** `src/stackward/commands/install_hooks.py`, tests in +`tests/test_install_hooks.py`. + +**Requirements.** +- Resolve the hooks directory with `git rev-parse --git-path hooks`. Do not + hardcode `.git/hooks`: `core.hooksPath` is redirected by several common + tools, and installing where git does not look is a silent no-op. +- **Refuse if the resolved hooks directory is tracked by git** (test with + `git ls-files --error-unmatch ` or equivalent). Writing there commits a + hook that runs a binary other clones may not have installed. Print the + reason and suggest chaining from the tracked hook instead. +- The hook body contains **no logic**: it locates the repository and execs + `stackward pre-commit`. Embed the absolute path to the running executable, + with a `PATH` lookup as fallback. A bare `exec stackward` from a GUI client + with a minimal `PATH` exits 127 and blocks every commit. +- Mark the generated hook with a fixed marker line so it can be recognised + later. If an existing hook is present and lacks the marker, back it up to + `.backup.` rather than refusing or overwriting. +- `chmod 0o755` explicitly. +- Idempotent: installing twice leaves one hook and creates no second backup. + +**Tests must cover.** Each rule, against a real temporary git repository: +default location; a redirected `core.hooksPath`; refusal on a tracked hooks +directory; an existing foreign hook being backed up; idempotence; the mode +being `0o755`; the body containing an absolute path. + +--- + +## Task 6: Credential store — profiles and encryption + +**Goal.** One profile-scoped store for backend credentials, encrypted at rest. + +**Files.** `src/stackward/store.py`, `src/stackward/crypto.py`, tests in +`tests/test_store.py` and `tests/test_crypto.py`. + +**Requirements.** +- Location: `${XDG_CONFIG_HOME:-~/.config}/stackward/`, holding `config` + (TOML, mode 0o644, backend identity, no credentials) and `credentials` + (mode 0o600, directory 0o700). +- `config` holds `default_profile` and `[profile.]` tables. A profile + carries **either** `backend_url` (passed through verbatim — `s3://`, `gs://`, + `azblob://`, `file://` must all work) **or** the component form `bucket`, + `prefix`, `endpoint`, `region` for an S3-compatible store. There is no + default backend: a profile is required and an absent one is an error. +- Profile selection precedence, first match wins: `--profile`, + `STACKWARD_PROFILE`, the repo config's `profile`, `default_profile`, then + error. Never guess. +- **Encryption**: one envelope per profile section. Argon2id for key + derivation (`cryptography.hazmat.primitives.kdf.argon2.Argon2id`) and + AES-256-GCM for sealing. Envelope is versioned JSON with fields `v`, `kdf`, + `salt`, `nonce`, `ct`, each base64. A fresh random salt and nonce per seal. +- **The profile name is the AAD**, so an envelope cannot be moved between + profiles undetected. +- A **store-level verifier**: a known-plaintext envelope written at init, so a + mistyped password fails immediately rather than weeks later at first use. +- Password rotation re-seals **every** envelope or none. +- All writes are atomic: write to a temporary file in the same directory, + `fsync`, set the mode, then `rename`. A crash must not leave a truncated + credentials file. +- Warn when a file's mode is more permissive than required. + +**Tests must cover.** Seal/open round trip; a wrong password failing cleanly +via the GCM tag rather than returning garbage; an envelope resealed under +profile A failing to open as profile B (AAD); the ciphertext containing none +of the plaintext (assert a known value's bytes are absent); rotation being +all-or-nothing under an induced failure; atomic write leaving the original +intact when the write fails; every precedence branch of profile selection; +the absent-profile error. + +--- + +## Task 7: `login`, `exec`, `shell` + +**Goal.** Point Pulumi at a profile's backend, and run commands with that +profile's credentials in the environment. + +**Files.** `src/stackward/commands/session.py`, tests in `tests/test_session.py`. + +**Requirements.** +- `pulumi login` persists only the backend URL. `AWS_ACCESS_KEY_ID`, + `AWS_SECRET_ACCESS_KEY` and `PULUMI_CONFIG_PASSPHRASE` are read from the + environment of **every subsequent** `pulumi` process. A store only + `stackward` can read therefore breaks a plain `pulumi up`, and `exec` is what + makes the store usable at all. +- `stackward exec -- ` decrypts the profile, injects those three + variables **and** `PULUMI_BACKEND_URL`, and runs the command as a child. + Secrets exist only in the child's environment; never write them to disk, + and never log them. +- `stackward shell` is the same with the user's `$SHELL` as the command. +- `stackward login` resolves the profile's backend URL and runs + `pulumi login `. +- **Backend guard:** before any mutating command, compare the resolved + profile's backend URL with Pulumi's current backend; refuse on mismatch, + naming both. This is what makes profiles safe rather than merely convenient. +- Propagate the child's exit status exactly. +- `--` terminates option parsing; `exec` with no command is a usage error. + +**Tests must cover.** The injected environment containing exactly the expected +keys (assert the values are passed but never logged); exit-status propagation +including a non-zero and a signal; the backend guard refusing a mismatch and +allowing a match; `exec` with no command erroring; that no secret appears in +captured stdout or stderr on any path. + +--- + +## Task 8: `set-secrets` + +**Goal.** Publish stack secrets from a declared manifest, values on stdin. + +**Files.** `src/stackward/commands/set_secrets.py`, tests in +`tests/test_set_secrets.py`. + +**Requirements.** +- Manifest lives in `.stackward.toml` under `[secrets.""]` with + `secret`, `plaintext` and `unmanaged` tables mapping config path → logical + name. A single-project repository uses one table; project directories are + always repository-root-relative. +- Value sources, precedence highest first: the process environment, then each + file in `[secrets.source].files` in order, later overriding earlier. + `{stack}` in a filename is substituted. +- Parse `KEY=VALUE` files: ignore blanks and `#` comments, strip a leading + `export `, strip one matched pair of surrounding quotes. Warn when such a + file's mode is more permissive than 0o600. +- Values are passed to `pulumi config set --secret --path ` **on stdin**. +- A name whose value is unset is **skipped, never cleared** — the command only + ever touches what it was given a value for. +- `--dry-run` reports what would be set without running anything. +- `--stack S` is forwarded to `pulumi`; omitted when not given. +- Paths listed under `[required]` cause a non-zero exit when unresolved. +- **Drift detection**: for declared `[[drift_pairs]]` of a bootstrap and a + managed variable, compare the local bootstrap value against the published + value (read with `pulumi config get`, which decrypts — `pulumi config` list + does not, and exits 0 under any passphrase). Warn only when they differ and + the published value was readable. Never print either value. +- A timeout or a missing `pulumi` binary is reported and returns failure for + that entry without aborting the run mid-rotation; the summary names what was + not set. + +**Tests must cover.** Precedence across environment and two files; skip-if-unset; +`--dry-run` writing nothing; the value reaching the subprocess via stdin and +**not** via `argv`; a required unresolved path failing; drift warning firing on +a difference and staying silent when equal or unreadable; a subprocess timeout +being reported without aborting the remaining entries; that no test prints a +value. + +--- + +## Task 9: `check-passphrase` + +**Goal.** Prove a passphrase actually decrypts a stack, which no ordinary +`pulumi` command does. + +**Files.** `src/stackward/commands/check_passphrase.py`, tests in +`tests/test_check_passphrase.py`. + +**Requirements.** +- Read the passphrase from stdin, falling back to `getpass` when stdin is a + TTY. Never accept it as an argument. +- Obtain a stack's state with `pulumi stack export --stack ` and read + `deployment.secrets_providers.state.salt`, whose format is + `v1::v1::`. +- Derive with **PBKDF2-HMAC-SHA256, 1,000,000 iterations, 32-byte key** — this + must match Pulumi's own scheme, and is deliberately different from the + Argon2id used for our own store, which is not interoperating with anything. +- Open with AES-GCM; the plaintext is the literal `b"pulumi"`. Report per + stack: accepted or rejected. +- Exit 0 only if every named stack accepted. +- Report a fingerprint (first 16 hex of SHA-256) rather than any part of the + passphrase, so two candidates can be told apart in a log safely. + +**Tests must cover.** A synthesised salt that the correct passphrase opens; the +same salt rejecting a wrong passphrase; a malformed salt string erroring rather +than crashing; the passphrase never appearing in output; the fingerprint being +stable and 16 characters. + +--- + +## Task 10: The model net + +**Goal.** Detect a credential the heuristic cannot name, using declarations +carried in a committed artifact. + +**Files.** `src/stackward/nets/model.py`, `src/stackward/bootstrap/regen.py`, +`src/stackward/commands/sync_declared.py`, tests in `tests/test_model_net.py`. + +**Background that determines the design.** A shipped binary bundles its own +interpreter and cannot import the consuming repository's classes. The +declarations must therefore cross that boundary as data. The obvious form — a +flat list of path patterns — is wrong: a self-referential model yields a +finite pattern list while the data it describes is unbounded, so the flat form +fails **open** exactly where it must fail closed. + +**Requirements.** +- The artifact is a **graph**, not a pattern list: models → fields → marks → + child-model references, written to `.stackward/declared-secrets.json`. The + matcher walks the graph and the data together, so recursion and mutual + recursion work by construction. +- Marking convention, published as this tool's contract: + `json_schema_extra={"secret": True}` on a pydantic field. `[check].declared_paths_fn` + overrides it for a non-standard convention. +- A marked field covers **everything beneath it**, whatever its annotation — + matching is prefix-based, not exact. +- `sync-declared-secrets` shells out to the interpreter named by `python` in + the repo config, imports the declared classes, and writes the artifact. It + **refuses** any annotation containing a model that it cannot walk — + `Union[A, B]`, `Sequence[A]`, `Mapping[str, A]`, bare `dict` — rather than + silently emitting nothing for it. +- Record a `sources` map: every file contributing a marked field, by git blob + id, walking the **MRO** so a mark inherited from a base class in another + file is captured. For modules outside the repository, record the dependency + lockfile's hash instead. +- Freshness: `check-config` reads the artifact **from the git index** + (`git show ":.stackward/declared-secrets.json"`), not the working tree — an + unstaged regeneration must not let a stale committed artifact pass. A blob-id + mismatch is a hard failure naming the regeneration command. +- `model_net = "none"` is a declared mode. It is never a fallback: a missing or + stale artifact under `"artifact"` mode fails, it does not degrade. + +**Tests must cover.** Synthetic pydantic models only (pydantic is a test-only +dependency). Include: a self-referential model (`Node` with `children: +list[Node]`) matching at depth; mutual recursion; a mark inherited from a base +class in another module; a mark carried by an `Annotated` alias defined +elsewhere; prefix coverage of a marked container's descendants; a dict key +containing `.`; refusal of each unwalkable annotation; a stale artifact +failing; `"none"` mode skipping cleanly. + +--- + +## Task 11: Provider interface + +**Goal.** Make the credential source swappable, so adopting a managed secret +store is a configuration change rather than a migration. + +**Files.** `src/stackward/providers/__init__.py` and one module per provider, +tests in `tests/test_providers.py`. + +**Requirements.** +- Two roles. `CredentialStore` resolves a profile's bootstrap set; + implementations: `file` (Task 6's encrypted store) and `env`. + `SecretSource` resolves manifest values; implementations: `dotenv` and `env`. +- Selection is by `provider = "..."` in the profile's `credentials` table and + in `[secrets.source]` respectively. +- A remote provider shells out to its CLI. Adding one must not add a Python + dependency. +- **No provider module may be imported from the gate path.** Enforce with a + test that imports the `check-config` entry point in a subprocess and asserts + no provider module appears in `sys.modules`. +- Fetched values are held in memory for the invocation only; never written to + disk. + +**Tests must cover.** The same operation producing identical results and +identical subprocess invocations against two different providers seeded with +the same values — this is the "swapping the provider changes nothing for the +caller" guarantee, and asserting it is what makes it true; the gate-path import +test; that a provider failure is reported rather than silently yielding an +empty value. From 97823f1a4c7aecaee4263ad639aa9f7eb3511787 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 00:32:19 -0700 Subject: [PATCH 02/44] feat: canonical config-path grammar (parse/render) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One representation for a Pulumi `--path`-style config path, used by findings, manifest keys and the matcher alike: dotted segments, `[n]` for list indices (int), and `["..."]` for a key containing `.`, `[`, `]`, `"`, or the empty string. A literal `"` inside a bracketed key is escaped as `\"`. `parse` only ever produces an `int` from an unquoted `[n]` — a dotted or quoted segment is always `str`, which is what keeps `["1"]` (the string key "1") and `[1]` (index 1) from collapsing into each other on either side of the round trip. `render` rejects `bool` and negative int segments and an empty segment list, since letting any of those through would make the stated round-trip property false rather than merely undocumented. Standard library only, per the dependency constraint. ValueErrors from `parse` name the offending position, never the surrounding text. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/paths.py | 161 ++++++++++++++++++++++++++++++++++++++ tests/test_paths.py | 174 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 src/stackward/paths.py create mode 100644 tests/test_paths.py diff --git a/src/stackward/paths.py b/src/stackward/paths.py new file mode 100644 index 0000000..f49a422 --- /dev/null +++ b/src/stackward/paths.py @@ -0,0 +1,161 @@ +"""The one config-path grammar, shared by findings, manifests and matching. + +Pulumi's `--path` syntax is dotted segments (`a.b`), `[n]` for a list index, +and `["..."]` for a key that cannot be written bare — one containing `.`, `[`, +`]` or `"`, or the empty string. Everything downstream (the finding a scan +prints, the key a manifest stores, the walk that matches config data against +policy) needs to agree on exactly this representation; if the gate and the +matcher each grew their own path formatter, a key that round-trips through one +and not the other would silently stop matching. So there is exactly one +`parse`/`render` pair, here, and nothing else in the tool writes a path by +hand. + +The one property that has to hold, in both directions: + + render(parse(text)) == text for every canonical text + parse(render(segments)) == segments for every segment list + +`parse` only ever produces an `int` from an *unquoted* `[n]` — a dotted or +quoted segment is always `str`, even when it looks like a number. That is +what keeps `["1"]` (the string key `"1"`) and `[1]` (list index `1`) distinct +on both sides of the round trip: `render` never has to guess, and `parse` +never has to coerce. +""" + +from __future__ import annotations + +# Characters that cannot appear in a bare or dotted segment. A key containing +# any of these, or the empty string, must be written `["..."]`. +_NEEDS_QUOTING = frozenset('.[]"') + + +def _needs_quoting(key: str) -> bool: + return key == "" or any(char in _NEEDS_QUOTING for char in key) + + +def render(segments: list[str | int]) -> str: + """The inverse of `parse`: bracket-quote a segment only when required. + + A digit-only string (the dict key `"1"`) is rendered bare/dotted, not + quoted — `parse` never treats a dotted segment as an index, so quoting + buys nothing and `render(parse(x)) == x` would otherwise fail for the + canonical text `a.1`. + """ + if not segments: + raise ValueError("invalid path: segment list is empty") + + parts: list[str] = [] + for index, segment in enumerate(segments): + if isinstance(segment, bool): + # bool is an int subclass; letting one through would silently + # render `True` as the list index `[1]`. + raise ValueError( + f"invalid path segment at index {index}: bool is not a valid segment" + ) + if isinstance(segment, int): + if segment < 0: + raise ValueError( + f"invalid path segment at index {index}: list indices cannot be negative" + ) + parts.append(f"[{segment}]") + elif _needs_quoting(segment): + parts.append('["{}"]'.format(segment.replace('"', '\\"'))) + elif index == 0: + parts.append(segment) + else: + parts.append(f".{segment}") + return "".join(parts) + + +def _scan_bare_key(text: str, pos: int) -> tuple[str, int]: + """Consume a bare/dotted key starting at `pos`, stopping before `.` or `[`.""" + start = pos + while pos < len(text) and text[pos] not in ".[": + if text[pos] in '"]': + raise ValueError( + f"malformed path at position {pos}: unexpected character in key" + ) + pos += 1 + key = text[start:pos] + if not key: + raise ValueError(f"malformed path at position {start}: empty key") + return key, pos + + +def _scan_bracket(text: str, pos: int) -> tuple[str | int, int]: + """Consume a `[...]` segment starting at the `[` and return it with the + position just past the closing `]`.""" + start = pos + pos += 1 # past '[' + if pos < len(text) and text[pos] == '"': + pos += 1 + content_start = pos + chars: list[str] = [] + closed = False + while pos < len(text): + char = text[pos] + if char == "\\" and pos + 1 < len(text) and text[pos + 1] == '"': + chars.append('"') + pos += 2 + continue + if char == '"': + closed = True + pos += 1 + break + chars.append(char) + pos += 1 + if not closed: + raise ValueError( + f"malformed path at position {content_start}: unterminated quoted key" + ) + if pos >= len(text) or text[pos] != "]": + raise ValueError( + f"malformed path at position {pos}: expected ']' after quoted key" + ) + return "".join(chars), pos + 1 + + digits_start = pos + while pos < len(text) and text[pos] != "]": + pos += 1 + if pos >= len(text): + raise ValueError(f"malformed path at position {start}: unterminated '[' bracket") + content = text[digits_start:pos] + if not content or any(char not in "0123456789" for char in content): + raise ValueError(f"malformed path at position {digits_start}: invalid list index") + return int(content), pos + 1 + + +def parse(text: str) -> list[str | int]: + """Parse Pulumi `--path` syntax into segments, list indices as `int`. + + Raises `ValueError` naming the offending position for malformed input. + The message never echoes the surrounding text — only the position and + what was wrong with it. + """ + if not text: + raise ValueError("malformed path at position 0: empty path") + + segments: list[str | int] = [] + pos = 0 + while pos < len(text): + char = text[pos] + if char == ".": + if not segments: + raise ValueError( + f"malformed path at position {pos}: path cannot start with '.'" + ) + key, pos = _scan_bare_key(text, pos + 1) + segments.append(key) + elif char == "[": + segment, pos = _scan_bracket(text, pos) + segments.append(segment) + else: + if segments: + # A bare character right after a previous segment, with no + # '.' or '[' separator — e.g. the 'b' in "a[0]b". + raise ValueError( + f"malformed path at position {pos}: expected '.' or '[' before next segment" + ) + key, pos = _scan_bare_key(text, pos) + segments.append(key) + return segments diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..121f3b2 --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,174 @@ +"""Tests for the config-path grammar: findings, manifests and matching all +depend on `parse`/`render` agreeing with each other in both directions.""" + +from __future__ import annotations + +import pytest + +from stackward.paths import parse, render + +# Canonical texts: render(parse(x)) must reproduce x exactly. Every entry +# here is already in the form `render` would itself produce. +CANONICAL_TEXTS = [ + "a.b.c", + "a[0]", + "a.b[3].c", + 'a["b.c"]', + 'a["b[c]"]', + 'a["b\\"c"]', + 'a[""]', + "a.1", + "[0]", + "a[0][1]", +] + +# Segment lists: parse(render(y)) must reproduce y exactly. Includes forms +# `render` would not itself choose (e.g. a quoted digit key), which is fine — +# the round trip only requires equality, not that the text be canonical. +SEGMENT_LISTS: list[list[str | int]] = [ + ["a", "b", "c"], + ["a", 0], + ["a", "b", 3, "c"], + ["a", "b.c"], + ["a", "b[c]"], + ['a', 'b"c'], + ["a", ""], + ["a", "1"], + [0], + ["1"], + [1], + ["a", 0, "b"], # dot must be restored after a bracket + ["a", 0, 1], # two indices in a row, no dot between brackets + ["a", 0, "b.c"], # a quoted bracket right after another bracket +] + + +def test_parses_a_plain_dotted_path(): + assert parse("a.b.c") == ["a", "b", "c"] + + +def test_parses_a_list_index_as_int(): + segments = parse("a[0]") + assert segments == ["a", 0] + assert type(segments[1]) is int + + +def test_key_containing_a_dot_round_trips_through_bracket_form(): + segments = ["a", "b.c"] + text = render(segments) + assert text == 'a["b.c"]' + assert parse(text) == segments + + +def test_key_containing_a_bracket_round_trips_through_bracket_form(): + segments = ["a", "b[c]"] + text = render(segments) + assert text == 'a["b[c]"]' + assert parse(text) == segments + + +def test_key_containing_a_quote_is_escaped_as_backslash_quote(): + segments = ["a", 'b"c'] + text = render(segments) + assert text == 'a["b\\"c"]' + assert parse(text) == segments + + +def test_empty_string_key_round_trips_through_bracket_form(): + segments = ["a", ""] + text = render(segments) + assert text == 'a[""]' + assert parse(text) == segments + + +def test_quoted_string_digit_key_and_unquoted_list_index_do_not_collapse_on_parse(): + """`["1"]` is the string "1"; `[1]` is the index 1 — parsing must keep + them apart, not just the segment lists' equality but their types too.""" + string_segments = parse('a["1"]') + index_segments = parse("a[1]") + + assert string_segments == ["a", "1"] + assert type(string_segments[1]) is str + + assert index_segments == ["a", 1] + assert type(index_segments[1]) is int + + assert string_segments != index_segments + + +def test_string_digit_key_and_list_index_do_not_collapse_on_render(): + """The same distinction, the other way: rendering a string "1" must not + produce the same text as rendering the index 1.""" + string_text = render(["a", "1"]) + index_text = render(["a", 1]) + + assert string_text == "a.1" + assert index_text == "a[1]" + assert string_text != index_text + + # And each parses back to the segment it came from, not the other one. + assert parse(string_text) == ["a", "1"] + assert parse(index_text) == ["a", 1] + + +@pytest.mark.parametrize("text", CANONICAL_TEXTS) +def test_render_of_parse_reproduces_canonical_text(text): + assert render(parse(text)) == text + + +@pytest.mark.parametrize("segments", SEGMENT_LISTS) +def test_parse_of_render_reproduces_segment_list(segments): + assert parse(render(segments)) == segments + + +def test_dot_is_restored_after_a_bracket_for_the_next_bare_segment(): + assert render(["a", 0, "b"]) == "a[0].b" + + +def test_no_dot_between_two_consecutive_indices(): + assert render(["a", 0, 1]) == "a[0][1]" + + +def test_no_dot_before_a_quoted_bracket_that_follows_another_bracket(): + assert render(["a", 0, "b.c"]) == 'a[0]["b.c"]' + + +def test_unterminated_bracket_raises_value_error_naming_the_position(): + with pytest.raises(ValueError) as exc_info: + parse("a[0") + message = str(exc_info.value) + assert "position 1" in message # the unclosed '[' + assert "a[0" not in message + + +def test_empty_key_between_dots_raises_value_error_naming_the_position(): + with pytest.raises(ValueError) as exc_info: + parse("a..b") + message = str(exc_info.value) + assert "position 2" in message + assert "a..b" not in message + + +def test_non_digit_list_index_raises_value_error_naming_the_position(): + with pytest.raises(ValueError) as exc_info: + parse("a[x]") + message = str(exc_info.value) + assert "position 2" in message + assert "a[x]" not in message + + +def test_render_rejects_bool_segment_rather_than_silently_treating_it_as_an_index(): + """bool is an int subclass; letting one through would make `render([True])` + silently produce `[1]`.""" + with pytest.raises(ValueError): + render(["a", True]) + + +def test_render_rejects_negative_index(): + with pytest.raises(ValueError): + render(["a", -1]) + + +def test_render_rejects_empty_segment_list(): + with pytest.raises(ValueError): + render([]) From 7b095169a3cb315e6850e395d4d281559372ad8d Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 00:51:27 -0700 Subject: [PATCH 03/44] fix: escape backslash in quoted path keys, not just the quote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit render(["a." + "\\"]) round-tripped through a broken text: escaping only `"` left a trailing literal `\` indistinguishable, on decode, from the backslash that starts a `\"` escape. Any key quoted for an unrelated reason (a dot, a bracket, being empty) that also ended in one or more literal backslashes failed parse(render(y)) == y. - render: escape `\` as `\\` before escaping `"` as `\"` (order matters — escaping `"` first would double-escape a backslash just inserted). - parse: decode both `\\` and `\"` as one two-character escape pair; a `\` followed by anything else (including end of input) is now a named ValueError, not a passthrough — the previous 1-char lookahead couldn't otherwise tell a real escape from a lone trailing backslash. - `\` joins `.`, `[`, `]`, `"` in `_NEEDS_QUOTING`: once backslash is an escape prefix inside brackets, a bare/dotted key containing one has to go through the same quoted-and-escaped regime rather than meaning itself literally in one context and an escape prefix in another. - Also: reject a leading zero in an unquoted list index (`a[01]`). `render` never produces one; tolerating it on parse would give the same index two silently-equivalent spellings. Added regression coverage: a key with only a mid-string backslash and no other reason to quote, a key ending in one literal backslash, a key ending in two, an invalid-escape-sequence malformed-input case, and the leading-zero case. Fixed a SyntaxWarning from an unescaped backslash in the module docstring along the way. uv run pytest -q: 61 passed (50 in tests/test_paths.py). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/paths.py | 54 +++++++++++++++++++++++++++++------------ tests/test_paths.py | 55 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 16 deletions(-) diff --git a/src/stackward/paths.py b/src/stackward/paths.py index f49a422..4362fd4 100644 --- a/src/stackward/paths.py +++ b/src/stackward/paths.py @@ -1,14 +1,15 @@ """The one config-path grammar, shared by findings, manifests and matching. Pulumi's `--path` syntax is dotted segments (`a.b`), `[n]` for a list index, -and `["..."]` for a key that cannot be written bare — one containing `.`, `[`, -`]` or `"`, or the empty string. Everything downstream (the finding a scan -prints, the key a manifest stores, the walk that matches config data against -policy) needs to agree on exactly this representation; if the gate and the -matcher each grew their own path formatter, a key that round-trips through one -and not the other would silently stop matching. So there is exactly one -`parse`/`render` pair, here, and nothing else in the tool writes a path by -hand. +and `["..."]` for a key that cannot be written bare — one containing a dot, +a bracket, a double quote, a backslash, or the empty string. Inside the +quotes, a literal double quote or backslash is escaped by prefixing it with +a backslash. Everything downstream (the finding a scan prints, the key a +manifest stores, the walk that matches config data against policy) needs to +agree on exactly this representation; if the gate and the matcher each grew +their own path formatter, a key that round-trips through one and not the +other would silently stop matching. So there is exactly one `parse`/`render` +pair, here, and nothing else in the tool writes a path by hand. The one property that has to hold, in both directions: @@ -25,8 +26,12 @@ from __future__ import annotations # Characters that cannot appear in a bare or dotted segment. A key containing -# any of these, or the empty string, must be written `["..."]`. -_NEEDS_QUOTING = frozenset('.[]"') +# any of these, or the empty string, must be written `["..."]`. `\` is in +# this set *because* it is the escape prefix inside a bracketed key — once +# backslash means something inside brackets, a bare/dotted key containing one +# has to go through the same quoted-and-escaped regime, not a bare one where +# the same character would mean something else (itself, literally). +_NEEDS_QUOTING = frozenset('.[]"\\') def _needs_quoting(key: str) -> bool: @@ -59,7 +64,10 @@ def render(segments: list[str | int]) -> str: ) parts.append(f"[{segment}]") elif _needs_quoting(segment): - parts.append('["{}"]'.format(segment.replace('"', '\\"'))) + # Escape `\` before `"` — reversing the order would double-escape + # a backslash that a prior `"` -> `\"` pass had just inserted. + escaped = segment.replace("\\", "\\\\").replace('"', '\\"') + parts.append(f'["{escaped}"]') elif index == 0: parts.append(segment) else: @@ -94,10 +102,19 @@ def _scan_bracket(text: str, pos: int) -> tuple[str | int, int]: closed = False while pos < len(text): char = text[pos] - if char == "\\" and pos + 1 < len(text) and text[pos + 1] == '"': - chars.append('"') - pos += 2 - continue + if char == "\\": + # The only two valid escapes: `\"` for a literal quote and + # `\\` for a literal backslash. Anything else after a `\` + # (including end of string) is malformed, not "an unescaped + # backslash" — a lone `\` right before the closing `"` would + # otherwise be indistinguishable from an escaped quote. + if pos + 1 < len(text) and text[pos + 1] in "\\\"": + chars.append(text[pos + 1]) + pos += 2 + continue + raise ValueError( + f"malformed path at position {pos}: invalid escape sequence" + ) if char == '"': closed = True pos += 1 @@ -120,7 +137,12 @@ def _scan_bracket(text: str, pos: int) -> tuple[str | int, int]: if pos >= len(text): raise ValueError(f"malformed path at position {start}: unterminated '[' bracket") content = text[digits_start:pos] - if not content or any(char not in "0123456789" for char in content): + # A leading zero (other than "0" itself) is rejected rather than + # tolerated: `render` never produces one, and accepting "01" as a second, + # silently-equivalent spelling of index 1 is exactly the kind of leniency + # this grammar exists to avoid. + has_leading_zero = len(content) > 1 and content[0] == "0" + if not content or has_leading_zero or any(char not in "0123456789" for char in content): raise ValueError(f"malformed path at position {digits_start}: invalid list index") return int(content), pos + 1 diff --git a/tests/test_paths.py b/tests/test_paths.py index 121f3b2..e985cb0 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -7,6 +7,12 @@ from stackward.paths import parse, render +# Spelled out via concatenation rather than string literals with `\\` runs, +# so the number of actual backslash characters in each fixture is unambiguous +# at the call site. +BACKSLASH = "\\" +TWO_BACKSLASHES = BACKSLASH * 2 + # Canonical texts: render(parse(x)) must reproduce x exactly. Every entry # here is already in the form `render` would itself produce. CANONICAL_TEXTS = [ @@ -20,6 +26,7 @@ "a.1", "[0]", "a[0][1]", + '["a\\\\b"]', # a literal backslash, doubled ] # Segment lists: parse(render(y)) must reproduce y exactly. Includes forms @@ -40,6 +47,9 @@ ["a", 0, "b"], # dot must be restored after a bracket ["a", 0, 1], # two indices in a row, no dot between brackets ["a", 0, "b.c"], # a quoted bracket right after another bracket + ["a" + BACKSLASH + "b"], # one backslash, no other reason to quote + ["a." + BACKSLASH], # quoted for the dot, then ends in one backslash + ["a." + TWO_BACKSLASHES], # quoted for the dot, then ends in two backslashes ] @@ -172,3 +182,48 @@ def test_render_rejects_negative_index(): def test_render_rejects_empty_segment_list(): with pytest.raises(ValueError): render([]) + + +def test_backslash_alone_is_quoted_and_escaped_as_double_backslash(): + """A key with no dot/bracket/quote but containing `\\` still has to be + quoted — `\\` is only safe to leave bare if it never means anything + special, and it does mean something special once it appears inside a + quoted key elsewhere.""" + segments = ["a" + BACKSLASH + "b"] + text = render(segments) + assert text == '["a\\\\b"]' + assert parse(text) == segments + + +def test_quoted_key_ending_in_one_literal_backslash_round_trips(): + """Regression: escaping only `"` (never `\\`) let a trailing backslash + merge with the closing delimiter's quote on decode, so a key quoted for + an unrelated reason (here, the dot) that also ends in `\\` failed to + round-trip — `parse(render(y))` raised instead of reproducing `y`.""" + segments = ["a." + BACKSLASH] + assert parse(render(segments)) == segments + + +def test_quoted_key_ending_in_two_literal_backslashes_round_trips(): + segments = ["a." + TWO_BACKSLASHES] + assert parse(render(segments)) == segments + + +def test_leading_zero_in_list_index_is_rejected_as_malformed(): + """`render` never produces "01" for index 1; tolerating it on parse would + give the same index two silently-equivalent spellings.""" + with pytest.raises(ValueError) as exc_info: + parse("a[01]") + message = str(exc_info.value) + assert "position 2" in message + assert "a[01]" not in message + + +def test_invalid_escape_sequence_raises_value_error_naming_the_position(): + """A `\\` inside a quoted key that isn't followed by `"` or `\\` is + malformed, not a literal backslash silently passed through.""" + with pytest.raises(ValueError) as exc_info: + parse('a["b\\nc"]') + message = str(exc_info.value) + assert "position 4" in message # the stray backslash itself + assert "b\\nc" not in message From af6291de886b7c63ebd40d93460a59c2694b45a1 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 01:14:08 -0700 Subject: [PATCH 04/44] feat: repository policy loader for .stackward.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add src/stackward/config.py: parse and validate .stackward.toml with tomllib (stdlib only, no new dependency). Recognises profile, python, min_version, [check] and [secrets.*]/[secrets.source]. Unknown keys at the top level and within [check] are errors naming the key; [secrets.*] content is validated only as far as "a table of tables" since Task 8 owns its semantics. A present-but-invalid config always raises ConfigError, never falls back to defaults; an absent config is None, left for the caller to decide. [check].sensitive_keys EXTENDS the built-in sensitive-key set; [check].sensitive_parents REPLACES it. This asymmetry is deliberate, documented at _extend/_replace: a repo can only add key names to flag, but may need to fully opt a whole parent category back out of the built-ins, which extension alone can never express. The built-in sensitive_parents default is empty on purpose — the brief supplies a built-in list for sensitive_keys but not for parents, and inventing one would be exactly the environment-specific guessing this module exists to avoid. Flagged in the task report for the controller. min_version enforcement (enforce_min_version) compares versions as numeric tuples, not strings, so "0.10.0" > "0.9.0" compares correctly. It honours cli.VERSION_CHECK_EXEMPT (doctor, self-update) via a function-local import, which also breaks the import cycle: cli now imports find_repo_config from config, and config needs VERSION_CHECK_EXEMPT from cli. Move find_repo_config (and CONFIG_FILENAME) out of cli.py into config.py verbatim; cli.py now imports it. config_home() stays in cli.py per the task brief (Task 6 owns the credential store's paths). Move the three tests that exercise find_repo_config from tests/test_cli.py to the new tests/test_config.py. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/cli.py | 19 +-- src/stackward/config.py | 329 ++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 26 ---- tests/test_config.py | 306 +++++++++++++++++++++++++++++++++++++ 4 files changed, 636 insertions(+), 44 deletions(-) create mode 100644 src/stackward/config.py create mode 100644 tests/test_config.py diff --git a/src/stackward/cli.py b/src/stackward/cli.py index 97f261a..228f18d 100644 --- a/src/stackward/cli.py +++ b/src/stackward/cli.py @@ -17,14 +17,13 @@ from pathlib import Path from . import __version__ +from .config import find_repo_config # Commands that must keep working even when a repo demands a newer stackward # than the one installed. Without this exemption the upgrade instruction would # itself be blocked by the check that prints it. VERSION_CHECK_EXEMPT = frozenset({"doctor", "self-update"}) -CONFIG_FILENAME = ".stackward.toml" - def config_home() -> Path: """Where profiles and credentials live. XDG, with the usual fallback.""" @@ -33,22 +32,6 @@ def config_home() -> Path: return base / "stackward" -def find_repo_config(start: Path | None = None) -> Path | None: - """Nearest .stackward.toml at or above `start`, stopping at the repo root. - - Returns None rather than falling back to a default: a tool that guesses - which backend it is talking to is worse than one that refuses. - """ - current = (start or Path.cwd()).resolve() - for candidate in [current, *current.parents]: - config = candidate / CONFIG_FILENAME - if config.is_file(): - return config - if (candidate / ".git").exists(): - break - return None - - def _tool_version(name: str) -> str: """Report an external tool's version, or why it cannot be used.""" path = shutil.which(name) diff --git a/src/stackward/config.py b/src/stackward/config.py new file mode 100644 index 0000000..28edbd3 --- /dev/null +++ b/src/stackward/config.py @@ -0,0 +1,329 @@ +"""Repository policy: `.stackward.toml`. + +Every later command needs to know, about *this* repository specifically: +which keys and parent categories count as sensitive, which pydantic classes +declare secrets for which stack, which secret manifest and value sources +apply, and the oldest `stackward` release this repository's policy is known +to work with. None of that is knowledge this tool ships with — it all +arrives through this one file, found by walking up from the working +directory to the repository root. That is the seam that keeps `stackward` +generic: swap the file, not the tool. + +Two states a caller must tell apart: **absent** (`find_repo_config` returns +`None`; there is no policy to apply, and it is on the caller to decide what +that means for the command at hand) and **present but invalid** +(`load_config` raises `ConfigError`). There is no third state where an +invalid file quietly falls back to defaults — a typo that silently disabled +part of a credential gate would be worse than one that refused to run at +all. +""" + +from __future__ import annotations + +import tomllib +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from . import __version__ + +CONFIG_FILENAME = ".stackward.toml" + +# Case-insensitive substrings that make a leaf's key suspect on their own, +# with no help from an ancestor. `[check].sensitive_keys` in the repo config +# EXTENDS this set — see `_extend`/`_replace` below for why keys extend but +# parents replace. +BUILTIN_SENSITIVE_KEYS: frozenset[str] = frozenset( + { + "password", + "passphrase", + "token", + "secret", + "apikey", + "api_key", + "jwt", + "credential", + "private_key", + "access_key_id", + "secret_access_key", + } +) + +# No parent key is sensitive by default. Unlike `sensitive_keys` above, this +# tool ships no built-in guess at what a "sensitive parent" category is +# called in any particular repository's config shape — inventing one (e.g. +# copying a name like "environment_variables" out of somebody's existing +# gate script) would be exactly the kind of environment-specific knowledge +# this module exists to keep out of a public, generic tool. A repo that +# wants whole categories of keys flagged declares `sensitive_parents` +# itself, which then REPLACES this (empty) default outright. +BUILTIN_SENSITIVE_PARENTS: frozenset[str] = frozenset() + +_MODEL_NET_VALUES = frozenset({"artifact", "none"}) +_TOP_LEVEL_KEYS = frozenset({"profile", "python", "min_version", "check", "secrets"}) +_CHECK_KEYS = frozenset( + { + "model_net", + "stack_models", + "sensitive_keys", + "sensitive_parents", + "allowed_references", + } +) + + +class ConfigError(Exception): + """`.stackward.toml` is present but the current invocation cannot proceed + because of it — invalid content, or (via `MinVersionError`) a + `min_version` the installed `stackward` does not meet. + + Never raised for a missing file — that is `find_repo_config` returning + `None`, which is a normal state callers decide how to handle. The caller + must not treat an invalid or unsatisfied config the same as "no config" + and fall back to defaults. + """ + + +class MinVersionError(ConfigError): + """The installed `stackward` is older than this repository's `min_version`.""" + + +@dataclass(frozen=True) +class CheckConfig: + """The `[check]` table: what the credential-scanning nets look for.""" + + model_net: str = "none" + stack_models: dict[str, str] = field(default_factory=dict) + sensitive_keys: frozenset[str] = BUILTIN_SENSITIVE_KEYS + sensitive_parents: frozenset[str] = BUILTIN_SENSITIVE_PARENTS + allowed_references: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class Config: + """A parsed, validated `.stackward.toml`.""" + + profile: str | None = None + python: str | None = None + min_version: str | None = None + check: CheckConfig = field(default_factory=CheckConfig) + # Raw `[secrets]` content: one entry per direct member, which is either + # the reserved "source" table or an arbitrary project-directory table. + # See `_build_secrets` for why this module validates no deeper than + # "a table of tables" here. + secrets: dict[str, dict[str, Any]] = field(default_factory=dict) + + +def find_repo_config(start: Path | None = None) -> Path | None: + """Nearest .stackward.toml at or above `start`, stopping at the repo root. + + Returns None rather than falling back to a default: a tool that guesses + which backend it is talking to is worse than one that refuses. + """ + current = (start or Path.cwd()).resolve() + for candidate in [current, *current.parents]: + config = candidate / CONFIG_FILENAME + if config.is_file(): + return config + if (candidate / ".git").exists(): + break + return None + + +def _parse_version(text: str) -> tuple[int, ...]: + """Parse a dotted version string into a tuple of ints for comparison. + + String comparison gets this wrong: `"0.10.0" > "0.9.0"` is False under + lexicographic ordering (`"1" < "9"` character by character) but True + numerically, which is the comparison a version floor actually needs. + """ + parts = text.split(".") + if not parts or any(not part.isdigit() for part in parts): + raise ValueError(f"not a numeric dotted version: {text!r}") + return tuple(int(part) for part in parts) + + +def _extend(builtin: frozenset[str], declared: list[str] | None) -> frozenset[str]: + """`sensitive_keys` EXTENDS the built-ins. + + A repo can only ever add to the set of key names that make a leaf + suspect. Silently letting a repo drop a built-in key would weaken the + gate without anyone having deliberately chosen that. + """ + if declared is None: + return builtin + return builtin | frozenset(declared) + + +def _replace(builtin: frozenset[str], declared: list[str] | None) -> frozenset[str]: + """`sensitive_parents` REPLACES the built-ins. + + Unlike a key name, a whole parent *category* may have to stay in + plaintext on purpose in some repository, and there is no "extend but + exclude one" operation available to opt a single built-in parent back + out. Replacing the list wholesale is the only way to do that, and it is + what a repo reaching for this knob wants. This asymmetry with `_extend` + above is deliberate, not an inconsistency — do not "fix" it into + matching behaviour. + """ + if declared is None: + return builtin + return frozenset(declared) + + +def _require_str(data: dict[str, Any], key: str, name: str) -> str | None: + if key not in data: + return None + value = data[key] + if not isinstance(value, str): + raise ConfigError(f"{name} must be a string") + return value + + +def _require_str_list(data: dict[str, Any], key: str, name: str) -> list[str] | None: + if key not in data: + return None + value = data[key] + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise ConfigError(f"{name} must be a list of strings") + return value + + +def _build_check(raw: Any) -> CheckConfig: + if not isinstance(raw, dict): + raise ConfigError("'check' must be a table") + + unknown = set(raw) - _CHECK_KEYS + if unknown: + raise ConfigError(f"unknown key 'check.{sorted(unknown)[0]}'") + + model_net = raw.get("model_net", "none") + if not isinstance(model_net, str) or model_net not in _MODEL_NET_VALUES: + raise ConfigError( + f"check.model_net must be one of {sorted(_MODEL_NET_VALUES)!r}, " + f"got {model_net!r}" + ) + + stack_models = raw.get("stack_models", {}) + if not isinstance(stack_models, dict) or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in stack_models.items() + ): + raise ConfigError("check.stack_models must be a table of string to string") + + sensitive_keys = _require_str_list(raw, "sensitive_keys", "check.sensitive_keys") + sensitive_parents = _require_str_list( + raw, "sensitive_parents", "check.sensitive_parents" + ) + allowed_references = _require_str_list( + raw, "allowed_references", "check.allowed_references" + ) + + return CheckConfig( + model_net=model_net, + stack_models=dict(stack_models), + sensitive_keys=_extend(BUILTIN_SENSITIVE_KEYS, sensitive_keys), + sensitive_parents=_replace(BUILTIN_SENSITIVE_PARENTS, sensitive_parents), + allowed_references=list(allowed_references or []), + ) + + +def _build_secrets(raw: Any) -> dict[str, dict[str, Any]]: + """Shape only: a table whose every direct member is itself a table. + + This is the boundary drawn in Task 2's brief: Task 8 owns the manifest's + *semantics* — what `secret`/`plaintext`/`unmanaged` mean inside a + project's table, what `[secrets.source]`'s own keys are — and this + module does not yet know what a valid entry looks like, so it does not + check for one. Unlike `[check]` above, whose keys this module enumerates + and rejects unknown ones for, `[secrets.*]` content is passed through + unexamined on purpose. That is not the same inconsistency either: it + holds because `[check]`'s keys are fully specified in this task's + brief and `[secrets.*]`'s are not — don't "fix" this one to match, and + don't loosen `[check]` to match this one. + """ + if not isinstance(raw, dict): + raise ConfigError("'secrets' must be a table") + + result: dict[str, dict[str, Any]] = {} + for name, value in raw.items(): + if not isinstance(value, dict): + raise ConfigError(f"secrets.{name!r} must be a table") + result[name] = value + return result + + +def _build_config(data: dict[str, Any]) -> Config: + unknown = set(data) - _TOP_LEVEL_KEYS + if unknown: + raise ConfigError(f"unknown key {sorted(unknown)[0]!r}") + + profile = _require_str(data, "profile", "profile") + python = _require_str(data, "python", "python") + min_version = _require_str(data, "min_version", "min_version") + if min_version is not None: + try: + _parse_version(min_version) + except ValueError as exc: + raise ConfigError(f"min_version: {exc}") from exc + + return Config( + profile=profile, + python=python, + min_version=min_version, + check=_build_check(data.get("check", {})), + secrets=_build_secrets(data.get("secrets", {})), + ) + + +def load_config(path: Path) -> Config: + """Parse and validate `path` as `.stackward.toml`. + + Raises `ConfigError` naming the offending key on anything invalid: an + unknown key at any recognised level, a value of the wrong type, or an + unparseable `min_version`. Never falls back to defaults — a config that + fails to validate must not be treated as though it did not exist. + """ + try: + with path.open("rb") as handle: + data = tomllib.load(handle) + except OSError as exc: + raise ConfigError(f"{path}: cannot read: {exc}") from exc + except tomllib.TOMLDecodeError as exc: + # `.stackward.toml` holds path and key *names* only, never a + # credential value, so it is safe for this message to include + # whatever tomllib quotes from the offending line. + raise ConfigError(f"{path}: invalid TOML: {exc}") from exc + + try: + return _build_config(data) + except ConfigError as exc: + raise ConfigError(f"{path}: {exc}") from exc + + +def enforce_min_version( + config: Config | None, + command: str, + *, + installed: str = __version__, +) -> None: + """Raise `MinVersionError` when `installed` is older than `config.min_version`. + + Commands in `cli.VERSION_CHECK_EXEMPT` (`doctor`, `self-update`) always + run: blocking either one would make the upgrade instruction this raises + unreachable. `cli` is imported locally rather than at module level + because `cli` imports `find_repo_config` from this module — importing + `cli` here at module scope would make the two modules need each other to + finish executing. + """ + from .cli import VERSION_CHECK_EXEMPT # local: breaks an import cycle + + if config is None or config.min_version is None: + return + if command in VERSION_CHECK_EXEMPT: + return + if _parse_version(installed) < _parse_version(config.min_version): + raise MinVersionError( + f"this repository requires stackward >= {config.min_version} " + f"(installed: {installed}); upgrade with: stackward self-update" + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 326123d..12249b3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,7 +9,6 @@ VERSION_CHECK_EXEMPT, config_home, crypto_selftest, - find_repo_config, main, ) @@ -76,28 +75,3 @@ def test_config_home_falls_back_to_dot_config(monkeypatch, tmp_path): monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) assert config_home() == tmp_path / ".config" / "stackward" - - -def test_repo_config_found_from_a_subdirectory(tmp_path): - (tmp_path / ".git").mkdir() - config = tmp_path / ".stackward.toml" - config.write_text("") - nested = tmp_path / "deploy" / "nested" - nested.mkdir(parents=True) - assert find_repo_config(nested) == config - - -def test_repo_config_absent_returns_none_rather_than_guessing(tmp_path): - """Refusing beats defaulting: a tool that guesses which backend it is - pointed at can publish a credential to the wrong place.""" - (tmp_path / ".git").mkdir() - assert find_repo_config(tmp_path) is None - - -def test_search_stops_at_the_repo_root(tmp_path): - """A config outside the repository must not be picked up — it would make - behaviour depend on where the repo happens to be checked out.""" - (tmp_path / ".stackward.toml").write_text("") - repo = tmp_path / "repo" - (repo / ".git").mkdir(parents=True) - assert find_repo_config(repo) is None diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..de998a7 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,306 @@ +"""Tests for `.stackward.toml` discovery, parsing and validation. + +This is the seam that keeps `stackward` generic: every fact a repository +wants applied — what counts as sensitive, which stack models declare +secrets, the oldest release its policy works with — arrives only through +this file. Absent means "no policy" (`find_repo_config` returns `None`); +present-but-invalid must raise, never fall back to a default. +""" + +from __future__ import annotations + +import pytest + +from stackward.config import ( + BUILTIN_SENSITIVE_KEYS, + Config, + ConfigError, + MinVersionError, + _extend, + _replace, + enforce_min_version, + find_repo_config, + load_config, +) + + +def write_config(tmp_path, text: str): + path = tmp_path / ".stackward.toml" + path.write_text(text) + return path + + +# --------------------------------------------------------------------------- +# find_repo_config — moved here from tests/test_cli.py along with the +# function itself (Task 2 brief). +# --------------------------------------------------------------------------- + + +def test_repo_config_found_from_a_subdirectory(tmp_path): + (tmp_path / ".git").mkdir() + config = tmp_path / ".stackward.toml" + config.write_text("") + nested = tmp_path / "deploy" / "nested" + nested.mkdir(parents=True) + assert find_repo_config(nested) == config + + +def test_repo_config_absent_returns_none_rather_than_guessing(tmp_path): + """Refusing beats defaulting: a tool that guesses which backend it is + pointed at can publish a credential to the wrong place.""" + (tmp_path / ".git").mkdir() + assert find_repo_config(tmp_path) is None + + +def test_search_stops_at_the_repo_root(tmp_path): + """A config outside the repository must not be picked up — it would make + behaviour depend on where the repo happens to be checked out.""" + (tmp_path / ".stackward.toml").write_text("") + repo = tmp_path / "repo" + (repo / ".git").mkdir(parents=True) + assert find_repo_config(repo) is None + + +# --------------------------------------------------------------------------- +# Each recognised key, parsed. +# --------------------------------------------------------------------------- + + +def test_profile_key_is_parsed(tmp_path): + config = load_config(write_config(tmp_path, 'profile = "staging"\n')) + assert config.profile == "staging" + + +def test_python_key_is_parsed(tmp_path): + config = load_config(write_config(tmp_path, 'python = ".venv/bin/python"\n')) + assert config.python == ".venv/bin/python" + + +def test_min_version_key_is_parsed(tmp_path): + config = load_config(write_config(tmp_path, 'min_version = "0.4.0"\n')) + assert config.min_version == "0.4.0" + + +def test_check_model_net_is_parsed(tmp_path): + config = load_config(write_config(tmp_path, '[check]\nmodel_net = "artifact"\n')) + assert config.check.model_net == "artifact" + + +def test_check_model_net_defaults_to_none(tmp_path): + config = load_config(write_config(tmp_path, "[check]\n")) + assert config.check.model_net == "none" + + +def test_check_stack_models_is_parsed(tmp_path): + config = load_config( + write_config( + tmp_path, + '[check.stack_models]\n"infra" = "infra.config:StackConfig"\n', + ) + ) + assert config.check.stack_models == {"infra": "infra.config:StackConfig"} + + +def test_check_sensitive_keys_is_parsed_and_extends_the_builtins(tmp_path): + config = load_config( + write_config(tmp_path, '[check]\nsensitive_keys = ["jwtsecret"]\n') + ) + # Both the declared addition and a built-in survive — extension, not + # replacement. + assert "jwtsecret" in config.check.sensitive_keys + assert "password" in config.check.sensitive_keys + + +def test_check_sensitive_parents_is_parsed(tmp_path): + config = load_config( + write_config(tmp_path, '[check]\nsensitive_parents = ["environment_variables"]\n') + ) + assert config.check.sensitive_parents == frozenset({"environment_variables"}) + + +def test_check_allowed_references_is_parsed(tmp_path): + config = load_config( + write_config(tmp_path, '[check]\nallowed_references = ["a.other_secret"]\n') + ) + assert config.check.allowed_references == ["a.other_secret"] + + +def test_secrets_project_table_is_parsed(tmp_path): + config = load_config( + write_config( + tmp_path, + '[secrets."infra"]\nsecret = { "db.password" = "DB_PASSWORD" }\n', + ) + ) + assert config.secrets["infra"]["secret"] == {"db.password": "DB_PASSWORD"} + + +def test_secrets_source_table_is_parsed(tmp_path): + config = load_config( + write_config( + tmp_path, + '[secrets.source]\nfiles = [".env", ".env.{stack}.local"]\n', + ) + ) + assert config.secrets["source"]["files"] == [".env", ".env.{stack}.local"] + + +# --------------------------------------------------------------------------- +# Unknown keys are loud, at every level this module validates. +# --------------------------------------------------------------------------- + + +def test_unknown_top_level_key_errors_naming_the_key(tmp_path): + with pytest.raises(ConfigError) as exc_info: + load_config(write_config(tmp_path, 'bogus = "oops"\n')) + assert "bogus" in str(exc_info.value) + + +def test_unknown_check_key_errors_naming_the_key(tmp_path): + """`[check]`'s keys are fully enumerated in the brief, so a typo there + must be as loud as an unknown top-level key — this module does not stop + validating strictly the moment it descends one table.""" + with pytest.raises(ConfigError) as exc_info: + load_config(write_config(tmp_path, '[check]\nbogus = "oops"\n')) + assert "bogus" in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# The extend-vs-replace asymmetry, proven for both lists. +# +# `sensitive_parents`' shipped built-in default is the empty set (see +# `BUILTIN_SENSITIVE_PARENTS` in config.py), so a load_config-level test +# alone cannot distinguish "replaced an empty set" from "extended an empty +# set" — both produce the same result. `_extend`/`_replace` are tested +# directly, against a synthetic non-empty `builtin`, to prove the mechanism +# itself rather than relying on the shipped default happening to be +# non-empty. +# --------------------------------------------------------------------------- + + +def test_extend_unions_declared_with_builtin(): + builtin = frozenset({"password"}) + assert _extend(builtin, ["apikey"]) == frozenset({"password", "apikey"}) + + +def test_extend_returns_builtin_unchanged_when_nothing_declared(): + builtin = frozenset({"password"}) + assert _extend(builtin, None) == builtin + + +def test_replace_discards_the_builtin_entirely(): + builtin = frozenset({"environment_variables"}) + result = _replace(builtin, ["only_this_one"]) + assert result == frozenset({"only_this_one"}) + assert "environment_variables" not in result + + +def test_replace_returns_builtin_unchanged_when_nothing_declared(): + builtin = frozenset({"environment_variables"}) + assert _replace(builtin, None) == builtin + + +def test_sensitive_keys_extension_survives_through_load_config(tmp_path): + """End-to-end proof, against the real (non-empty) built-in default: a + declared sensitive_keys list adds to BUILTIN_SENSITIVE_KEYS rather than + supplanting it.""" + config = load_config( + write_config(tmp_path, '[check]\nsensitive_keys = ["custom_marker"]\n') + ) + assert BUILTIN_SENSITIVE_KEYS <= config.check.sensitive_keys + assert "custom_marker" in config.check.sensitive_keys + + +# --------------------------------------------------------------------------- +# min_version enforcement. +# --------------------------------------------------------------------------- + + +def test_min_version_accepts_an_equal_installed_version(): + config = Config(min_version="0.4.0") + enforce_min_version(config, "check-config", installed="0.4.0") # must not raise + + +def test_min_version_accepts_a_higher_installed_version(): + config = Config(min_version="0.4.0") + enforce_min_version(config, "check-config", installed="0.5.0") # must not raise + + +def test_min_version_rejects_a_lower_installed_version(): + config = Config(min_version="0.4.0") + with pytest.raises(MinVersionError) as exc_info: + enforce_min_version(config, "check-config", installed="0.3.0") + message = str(exc_info.value) + assert "0.4.0" in message + assert "self-update" in message + + +def test_min_version_compares_numerically_not_lexicographically(): + """"0.10.0" > "0.9.0" is False under string ordering but True + numerically — string comparison would wrongly block here.""" + config = Config(min_version="0.9.0") + enforce_min_version(config, "check-config", installed="0.10.0") # must not raise + + +def test_min_version_numeric_comparison_rejects_when_genuinely_lower(): + config = Config(min_version="0.10.0") + with pytest.raises(MinVersionError): + enforce_min_version(config, "check-config", installed="0.9.0") + + +def test_min_version_with_fewer_components_than_installed_still_compares(): + config = Config(min_version="0.2") + with pytest.raises(MinVersionError): + enforce_min_version(config, "check-config", installed="0.1.1") + + +def test_min_version_absent_never_raises(): + config = Config(min_version=None) + enforce_min_version(config, "check-config", installed="0.0.1") # must not raise + + +def test_min_version_config_none_never_raises(): + enforce_min_version(None, "check-config", installed="0.0.1") # must not raise + + +def test_doctor_is_exempt_from_min_version(): + config = Config(min_version="99.0.0") + enforce_min_version(config, "doctor", installed="0.1.1") # must not raise + + +def test_self_update_is_exempt_from_min_version(): + config = Config(min_version="99.0.0") + enforce_min_version(config, "self-update", installed="0.1.1") # must not raise + + +# --------------------------------------------------------------------------- +# General fail-closed behaviour: present-but-invalid always raises, never +# falls back to a default. +# --------------------------------------------------------------------------- + + +def test_malformed_toml_raises_naming_the_path(tmp_path): + path = write_config(tmp_path, "this is not valid toml [[[\n") + with pytest.raises(ConfigError) as exc_info: + load_config(path) + assert str(path) in str(exc_info.value) + + +def test_invalid_model_net_value_raises(tmp_path): + with pytest.raises(ConfigError): + load_config(write_config(tmp_path, '[check]\nmodel_net = "bogus"\n')) + + +def test_malformed_min_version_raises(tmp_path): + with pytest.raises(ConfigError): + load_config(write_config(tmp_path, 'min_version = "not-a-version"\n')) + + +def test_secrets_value_that_is_not_a_table_raises(tmp_path): + with pytest.raises(ConfigError): + load_config(write_config(tmp_path, 'secrets = "oops"\n')) + + +def test_secrets_member_that_is_not_a_table_raises(tmp_path): + with pytest.raises(ConfigError): + load_config(write_config(tmp_path, '[secrets]\ninfra = "oops"\n')) From 686021b10483ee27a3ee468a441fa59a38da5ba1 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 01:29:25 -0700 Subject: [PATCH 05/44] fix: validate stack_models shape; prove sensitive_parents replace wiring Two Important findings from review of af6291d: 1. The extend-vs-replace asymmetry test coverage proved _extend/_replace differ in isolation, but nothing exercised the wiring at _build_check's call site. Because the shipped BUILTIN_SENSITIVE_PARENTS is empty, swapping _replace for _extend there would not have failed any existing test (replacing or extending an empty set is the same set). Added a load_config-level test that monkeypatches BUILTIN_SENSITIVE_PARENTS to a non-empty synthetic value and asserts a declared sensitive_parents list fully replaces it. Verified the fix actually catches the mutation it targets by temporarily swapping the call site to _extend, confirming the new test fails, then reverting. 2. stack_models values ("" = "module:Class") were accepted as any string; the brief states this shape explicitly (unlike [secrets.*]'s interior, which it leaves unspecified and Task 8 owns), so a typo like a dot instead of a colon loaded silently and would only have surfaced later as a confusing ImportError deep in Task 10. Added _require_module_class: exactly one ':' with non-empty module and class segments, raised as ConfigError naming the key. Four new tests: no colon, two colons, empty module, empty class. Also: dropped the dead isinstance(key, str) check on stack_models keys (TOML table keys are always strings); replaced "environment_variables" as an example sensitive_parents value in tests with a neutral placeholder, so no test reads as documenting an intended-but-shipped-empty default; corrected the test-count arithmetic in the task report. uv run pytest tests/test_config.py -v: 41 passed. uv run pytest -q (full suite): 99 passed, no warnings. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/config.py | 37 +++++++++++++++++---- tests/test_config.py | 72 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 98 insertions(+), 11 deletions(-) diff --git a/src/stackward/config.py b/src/stackward/config.py index 28edbd3..405ea15 100644 --- a/src/stackward/config.py +++ b/src/stackward/config.py @@ -189,6 +189,24 @@ def _require_str_list(data: dict[str, Any], key: str, name: str) -> list[str] | return value +def _require_module_class(value: str, name: str) -> None: + """Validate the `"module:Class"` shape the brief states explicitly for + `stack_models` values. + + Unlike `[secrets.*]`'s interior (left unspecified by the brief, and + deferred to Task 8 — see `_build_secrets`), this shape *is* specified + here, so it gets the same fail-closed treatment as `model_net`'s enum: + a malformed value (a dot instead of a colon, an empty module or class + name) is rejected at load time, naming the key, rather than loading + silently and surfacing later as a confusing `ImportError` deep inside + Task 10, far from the file that caused it. This only checks shape — + it never imports the module or resolves the class. + """ + parts = value.split(":") + if len(parts) != 2 or not parts[0] or not parts[1]: + raise ConfigError(f"{name} must be of the form 'module:Class', got {value!r}") + + def _build_check(raw: Any) -> CheckConfig: if not isinstance(raw, dict): raise ConfigError("'check' must be a table") @@ -204,12 +222,19 @@ def _build_check(raw: Any) -> CheckConfig: f"got {model_net!r}" ) - stack_models = raw.get("stack_models", {}) - if not isinstance(stack_models, dict) or not all( - isinstance(key, str) and isinstance(value, str) - for key, value in stack_models.items() - ): + stack_models_raw = raw.get("stack_models", {}) + if not isinstance(stack_models_raw, dict): raise ConfigError("check.stack_models must be a table of string to string") + stack_models: dict[str, str] = {} + for namespace, target in stack_models_raw.items(): + # TOML table keys are always strings; only the value needs checking. + if not isinstance(target, str): + raise ConfigError( + f"check.stack_models.{namespace!r} must be a string of the " + "form 'module:Class'" + ) + _require_module_class(target, f"check.stack_models.{namespace!r}") + stack_models[namespace] = target sensitive_keys = _require_str_list(raw, "sensitive_keys", "check.sensitive_keys") sensitive_parents = _require_str_list( @@ -221,7 +246,7 @@ def _build_check(raw: Any) -> CheckConfig: return CheckConfig( model_net=model_net, - stack_models=dict(stack_models), + stack_models=stack_models, sensitive_keys=_extend(BUILTIN_SENSITIVE_KEYS, sensitive_keys), sensitive_parents=_replace(BUILTIN_SENSITIVE_PARENTS, sensitive_parents), allowed_references=list(allowed_references or []), diff --git a/tests/test_config.py b/tests/test_config.py index de998a7..aa25052 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -101,6 +101,44 @@ def test_check_stack_models_is_parsed(tmp_path): assert config.check.stack_models == {"infra": "infra.config:StackConfig"} +def test_check_stack_models_value_with_no_colon_raises(tmp_path): + """The brief states the `"module:Class"` shape explicitly (unlike + `[secrets.*]`'s interior), so a dot-instead-of-colon typo must be + rejected here rather than surfacing later as an ImportError in Task 10.""" + with pytest.raises(ConfigError) as exc_info: + load_config( + write_config( + tmp_path, + '[check.stack_models]\n"infra" = "infra.config.StackConfig"\n', + ) + ) + assert "infra" in str(exc_info.value) + + +def test_check_stack_models_value_with_two_colons_raises(tmp_path): + with pytest.raises(ConfigError): + load_config( + write_config( + tmp_path, + '[check.stack_models]\n"infra" = "infra:config:StackConfig"\n', + ) + ) + + +def test_check_stack_models_value_with_empty_module_raises(tmp_path): + with pytest.raises(ConfigError): + load_config( + write_config(tmp_path, '[check.stack_models]\n"infra" = ":StackConfig"\n') + ) + + +def test_check_stack_models_value_with_empty_class_raises(tmp_path): + with pytest.raises(ConfigError): + load_config( + write_config(tmp_path, '[check.stack_models]\n"infra" = "infra.config:"\n') + ) + + def test_check_sensitive_keys_is_parsed_and_extends_the_builtins(tmp_path): config = load_config( write_config(tmp_path, '[check]\nsensitive_keys = ["jwtsecret"]\n') @@ -113,9 +151,9 @@ def test_check_sensitive_keys_is_parsed_and_extends_the_builtins(tmp_path): def test_check_sensitive_parents_is_parsed(tmp_path): config = load_config( - write_config(tmp_path, '[check]\nsensitive_parents = ["environment_variables"]\n') + write_config(tmp_path, '[check]\nsensitive_parents = ["example_parent"]\n') ) - assert config.check.sensitive_parents == frozenset({"environment_variables"}) + assert config.check.sensitive_parents == frozenset({"example_parent"}) def test_check_allowed_references_is_parsed(tmp_path): @@ -189,17 +227,41 @@ def test_extend_returns_builtin_unchanged_when_nothing_declared(): def test_replace_discards_the_builtin_entirely(): - builtin = frozenset({"environment_variables"}) + builtin = frozenset({"example_parent"}) result = _replace(builtin, ["only_this_one"]) assert result == frozenset({"only_this_one"}) - assert "environment_variables" not in result + assert "example_parent" not in result def test_replace_returns_builtin_unchanged_when_nothing_declared(): - builtin = frozenset({"environment_variables"}) + builtin = frozenset({"example_parent"}) assert _replace(builtin, None) == builtin +def test_sensitive_parents_replacement_survives_through_load_config(tmp_path, monkeypatch): + """End-to-end proof that `_build_check`'s call site actually wires + `_replace` (not `_extend`) to `sensitive_parents`. + + The shipped `BUILTIN_SENSITIVE_PARENTS` is empty, so a test against the + real default can't tell the two apart: extending or replacing an empty + set both yield exactly the declared list. Monkeypatching the builtin to + a non-empty synthetic value makes the two operations diverge, so this + proves the wiring itself, not just the `_extend`/`_replace` helpers in + isolation — mirrors + `test_sensitive_keys_extension_survives_through_load_config` below, + which gets this proof for free against the real (non-empty) + `sensitive_keys` builtin. + """ + monkeypatch.setattr( + "stackward.config.BUILTIN_SENSITIVE_PARENTS", frozenset({"builtin_only"}) + ) + config = load_config( + write_config(tmp_path, '[check]\nsensitive_parents = ["declared_only"]\n') + ) + assert config.check.sensitive_parents == frozenset({"declared_only"}) + assert "builtin_only" not in config.check.sensitive_parents + + def test_sensitive_keys_extension_survives_through_load_config(tmp_path): """End-to-end proof, against the real (non-empty) built-in default: a declared sensitive_keys list adds to BUILTIN_SENSITIVE_KEYS rather than From 8c2ef8de1e2d343860d7753d0e3dabbc819a8cdb Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 01:53:19 -0700 Subject: [PATCH 06/44] feat: heuristic credential-detection net and check-config command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Task 3: the gate itself. src/stackward/nets/heuristic.py walks an already-parsed YAML document (never a rendered path string) and reports plaintext credentials as canonical paths via paths.render — a leaf is a finding when its own key matches sensitive_keys or a strict ancestor key is in sensitive_parents, unless its parent mapping is exactly {"secure": ...} (tested by shape, never by a ".secure" path suffix), the value is None, "", True or False (tested by identity/exact type, never a containment test that would also swallow the integers 0 and 1), or its path is in allowed_references. find_plaintext_credentials(document, check) is content-level: it takes parsed data plus policy, does no I/O, and imports nothing beyond ..config and ..paths. src/stackward/commands/check_config.py adds scan_file as the thin file-path wrapper for the CLI, so a later staged-blob caller (git show ":") can call the net directly without round-tripping through a temp file. check-config registered in cli.py; dispatch itself is untouched, so doctor still runs with a malformed .stackward.toml (regression test added). Exit codes: 0 clean, 1 a credential was found, 2 the check could not run (bad path, invalid YAML, non-mapping document, unusable repo config) — never conflated. Findings print as ": plaintext credential at ''", sorted, never the value. Tests: tests/test_heuristic.py covers every rule individually plus the scan_file/check-config CLI contract; two additions to test_cli.py (wiring smoke test, doctor-survives-malformed-config regression). 125 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/cli.py | 7 + src/stackward/commands/__init__.py | 3 + src/stackward/commands/check_config.py | 104 ++++++++++++ src/stackward/nets/__init__.py | 10 ++ src/stackward/nets/heuristic.py | 207 +++++++++++++++++++++++ tests/test_cli.py | 20 +++ tests/test_heuristic.py | 221 +++++++++++++++++++++++++ 7 files changed, 572 insertions(+) create mode 100644 src/stackward/commands/__init__.py create mode 100644 src/stackward/commands/check_config.py create mode 100644 src/stackward/nets/__init__.py create mode 100644 src/stackward/nets/heuristic.py create mode 100644 tests/test_heuristic.py diff --git a/src/stackward/cli.py b/src/stackward/cli.py index 228f18d..572571d 100644 --- a/src/stackward/cli.py +++ b/src/stackward/cli.py @@ -17,6 +17,7 @@ from pathlib import Path from . import __version__ +from .commands.check_config import cmd_check_config from .config import find_repo_config # Commands that must keep working even when a repo demands a newer stackward @@ -112,6 +113,12 @@ def build_parser() -> argparse.ArgumentParser: doctor = sub.add_parser("doctor", help="report resolved configuration and environment") doctor.set_defaults(func=cmd_doctor) + check_config = sub.add_parser( + "check-config", help="scan stack config file(s) for a plaintext credential" + ) + check_config.add_argument("files", nargs="+", metavar="FILE") + check_config.set_defaults(func=cmd_check_config) + return parser diff --git a/src/stackward/commands/__init__.py b/src/stackward/commands/__init__.py new file mode 100644 index 0000000..5167706 --- /dev/null +++ b/src/stackward/commands/__init__.py @@ -0,0 +1,3 @@ +"""CLI command implementations, one module per subcommand.""" + +from __future__ import annotations diff --git a/src/stackward/commands/check_config.py b/src/stackward/commands/check_config.py new file mode 100644 index 0000000..13f48b5 --- /dev/null +++ b/src/stackward/commands/check_config.py @@ -0,0 +1,104 @@ +"""`check-config`: the heuristic net, run over one or more stack config files. + +Registered in `cli.build_parser` as `stackward check-config FILE...`. Exit +codes are deliberately distinct: 0 clean, 1 a credential was found, 2 the +check could not run at all (an unreadable path, unparsable YAML, a +`.stackward.toml` this module could not load, or a document that is not a +mapping) — never conflated, so a later fail-closed refusal (also exit 2) +reads the same way this one does: "the gate did not get to answer". +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import yaml + +from ..config import CheckConfig, ConfigError, find_repo_config, load_config +from ..nets.heuristic import DocumentError, find_plaintext_credentials + + +class CheckError(Exception): + """A file could not be checked at all: an unreadable path, unparsable + YAML, or a document that is not a mapping. Maps to exit code 2, never + 1 — that code means "a credential was found", which this is not.""" + + +def scan_file(path: Path, check: CheckConfig) -> list[str]: + """Thin file-path wrapper over `find_plaintext_credentials`. + + Reads `path` from the working tree and parses it as YAML before handing + the result to the content-level net. This is deliberately the *only* + place that happens: `find_plaintext_credentials` itself takes + already-parsed document data, so a caller that must check staged + content instead of the working tree — `pre-commit`, reading + `git show ":"` — calls that function directly rather than going + through this wrapper, and never round-trips a blob through a temp file + to get there. + + Raises `CheckError` for anything that means the check could not run: + the path cannot be read, the content is not valid YAML, or the parsed + document is not a mapping. + """ + try: + text = path.read_text() + except OSError as exc: + raise CheckError(f"{path}: cannot read: {exc}") from exc + + try: + document = yaml.safe_load(text) + except yaml.YAMLError as exc: + raise CheckError(f"{path}: invalid YAML: {exc}") from exc + + try: + return find_plaintext_credentials(document, check) + except DocumentError as exc: + raise CheckError(f"{path}: {exc}") from exc + + +def _load_check_policy() -> CheckConfig: + """The `[check]` policy for the repository containing the current + working directory, or the built-in defaults when there is none — + absence is a normal state (see `config.py`), not an error.""" + config_path = find_repo_config() + if config_path is None: + return CheckConfig() + return load_config(config_path).check + + +def cmd_check_config(args: argparse.Namespace) -> int: + """Entry point for `stackward check-config FILE...`. + + Findings print as `: plaintext credential at ''`, sorted, + one per line — file and config path only, never the value. A + could-not-run error on any file takes priority over findings on + another: it is reported and the whole invocation exits 2, since exit 1 + means specifically "a credential was found", which an invocation that + did not fully run cannot claim to know. + """ + try: + check = _load_check_policy() + except ConfigError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + findings: list[tuple[str, str]] = [] + errored = False + for file_arg in args.files: + try: + paths = scan_file(Path(file_arg), check) + except CheckError as exc: + print(f"error: {exc}", file=sys.stderr) + errored = True + continue + findings.extend((file_arg, path) for path in paths) + + if errored: + return 2 + if findings: + for file_arg, path in sorted(findings): + print(f"{file_arg}: plaintext credential at '{path}'") + return 1 + return 0 diff --git a/src/stackward/nets/__init__.py b/src/stackward/nets/__init__.py new file mode 100644 index 0000000..e551e45 --- /dev/null +++ b/src/stackward/nets/__init__.py @@ -0,0 +1,10 @@ +"""The credential-detection nets: independent ways to answer "is this a +plaintext credential?" over a parsed stack config document. + +`heuristic` is the key-name/parent-name net and ships unconditionally. A +model net (walking a repository's own pydantic models, via `[check]`'s +`model_net`/`stack_models`) is a separate, later addition — this package is +named for the plural on purpose. +""" + +from __future__ import annotations diff --git a/src/stackward/nets/heuristic.py b/src/stackward/nets/heuristic.py new file mode 100644 index 0000000..d8ff149 --- /dev/null +++ b/src/stackward/nets/heuristic.py @@ -0,0 +1,207 @@ +"""The heuristic net: a key-name/parent-name scan for plaintext credentials. + +This is the check that runs unconditionally, with no repository cooperation +required — it needs nothing beyond the `[check]` policy and the parsed YAML +document. It walks the *parsed structure*, never a rendered path string: a +dict key may legally contain `.` or `[`, and re-parsing a rendered path to +decide sensitivity would silently lose track of such a key. `paths.render` +is the only thing that turns a walked position back into text, for exactly +the paths this module reports. + +Content-level, not file-level, by design. `find_plaintext_credentials` takes +already-parsed document data plus policy and returns findings; it does no +I/O, imports nothing beyond `..config` and `..paths`, and in particular +never imports `yaml` or `cryptography`. That keeps it callable both from a +file on disk (`commands.check_config`) and from a staged git blob +(`commands.pre_commit`, checking `git show ":"` output) without either +caller having to round-trip through a temp file first. + +Two rules a plausible implementation gets wrong — see `_is_encrypted` and +`_is_empty` for why, in detail: + +- "Encrypted" is a property of the leaf's *parent mapping*, not of the + leaf's own path text. `path.endswith(".secure")` cannot tell a genuine + `{"secure": ciphertext}` apart from a malformed `{"secure": ..., "other": + ...}` sitting next to it — both end in `.secure`. +- "Empty" is `None` or `""`, tested so that `True`/`False` also pass but the + integers `0` and `1` do not — a plain `value in (None, "", False, True)` + gets this backwards, because `0 == False` and `1 == True` in Python. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from ..config import CheckConfig +from ..paths import render + +# The one shape that means "this value is encrypted, not stored in +# plaintext" in a Pulumi stack config: the leaf's parent mapping has exactly +# one key, named `secure`. Anything else — extra sibling keys included — is +# not this shape, however much a stringly-typed check might want it to be. +_SECURE_WRAPPER_KEYS = frozenset({"secure"}) + + +class DocumentError(ValueError): + """The document cannot be scanned at all: it is not a mapping. + + Raised, never swallowed — a YAML document that failed to parse into a + key/value structure is a reason to refuse the check, not a reason to + report zero findings. Callers map this to the "could not run" exit + code, distinct from both "clean" and "findings present". + """ + + +def find_plaintext_credentials(document: Any, check: CheckConfig) -> list[str]: + """Walk `document` for plaintext credentials under `check`'s policy. + + `document` is already-parsed YAML data (e.g. the result of + `yaml.safe_load`) — this function does no parsing and no file I/O, so it + works identically whether the document came from a file on disk or a + `git show ":"` blob. + + A leaf is a finding when its own key matches `check.sensitive_keys` + (case-insensitive substring) or any strict ancestor key is a member of + `check.sensitive_parents` — unless the leaf's parent mapping is the + `{"secure": ...}` encryption wrapper, the value is empty (`None`, `""`, + `True` or `False`), or the leaf's rendered path is one of + `check.allowed_references` (a path that names another secret rather + than holding one). + + Returns the rendered paths of every finding, sorted. Never returns or + inspects a value for anything other than its structure and identity — + no finding is reported by value, only by path. + + Raises `DocumentError` if `document` is not a mapping at the top level. + """ + if not isinstance(document, dict): + raise DocumentError( + "stack config must be a mapping at the top level, got " + f"{type(document).__name__}" + ) + + ctx = _ScanContext( + check=check, + allowed_references=frozenset(check.allowed_references), + findings=[], + ) + _walk(document, [], frozenset(), ctx) + return sorted(ctx.findings) + + +@dataclass(frozen=True) +class _ScanContext: + """The parts of a scan that stay the same as `_walk`/`_check_leaf` + recurse, bundled so their signatures carry only what actually varies + per call: the node, its path, and its ancestor set. + + `findings` is a plain mutable list, appended to in place — `frozen` + only stops `_walk`/`_check_leaf` from *reassigning* `ctx.findings` to a + different list, which they never need to do. + """ + + check: CheckConfig + allowed_references: frozenset[str] + findings: list[str] + + +def _walk( + node: Any, + path: list[str | int], + ancestors: frozenset[str], + ctx: _ScanContext, +) -> None: + """Recurse through `node`, appending a rendered path to `ctx.findings` + for every leaf that qualifies. `ancestors` holds every dict key seen + strictly above the current position — never the current key itself, + since `sensitive_parents` matching is defined over ancestors, and a + key's own membership in `sensitive_parents` only matters for what is + *beneath* it. + """ + if isinstance(node, dict): + for raw_key, value in node.items(): + # YAML mapping keys are ordinarily strings already; this only + # guards the corner cases (a YAML 1.1 bareword like `on:` + # parsing as a bool, a stray int key) from crashing `render`, + # which requires str|int segments and rejects bool outright. + key = raw_key if isinstance(raw_key, str) else str(raw_key) + child_path = [*path, key] + if isinstance(value, (dict, list)): + _walk(value, child_path, ancestors | {key}, ctx) + else: + _check_leaf(node, key, value, child_path, ancestors, ctx) + elif isinstance(node, list): + for index, value in enumerate(node): + child_path = [*path, index] + if isinstance(value, (dict, list)): + _walk(value, child_path, ancestors, ctx) + else: + _check_leaf(None, None, value, child_path, ancestors, ctx) + + +def _check_leaf( + parent: dict[str, Any] | None, + own_key: str | None, + value: Any, + path: list[str | int], + ancestors: frozenset[str], + ctx: _ScanContext, +) -> None: + """Decide whether the scalar `value` at `path` is a finding. + + `parent` is the mapping directly containing this leaf (`None` when the + leaf sits directly in a list, which cannot be the `{"secure": ...}` + shape). `own_key` is that mapping's key for this leaf (`None` for a + list element, which has no name to match `sensitive_keys` against — + only an ancestor dict key further up can make such a leaf sensitive). + """ + sensitive_by_key = own_key is not None and _matches_sensitive_key( + own_key, ctx.check.sensitive_keys + ) + sensitive_by_ancestor = bool(ancestors & ctx.check.sensitive_parents) + if not (sensitive_by_key or sensitive_by_ancestor): + return + if parent is not None and _is_encrypted(parent): + return + if _is_empty(value): + return + rendered = render(path) + if rendered in ctx.allowed_references: + return + ctx.findings.append(rendered) + + +def _matches_sensitive_key(key: str, sensitive_keys: frozenset[str]) -> bool: + lowered = key.lower() + return any(pattern.lower() in lowered for pattern in sensitive_keys) + + +def _is_encrypted(parent: dict[str, Any]) -> bool: + """The leaf's parent mapping is exactly `{"secure": ...}` — one key, + named `secure`. + + Tested against the mapping's shape, never against the leaf's own + rendered path: `path.endswith(".secure")` cannot distinguish this from + a malformed `{"secure": ..., "other": ...}`, whose `.secure` child is + not encrypted (nothing tells a reader the sibling `other` was not the + plaintext value that leaked) and must still be flagged. + """ + return set(parent.keys()) == _SECURE_WRAPPER_KEYS + + +def _is_empty(value: Any) -> bool: + """`None`, `""`, `True` and `False` are excluded; every integer, + including `0` and `1`, is not. + + `None`/`True`/`False` are tested by identity and `""` by an + exact-type-safe equality (`isinstance` first) — never by a containment + test like `value in (None, "", False, True)`. That test uses `==` under + the hood, and in Python `0 == False` and `1 == True`, so it would + silently also exclude the integers `0` and `1` — which occur as real + config leaves (e.g. a numeric value written in plaintext) and must + still be findings. + """ + if value is None or value is True or value is False: + return True + return isinstance(value, str) and value == "" diff --git a/tests/test_cli.py b/tests/test_cli.py index 12249b3..e71c80f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -75,3 +75,23 @@ def test_config_home_falls_back_to_dot_config(monkeypatch, tmp_path): monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) assert config_home() == tmp_path / ".config" / "stackward" + + +def test_check_config_is_registered_and_dispatches(tmp_path): + """Wiring only — the rules `check-config` enforces are covered in + tests/test_heuristic.py.""" + clean = tmp_path / "Pulumi.dev.yaml" + clean.write_text("name: myproject\n") + assert main(["check-config", str(clean)]) == 0 + + +def test_doctor_still_works_when_repo_config_is_malformed(tmp_path, monkeypatch, capsys): + """`doctor` is the command you run to diagnose a broken + `.stackward.toml`, so dispatch must not load it before routing to + `doctor` — that would make the one command that reports the problem + unable to run at all.""" + (tmp_path / ".git").mkdir() + (tmp_path / ".stackward.toml").write_text("this is not valid toml [[[") + monkeypatch.chdir(tmp_path) + assert main(["doctor"]) == 0 + assert str(tmp_path / ".stackward.toml") in capsys.readouterr().out diff --git a/tests/test_heuristic.py b/tests/test_heuristic.py new file mode 100644 index 0000000..30fc1c2 --- /dev/null +++ b/tests/test_heuristic.py @@ -0,0 +1,221 @@ +"""Tests for the heuristic net (`find_plaintext_credentials`) and the +`check-config` command built on top of it. + +This is the gate itself: everything else in the project is scaffolding +around this check being correct. Each subtle rule in the brief — what +"encrypted" and "empty" mean, exactly — gets its own test, named for the +wrong implementation it would catch. +""" + +from __future__ import annotations + +import pytest + +from stackward.cli import main +from stackward.commands.check_config import CheckError, scan_file +from stackward.config import CheckConfig +from stackward.nets.heuristic import DocumentError, find_plaintext_credentials + +# --------------------------------------------------------------------------- +# find_plaintext_credentials — the content-level net. +# --------------------------------------------------------------------------- + + +def test_clean_config_has_no_findings(): + document = { + "name": "myproject", + "runtime": "nodejs", + "config": {"aws:region": "us-east-1"}, + } + assert find_plaintext_credentials(document, CheckConfig()) == [] + + +def test_plaintext_value_flagged_by_key_match(): + """The leaf's own key matches `sensitive_keys` (case-insensitive + substring) — no parent policy needed.""" + document = {"config": {"myproject:dbPassword": "hunter2"}} + assert find_plaintext_credentials(document, CheckConfig()) == [ + "config.myproject:dbPassword" + ] + + +def test_plaintext_value_flagged_by_sensitive_parent(): + """The leaf's own key ("DB_USER") matches no built-in sensitive key — + only a declared `sensitive_parents` ancestor makes it a finding.""" + check = CheckConfig(sensitive_parents=frozenset({"environment_variables"})) + document = {"environment_variables": {"DB_USER": "admin"}} + assert find_plaintext_credentials(document, check) == ["environment_variables.DB_USER"] + + +def test_encrypted_leaf_passes(): + """A leaf made sensitive only by its `sensitive_parents` ancestor, whose + parent mapping is exactly `{"secure": ...}`, must not be flagged.""" + check = CheckConfig(sensitive_parents=frozenset({"environment_variables"})) + document = {"environment_variables": {"DB_PASS": {"secure": "v1:AAAA"}}} + assert find_plaintext_credentials(document, check) == [] + + +def test_malformed_secure_wrapper_with_extra_sibling_is_flagged(): + """`{"secure": ..., "other": ...}` is NOT the one-key encryption shape — + both children must be flagged. A `path.endswith(".secure")` check would + wrongly exclude the first of the two; asserting it is present is the + point of this test.""" + check = CheckConfig(sensitive_parents=frozenset({"environment_variables"})) + document = { + "environment_variables": { + "DB_PASS": {"secure": "v1:AAAA", "other": "leaked-plaintext"} + } + } + findings = find_plaintext_credentials(document, check) + assert findings == [ + "environment_variables.DB_PASS.other", + "environment_variables.DB_PASS.secure", + ] + + +def test_none_and_empty_string_pass(): + document = {"password": None, "token": ""} + assert find_plaintext_credentials(document, CheckConfig()) == [] + + +def test_true_and_false_pass(): + """Identity, not the `value in (None, "", False, True)` containment + test this rule replaces: booleans are excluded regardless.""" + document = {"password": True, "token": False} + assert find_plaintext_credentials(document, CheckConfig()) == [] + + +def test_integers_zero_one_and_larger_are_flagged(): + """`0 == False` and `1 == True` in Python, so a containment test for + emptiness wrongly swallows these — they must still be findings.""" + document = {"password": 0, "token": 1, "secret": 42} + assert find_plaintext_credentials(document, CheckConfig()) == [ + "password", + "secret", + "token", + ] + + +def test_dict_key_containing_dot_is_reported_with_bracket_quoting(): + """The path is produced by `paths.render`, never by joining segments + with `.` — a key containing a literal `.` must come back bracket-quoted, + not run together with its parent.""" + document = {"config": {"my.password": "hunter2"}} + assert find_plaintext_credentials(document, CheckConfig()) == ['config["my.password"]'] + + +def test_allowed_reference_path_passes(): + """A path listed in `allowed_references` names another secret rather + than holding one, and is never a finding even though it would otherwise + qualify.""" + check = CheckConfig(allowed_references=['config["my.password"]']) + document = {"config": {"my.password": "SOME_OTHER_SECRET_NAME"}} + assert find_plaintext_credentials(document, check) == [] + + +def test_list_document_raises(): + with pytest.raises(DocumentError): + find_plaintext_credentials(["not", "a", "mapping"], CheckConfig()) + + +def test_scalar_document_raises(): + with pytest.raises(DocumentError): + find_plaintext_credentials("just a string", CheckConfig()) + + +def test_empty_document_raises(): + """An empty YAML file parses to `None` — still not a mapping, still an + error rather than "no findings".""" + with pytest.raises(DocumentError): + find_plaintext_credentials(None, CheckConfig()) + + +# --------------------------------------------------------------------------- +# scan_file / check-config — the file-path wrapper and the CLI it backs. +# --------------------------------------------------------------------------- + + +def write_yaml(tmp_path, name: str, text: str): + path = tmp_path / name + path.write_text(text) + return path + + +def test_scan_file_returns_no_findings_for_a_clean_file(tmp_path): + path = write_yaml(tmp_path, "Pulumi.dev.yaml", "name: myproject\nruntime: nodejs\n") + assert scan_file(path, CheckConfig()) == [] + + +def test_scan_file_finds_a_plaintext_credential(tmp_path): + path = write_yaml( + tmp_path, "Pulumi.dev.yaml", "config:\n myproject:dbPassword: hunter2\n" + ) + assert scan_file(path, CheckConfig()) == ["config.myproject:dbPassword"] + + +def test_scan_file_raises_check_error_on_invalid_yaml(tmp_path): + path = write_yaml(tmp_path, "Pulumi.dev.yaml", "key: [unclosed\n") + with pytest.raises(CheckError): + scan_file(path, CheckConfig()) + + +def test_scan_file_raises_check_error_on_non_mapping_document(tmp_path): + path = write_yaml(tmp_path, "Pulumi.dev.yaml", "- a\n- b\n") + with pytest.raises(CheckError): + scan_file(path, CheckConfig()) + + +def test_scan_file_raises_check_error_on_missing_file(tmp_path): + with pytest.raises(CheckError): + scan_file(tmp_path / "does-not-exist.yaml", CheckConfig()) + + +def test_check_config_exits_0_when_clean(tmp_path, capsys): + path = write_yaml(tmp_path, "Pulumi.dev.yaml", "name: myproject\nruntime: nodejs\n") + assert main(["check-config", str(path)]) == 0 + assert capsys.readouterr().out == "" + + +def test_check_config_exits_1_and_prints_the_finding_format(tmp_path, capsys): + path = write_yaml( + tmp_path, "Pulumi.dev.yaml", "config:\n myproject:dbPassword: hunter2\n" + ) + assert main(["check-config", str(path)]) == 1 + out = capsys.readouterr().out + assert out == f"{path}: plaintext credential at 'config.myproject:dbPassword'\n" + assert "hunter2" not in out + + +def test_check_config_findings_print_sorted(tmp_path, capsys): + """Insertion order in the file is zebra-then-apple; output must be + sorted regardless.""" + path = write_yaml( + tmp_path, + "Pulumi.dev.yaml", + "zebra_password: hunter2\napple_token: abc123\n", + ) + assert main(["check-config", str(path)]) == 1 + lines = capsys.readouterr().out.splitlines() + assert lines == [ + f"{path}: plaintext credential at 'apple_token'", + f"{path}: plaintext credential at 'zebra_password'", + ] + + +def test_check_config_exits_2_on_invalid_yaml(tmp_path, capsys): + path = write_yaml(tmp_path, "Pulumi.dev.yaml", "key: [unclosed\n") + assert main(["check-config", str(path)]) == 2 + assert capsys.readouterr().out == "" + + +def test_check_config_exits_2_on_non_mapping_document(tmp_path): + path = write_yaml(tmp_path, "Pulumi.dev.yaml", "- a\n- b\n") + assert main(["check-config", str(path)]) == 2 + + +def test_check_config_exits_2_with_no_files_given(): + """argparse's own usage error for a missing required FILE — exit 2, + same code as any other "could not run" reason, never 1.""" + with pytest.raises(SystemExit) as exit_info: + main(["check-config"]) + assert exit_info.value.code == 2 From 279e0d1b5b0cc3106a630871f771e764768a074e Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 01:59:48 -0700 Subject: [PATCH 07/44] fix: propagate sensitive_keys matches to their subtree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Controller ruling: under the shipped default policy (sensitive_parents ships empty by design), a sensitive key's own {"secure": ...} envelope renamed the actual leaf's key to "secure"/"other", which matches no built-in pattern. With no sensitive_parents ancestor declared either, the leaf was never even considered sensitive, so _is_encrypted's careful parent-shape check never ran — the malformed-envelope protection (apiToken: {secure: X, other: Y}, other a plaintext leak) was unreachable in exactly the policy every repository starts with. Fix: a leaf is now a finding when its own key matches sensitive_keys, OR any ancestor key matched sensitive_keys, OR any ancestor key is in sensitive_parents. _walk threads a single ancestor_sensitive boolean down the recursion (replacing the frozenset[str] ancestor-keys tracking used only for sensitive_parents) that turns true once and is never cleared, so a sensitive key's whole subtree stays sensitive without leaking sideways to a sibling. _is_encrypted and _is_empty are unchanged. Accepted cost, stated in the module docstring: a sensitive-keyed mapping holding structured non-credential data now reports every leaf beneath it (token_settings: {retries: 3} flags token_settings.retries) — a deliberate false-positive-over-false-negative trade for a commit gate. Five new covering tests in tests/test_heuristic.py, including the regression test (apiToken: {secure: X, other: Y} under the default policy, previously []) verified against a reproduction of the pre-ruling logic to confirm it fails for the right reason. Full suite: 130 passed (125 + 5). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/nets/heuristic.py | 72 ++++++++++++++++++++++++--------- tests/test_heuristic.py | 54 +++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 19 deletions(-) diff --git a/src/stackward/nets/heuristic.py b/src/stackward/nets/heuristic.py index d8ff149..afb9044 100644 --- a/src/stackward/nets/heuristic.py +++ b/src/stackward/nets/heuristic.py @@ -26,6 +26,31 @@ - "Empty" is `None` or `""`, tested so that `True`/`False` also pass but the integers `0` and `1` do not — a plain `value in (None, "", False, True)` gets this backwards, because `0 == False` and `1 == True` in Python. + +A third rule, added after the first version of this module shipped: +**a key matching `sensitive_keys` makes its entire subtree sensitive**, not +just a leaf sitting directly under it. The `{"secure": ...}` envelope is +itself one level of nesting — `apiToken: {"secure": "v1:..."}` puts the +actual leaf at `apiToken.secure`, whose own key is `secure`, which matches +no built-in pattern. Under the original "only the leaf's own key, or an +explicitly-declared `sensitive_parents` ancestor" rule, that leaf was never +even considered sensitive when a repository declares no `sensitive_parents` +— which is every repository's starting policy, since `sensitive_parents` +ships empty by design. That made the malformed-envelope case this module +exists to catch (`apiToken: {"secure": X, "other": Y}`, `other` a plaintext +leak) *unreachable* under the default policy: neither `secure` nor `other` +ever became a candidate, so `_is_encrypted`'s careful parent-shape check +never ran. Propagating `sensitive_keys` down the subtree closes that gap +without inventing an environment-specific default for `sensitive_parents` +(which Global Constraint 5 forbids) — it needs no new configuration, only a +key a repository (or the built-ins) already flagged as sensitive. + +**Accepted cost:** a sensitive-keyed mapping holding structured +non-credential data now reports every leaf beneath it — +`token_settings: {retries: 3}` flags `token_settings.retries`, even though +`3` is not a credential. This is a deliberate trade, not an oversight: in a +commit gate, a false positive costs someone a minute reading a diff; a +missed credential is permanent and public the moment it is pushed. """ from __future__ import annotations @@ -62,12 +87,14 @@ def find_plaintext_credentials(document: Any, check: CheckConfig) -> list[str]: `git show ":"` blob. A leaf is a finding when its own key matches `check.sensitive_keys` - (case-insensitive substring) or any strict ancestor key is a member of + (case-insensitive substring), OR any ancestor key matched + `check.sensitive_keys`, OR any ancestor key is a member of `check.sensitive_parents` — unless the leaf's parent mapping is the `{"secure": ...}` encryption wrapper, the value is empty (`None`, `""`, `True` or `False`), or the leaf's rendered path is one of `check.allowed_references` (a path that names another secret rather - than holding one). + than holding one). A sensitive key's subtree is sensitive all the way + down — see the module docstring for why. Returns the rendered paths of every finding, sorted. Never returns or inspects a value for anything other than its structure and identity — @@ -86,7 +113,7 @@ def find_plaintext_credentials(document: Any, check: CheckConfig) -> list[str]: allowed_references=frozenset(check.allowed_references), findings=[], ) - _walk(document, [], frozenset(), ctx) + _walk(document, [], False, ctx) return sorted(ctx.findings) @@ -94,7 +121,7 @@ def find_plaintext_credentials(document: Any, check: CheckConfig) -> list[str]: class _ScanContext: """The parts of a scan that stay the same as `_walk`/`_check_leaf` recurse, bundled so their signatures carry only what actually varies - per call: the node, its path, and its ancestor set. + per call: the node, its path, and whether an ancestor was sensitive. `findings` is a plain mutable list, appended to in place — `frozen` only stops `_walk`/`_check_leaf` from *reassigning* `ctx.findings` to a @@ -109,15 +136,19 @@ class _ScanContext: def _walk( node: Any, path: list[str | int], - ancestors: frozenset[str], + ancestor_sensitive: bool, ctx: _ScanContext, ) -> None: """Recurse through `node`, appending a rendered path to `ctx.findings` - for every leaf that qualifies. `ancestors` holds every dict key seen - strictly above the current position — never the current key itself, - since `sensitive_parents` matching is defined over ancestors, and a - key's own membership in `sensitive_parents` only matters for what is - *beneath* it. + for every leaf that qualifies. + + `ancestor_sensitive` is true once any *strict* ancestor key has matched + `sensitive_keys` or belonged to `sensitive_parents` — never the current + key itself, since that key's own contribution (if any) is what makes + its *children* sensitive, not itself; a leaf's own sensitivity is + checked separately in `_check_leaf`. It only ever turns true going + down and is never cleared, so a sensitive key's entire subtree stays + sensitive to the bottom. """ if isinstance(node, dict): for raw_key, value in node.items(): @@ -128,16 +159,19 @@ def _walk( key = raw_key if isinstance(raw_key, str) else str(raw_key) child_path = [*path, key] if isinstance(value, (dict, list)): - _walk(value, child_path, ancestors | {key}, ctx) + key_is_sensitive = _matches_sensitive_key( + key, ctx.check.sensitive_keys + ) or key in ctx.check.sensitive_parents + _walk(value, child_path, ancestor_sensitive or key_is_sensitive, ctx) else: - _check_leaf(node, key, value, child_path, ancestors, ctx) + _check_leaf(node, key, value, child_path, ancestor_sensitive, ctx) elif isinstance(node, list): for index, value in enumerate(node): child_path = [*path, index] if isinstance(value, (dict, list)): - _walk(value, child_path, ancestors, ctx) + _walk(value, child_path, ancestor_sensitive, ctx) else: - _check_leaf(None, None, value, child_path, ancestors, ctx) + _check_leaf(None, None, value, child_path, ancestor_sensitive, ctx) def _check_leaf( @@ -145,7 +179,7 @@ def _check_leaf( own_key: str | None, value: Any, path: list[str | int], - ancestors: frozenset[str], + ancestor_sensitive: bool, ctx: _ScanContext, ) -> None: """Decide whether the scalar `value` at `path` is a finding. @@ -153,14 +187,14 @@ def _check_leaf( `parent` is the mapping directly containing this leaf (`None` when the leaf sits directly in a list, which cannot be the `{"secure": ...}` shape). `own_key` is that mapping's key for this leaf (`None` for a - list element, which has no name to match `sensitive_keys` against — - only an ancestor dict key further up can make such a leaf sensitive). + list element, which has no name of its own to match `sensitive_keys` + against — only sensitivity inherited from a strict ancestor can make + such a leaf sensitive). """ sensitive_by_key = own_key is not None and _matches_sensitive_key( own_key, ctx.check.sensitive_keys ) - sensitive_by_ancestor = bool(ancestors & ctx.check.sensitive_parents) - if not (sensitive_by_key or sensitive_by_ancestor): + if not (sensitive_by_key or ancestor_sensitive): return if parent is not None and _is_encrypted(parent): return diff --git a/tests/test_heuristic.py b/tests/test_heuristic.py index 30fc1c2..dd40756 100644 --- a/tests/test_heuristic.py +++ b/tests/test_heuristic.py @@ -73,6 +73,60 @@ def test_malformed_secure_wrapper_with_extra_sibling_is_flagged(): ] +# --------------------------------------------------------------------------- +# Ruling: a sensitive key propagates to its subtree. Without this, the +# malformed-envelope case above is unreachable under the default policy — +# see the module docstring for why. +# --------------------------------------------------------------------------- + + +def test_encrypted_leaf_passes_under_default_policy_via_key_propagation(): + """`apiToken` matches `sensitive_keys` directly and propagates to its + subtree; the leaf `apiToken.secure` inherits that sensitivity, but its + parent mapping is exactly `{"secure": ...}` — still exempt. The + exemption keeps working through propagation, exactly as it did through + `sensitive_parents` above.""" + document = {"apiToken": {"secure": "v1:AAAA"}} + assert find_plaintext_credentials(document, CheckConfig()) == [] + + +def test_malformed_secure_wrapper_flagged_under_default_policy(): + """Regression for the propagation ruling: under the shipped default + policy (no `sensitive_parents` declared, which is every repository's + starting policy), this returned `[]` before propagation — neither + "secure" nor "other" matches a built-in key, and no ancestor was + declared a sensitive parent, so `_is_encrypted`'s parent-shape check + never even ran. A sensitive key now makes its whole subtree sensitive, + so both leaves are correctly flagged.""" + document = {"apiToken": {"secure": "v1:AAAA", "other": "leaked-plaintext"}} + findings = find_plaintext_credentials(document, CheckConfig()) + assert findings == ["apiToken.other", "apiToken.secure"] + + +def test_sensitive_key_propagates_through_several_levels_of_nesting(): + document = {"credential": {"a": {"b": {"c": "leaked-plaintext"}}}} + assert find_plaintext_credentials(document, CheckConfig()) == ["credential.a.b.c"] + + +def test_empty_value_under_a_sensitive_key_still_passes(): + """Empty beats propagation: a sensitive key's subtree still exempts + `None`/`""`/`True`/`False` leaves, same as a directly-matched key + would.""" + document = {"credential": {"nested": {"value": None}}} + assert find_plaintext_credentials(document, CheckConfig()) == [] + + +def test_propagation_does_not_leak_to_a_sibling_non_sensitive_mapping(): + """A sensitive key's subtree is sensitive; an unrelated sibling + subtree, whose own keys match nothing, is not — propagation flows + down a branch, never sideways.""" + document = { + "credential": {"a": "leaked-plaintext"}, + "unrelated": {"b": "not-flagged-by-this-policy"}, + } + assert find_plaintext_credentials(document, CheckConfig()) == ["credential.a"] + + def test_none_and_empty_string_pass(): document = {"password": None, "token": ""} assert find_plaintext_credentials(document, CheckConfig()) == [] From e9348488627013228945408fc667459ed44b0583 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 02:17:26 -0700 Subject: [PATCH 08/44] fix: stop a YAML parse error leaking the credential, and an uncaught exception exiting 1 Review findings, both verified empirically against the real CLI. Important 1: scan_file built its CheckError message from str(exc) on the raw yaml.YAMLError. MarkedYAMLError.__str__ calls Mark.__str__, which calls Mark.get_snippet() -- a slice of the actual source line around the error. A file containing "myproject:dbPassword: hunter2: not-valid-yaml" (an unescaped colon in a value) printed hunter2 to stderr. This is the one file in the whole system expected to hold a credential. Fixed with _describe_yaml_error, which reports only exc.problem plus exc.problem_mark.line/.column for a MarkedYAMLError, and a generic message otherwise -- never str(exc) or str(mark). Important 2: cmd_check_config's per-file loop caught only CheckError. Python's default exit code for an uncaught exception is 1, the code reserved for "a credential was found", so a file the checker never evaluated could report as a file with a credential in it. Two confirmed triggers: a self-referential YAML alias (_walk had no cycle guard and recursed forever -- confirmed RecursionError against the pre-fix logic) and a non-UTF-8 file (path.read_text() raises UnicodeDecodeError, not an OSError, uncaught). Fixed with (1) a visiting: frozenset[int] cycle guard threaded through _walk, tracking container id() along the current descent path (not a global seen-set, so two branches legitimately sharing one non-cyclic object via separate YAML anchors are each still scanned), and (2) a catch-all except Exception in cmd_check_config's per-file loop mapping anything else to exit 2, reporting only type(exc).__name__, never str(exc). Also fixed: a real finding was suppressed when a later file errored (cmd_check_config returned 2 before the findings-print loop) -- moved the print above the errored check, verified the old logic really did print nothing for good.yaml+broken.yaml. And the sideways-leak propagation test only discriminated by dict insertion order -- added a reversed-order variant. _is_encrypted, _is_empty, and the propagation logic are unchanged; the review confirmed them correct. 8 new tests in tests/test_heuristic.py. Full suite: 138 passed (130 + 8). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/commands/check_config.py | 64 ++++++++++++--- src/stackward/nets/heuristic.py | 30 ++++++- tests/test_heuristic.py | 107 +++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 13 deletions(-) diff --git a/src/stackward/commands/check_config.py b/src/stackward/commands/check_config.py index 13f48b5..3c3340f 100644 --- a/src/stackward/commands/check_config.py +++ b/src/stackward/commands/check_config.py @@ -50,7 +50,7 @@ def scan_file(path: Path, check: CheckConfig) -> list[str]: try: document = yaml.safe_load(text) except yaml.YAMLError as exc: - raise CheckError(f"{path}: invalid YAML: {exc}") from exc + raise CheckError(f"{path}: invalid YAML: {_describe_yaml_error(exc)}") from exc try: return find_plaintext_credentials(document, check) @@ -58,6 +58,30 @@ def scan_file(path: Path, check: CheckConfig) -> list[str]: raise CheckError(f"{path}: {exc}") from exc +def _describe_yaml_error(exc: yaml.YAMLError) -> str: + """A `YAMLError` message that never includes a source-line snippet. + + `MarkedYAMLError.__str__` (and the `Mark.__str__` it calls for + `problem_mark`/`context_mark`) includes `Mark.get_snippet()` — a slice + of the *actual file content* around the error. This is the one file in + the whole system expected to contain a credential, so that snippet is + exactly the value this module must never print. `exc.problem` and + `exc.problem_mark`'s `line`/`column` carry no source text; that is all + this reports. Contrast `config.py`'s `load_config`, which safely echoes + tomllib's message in full because `.stackward.toml` holds only path and + key *names* — this file holds the opposite, so it gets the more + careful treatment. + """ + if isinstance(exc, yaml.MarkedYAMLError) and exc.problem is not None: + if exc.problem_mark is not None: + return ( + f"{exc.problem} (line {exc.problem_mark.line + 1}, " + f"column {exc.problem_mark.column + 1})" + ) + return exc.problem + return "invalid YAML syntax" + + def _load_check_policy() -> CheckConfig: """The `[check]` policy for the repository containing the current working directory, or the built-in defaults when there is none — @@ -72,11 +96,24 @@ def cmd_check_config(args: argparse.Namespace) -> int: """Entry point for `stackward check-config FILE...`. Findings print as `: plaintext credential at ''`, sorted, - one per line — file and config path only, never the value. A - could-not-run error on any file takes priority over findings on - another: it is reported and the whole invocation exits 2, since exit 1 - means specifically "a credential was found", which an invocation that - did not fully run cannot claim to know. + one per line — file and config path only, never the value. They print + even when *another* file in the same invocation errors: a real finding + on `good.yaml` must never be hidden behind a parse error on + `broken.yaml` that happens to sort after it, so the exit code alone + carries the could-not-run signal. A could-not-run error on any file + still wins the exit code over findings elsewhere: it exits 2, since + exit 1 means specifically "a credential was found", which an + invocation that did not fully run cannot claim to know. + + Anything a per-file scan raises other than `CheckError` — a file that + is not valid UTF-8, or an unanticipated bug elsewhere in this tool — + is also mapped to the could-not-run exit code rather than left to + propagate: Python's default exit code for an uncaught exception is 1, + which here would misreport "a credential was found" for a file this + command never actually finished evaluating. The message never includes + `str(exc)`, for the same reason `_describe_yaml_error` avoids it: an + unanticipated exception's text is not something this module can vouch + for as free of file content. """ try: check = _load_check_policy() @@ -93,12 +130,19 @@ def cmd_check_config(args: argparse.Namespace) -> int: print(f"error: {exc}", file=sys.stderr) errored = True continue + except Exception as exc: # noqa: BLE001 - fail closed on anything unanticipated + print( + f"error: {file_arg}: could not check ({type(exc).__name__})", + file=sys.stderr, + ) + errored = True + continue findings.extend((file_arg, path) for path in paths) - if errored: - return 2 if findings: for file_arg, path in sorted(findings): print(f"{file_arg}: plaintext credential at '{path}'") - return 1 - return 0 + + if errored: + return 2 + return 1 if findings else 0 diff --git a/src/stackward/nets/heuristic.py b/src/stackward/nets/heuristic.py index afb9044..c844eb3 100644 --- a/src/stackward/nets/heuristic.py +++ b/src/stackward/nets/heuristic.py @@ -113,7 +113,7 @@ def find_plaintext_credentials(document: Any, check: CheckConfig) -> list[str]: allowed_references=frozenset(check.allowed_references), findings=[], ) - _walk(document, [], False, ctx) + _walk(document, [], False, frozenset({id(document)}), ctx) return sorted(ctx.findings) @@ -137,6 +137,7 @@ def _walk( node: Any, path: list[str | int], ancestor_sensitive: bool, + visiting: frozenset[int], ctx: _ScanContext, ) -> None: """Recurse through `node`, appending a rendered path to `ctx.findings` @@ -149,6 +150,19 @@ def _walk( checked separately in `_check_leaf`. It only ever turns true going down and is never cleared, so a sensitive key's entire subtree stays sensitive to the bottom. + + `visiting` holds `id()` of every container currently open on *this* + descent path — the call stack, not a global "already scanned" set. A + YAML anchor/alias pair (`credential: &x\\n b: *x\\n`) makes a dict or + list contain itself: `safe_load` permits it, and revisiting it would + recurse forever, each cycle producing a longer, distinct rendered path + (a depth cap would only delay the crash, not avoid it, and would also + have to pick an arbitrary limit). A container whose `id()` is already + in `visiting` is such a cycle and is skipped rather than walked. This + must be scoped to the current path, not "every id ever seen": two + *different*, non-cyclic branches legitimately sharing one object via + two YAML anchors (no alias pointing back at an ancestor) must each + still be walked at their own path. """ if isinstance(node, dict): for raw_key, value in node.items(): @@ -159,17 +173,27 @@ def _walk( key = raw_key if isinstance(raw_key, str) else str(raw_key) child_path = [*path, key] if isinstance(value, (dict, list)): + if id(value) in visiting: + continue key_is_sensitive = _matches_sensitive_key( key, ctx.check.sensitive_keys ) or key in ctx.check.sensitive_parents - _walk(value, child_path, ancestor_sensitive or key_is_sensitive, ctx) + _walk( + value, + child_path, + ancestor_sensitive or key_is_sensitive, + visiting | {id(value)}, + ctx, + ) else: _check_leaf(node, key, value, child_path, ancestor_sensitive, ctx) elif isinstance(node, list): for index, value in enumerate(node): child_path = [*path, index] if isinstance(value, (dict, list)): - _walk(value, child_path, ancestor_sensitive, ctx) + if id(value) in visiting: + continue + _walk(value, child_path, ancestor_sensitive, visiting | {id(value)}, ctx) else: _check_leaf(None, None, value, child_path, ancestor_sensitive, ctx) diff --git a/tests/test_heuristic.py b/tests/test_heuristic.py index dd40756..3a51e50 100644 --- a/tests/test_heuristic.py +++ b/tests/test_heuristic.py @@ -127,6 +127,54 @@ def test_propagation_does_not_leak_to_a_sibling_non_sensitive_mapping(): assert find_plaintext_credentials(document, CheckConfig()) == ["credential.a"] +def test_propagation_does_not_leak_to_a_sibling_non_sensitive_mapping_reversed_order(): + """Same document as above with insertion order reversed. `_walk` + computes each key's contribution fresh from the `ancestor_sensitive` + value it was called with, never by mutating a variable shared across + loop iterations — so this must pass regardless of which sibling a dict + happens to iterate first. A bug that leaked sensitivity across sibling + branches via shared mutable state, instead of a fresh value per + branch, could otherwise hide behind dict ordering.""" + document = { + "unrelated": {"b": "not-flagged-by-this-policy"}, + "credential": {"a": "leaked-plaintext"}, + } + assert find_plaintext_credentials(document, CheckConfig()) == ["credential.a"] + + +def test_self_referential_mapping_terminates_instead_of_recursing_forever(): + """A YAML anchor/alias pair like `credential: &x\\n b: *x\\n` produces + a dict that contains itself. Without a cycle guard, `_walk` recurses + forever — each cycle produces a longer, distinct rendered path, so a + depth cap would only delay the crash, not avoid it.""" + cyclic: dict = {"b": None} + cyclic["b"] = cyclic + document = {"credential": cyclic} + assert find_plaintext_credentials(document, CheckConfig()) == [] + + +def test_self_referential_sequence_terminates_instead_of_recursing_forever(): + """The same cycle, one level further down inside a list rather than a + dict.""" + cyclic: list = ["placeholder"] + cyclic[0] = cyclic + document = {"credential": cyclic} + assert find_plaintext_credentials(document, CheckConfig()) == [] + + +def test_shared_non_cyclic_object_is_scanned_at_each_occurrence(): + """Two YAML anchors pointing at the same (non-cyclic) mapping from two + unrelated branches is not a cycle — the cycle guard is scoped to the + current descent path, not "every id ever seen", so both occurrences + must still be scanned at their own distinct path.""" + shared = {"password": "leaked-plaintext"} + document = {"copy1": shared, "copy2": shared} + assert find_plaintext_credentials(document, CheckConfig()) == [ + "copy1.password", + "copy2.password", + ] + + def test_none_and_empty_string_pass(): document = {"password": None, "token": ""} assert find_plaintext_credentials(document, CheckConfig()) == [] @@ -213,6 +261,23 @@ def test_scan_file_raises_check_error_on_invalid_yaml(tmp_path): scan_file(path, CheckConfig()) +def test_invalid_yaml_error_never_prints_a_credential_shaped_value(tmp_path): + """`MarkedYAMLError.__str__` includes a source-line snippet via PyYAML's + `Mark.get_snippet()` — an unescaped colon in a value (entirely + plausible for a real password) produces a parse error whose default + message would otherwise embed that value verbatim. This is the one + file in the whole system expected to contain a credential, so the + reported message must never include it.""" + path = write_yaml( + tmp_path, + "Pulumi.leak.yaml", + "config:\n myproject:dbPassword: hunter2: not-valid-yaml\n", + ) + with pytest.raises(CheckError) as exc_info: + scan_file(path, CheckConfig()) + assert "hunter2" not in str(exc_info.value) + + def test_scan_file_raises_check_error_on_non_mapping_document(tmp_path): path = write_yaml(tmp_path, "Pulumi.dev.yaml", "- a\n- b\n") with pytest.raises(CheckError): @@ -273,3 +338,45 @@ def test_check_config_exits_2_with_no_files_given(): with pytest.raises(SystemExit) as exit_info: main(["check-config"]) assert exit_info.value.code == 2 + + +def test_check_config_exits_2_on_non_utf8_file(tmp_path, capsys): + """`Path.read_text()` raises `UnicodeDecodeError` for a file that is + not valid UTF-8 — not an `OSError`, and previously uncaught, which + would exit 1 (Python's default for an uncaught exception): the code + reserved exclusively for "a credential was found". A file this command + never finished reading is a could-not-run failure, not a finding.""" + path = tmp_path / "Pulumi.dev.yaml" + path.write_bytes(b"password: \xff\xfe not valid utf8\n") + assert main(["check-config", str(path)]) == 2 + assert capsys.readouterr().out == "" + + +def test_check_config_exits_2_on_unanticipated_scan_error(tmp_path, monkeypatch, capsys): + """Any exception a per-file scan raises other than `CheckError` must + still map to exit 2, not propagate and exit 1 (Python's default for an + uncaught exception) — regardless of what raised it. Simulated here + with a directly-injected failure rather than relying on a specific + trigger, so this test does not duplicate the self-referential-YAML or + non-UTF8 regressions covered elsewhere.""" + path = write_yaml(tmp_path, "Pulumi.dev.yaml", "name: myproject\n") + + def boom(_path, _check): + raise RuntimeError("unanticipated failure, not a CheckError") + + monkeypatch.setattr("stackward.commands.check_config.scan_file", boom) + assert main(["check-config", str(path)]) == 2 + assert capsys.readouterr().out == "" + + +def test_check_config_prints_a_real_finding_even_when_another_file_errors( + tmp_path, capsys +): + """A parse error on one file must never hide a genuine finding on + another: the exit code (2, since a check-could-not-run error always + wins) carries the could-not-run signal, but the finding still prints.""" + good = write_yaml(tmp_path, "good.yaml", 'password: "hunter2"\n') + broken = write_yaml(tmp_path, "broken.yaml", "key: [unclosed\n") + assert main(["check-config", str(good), str(broken)]) == 2 + out = capsys.readouterr().out + assert out == f"{good}: plaintext credential at 'password'\n" From 87357252a400f98f026b548e79fc4d66f3687340 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 02:29:37 -0700 Subject: [PATCH 09/44] fix: redact quoted fragments PyYAML's %r interpolates into error messages Round-2 review finding: the round-1 fix's premise that exc.problem and problem_mark "carry no source text" was wrong. Several PyYAML scanner/parser messages interpolate one raw character or a user-chosen name via %r -- "found character %r that cannot start any token", "found unknown escape character %r", the "expected ... but found %r" family, and anchor/alias/tag-handle names in duplicate/undefined-alias errors. Reproduced: password: "hunter\2ok" yielded "found unknown escape character '2' (line 1, column 19)" -- one character of the credential. Fixed by redacting every '...'-quoted run in exc.problem (_QUOTED_FRAGMENT = re.compile(r"'[^']*'")) before interpolating it, per the reviewer's instruction not to whitelist specific PyYAML message shapes since they vary by version. Diagnostic value (error kind, line, column) survives; the credential character does not. Corrected _describe_yaml_error's docstring, which had claimed exc.problem carries no source text -- restated as the guarantee the code actually provides (built from PyYAML's description and position only, with quoted fragments redacted) and why str(exc) must never be used here. Two new tests in tests/test_heuristic.py, one per named message shape (bad escape, reserved leading character), each with a credential-shaped value, asserting the leaked character is absent while error kind and line/column survive -- verified against a reproduction of the pre-fix _describe_yaml_error to confirm they fail for the right reason. Cycle guard, catch-all, finding-print ordering, and the order-independent propagation test are unchanged, per the reviewer's explicit instruction -- all confirmed correct in this round. Full suite: 140 passed (138 + 2). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/commands/check_config.py | 48 ++++++++++++++++++-------- tests/test_heuristic.py | 40 +++++++++++++++++++++ 2 files changed, 74 insertions(+), 14 deletions(-) diff --git a/src/stackward/commands/check_config.py b/src/stackward/commands/check_config.py index 3c3340f..f575ad2 100644 --- a/src/stackward/commands/check_config.py +++ b/src/stackward/commands/check_config.py @@ -11,6 +11,7 @@ from __future__ import annotations import argparse +import re import sys from pathlib import Path @@ -19,6 +20,11 @@ from ..config import CheckConfig, ConfigError, find_repo_config, load_config from ..nets.heuristic import DocumentError, find_plaintext_credentials +# Matches a `'...'`-quoted fragment the way PyYAML's `%r` interpolation +# produces one (e.g. `'2'`, `` '`' ``, `'id001'`) — see `_describe_yaml_error` +# for why every such fragment gets redacted rather than trusted. +_QUOTED_FRAGMENT = re.compile(r"'[^']*'") + class CheckError(Exception): """A file could not be checked at all: an unreadable path, unparsable @@ -59,26 +65,40 @@ def scan_file(path: Path, check: CheckConfig) -> list[str]: def _describe_yaml_error(exc: yaml.YAMLError) -> str: - """A `YAMLError` message that never includes a source-line snippet. - - `MarkedYAMLError.__str__` (and the `Mark.__str__` it calls for - `problem_mark`/`context_mark`) includes `Mark.get_snippet()` — a slice - of the *actual file content* around the error. This is the one file in - the whole system expected to contain a credential, so that snippet is - exactly the value this module must never print. `exc.problem` and - `exc.problem_mark`'s `line`/`column` carry no source text; that is all - this reports. Contrast `config.py`'s `load_config`, which safely echoes - tomllib's message in full because `.stackward.toml` holds only path and - key *names* — this file holds the opposite, so it gets the more - careful treatment. + """A `YAMLError` message built only from PyYAML's own description and + position, with every `'...'`-quoted fragment in it redacted — never + from `str(exc)`. + + `str(exc)` on a `MarkedYAMLError` calls `Mark.__str__`, which calls + `Mark.get_snippet()` and emits the *whole source line* around the + error — reason enough on its own never to use it here, in the one file + in the whole system expected to hold a credential. + + But `exc.problem` is not unconditionally safe either, which an earlier + version of this docstring claimed and which a review disproved: several + PyYAML scanner/parser messages interpolate one raw character or a + user-chosen name via `%r` — "found character %r that cannot start any + token", "found unknown escape character %r", an anchor/alias/tag-handle + name in a duplicate/undefined-alias error. A value like `"hunter\2ok"` + yields `found unknown escape character '2'` — one character of a real + credential. Rather than whitelisting which PyYAML message shapes are + safe (which varies by version and is not this module's job to track), + every single-quoted run is replaced with `` before use. The + message keeps its diagnostic value (error kind, line, column) while + never carrying document content, current or future PyYAML wording + notwithstanding. Contrast `config.py`'s `load_config`, which safely + echoes tomllib's message in full because `.stackward.toml` holds only + path and key *names* — this file holds the opposite, so it gets the + more careful treatment. """ if isinstance(exc, yaml.MarkedYAMLError) and exc.problem is not None: + problem = _QUOTED_FRAGMENT.sub("", exc.problem) if exc.problem_mark is not None: return ( - f"{exc.problem} (line {exc.problem_mark.line + 1}, " + f"{problem} (line {exc.problem_mark.line + 1}, " f"column {exc.problem_mark.column + 1})" ) - return exc.problem + return problem return "invalid YAML syntax" diff --git a/tests/test_heuristic.py b/tests/test_heuristic.py index 3a51e50..4d1e730 100644 --- a/tests/test_heuristic.py +++ b/tests/test_heuristic.py @@ -278,6 +278,46 @@ def test_invalid_yaml_error_never_prints_a_credential_shaped_value(tmp_path): assert "hunter2" not in str(exc_info.value) +def test_invalid_yaml_bad_escape_error_redacts_the_leaked_character(tmp_path): + """`exc.problem` is not unconditionally free of document content: + PyYAML's scanner interpolates one raw character via `%r` into + "found unknown escape character %r" for a bad backslash escape. A + value like `"hunter\\2ok"` (a plausible fragment of a real password) + would otherwise leak the digit `2` into the error message. The error + kind and position must survive redaction even though the character + does not.""" + path = write_yaml(tmp_path, "Pulumi.leak.yaml", 'password: "hunter\\2ok"\n') + with pytest.raises(CheckError) as exc_info: + scan_file(path, CheckConfig()) + message = str(exc_info.value) + assert "'2'" not in message + assert "hunter" not in message + assert "" in message + assert "found unknown escape character" in message + assert "line 1" in message + assert "column 19" in message + + +def test_invalid_yaml_reserved_leading_character_error_redacts_the_leaked_character( + tmp_path, +): + """The same `%r` leak, from a different PyYAML message: a character + that cannot start any token — a backtick right before what looks like + a plaintext credential — interpolates that character into + "found character %r that cannot start any token".""" + path = write_yaml(tmp_path, "Pulumi.leak.yaml", "password: `s3cr3tPass99\n") + with pytest.raises(CheckError) as exc_info: + scan_file(path, CheckConfig()) + message = str(exc_info.value) + assert "'`'" not in message + assert "s3cr3tPass99" not in message + assert "" in message + assert "found character" in message + assert "that cannot start any token" in message + assert "line 1" in message + assert "column 11" in message + + def test_scan_file_raises_check_error_on_non_mapping_document(tmp_path): path = write_yaml(tmp_path, "Pulumi.dev.yaml", "- a\n- b\n") with pytest.raises(CheckError): From db48ff975b9c6173b0ec7650fb67346fbad299f0 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 02:42:29 -0700 Subject: [PATCH 10/44] fix: redact both repr() delimiter forms, and test the property by shape Round-3 review finding: both round-2 findings not addressed. _QUOTED_FRAGMENT matched '[^']*' only. Python's repr() switches to double quotes whenever the value contains ' and no " -- so when the character PyYAML interpolates via %r is itself an apostrophe, the fragment is "'"', not ''', and the single-quote-only pattern doesn't touch it. Reproduced: password: "s3cr3t\'value" produced "found unknown escape character "'"" in the output -- an apostrophe from the credential's own content. Fixed: _QUOTED_FRAGMENT = re.compile(r"'[^']*'|\"[^\"]*\"") -- redacts both delimiter forms, per the reviewer's instruction not to whitelist message shapes. The deeper finding: the round-2 tests pinned one character each ('2', '`') and both passed with the hole wide open, because neither ever supplied an apostrophe. Replaced them with two tests parameterized over five trigger characters (apostrophe, double quote, backslash, newline, non-printable), one per PyYAML message family that interpolates a raw character via %r, each constructing a real yaml.MarkedYAMLError and calling the actual _describe_yaml_error directly -- testing double-quote and backslash this way because both are themselves valid YAML escapes and so cannot trigger "unknown escape character" through real parsing at all. Kept a new real end-to-end regression test reproducing the reviewer's exact repro through scan_file, alongside the round-2 real tests. Verified the parameterized tests actually discriminate the two implementations: reproduced the pre-round-3 (single-quote-only) regex against all five characters -- only apostrophe leaks, confirming the round-2 tests could never have caught this regardless of how many times run. Corrected _describe_yaml_error's docstring, which claimed the message "never carr[ies] document content, current or future PyYAML wording notwithstanding" -- an unbounded promise a quote-shaped regex cannot license. Restated as the mechanism and its boundary: removes every character PyYAML is currently known to interpolate via %r, by shape, and the parameterized test (not the docstring) is what would catch a future wording change that escapes it. Cycle guard, catch-all, finding-print ordering, and the order-independent propagation test unchanged, per the reviewer's explicit instruction. Full suite: 151 passed (140 + 11 new). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/commands/check_config.py | 40 ++++++++----- tests/test_heuristic.py | 77 ++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 15 deletions(-) diff --git a/src/stackward/commands/check_config.py b/src/stackward/commands/check_config.py index f575ad2..73da455 100644 --- a/src/stackward/commands/check_config.py +++ b/src/stackward/commands/check_config.py @@ -20,10 +20,14 @@ from ..config import CheckConfig, ConfigError, find_repo_config, load_config from ..nets.heuristic import DocumentError, find_plaintext_credentials -# Matches a `'...'`-quoted fragment the way PyYAML's `%r` interpolation -# produces one (e.g. `'2'`, `` '`' ``, `'id001'`) — see `_describe_yaml_error` -# for why every such fragment gets redacted rather than trusted. -_QUOTED_FRAGMENT = re.compile(r"'[^']*'") +# Matches a repr-quoted fragment the way PyYAML's `%r` interpolation +# produces one — e.g. `'2'`, `` '`' ``, `'id001'`. Python's `repr()` flips +# to double quotes whenever the value contains `'` and no `"` (so `%r` on a +# lone apostrophe is `"'"`, not `'''`), which is why both delimiter forms +# are matched here: matching only `'...'` leaves exactly that one trigger +# character unredacted. See `_describe_yaml_error` for why every such +# fragment gets redacted rather than trusted. +_QUOTED_FRAGMENT = re.compile(r"'[^']*'|\"[^\"]*\"") class CheckError(Exception): @@ -66,8 +70,8 @@ def scan_file(path: Path, check: CheckConfig) -> list[str]: def _describe_yaml_error(exc: yaml.YAMLError) -> str: """A `YAMLError` message built only from PyYAML's own description and - position, with every `'...'`-quoted fragment in it redacted — never - from `str(exc)`. + the error position, with every repr-quoted fragment in it redacted — + never from `str(exc)`. `str(exc)` on a `MarkedYAMLError` calls `Mark.__str__`, which calls `Mark.get_snippet()` and emits the *whole source line* around the @@ -81,15 +85,21 @@ def _describe_yaml_error(exc: yaml.YAMLError) -> str: token", "found unknown escape character %r", an anchor/alias/tag-handle name in a duplicate/undefined-alias error. A value like `"hunter\2ok"` yields `found unknown escape character '2'` — one character of a real - credential. Rather than whitelisting which PyYAML message shapes are - safe (which varies by version and is not this module's job to track), - every single-quoted run is replaced with `` before use. The - message keeps its diagnostic value (error kind, line, column) while - never carrying document content, current or future PyYAML wording - notwithstanding. Contrast `config.py`'s `load_config`, which safely - echoes tomllib's message in full because `.stackward.toml` holds only - path and key *names* — this file holds the opposite, so it gets the - more careful treatment. + credential; `repr()` flips to double quotes whenever that character is + itself an apostrophe (`"'"`, not `'''`), which is why `_QUOTED_FRAGMENT` + matches both delimiter forms rather than only `'...'`. + + This removes every document character PyYAML is *currently known* to + interpolate via `%r`, by shape (any repr-quoted run) rather than by + enumerating today's message text — that is a mechanism with a stated + boundary, not a guarantee that no PyYAML wording could ever leak a + character some other way. `tests/test_heuristic.py`'s parameterized + redaction test is what would catch a future wording change that + escapes this pattern; this docstring does not promise it can't happen. + Contrast `config.py`'s `load_config`, which safely echoes tomllib's + message in full because `.stackward.toml` holds only path and key + *names* — this file holds the opposite, so it gets the more careful, + still-bounded treatment. """ if isinstance(exc, yaml.MarkedYAMLError) and exc.problem is not None: problem = _QUOTED_FRAGMENT.sub("", exc.problem) diff --git a/tests/test_heuristic.py b/tests/test_heuristic.py index 4d1e730..3491536 100644 --- a/tests/test_heuristic.py +++ b/tests/test_heuristic.py @@ -10,12 +10,36 @@ from __future__ import annotations import pytest +import yaml from stackward.cli import main +from stackward.commands import check_config as check_config_module from stackward.commands.check_config import CheckError, scan_file from stackward.config import CheckConfig from stackward.nets.heuristic import DocumentError, find_plaintext_credentials +# Characters that flow through PyYAML's `%r` interpolation into a scanner +# or parser message. `_YAML_TRIGGER_CHARACTERS`'s point is exactly that it +# is a *set*, not one instance: the apostrophe is the one character whose +# `repr()` switches delimiter (`"'"`, not `'''`), and a redaction pattern +# that only matched `'...'` passed every test built from the others. +_YAML_TRIGGER_CHARACTERS = [ + pytest.param("'", id="apostrophe"), + pytest.param('"', id="double_quote"), + pytest.param("\\", id="backslash"), + pytest.param("\n", id="newline"), + pytest.param("\x01", id="non_printable"), +] + + +def _marked_yaml_error(problem: str) -> yaml.MarkedYAMLError: + """A `MarkedYAMLError` shaped like the ones PyYAML's scanner/parser + actually raise, with a fixed, recognisable line/column so a test can + assert position survives redaction alongside the interpolated + character not surviving it.""" + mark = yaml.Mark("", 0, 4, 17, None, None) + return yaml.MarkedYAMLError(problem=problem, problem_mark=mark) + # --------------------------------------------------------------------------- # find_plaintext_credentials — the content-level net. # --------------------------------------------------------------------------- @@ -318,6 +342,59 @@ def test_invalid_yaml_reserved_leading_character_error_redacts_the_leaked_charac assert "column 11" in message +def test_scan_file_redacts_an_apostrophe_that_flips_reprs_delimiter(tmp_path): + """Regression for the specific gap the two tests above could not + catch: when the character PyYAML interpolates via `%r` is itself an + apostrophe, Python's `repr()` switches to double quotes (`"'"`, not + `'''`) — a redaction pattern that only matched `'...'` left exactly + this one character unredacted, end to end through the real CLI path, + not just a synthetic exception.""" + path = write_yaml(tmp_path, "Pulumi.leak.yaml", "password: \"s3cr3t\\'value\"\n") + with pytest.raises(CheckError) as exc_info: + scan_file(path, CheckConfig()) + message = str(exc_info.value) + assert "'" not in message + assert "s3cr3t" not in message + assert "value" not in message + assert "" in message + assert "found unknown escape character" in message + + +@pytest.mark.parametrize("trigger_char", _YAML_TRIGGER_CHARACTERS) +def test_yaml_error_redaction_removes_any_percent_r_escape_character(trigger_char): + """The property, not an instance: redaction must hold for *any* + character PyYAML's scanner interpolates via `%r` into "found unknown + escape character %r" — not just the one or two a hand-picked + reproduction happens to trigger. Two instance-pinned tests (`'2'`, + `` '`' ``) both passed while this exact class of gap — the apostrophe + flipping `repr()`'s delimiter to `"'"` — was still open, which is + precisely what a test quantified over characters, rather than fixed to + one, is for.""" + exc = _marked_yaml_error(f"found unknown escape character {trigger_char!r}") + message = check_config_module._describe_yaml_error(exc) + assert trigger_char not in message + assert "" in message + assert "found unknown escape character" in message + assert "line 5" in message + assert "column 18" in message + + +@pytest.mark.parametrize("trigger_char", _YAML_TRIGGER_CHARACTERS) +def test_yaml_error_redaction_removes_any_percent_r_reserved_character(trigger_char): + """The same property, for the other PyYAML message family that + interpolates a raw character: "found character %r that cannot start + any token".""" + problem = f"found character {trigger_char!r} that cannot start any token" + exc = _marked_yaml_error(problem) + message = check_config_module._describe_yaml_error(exc) + assert trigger_char not in message + assert "" in message + assert "found character" in message + assert "that cannot start any token" in message + assert "line 5" in message + assert "column 18" in message + + def test_scan_file_raises_check_error_on_non_mapping_document(tmp_path): path = write_yaml(tmp_path, "Pulumi.dev.yaml", "- a\n- b\n") with pytest.raises(CheckError): From 391fd718983b3e12ee5b574c36ed768fe87ad170 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 03:27:57 -0700 Subject: [PATCH 11/44] feat: pre-commit gate over staged content, plus describe_yaml_error rename Implements Task 4: `stackward pre-commit`, the git-hook-body subcommand that scans *staged* content for plaintext credentials before a commit object exists -- the layer that actually prevents a leak, since CI can only ever report one that is already public. - `git diff --cached --name-only -z --diff-filter=ACMR` selects staged files (`R` included, so a rename plus an edit cannot bypass the gate). - A dedicated `--diff-filter=U` check runs first: an unmerged index entry is invisible to the ACMR listing above, so without this it would silently pass rather than error. Documented in `cmd_pre_commit` as a deliberate deviation from the brief's "state export refused before any other check" ordering -- see the report for the full reasoning. - Pulumi state exports (`state.json`, `*.stack-export.json`) are refused outright, before the credential scan or `.stackward.toml` is even loaded. - `Pulumi..yaml` matched by prefix/suffix with a required non-empty stack name, so the `Pulumi.yaml` project file is excluded. - Every staged blob is read via `git show ":"` and handed straight to `find_plaintext_credentials` -- never the working tree, never a temp-file round-trip through the file-path wrapper. - Exit 0 clean / 1 blocked (finding or state export) / 2 could-not-run (unmerged entry, git failure, undecodable/unparseable content, non-mapping document, bad config, or any unanticipated exception -- every scan step is wrapped so Python's uncaught-exception default of exit 1 can never misreport "credential found"). Also keeps the uncommitted rename from the previous, interrupted attempt at this task: `check_config._describe_yaml_error` -> the public `describe_yaml_error`, so `pre_commit.py` can reuse its redaction of PyYAML's `%r`-interpolated error fragments for staged-blob YAML errors too, rather than a second, independently-written copy of it. 38 new tests in tests/test_pre_commit.py, all against real temporary git repositories (git is never mocked) -- covering a rename+edit staged as `R`, a staged state export, staged-vs-working-tree divergence in both directions, filenames with a space and with non-ASCII characters, and a real merge conflict. 189 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/cli.py | 7 + src/stackward/commands/check_config.py | 22 +- src/stackward/commands/pre_commit.py | 320 ++++++++++++++++ tests/test_heuristic.py | 4 +- tests/test_pre_commit.py | 494 +++++++++++++++++++++++++ 5 files changed, 840 insertions(+), 7 deletions(-) create mode 100644 src/stackward/commands/pre_commit.py create mode 100644 tests/test_pre_commit.py diff --git a/src/stackward/cli.py b/src/stackward/cli.py index 572571d..3fff5de 100644 --- a/src/stackward/cli.py +++ b/src/stackward/cli.py @@ -18,6 +18,7 @@ from . import __version__ from .commands.check_config import cmd_check_config +from .commands.pre_commit import cmd_pre_commit from .config import find_repo_config # Commands that must keep working even when a repo demands a newer stackward @@ -119,6 +120,12 @@ def build_parser() -> argparse.ArgumentParser: check_config.add_argument("files", nargs="+", metavar="FILE") check_config.set_defaults(func=cmd_check_config) + pre_commit = sub.add_parser( + "pre-commit", + help="check staged content for a plaintext credential (git hook body)", + ) + pre_commit.set_defaults(func=cmd_pre_commit) + return parser diff --git a/src/stackward/commands/check_config.py b/src/stackward/commands/check_config.py index 73da455..29f131e 100644 --- a/src/stackward/commands/check_config.py +++ b/src/stackward/commands/check_config.py @@ -25,7 +25,7 @@ # to double quotes whenever the value contains `'` and no `"` (so `%r` on a # lone apostrophe is `"'"`, not `'''`), which is why both delimiter forms # are matched here: matching only `'...'` leaves exactly that one trigger -# character unredacted. See `_describe_yaml_error` for why every such +# character unredacted. See `describe_yaml_error` for why every such # fragment gets redacted rather than trusted. _QUOTED_FRAGMENT = re.compile(r"'[^']*'|\"[^\"]*\"") @@ -33,7 +33,13 @@ class CheckError(Exception): """A file could not be checked at all: an unreadable path, unparsable YAML, or a document that is not a mapping. Maps to exit code 2, never - 1 — that code means "a credential was found", which this is not.""" + 1 — that code means "a credential was found", which this is not. + + Reused as-is by `commands.pre_commit` for the same "could not run" + conditions on a staged blob (undecodable content, unparsable YAML, a + non-mapping document, an unmerged index entry, a git command that + failed) — one exception type for one meaning, not a second class that + would have to be kept consistent with this one by hand.""" def scan_file(path: Path, check: CheckConfig) -> list[str]: @@ -60,7 +66,7 @@ def scan_file(path: Path, check: CheckConfig) -> list[str]: try: document = yaml.safe_load(text) except yaml.YAMLError as exc: - raise CheckError(f"{path}: invalid YAML: {_describe_yaml_error(exc)}") from exc + raise CheckError(f"{path}: invalid YAML: {describe_yaml_error(exc)}") from exc try: return find_plaintext_credentials(document, check) @@ -68,11 +74,17 @@ def scan_file(path: Path, check: CheckConfig) -> list[str]: raise CheckError(f"{path}: {exc}") from exc -def _describe_yaml_error(exc: yaml.YAMLError) -> str: +def describe_yaml_error(exc: yaml.YAMLError) -> str: """A `YAMLError` message built only from PyYAML's own description and the error position, with every repr-quoted fragment in it redacted — never from `str(exc)`. + Public (not `_`-prefixed) specifically so `commands.pre_commit` can + reuse it for the same reason: a staged blob it parses can hold a + credential just as easily as a file on disk can, so a YAML parse + failure there needs the exact same redaction, not a second, + independently-written copy of it. + `str(exc)` on a `MarkedYAMLError` calls `Mark.__str__`, which calls `Mark.get_snippet()` and emits the *whole source line* around the error — reason enough on its own never to use it here, in the one file @@ -141,7 +153,7 @@ def cmd_check_config(args: argparse.Namespace) -> int: propagate: Python's default exit code for an uncaught exception is 1, which here would misreport "a credential was found" for a file this command never actually finished evaluating. The message never includes - `str(exc)`, for the same reason `_describe_yaml_error` avoids it: an + `str(exc)`, for the same reason `describe_yaml_error` avoids it: an unanticipated exception's text is not something this module can vouch for as free of file content. """ diff --git a/src/stackward/commands/pre_commit.py b/src/stackward/commands/pre_commit.py new file mode 100644 index 0000000..66e4d84 --- /dev/null +++ b/src/stackward/commands/pre_commit.py @@ -0,0 +1,320 @@ +"""`pre-commit`: the gate, run as a git hook body over *staged* content. + +Registered in `cli.build_parser` as `stackward pre-commit`, taking no +arguments -- it always operates on the current repository's index, exactly +as a git hook would invoke it. Installing this as the actual hook body is +Task 5's job (`commands.install_hooks`); this module only implements what +runs once invoked. + +This is the layer that actually *prevents* a credential from being +published: it runs before the commit object exists, so a block here means +the credential never enters history at all. A CI-side check (elsewhere in +this project's plan) can only ever report one that is already public. +Consequently every rule below leans the same way when in doubt: refuse +rather than guess, because the cost of a false positive here is a rerun, +and the cost of a false negative is permanent. + +**Staged, never working-tree.** Every path this module reads goes through +`git show ":"` (see `_read_staged_blob`), which is stage 0 of the +index as it stands right now -- not `Path.read_text()`. An unstaged fix +sitting only in the working tree must not let an already-staged credential +through, and a working-tree file that merely *looks* clean must not be +mistaken for what will actually be committed. + +**Content-level, not file-level.** `_scan_staged_config` calls +`nets.heuristic.find_plaintext_credentials` directly with an +already-parsed document, the same content-level entry point +`commands.check_config.scan_file` calls for a file on disk. Neither this +module nor that one ever round-trips a blob through a temporary file to +reuse the other's file-path wrapper -- doing that here specifically would +write staged (possibly credential-bearing) content to disk outside the +index, and would reintroduce the working-tree-vs-index confusion this +module exists to avoid. + +**No credential resolution.** This module imports `yaml`, `..config` and +`..nets.heuristic` -- never `cryptography` and never any credential +provider. The gate decides whether a value looks like a plaintext +credential; it never fetches, decrypts, or connects anywhere to do it. + +**Path discovery from any working directory.** `git diff --cached +--name-only` and `git show ":"` both resolve paths relative to the +repository's top level regardless of the caller's current working +directory (verified empirically: a path printed by the first command, +handed unchanged to the second, resolves correctly whether invoked from +the repo root or a subdirectory several levels down). `_load_check_policy` +relies on the same property transitively through `find_repo_config`, which +walks upward from `Path.cwd()` to the repository root and checks +`/.stackward.toml` before checking whether `/.git` +exists -- so the repo root's own `.stackward.toml` is always found on the +same pass that discovers `.git`, never one step too late. Between the two, +this command needs no special handling for "the hook ran from a +subdirectory": there is no such thing, only "the hook ran somewhere inside +the repository." +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import PurePosixPath + +import yaml + +from ..config import CheckConfig, ConfigError, find_repo_config, load_config +from ..nets.heuristic import DocumentError, find_plaintext_credentials +from .check_config import CheckError, describe_yaml_error + +_STATE_EXPORT_NAME = "state.json" +_STATE_EXPORT_SUFFIX = ".stack-export.json" + +_STACK_CONFIG_PREFIX = "Pulumi." +_STACK_CONFIG_SUFFIX = ".yaml" + + +def _run_git(args: list[str]) -> bytes: + """Run `git ` from the current working directory and return its + stdout as bytes. + + Raises `CheckError` for anything that means this check cannot proceed: + the `git` executable missing, the current directory not inside a work + tree, or the invoked subcommand failing for any git-reported reason + (including `git show` on a path with no stage-0 entry -- an unmerged + path). The message includes git's own stderr text verbatim: the only + two invocations this module ever makes are `diff --cached --name-only` + and `show ":"`, and a *failing* run of either never gets far + enough to have printed blob content -- a non-zero exit here means git + could not produce output at all, so its diagnostic is always about a + path or a ref, never about what a blob contains. + """ + try: + proc = subprocess.run(["git", *args], capture_output=True, check=False) + except OSError as exc: + raise CheckError(f"cannot run git {' '.join(args)}: {exc}") from exc + if proc.returncode != 0: + stderr = proc.stderr.decode("utf-8", errors="replace").strip() + raise CheckError(f"git {' '.join(args)} failed: {stderr}") + return proc.stdout + + +def _staged_paths(diff_filter: str) -> list[str]: + """Staged paths matching `diff_filter`, exactly as git names them -- + relative to the repository's top level regardless of the caller's own + working directory (see the module docstring). + + Always `-z`, split on NUL: without it, git quotes and C-escapes any + path containing a space, a newline, or a non-ASCII byte, which would + corrupt exactly the paths this command exists to protect. + + Called with `"ACMR"` for the files actually being committed (`R` + matters: a rename plus an edit stages as `R`, and a filter that + omitted it would let that change bypass every check below) and, + separately, with `"U"` to find anything unmerged. Deliberately never + called with the two combined (`"ACMRU"`): an unmerged path must be + caught by the dedicated check in `cmd_pre_commit` and refused before + anything else runs, not folded into the "files to scan" list, where + finding it there could be mistaken for having already decided what to + do with it. + """ + raw = _run_git( + ["diff", "--cached", "--name-only", "-z", f"--diff-filter={diff_filter}"] + ) + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise CheckError(f"staged path list is not valid UTF-8: {exc}") from exc + return [path for path in text.split("\0") if path] + + +def _is_state_export(path: str) -> bool: + """`state.json`, or a name ending `.stack-export.json` -- matched by + basename, so one nested under any directory still counts. Either shape + is a full Pulumi state export: every resource input the stack has, + credentials included, in plaintext, by design rather than by mistake. + """ + name = PurePosixPath(path).name + return name == _STATE_EXPORT_NAME or name.endswith(_STATE_EXPORT_SUFFIX) + + +def _is_pulumi_stack_config(path: str) -> bool: + """`Pulumi..yaml`, matched by basename on the `Pulumi.` prefix + and `.yaml` suffix rather than a restrictive character class -- + `` may itself contain dots (`Pulumi.prod.eu.yaml` is a legal + stack name). + + The empty-stack case is excluded on purpose. `Pulumi.yaml` -- the + *project* file, which holds no stack config at all -- also satisfies a + naive `name.startswith("Pulumi.") and name.endswith(".yaml")`, because + the two literals overlap on the single "." they share: all 11 + characters of `"Pulumi.yaml"` are covered by `"Pulumi."` (the first 7) + and by `".yaml"` (the last 5) at the same time, with nothing left over + for a stack name to occupy. Requiring a non-empty slice between the two + literals is what tells `Pulumi.yaml` apart from `Pulumi.dev.yaml`. + """ + name = PurePosixPath(path).name + if not (name.startswith(_STACK_CONFIG_PREFIX) and name.endswith(_STACK_CONFIG_SUFFIX)): + return False + stack = name[len(_STACK_CONFIG_PREFIX) : -len(_STACK_CONFIG_SUFFIX)] + return bool(stack) + + +def _read_staged_blob(path: str) -> str: + """The staged (index) content of `path`, decoded as UTF-8 -- never the + working tree. `git show ":"` reads stage 0 of the index exactly + as it stands right now, so a working-tree edit made after `git add` + (or never staged at all) cannot hide a credential that is already + staged, and cannot be mistaken for one that is not. + + Raises `CheckError` if git cannot produce the blob at all -- most + notably a path with no stage-0 entry, i.e. unmerged, though + `cmd_pre_commit`'s dedicated check should already have refused before + this is ever reached for such a path -- or if the bytes it does + produce are not valid UTF-8. + """ + raw = _run_git(["show", f":{path}"]) + try: + return raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise CheckError(f"{path}: staged content is not valid UTF-8: {exc}") from exc + + +def _scan_staged_config(path: str, check: CheckConfig) -> list[str]: + """Content-level scan of `path`'s *staged* blob. + + Mirrors `check_config.scan_file` exactly, except the source of text is + `_read_staged_blob` (the index) rather than `Path.read_text()` (the + working tree): both parse with `yaml.safe_load` and hand the resulting + document straight to `find_plaintext_credentials`, and both wrap + every way that can fail in `CheckError` -- unreadable/undecodable + content, invalid YAML (via `describe_yaml_error`, which redacts what + PyYAML would otherwise interpolate into its own message), or a + document that is not a mapping. + """ + text = _read_staged_blob(path) + try: + document = yaml.safe_load(text) + except yaml.YAMLError as exc: + raise CheckError(f"{path}: invalid YAML: {describe_yaml_error(exc)}") from exc + try: + return find_plaintext_credentials(document, check) + except DocumentError as exc: + raise CheckError(f"{path}: {exc}") from exc + + +def _load_check_policy() -> CheckConfig: + """The `[check]` policy for the repository containing the current + working directory, or the built-in defaults when there is none -- + absence is a normal state (see `config.py`), not an error. + + The same three lines as `check_config`'s private helper of the same + name, built entirely from this module's own public imports + (`find_repo_config`, `load_config`); not worth an inter-command import + for something this small. + """ + config_path = find_repo_config() + if config_path is None: + return CheckConfig() + return load_config(config_path).check + + +def cmd_pre_commit(_args: argparse.Namespace) -> int: + """Entry point for `stackward pre-commit` -- the hook body's whole job. + + Exit codes, never conflated (see the module docstring in + `check_config.py` for why this distinction matters to a later, + fail-closed refusal): **0** clean; **1** a credential was found by the + heuristic net, or a Pulumi state export was staged (refused outright, + since a state export is credentials by construction, not by finding); + **2** the check could not run at all -- an unmerged index entry, a git + command failing, staged content that is not decodable or not valid + YAML, a document that is not a mapping, an invalid `.stackward.toml`, + or an unanticipated exception. Python's own default for an uncaught + exception is exit 1, which here would misreport "a credential was + found" for a file this command never actually finished evaluating -- + every per-file scan below is wrapped accordingly. + + Checks run in this fixed order, each a hard gate before the next: + + 1. Unmerged index entries. Not a content check at all -- a + precondition on whether the index can be trusted enough to read. + `--diff-filter=ACMR`, used for everything below, silently *excludes* + unmerged paths (git reports them as bare status `U`, which matches + neither `A`, `C`, `M` nor `R`), so without this dedicated check an + unmerged file would never reach either check below and this command + would report "clean" for a commit git itself already refuses to + allow. Checked for *any* unmerged path, not only ones that look + like a stack config or state export: git's own refusal to commit + ("Committing is not possible because you have unmerged files") is + likewise unconditional on which path is unmerged, and this command + can be invoked directly, bypassing that refusal. + 2. Staged Pulumi state exports (`state.json`, `*.stack-export.json`) -- + refused outright, before the credential scan below even starts. + 3. The heuristic net, over every staged `Pulumi..yaml`. + + Never prints a credential value: a finding is reported only by its + rendered config path, exactly like `check-config`. + """ + try: + unmerged = _staged_paths("U") + except CheckError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + if unmerged: + for path in sorted(unmerged): + print(f"error: {path}: unresolved merge conflict in the index", file=sys.stderr) + print("error: resolve conflicts and re-stage before committing", file=sys.stderr) + return 2 + + try: + staged = _staged_paths("ACMR") + except CheckError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + state_exports = sorted(path for path in staged if _is_state_export(path)) + if state_exports: + print( + "State exports (state.json, *.stack-export.json) hold every " + "resource input a stack has, credentials included, in plaintext." + ) + for path in state_exports: + print(f"{path}: Pulumi state export staged -- refused") + print(f" fix: git restore --staged '{path}'") + print("git commit --no-verify bypasses this hook.") + return 1 + + try: + check = _load_check_policy() + except ConfigError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + stack_configs = sorted(path for path in staged if _is_pulumi_stack_config(path)) + + findings: list[tuple[str, str]] = [] + errored = False + for path in stack_configs: + try: + found = _scan_staged_config(path, check) + except CheckError as exc: + print(f"error: {exc}", file=sys.stderr) + errored = True + continue + except Exception as exc: # noqa: BLE001 - fail closed on anything unanticipated + print( + f"error: {path}: could not check ({type(exc).__name__})", + file=sys.stderr, + ) + errored = True + continue + findings.extend((path, finding) for finding in found) + + if findings: + for file_path, finding_path in sorted(findings): + print(f"{file_path}: plaintext credential at '{finding_path}'") + print(f" fix: pulumi config set --secret --path '{finding_path}'") + print("git commit --no-verify bypasses this hook.") + + if errored: + return 2 + return 1 if findings else 0 diff --git a/tests/test_heuristic.py b/tests/test_heuristic.py index 3491536..a8b6e76 100644 --- a/tests/test_heuristic.py +++ b/tests/test_heuristic.py @@ -371,7 +371,7 @@ def test_yaml_error_redaction_removes_any_percent_r_escape_character(trigger_cha precisely what a test quantified over characters, rather than fixed to one, is for.""" exc = _marked_yaml_error(f"found unknown escape character {trigger_char!r}") - message = check_config_module._describe_yaml_error(exc) + message = check_config_module.describe_yaml_error(exc) assert trigger_char not in message assert "" in message assert "found unknown escape character" in message @@ -386,7 +386,7 @@ def test_yaml_error_redaction_removes_any_percent_r_reserved_character(trigger_c any token".""" problem = f"found character {trigger_char!r} that cannot start any token" exc = _marked_yaml_error(problem) - message = check_config_module._describe_yaml_error(exc) + message = check_config_module.describe_yaml_error(exc) assert trigger_char not in message assert "" in message assert "found character" in message diff --git a/tests/test_pre_commit.py b/tests/test_pre_commit.py new file mode 100644 index 0000000..cbdecd8 --- /dev/null +++ b/tests/test_pre_commit.py @@ -0,0 +1,494 @@ +"""Tests for `pre-commit` -- the gate run as a git hook body over staged +content. + +Every test here uses a real temporary git repository, created with `git +init`. This command's entire reason to exist is a handful of git behaviours +that are easy to get subtly wrong (a rename-plus-edit staging as `R`, an +unmerged path vanishing from a plain `--diff-filter=ACMR` listing, a +non-ASCII or space-containing path being quoted without `-z`) -- mocking +git would hide exactly the bugs this suite exists to catch. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from stackward.cli import main +from stackward.commands import pre_commit as pre_commit_module + +# --------------------------------------------------------------------------- +# Real-git-repository fixtures and helpers. +# --------------------------------------------------------------------------- + + +def _git(repo: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", *args], cwd=repo, capture_output=True, text=True, check=True + ) + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + """A real, minimal git repository with one initial commit -- so every + `git diff --cached` below has a HEAD to diff against.""" + path = tmp_path / "repo" + path.mkdir() + _git(path, "init", "-q") + _git(path, "config", "user.email", "test@example.com") + _git(path, "config", "user.name", "Test") + _git(path, "commit", "-q", "--allow-empty", "-m", "init") + return path + + +def write(repo: Path, name: str, content: str) -> Path: + target = repo / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content) + return target + + +def write_bytes(repo: Path, name: str, content: bytes) -> Path: + target = repo / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + return target + + +def stage(repo: Path, *names: str) -> None: + _git(repo, "add", *names) + + +def run_pre_commit(repo: Path, monkeypatch) -> int: + monkeypatch.chdir(repo) + return main(["pre-commit"]) + + +def numbered_yaml(count: int = 200) -> str: + """A YAML mapping large enough that git's default rename-similarity + threshold (50%) still classifies a one-line edit as a rename, not a + delete-plus-add.""" + return "\n".join(f"key{i}: value{i}" for i in range(count)) + "\n" + + +# --------------------------------------------------------------------------- +# Clean and simple credential-finding cases. +# --------------------------------------------------------------------------- + + +def test_clean_stack_config_exits_0_with_no_output(repo, monkeypatch, capsys): + write(repo, "Pulumi.dev.yaml", "name: myproject\nruntime: nodejs\n") + stage(repo, "Pulumi.dev.yaml") + assert run_pre_commit(repo, monkeypatch) == 0 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + +def test_plaintext_credential_exits_1_with_finding_and_remediation( + repo, monkeypatch, capsys +): + write(repo, "Pulumi.dev.yaml", "config:\n myproject:dbPassword: hunter2\n") + stage(repo, "Pulumi.dev.yaml") + assert run_pre_commit(repo, monkeypatch) == 1 + out = capsys.readouterr().out + assert "Pulumi.dev.yaml: plaintext credential at 'config.myproject:dbPassword'" in out + assert "pulumi config set --secret --path 'config.myproject:dbPassword'" in out + assert "--no-verify" in out + # The value itself must never appear anywhere in the output. + assert "hunter2" not in out + + +# --------------------------------------------------------------------------- +# R matters: a rename plus an edit stages as `R`, not `A`/`M`. +# --------------------------------------------------------------------------- + + +def test_rename_plus_edit_staged_as_r_is_caught(repo, monkeypatch, capsys): + """The exact bug `--diff-filter=ACMR` (not `ACM`) exists to prevent: + renaming a stack config to a new name while also introducing a + credential stages as a single `R` change, not `A`+`D` or `M`. Omitting + `R` from the filter would let this change bypass the gate entirely.""" + write(repo, "Pulumi.old.yaml", numbered_yaml()) + stage(repo, "Pulumi.old.yaml") + _git(repo, "commit", "-q", "-m", "add old config") + + _git(repo, "mv", "Pulumi.old.yaml", "Pulumi.dev.yaml") + edited = (repo / "Pulumi.dev.yaml").read_text().replace( + "key5: value5", "password: hunter2" + ) + write(repo, "Pulumi.dev.yaml", edited) + stage(repo, "Pulumi.dev.yaml") + + # Prove the premise: git really did record this as a rename, not a + # plain modify or an add+delete pair. Without this assertion the test + # could pass for the wrong reason if git's rename heuristic ever + # stopped firing here. + status = _git(repo, "diff", "--cached", "--name-status").stdout + assert status.startswith("R") + + assert run_pre_commit(repo, monkeypatch) == 1 + out = capsys.readouterr().out + assert "Pulumi.dev.yaml: plaintext credential at 'password'" in out + + +def test_diff_filter_without_r_would_have_missed_the_renamed_file(repo, monkeypatch): + """Direct proof of why `R` is in the filter: with it excluded, the + renamed-and-edited path is invisible to the staged-file listing this + command builds everything else on top of.""" + write(repo, "Pulumi.old.yaml", numbered_yaml()) + stage(repo, "Pulumi.old.yaml") + _git(repo, "commit", "-q", "-m", "add old config") + _git(repo, "mv", "Pulumi.old.yaml", "Pulumi.dev.yaml") + edited = (repo / "Pulumi.dev.yaml").read_text().replace( + "key5: value5", "password: hunter2" + ) + write(repo, "Pulumi.dev.yaml", edited) + stage(repo, "Pulumi.dev.yaml") + + monkeypatch.chdir(repo) + assert pre_commit_module._staged_paths("ACM") == [] + assert pre_commit_module._staged_paths("ACMR") == ["Pulumi.dev.yaml"] + + +# --------------------------------------------------------------------------- +# Pulumi state exports: refused outright, before any content check. +# --------------------------------------------------------------------------- + + +def test_staged_state_json_is_refused_before_any_scan(repo, monkeypatch, capsys): + write(repo, "state.json", '{"deployment": {"resources": []}}') + stage(repo, "state.json") + assert run_pre_commit(repo, monkeypatch) == 1 + out = capsys.readouterr().out + assert "state.json" in out + assert "refused" in out + assert "--no-verify" in out + + +def test_staged_stack_export_json_suffix_is_refused(repo, monkeypatch, capsys): + write(repo, "backups/mydeploy.stack-export.json", "{}") + stage(repo, "backups/mydeploy.stack-export.json") + assert run_pre_commit(repo, monkeypatch) == 1 + out = capsys.readouterr().out + assert "backups/mydeploy.stack-export.json" in out + + +def test_state_export_refusal_wins_over_a_broken_pulumi_config(repo, monkeypatch, capsys): + """"Before any other check": a state export staged alongside a stack + config that would otherwise fail to parse must still exit 1 (refused), + never 2 (as the parse failure alone would) -- the state-export rule is + the very first gate, not one gate among several evaluated together.""" + write(repo, "state.json", "{}") + write(repo, "Pulumi.dev.yaml", "key: [unclosed\n") + stage(repo, "state.json", "Pulumi.dev.yaml") + assert run_pre_commit(repo, monkeypatch) == 1 + assert "state.json" in capsys.readouterr().out + + +def test_state_export_refusal_needs_no_valid_repo_config(repo, monkeypatch, capsys): + """The outright refusal happens before `.stackward.toml` is even + loaded: a state export staged in a repository whose policy file is + broken must still exit 1 (refused), never 2 (as the broken config + alone would if a real credential scan needed to run). The broken file + only needs to be on disk -- `find_repo_config` reads the working tree, + not the index -- so it is deliberately not staged here.""" + write(repo, ".stackward.toml", "this is not valid toml [[[") + write(repo, "state.json", "{}") + stage(repo, "state.json") + assert run_pre_commit(repo, monkeypatch) == 1 + assert "state.json" in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# Matching `Pulumi..yaml` by prefix/suffix, not a character class. +# --------------------------------------------------------------------------- + + +def test_project_file_pulumi_yaml_is_not_a_stack_config(repo, monkeypatch, capsys): + """`Pulumi.yaml` (no stack name) is the *project* file, not a stack + config -- a naive `startswith("Pulumi.") and endswith(".yaml")` also + matches it, because the two literals overlap on the shared `.`.""" + write(repo, "Pulumi.yaml", "name: myproject\npassword: hunter2\n") + stage(repo, "Pulumi.yaml") + assert run_pre_commit(repo, monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +def test_stack_name_containing_dots_is_matched(repo, monkeypatch, capsys): + write(repo, "Pulumi.prod.eu.yaml", "password: hunter2\n") + stage(repo, "Pulumi.prod.eu.yaml") + assert run_pre_commit(repo, monkeypatch) == 1 + assert "Pulumi.prod.eu.yaml: plaintext credential at 'password'" in capsys.readouterr().out + + +@pytest.mark.parametrize( + "basename", + [ + "Pulumi.dev.yaml", + "Pulumi.prod.eu.yaml", + # Stack name is a single "." -- "Pulumi." + "." + ".yaml". + "Pulumi...yaml", + ], +) +def test_is_pulumi_stack_config_matches_a_non_empty_stack(basename): + assert pre_commit_module._is_pulumi_stack_config(basename) is True + + +@pytest.mark.parametrize( + "basename", + [ + "Pulumi.yaml", + "NotPulumi.dev.yaml", + "Pulumi.dev.yml", + "readme.md", + # "Pulumi." + "" + ".yaml": the prefix and suffix abut with no + # stack name between them at all -- the empty-stack case, same as + # "Pulumi.yaml" but spelled with the shared "." written out twice + # instead of once. + "Pulumi..yaml", + ], +) +def test_is_pulumi_stack_config_rejects_non_stack_names(basename): + assert pre_commit_module._is_pulumi_stack_config(basename) is False + + +@pytest.mark.parametrize( + "path", ["state.json", "backups/state.json", "x.stack-export.json"] +) +def test_is_state_export_matches(path): + assert pre_commit_module._is_state_export(path) is True + + +@pytest.mark.parametrize( + "path", ["state.json.bak", "notstate.json", "Pulumi.dev.yaml"] +) +def test_is_state_export_rejects_lookalikes(path): + assert pre_commit_module._is_state_export(path) is False + + +# --------------------------------------------------------------------------- +# Staged content, not the working tree. +# --------------------------------------------------------------------------- + + +def test_staged_credential_is_caught_even_after_the_working_tree_is_cleaned( + repo, monkeypatch, capsys +): + """The defining behaviour of this command: it reads `git show + ":"`, so an edit made *after* staging (here, one that removes the + credential from the working tree entirely) must not let the + already-staged credential through.""" + write(repo, "Pulumi.dev.yaml", "config:\n myproject:dbPassword: hunter2\n") + stage(repo, "Pulumi.dev.yaml") + # Overwrite the working tree with a clean version, without staging it. + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + + assert run_pre_commit(repo, monkeypatch) == 1 + out = capsys.readouterr().out + assert "plaintext credential at 'config.myproject:dbPassword'" in out + + +def test_unstaged_credential_in_the_working_tree_does_not_block(repo, monkeypatch, capsys): + """The inverse of the test above: a credential sitting only in the + working tree, never staged, must not fail a commit of the clean staged + content -- this command checks the index, not the working tree.""" + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(repo, "Pulumi.dev.yaml") + # Introduce a credential in the working tree without staging it. + write(repo, "Pulumi.dev.yaml", "password: hunter2\n") + + assert run_pre_commit(repo, monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +# --------------------------------------------------------------------------- +# Paths with a space or non-ASCII characters, via -z / NUL splitting. +# --------------------------------------------------------------------------- + + +def test_filename_containing_a_space_is_checked(repo, monkeypatch, capsys): + write(repo, "Pulumi.my stack.yaml", "password: hunter2\n") + stage(repo, "Pulumi.my stack.yaml") + assert run_pre_commit(repo, monkeypatch) == 1 + out = capsys.readouterr().out + assert "Pulumi.my stack.yaml: plaintext credential at 'password'" in out + + +def test_filename_containing_non_ascii_is_checked(repo, monkeypatch, capsys): + write(repo, "Pulumi.tést.yaml", "password: hunter2\n") + stage(repo, "Pulumi.tést.yaml") + assert run_pre_commit(repo, monkeypatch) == 1 + out = capsys.readouterr().out + assert "Pulumi.tést.yaml: plaintext credential at 'password'" in out + + +# --------------------------------------------------------------------------- +# Unmerged index entries: an error, not a pass. +# --------------------------------------------------------------------------- + + +def test_unmerged_index_entry_exits_2_even_with_an_unrelated_clean_file_staged( + repo, monkeypatch, capsys +): + """A real merge conflict on a file unrelated to any Pulumi config + still refuses the commit outright: git itself would never let a + `git commit` reach this hook while any path is unmerged, and this + command's own listing (`--diff-filter=ACMR`) silently excludes + unmerged paths, so without a dedicated check, a clean Pulumi config + staged alongside an unresolved conflict elsewhere would wrongly read + as clean.""" + write(repo, "f.txt", "a\n") + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(repo, "f.txt", "Pulumi.dev.yaml") + _git(repo, "commit", "-q", "-m", "base") + + _git(repo, "checkout", "-q", "-b", "other") + write(repo, "f.txt", "from-other-branch\n") + _git(repo, "commit", "-q", "-am", "change on other") + + _git(repo, "checkout", "-q", "-") + write(repo, "f.txt", "from-main-branch\n") + _git(repo, "commit", "-q", "-am", "change on main") + + subprocess.run( + ["git", "merge", "other"], cwd=repo, capture_output=True, text=True + ) + status = _git(repo, "status", "--porcelain").stdout + assert "UU f.txt" in status + + assert run_pre_commit(repo, monkeypatch) == 2 + err = capsys.readouterr().err + assert "f.txt" in err + assert "unmerged" in err.lower() or "conflict" in err.lower() + + +def test_diff_filter_acmr_excludes_unmerged_paths(repo, monkeypatch): + """The assumption `cmd_pre_commit`'s unmerged check depends on: a + plain `--diff-filter=ACMR` listing does not surface an unmerged path + at all (git reports it as bare status `U`), so relying on that listing + alone -- without the dedicated `U` check -- would silently pass over + it rather than error.""" + write(repo, "f.txt", "a\n") + stage(repo, "f.txt") + _git(repo, "commit", "-q", "-m", "base") + _git(repo, "checkout", "-q", "-b", "other") + write(repo, "f.txt", "from-other\n") + _git(repo, "commit", "-q", "-am", "c1") + _git(repo, "checkout", "-q", "-") + write(repo, "f.txt", "from-main\n") + _git(repo, "commit", "-q", "-am", "c2") + subprocess.run(["git", "merge", "other"], cwd=repo, capture_output=True, text=True) + + monkeypatch.chdir(repo) + assert pre_commit_module._staged_paths("ACMR") == [] + assert pre_commit_module._staged_paths("U") == ["f.txt"] + + +# --------------------------------------------------------------------------- +# Could-not-run conditions: exit 2, never a silent pass or exit 1. +# --------------------------------------------------------------------------- + + +def test_invalid_yaml_exits_2_and_never_leaks_the_credential(repo, monkeypatch, capsys): + write(repo, "Pulumi.dev.yaml", 'password: "hunter2: not-valid-yaml\n') + stage(repo, "Pulumi.dev.yaml") + assert run_pre_commit(repo, monkeypatch) == 2 + err = capsys.readouterr().err + assert "hunter2" not in err + + +def test_non_mapping_document_exits_2(repo, monkeypatch, capsys): + write(repo, "Pulumi.dev.yaml", "- a\n- b\n") + stage(repo, "Pulumi.dev.yaml") + assert run_pre_commit(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "Pulumi.dev.yaml" in captured.err + + +def test_non_utf8_staged_content_exits_2(repo, monkeypatch, capsys): + write_bytes(repo, "Pulumi.dev.yaml", b"password: \xff\xfe not valid utf8\n") + stage(repo, "Pulumi.dev.yaml") + assert run_pre_commit(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "Pulumi.dev.yaml" in captured.err + + +def test_unanticipated_scan_exception_exits_2_not_1(repo, monkeypatch, capsys): + """Python's default exit code for an uncaught exception is 1 -- the + code reserved exclusively for "a credential was found". A bug + somewhere unrelated to a real finding must still map to 2.""" + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(repo, "Pulumi.dev.yaml") + + def boom(_path, _check): + raise RuntimeError("unanticipated failure, not a CheckError") + + monkeypatch.setattr(pre_commit_module, "_scan_staged_config", boom) + assert run_pre_commit(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "Pulumi.dev.yaml" in captured.err + assert "RuntimeError" in captured.err + + +def test_a_real_finding_still_prints_even_when_another_file_errors( + repo, monkeypatch, capsys +): + write(repo, "Pulumi.dev.yaml", 'password: "hunter2"\n') + write(repo, "Pulumi.broken.yaml", "key: [unclosed\n") + stage(repo, "Pulumi.dev.yaml", "Pulumi.broken.yaml") + assert run_pre_commit(repo, monkeypatch) == 2 + out = capsys.readouterr().out + assert "Pulumi.dev.yaml: plaintext credential at 'password'" in out + + +def test_git_show_failure_exits_2(repo, monkeypatch, capsys): + """A `git show` failure that is not "unmerged" -- e.g. the object + somehow being gone -- must still be a could-not-run error, converted + by `_run_git` itself, not left to propagate as an uncaught + `CalledProcessError`. + + The failure injected here is a genuine non-zero exit from a real + subprocess call (`subprocess.run` itself is replaced, not `_run_git`), + so this exercises `_run_git`'s own conversion of a failing `git show` + into `CheckError` -- unlike asserting on a pre-raised `CheckError` + directly, which would prove nothing about that conversion. + """ + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(repo, "Pulumi.dev.yaml") + + real_run = subprocess.run + + def failing_run(args, **kwargs): + if args[:2] == ["git", "show"]: + return subprocess.CompletedProcess( + args, returncode=128, stdout=b"", stderr=b"fatal: simulated failure\n" + ) + return real_run(args, **kwargs) + + monkeypatch.setattr(subprocess, "run", failing_run) + assert run_pre_commit(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "simulated failure" in captured.err + + +def test_pre_commit_is_registered_and_dispatches(repo, monkeypatch): + """Wiring only -- the rules `pre-commit` enforces are covered above.""" + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(repo, "Pulumi.dev.yaml") + assert run_pre_commit(repo, monkeypatch) == 0 + + +def test_unexpected_argument_is_a_usage_error_exiting_2(): + """`pre-commit` takes no arguments -- it always operates on the current + repository's index. argparse's own usage-error exit code (2) is the + same code reserved for every other could-not-run reason, never 1.""" + with pytest.raises(SystemExit) as exit_info: + main(["pre-commit", "unexpected-argument"]) + assert exit_info.value.code == 2 From 95874f081e1c8ff1ad2acc3604f309b3c92805ba Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 03:46:08 -0700 Subject: [PATCH 12/44] fix: close two fail-open gaps found in review of the pre-commit gate Addresses review findings on Task 4 (`pre-commit`): 1. Important: `cmd_check_config` and `cmd_pre_commit` each had call sites outside their per-file scan loop -- policy loading in both, plus the staged-file listing in `cmd_pre_commit` -- guarded only by `except ConfigError`/`except CheckError`. A different, unanticipated exception from either (concretely: `find_repo_config` walks parent directories with bare `.is_file()`/`.exists()` calls, so a `PermissionError` there is a plain `OSError`) would propagate out of `main()` uncaught and exit 1 by Python's default -- misreporting "credential found" for an invocation that never got far enough to check anything. Fixed with one shared `fail_closed` decorator (in `check_config.py`, applied to both `cmd_check_config` and `cmd_pre_commit`) rather than patching three call sites individually and leaving the next command built this way exposed by default. It is the outer boundary the per-file loops' own catch-alls don't reach. 2. Ruling: `--diff-filter=ACMR` (the brief's own literal text) excludes `T` (type-change). Converting an already-tracked `Pulumi..yaml` into a symlink stages as `T`, not `A`/`M`/`R` -- verified empirically -- so it vanished from the staged-file listing exactly the way an unmerged path vanishes without `U`, and the commit exited 0. Fixed: `_staged_paths("ACMR")` -> `_staged_paths("ACMRT")`. Both fixes reproduced as real regressions before being fixed (decorator temporarily removed / filter temporarily reverted, confirming the new tests fail for the exact reason described) and restored once confirmed. 6 new tests: 3 for the fail_closed boundary (a policy-load failure for each command, plus 2 direct unit tests of the decorator itself), 3 for the diff-filter gap (a tracked config converted to a symlink exits 2, plus a direct proof that ACMR alone misses it while ACMRT does not). 195 passed, 0 failed (`uv run pytest`). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/commands/check_config.py | 54 ++++++++++++++++++ src/stackward/commands/pre_commit.py | 64 ++++++++++++++-------- tests/test_heuristic.py | 67 ++++++++++++++++++++++- tests/test_pre_commit.py | 76 ++++++++++++++++++++++++++ 4 files changed, 238 insertions(+), 23 deletions(-) diff --git a/src/stackward/commands/check_config.py b/src/stackward/commands/check_config.py index 29f131e..2f0a78e 100644 --- a/src/stackward/commands/check_config.py +++ b/src/stackward/commands/check_config.py @@ -11,8 +11,10 @@ from __future__ import annotations import argparse +import functools import re import sys +from collections.abc import Callable from pathlib import Path import yaml @@ -42,6 +44,51 @@ class CheckError(Exception): would have to be kept consistent with this one by hand.""" +def fail_closed( + command: Callable[[argparse.Namespace], int], +) -> Callable[[argparse.Namespace], int]: + """Wrap a command entry point so any exception it does not already + handle itself becomes exit code 2, never left to propagate. + + Both `cmd_check_config` and `cmd_pre_commit` already wrap each + per-file scan individually — see either function's own per-file + `except CheckError` / catch-all `except Exception` pair — but that + only protects the scan step itself. Policy loading + (`_load_check_policy`, built on `find_repo_config`/`load_config`) and, + in `pre_commit`, the staged-file listing (`_staged_paths`) both run + *outside* that loop, each guarded only by `except ConfigError` or + `except CheckError` respectively. A review of this module found the + gap concretely: `find_repo_config` walks upward through parent + directories with bare `.is_file()`/`.exists()` calls and no + try/except of its own, so a `PermissionError` on a non-traversable + parent surfaces as a plain `OSError` — neither `ConfigError` nor + `CheckError` — which would otherwise escape both commands uncaught + and exit 1 by Python's own default: the code this tool reserves + exclusively for "a credential was found", misreporting exactly that + for an invocation that never got far enough to check anything. + + This decorator is the outer boundary that catches precisely what the + inner, per-file guards do not, from one definition shared by both + commands rather than two independently-maintained copies of the same + try/except (or, worse, three or more scattered call-site patches that + would leave the next command built this way exposed by default). + Never prints `str(exc)` — the exception type only — for the same + reason `describe_yaml_error` never echoes `str(exc)` on a YAML error: + an unanticipated exception's text is not something this boundary can + vouch for as free of file content. + """ + + @functools.wraps(command) + def wrapped(args: argparse.Namespace) -> int: + try: + return command(args) + except Exception as exc: # noqa: BLE001 - the fail-closed boundary itself + print(f"error: could not run ({type(exc).__name__})", file=sys.stderr) + return 2 + + return wrapped + + def scan_file(path: Path, check: CheckConfig) -> list[str]: """Thin file-path wrapper over `find_plaintext_credentials`. @@ -134,6 +181,7 @@ def _load_check_policy() -> CheckConfig: return load_config(config_path).check +@fail_closed def cmd_check_config(args: argparse.Namespace) -> int: """Entry point for `stackward check-config FILE...`. @@ -156,6 +204,12 @@ def cmd_check_config(args: argparse.Namespace) -> int: `str(exc)`, for the same reason `describe_yaml_error` avoids it: an unanticipated exception's text is not something this module can vouch for as free of file content. + + That per-file guard only covers the scan loop. Anything policy-loading + raises other than `ConfigError` — this function's own `except + ConfigError` below only catches that one type — is caught instead by + the `@fail_closed` decorator wrapping this function, for the same + "never exit 1 for a reason other than a real finding" guarantee. """ try: check = _load_check_policy() diff --git a/src/stackward/commands/pre_commit.py b/src/stackward/commands/pre_commit.py index 66e4d84..9abd727 100644 --- a/src/stackward/commands/pre_commit.py +++ b/src/stackward/commands/pre_commit.py @@ -63,7 +63,7 @@ from ..config import CheckConfig, ConfigError, find_repo_config, load_config from ..nets.heuristic import DocumentError, find_plaintext_credentials -from .check_config import CheckError, describe_yaml_error +from .check_config import CheckError, describe_yaml_error, fail_closed _STATE_EXPORT_NAME = "state.json" _STATE_EXPORT_SUFFIX = ".stack-export.json" @@ -106,15 +106,25 @@ def _staged_paths(diff_filter: str) -> list[str]: path containing a space, a newline, or a non-ASCII byte, which would corrupt exactly the paths this command exists to protect. - Called with `"ACMR"` for the files actually being committed (`R` - matters: a rename plus an edit stages as `R`, and a filter that - omitted it would let that change bypass every check below) and, - separately, with `"U"` to find anything unmerged. Deliberately never - called with the two combined (`"ACMRU"`): an unmerged path must be - caught by the dedicated check in `cmd_pre_commit` and refused before - anything else runs, not folded into the "files to scan" list, where - finding it there could be mistaken for having already decided what to - do with it. + Called with `"ACMRT"` for the files actually being committed, and + separately with `"U"` to find anything unmerged. + + `R` matters: a rename plus an edit stages as `R`, and a filter that + omitted it would let that change bypass every check below. `T` + (type-change) matters for the same reason, discovered in review of + the brief's own literal `"ACMR"`: converting an *already-tracked* + `Pulumi..yaml` into a symlink stages as `T`, not `A`/`M`/`R` -- + without `T`, that file would vanish from this listing exactly the way + an unmerged path does, and the credential scan below would never see + it at all. A *new* symlink stages as `A`, which `"ACMRT"` already + covered even before `T` was added; `T` closes the gap for a symlink + replacing a file git was already tracking as ordinary content. + + Deliberately never called combined with `"U"` (`"ACMRTU"`): an + unmerged path must be caught by the dedicated check in + `cmd_pre_commit` and refused before anything else runs, not folded + into the "files to scan" list, where finding it there could be + mistaken for having already decided what to do with it. """ raw = _run_git( ["diff", "--cached", "--name-only", "-z", f"--diff-filter={diff_filter}"] @@ -217,6 +227,7 @@ def _load_check_policy() -> CheckConfig: return load_config(config_path).check +@fail_closed def cmd_pre_commit(_args: argparse.Namespace) -> int: """Entry point for `stackward pre-commit` -- the hook body's whole job. @@ -231,22 +242,31 @@ def cmd_pre_commit(_args: argparse.Namespace) -> int: or an unanticipated exception. Python's own default for an uncaught exception is exit 1, which here would misreport "a credential was found" for a file this command never actually finished evaluating -- - every per-file scan below is wrapped accordingly. + every per-file scan below is wrapped accordingly, and the `@fail_closed` + decorator above is the outer boundary that catches anything else this + function does not: policy loading (`_load_check_policy`) and the + staged-file listing (`_staged_paths`) both run *outside* the per-file + loop, guarded only by `except ConfigError` / `except CheckError` + respectively, so a different, unanticipated exception from either -- + a bare `OSError` from `find_repo_config` walking through an + unreadable parent directory, say -- needs `@fail_closed` to avoid + escaping uncaught and exiting 1 by Python's own default. Checks run in this fixed order, each a hard gate before the next: 1. Unmerged index entries. Not a content check at all -- a precondition on whether the index can be trusted enough to read. - `--diff-filter=ACMR`, used for everything below, silently *excludes* - unmerged paths (git reports them as bare status `U`, which matches - neither `A`, `C`, `M` nor `R`), so without this dedicated check an - unmerged file would never reach either check below and this command - would report "clean" for a commit git itself already refuses to - allow. Checked for *any* unmerged path, not only ones that look - like a stack config or state export: git's own refusal to commit - ("Committing is not possible because you have unmerged files") is - likewise unconditional on which path is unmerged, and this command - can be invoked directly, bypassing that refusal. + `--diff-filter=ACMRT`, used for everything below, silently + *excludes* unmerged paths (git reports them as bare status `U`, + which matches none of `A`, `C`, `M`, `R`, `T`), so without this + dedicated check an unmerged file would never reach either check + below and this command would report "clean" for a commit git + itself already refuses to allow. Checked for *any* unmerged path, + not only ones that look like a stack config or state export: git's + own refusal to commit ("Committing is not possible because you + have unmerged files") is likewise unconditional on which path is + unmerged, and this command can be invoked directly, bypassing that + refusal. 2. Staged Pulumi state exports (`state.json`, `*.stack-export.json`) -- refused outright, before the credential scan below even starts. 3. The heuristic net, over every staged `Pulumi..yaml`. @@ -266,7 +286,7 @@ def cmd_pre_commit(_args: argparse.Namespace) -> int: return 2 try: - staged = _staged_paths("ACMR") + staged = _staged_paths("ACMRT") except CheckError as exc: print(f"error: {exc}", file=sys.stderr) return 2 diff --git a/tests/test_heuristic.py b/tests/test_heuristic.py index a8b6e76..4f15e0f 100644 --- a/tests/test_heuristic.py +++ b/tests/test_heuristic.py @@ -14,7 +14,7 @@ from stackward.cli import main from stackward.commands import check_config as check_config_module -from stackward.commands.check_config import CheckError, scan_file +from stackward.commands.check_config import CheckError, fail_closed, scan_file from stackward.config import CheckConfig from stackward.nets.heuristic import DocumentError, find_plaintext_credentials @@ -256,6 +256,46 @@ def test_empty_document_raises(): find_plaintext_credentials(None, CheckConfig()) +# --------------------------------------------------------------------------- +# fail_closed — the outer boundary shared by check-config and pre-commit. +# --------------------------------------------------------------------------- + + +def test_fail_closed_converts_an_unhandled_exception_to_exit_2(capsys): + """The property `fail_closed` exists for, isolated from either + command: an exception the wrapped function does not itself catch + becomes exit 2, never left to propagate (which would exit 1 by + Python's own default — the code reserved for "a credential was + found").""" + + @fail_closed + def boom(_args): + raise OSError("simulated failure unrelated to any file's content") + + assert boom(None) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "OSError" in captured.err + # Only the exception's type name is reported, never its own text — + # the same reasoning `describe_yaml_error` uses for never trusting + # `str(exc)`: an unanticipated exception's message is not something + # this boundary can vouch for as free of file content. + assert "simulated failure unrelated to any file's content" not in captured.err + + +def test_fail_closed_does_not_interfere_with_a_normal_return(capsys): + """A wrapped function that returns normally is unaffected — the + decorator only ever intervenes on an exception.""" + + @fail_closed + def clean(_args): + print("normal output") + return 0 + + assert clean(None) == 0 + assert "normal output" in capsys.readouterr().out + + # --------------------------------------------------------------------------- # scan_file / check-config — the file-path wrapper and the CLI it backs. # --------------------------------------------------------------------------- @@ -486,6 +526,31 @@ def boom(_path, _check): assert capsys.readouterr().out == "" +def test_check_config_exits_2_when_policy_loading_raises_an_unexpected_error( + tmp_path, monkeypatch, capsys +): + """A bare exception from policy loading -- not `ConfigError`, which + `cmd_check_config`'s own `except ConfigError` already handles -- must + not escape uncaught. Concretely: `find_repo_config` walks upward + through parent directories with plain `.is_file()`/`.exists()` calls + and no try/except of its own, so a `PermissionError` on a + non-traversable parent would surface as a bare `OSError`. Without the + `@fail_closed` decorator on `cmd_check_config`, that would propagate + out of `main()` entirely and exit 1 by Python's own default -- + misreporting "credential found" for an invocation that never got as + far as loading policy, let alone scanning a file.""" + path = write_yaml(tmp_path, "Pulumi.dev.yaml", "name: myproject\n") + + def boom(): + raise OSError("simulated permission error walking parent directories") + + monkeypatch.setattr("stackward.commands.check_config.find_repo_config", boom) + assert main(["check-config", str(path)]) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "OSError" in captured.err + + def test_check_config_prints_a_real_finding_even_when_another_file_errors( tmp_path, capsys ): diff --git a/tests/test_pre_commit.py b/tests/test_pre_commit.py index cbdecd8..2931fc1 100644 --- a/tests/test_pre_commit.py +++ b/tests/test_pre_commit.py @@ -153,6 +153,58 @@ def test_diff_filter_without_r_would_have_missed_the_renamed_file(repo, monkeypa assert pre_commit_module._staged_paths("ACMR") == ["Pulumi.dev.yaml"] +# --------------------------------------------------------------------------- +# T matters too: converting an already-tracked stack config into a +# symlink stages as a type-change (`T`), which `ACMR` alone excludes just +# as it excludes an unmerged path -- ruled during review as a genuine +# fail-open gap in the brief's own literal `--diff-filter=ACMR`, closed by +# adding `T`. +# --------------------------------------------------------------------------- + + +def test_tracked_config_replaced_by_a_symlink_exits_2_not_0(repo, monkeypatch, capsys): + """A symlink's staged "content" (via `git show ":"`) is its + target path, not YAML -- so once the type-change is actually seen by + the scan, it fails closed (exit 2) rather than passing as clean or + silently vanishing from the listing entirely.""" + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(repo, "Pulumi.dev.yaml") + _git(repo, "commit", "-q", "-m", "add config") + + (repo / "Pulumi.dev.yaml").unlink() + (repo / "Pulumi.dev.yaml").symlink_to("/etc/hostname") + stage(repo, "Pulumi.dev.yaml") + + # Prove the premise: git really did record this as a type-change, not + # a plain modify. + status = _git(repo, "diff", "--cached", "--name-status").stdout + assert status.startswith("T") + + assert run_pre_commit(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "Pulumi.dev.yaml" in captured.err + + +def test_diff_filter_without_t_would_have_missed_the_symlinked_config(repo, monkeypatch): + """Direct proof of why `T` is in the filter: with it excluded, a + tracked stack config converted to a symlink is invisible to the + staged-file listing -- exactly the way an unmerged path is invisible + to a filter that omits `U` -- so the commit would exit 0 instead of + ever reaching the scan that would refuse it.""" + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(repo, "Pulumi.dev.yaml") + _git(repo, "commit", "-q", "-m", "add config") + + (repo / "Pulumi.dev.yaml").unlink() + (repo / "Pulumi.dev.yaml").symlink_to("/etc/hostname") + stage(repo, "Pulumi.dev.yaml") + + monkeypatch.chdir(repo) + assert pre_commit_module._staged_paths("ACMR") == [] + assert pre_commit_module._staged_paths("ACMRT") == ["Pulumi.dev.yaml"] + + # --------------------------------------------------------------------------- # Pulumi state exports: refused outright, before any content check. # --------------------------------------------------------------------------- @@ -436,6 +488,30 @@ def boom(_path, _check): assert "RuntimeError" in captured.err +def test_policy_load_failure_exits_2_not_1(repo, monkeypatch, capsys): + """A bare exception from policy loading -- not `ConfigError`, which + `_load_check_policy`'s own caller already handles -- must not escape + `cmd_pre_commit` uncaught. Concretely: `find_repo_config` walks + upward through parent directories with plain `.is_file()`/`.exists()` + calls and no try/except of its own, so a `PermissionError` on a + non-traversable parent would surface as a bare `OSError`. Without the + `@fail_closed` decorator on `cmd_pre_commit`, that would propagate + out of `main()` entirely and exit 1 by Python's own default -- + misreporting "credential found" for an invocation that never got as + far as loading policy, let alone scanning a file.""" + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(repo, "Pulumi.dev.yaml") + + def boom(): + raise OSError("simulated permission error walking parent directories") + + monkeypatch.setattr(pre_commit_module, "find_repo_config", boom) + assert run_pre_commit(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "OSError" in captured.err + + def test_a_real_finding_still_prints_even_when_another_file_errors( repo, monkeypatch, capsys ): From 8df33b715536132a6a88590765f07dc4b4ace138 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 04:07:59 -0700 Subject: [PATCH 13/44] =?UTF-8?q?feat:=20hooks=20install=20=E2=80=94=20wri?= =?UTF-8?q?te=20the=20pre-commit=20gate=20into=20a=20repo's=20real=20hooks?= =?UTF-8?q?=20dir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `stackward hooks install`, a logic-free installer for the `pre-commit` gate built in Task 4. The hook body only locates the `stackward` executable and execs `stackward pre-commit`; every actual rule stays in that command, so a fix or a new check reaches an already-installed hook through a normal stackward upgrade rather than going stale in every clone that installed it once. - Resolves the hooks directory with `git rev-parse --git-path hooks`, never `.git/hooks` hardcoded — `core.hooksPath` redirection (husky, the `pre-commit` framework, org config) makes that hardcoding a silent no-op. - Refuses when the resolved hooks directory is tracked by git (`git ls-files --error-unmatch `), printing the reason and suggesting chaining from the tracked hook instead of overwriting it. - Embeds the absolute path to the running executable (handling the frozen build, ordinary console-script, and `python -m stackward` cases differently — the last has a non-executable `sys.argv[0]`, so it falls back to a `PATH` lookup at install time too), with the hook itself falling back to `PATH` again at runtime if that embedded path stops working. - A fixed marker line identifies a hook this tool wrote; an existing hook without it is backed up to `.backup.` rather than refused or overwritten. Installing twice leaves one hook and no second backup. - `chmod 0o755` explicitly on every write. Tests (tests/test_install_hooks.py, 32 tests) use real temporary git repositories only — git is never mocked. Beyond the brief's required coverage, several tests drive a real `git commit` through the actual installed hook (not just asserting a file landed at the expected path), which is what actually proves the hooks-directory resolution works rather than merely appearing to. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/cli.py | 8 + src/stackward/commands/install_hooks.py | 304 +++++++++++++ tests/test_install_hooks.py | 562 ++++++++++++++++++++++++ 3 files changed, 874 insertions(+) create mode 100644 src/stackward/commands/install_hooks.py create mode 100644 tests/test_install_hooks.py diff --git a/src/stackward/cli.py b/src/stackward/cli.py index 3fff5de..b1d951e 100644 --- a/src/stackward/cli.py +++ b/src/stackward/cli.py @@ -18,6 +18,7 @@ from . import __version__ from .commands.check_config import cmd_check_config +from .commands.install_hooks import cmd_install_hooks from .commands.pre_commit import cmd_pre_commit from .config import find_repo_config @@ -126,6 +127,13 @@ def build_parser() -> argparse.ArgumentParser: ) pre_commit.set_defaults(func=cmd_pre_commit) + hooks = sub.add_parser("hooks", help="manage this repository's git hooks") + hooks_sub = hooks.add_subparsers(dest="hooks_command", metavar="") + hooks_install = hooks_sub.add_parser( + "install", help="install the pre-commit gate into this repository's hooks" + ) + hooks_install.set_defaults(func=cmd_install_hooks) + return parser diff --git a/src/stackward/commands/install_hooks.py b/src/stackward/commands/install_hooks.py new file mode 100644 index 0000000..faca9a1 --- /dev/null +++ b/src/stackward/commands/install_hooks.py @@ -0,0 +1,304 @@ +"""`hooks install`: write the `pre-commit` gate into this repository's git +hooks directory. + +Registered in `cli.build_parser` as `stackward hooks install`, taking no +arguments -- like `pre-commit` itself, it always operates on the repository +containing the current working directory. + +**The hook body has no logic.** It locates the `stackward` executable and +execs `stackward pre-commit` -- nothing else. Git does not version hooks, so +whatever text this command writes into a hook file sits there, unchanged, +until someone runs this command again; a hook body that duplicated any of +`commands.pre_commit`'s actual rules would silently drift from that module +the first time either one changed, in every clone that had installed it +before the drift. Keeping the hook body to "find the binary, exec it" means +every fix and every new rule reaches an already-installed hook automatically, +through the same `stackward` upgrade that would have shipped it anyway -- +nothing about the hook file itself has to change, or even be reinstalled. + +**Never hardcode `.git/hooks`.** `core.hooksPath` is routinely redirected -- +by husky, by the `pre-commit` framework, by a org-wide global config -- and a +directory git does not consult is a *silent* no-op: nothing errors, the +command reports success, and the next commit with a plaintext credential in +it sails straight through a gate everyone believes is installed. `_hooks_dir` +resolves the real location with `git rev-parse --git-path hooks`, exactly +once, and every other function in this module treats that resolved `Path` as +the hooks directory rather than reconstructing or assuming it. + +**Refuse a tracked hooks directory outright.** Some repositories commit their +hook *scripts* to a tracked directory (`.githooks/`, say) and point +`core.hooksPath` at it precisely so every clone gets the same hooks without a +separate install step. Writing this tool's hook into a directory like that +would commit a file that `exec`s a `stackward` binary the next clone may not +have installed at all -- turning a shared convenience into a hook that blocks +every commit in every clone until someone notices and reverts it. `_is_tracked` +checks this with `git ls-files --error-unmatch ` (a tracked-directory +pathspec matches if *any* file under it is tracked; git's ordinary, +unredirected `.git/hooks` never matches, because paths under `.git/` are not +part of the repository's tracked namespace at all) and `cmd_install_hooks` +refuses before writing anything -- printing the reason and pointing at +chaining from the tracked hook instead of overwriting it. + +**Absolute path, with a `PATH` fallback baked into the hook itself.** The +obvious one-liner, `exec stackward pre-commit`, depends on `stackward` being +on `PATH` *at the moment git invokes the hook* -- and several everyday commit +paths (GUI clients, IDEs, cron) run git with a minimal environment that does +not include the shell profile where `PATH` normally gets extended. `exec` +against a name that is not found there exits 127, and a hook that exits +non-zero blocks the commit -- exactly the experience that teaches people to +reach for `git commit --no-verify` and never look back. `_running_executable` +embeds the absolute path this command was itself invoked with (see its own +docstring for why that differs between a frozen build and a console-script +install), and the hook body it writes still falls back to a `PATH` lookup if +that embedded path is not executable when the hook actually runs -- covering +the case where `stackward` moved, or this hook was installed from one clone's +install layout and is now running against another's. + +**A fixed marker line, backup rather than refuse or overwrite.** Some other +tool, or a human, may already have written a hook at the same path (`husky`, +`pre-commit`, or a hand-written script). This command must not silently +destroy that -- but it also must not refuse forever, since re-running this +command is exactly how the hook body picks up a `stackward` upgrade. The +answer is the fixed line `_MARKER`: a hook that already carries it was +written by this command and is safe to overwrite in place (this is what +makes running the command twice leave exactly one hook, with no growing pile +of backups); a hook that lacks it is somebody else's and is moved aside to +`.backup.` -- never overwritten, never dropped, and +never a reason to refuse the install outright. +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +from .check_config import fail_closed + +_HOOK_NAME = "pre-commit" + +# Exact line a hook this command wrote always contains, and the sole thing +# that tells "ours, safe to overwrite in place" apart from "somebody else's, +# back it up" -- see the module docstring. Never change this text without +# also handling hooks written by a prior version that still carry the old +# one; nothing in this codebase (yet) needs that migration. +_MARKER = "# managed by: stackward hooks install" + + +class InstallError(Exception): + """Installation cannot proceed for a reason that is safe to print + verbatim: git is missing, the current directory is not inside a git + working tree, or a git command genuinely failed. Never raised for a + plaintext credential -- this module never reads staged or working-tree + *content* at all, only git's own metadata about paths.""" + + +def _run_git(args: list[str]) -> str: + """Run `git ` in the current working directory and return its + stdout, stripped of surrounding whitespace. + + Raises `InstallError` for anything that means installation cannot + proceed: the `git` executable missing, or the invoked subcommand + failing for any git-reported reason (most notably, the current + directory not being inside a git working tree at all).""" + try: + proc = subprocess.run(["git", *args], capture_output=True, text=True, check=False) + except OSError as exc: + raise InstallError(f"cannot run git {' '.join(args)}: {exc}") from exc + if proc.returncode != 0: + stderr = proc.stderr.strip() + raise InstallError(f"git {' '.join(args)} failed: {stderr}") + return proc.stdout.strip() + + +def _hooks_dir() -> Path: + """The repository's actual hooks directory -- `git rev-parse --git-path + hooks`, resolved to an absolute path, never `.git/hooks` hardcoded. See + the module docstring for why a redirected `core.hooksPath` makes that + hardcoding a silent no-op rather than a loud failure.""" + return Path(_run_git(["rev-parse", "--git-path", "hooks"])).resolve() + + +def _is_tracked(path: Path) -> bool: + """Whether any file under `path` (already an absolute `Path`) is + tracked by git, tested exactly as the brief specifies: `git ls-files + --error-unmatch ` -- a directory pathspec matches if git tracks + anything underneath it. Passing the pre-resolved absolute path, rather + than a relative one, keeps this correct regardless of what the current + working directory happens to be. Git's own `.git/hooks`, unredirected, + never matches: paths under `.git/` are not part of the tracked + namespace at all, so this reliably distinguishes that default location + from a `core.hooksPath` deliberately pointed at a tracked directory.""" + proc = subprocess.run( + ["git", "ls-files", "--error-unmatch", str(path)], + capture_output=True, + text=True, + check=False, + ) + return proc.returncode == 0 + + +def _running_executable() -> str: + """Absolute path to the running `stackward`, embedded verbatim as the + hook body's primary way of finding the binary (the `PATH` lookup in + `_hook_body` is only the runtime fallback). + + A frozen PyInstaller build's `sys.executable` *is* the compiled + `stackward` binary -- the same fact `cli.cmd_doctor` already reports + via `getattr(sys, "frozen", False)`. For an ordinary source or + console-script install, `sys.executable` is the Python interpreter + instead, which a bare `exec` cannot usefully invoke on its own; there, + `sys.argv[0]` is normally the console-script file itself -- an + executable file with its own shebang -- and resolving it to an + absolute path is what actually names the command that is running. + + `sys.argv[0]` is not always that, though: invoked as `python -m + stackward`, it resolves to `.../stackward/__main__.py`, a plain + source file with no shebang and no execute bit -- embedding it would + make the hook silently fall through to its own `PATH` fallback on + every single run, which is exactly the dependency this whole scheme + exists to avoid relying on (see the module docstring). Checked with + `os.access(..., os.X_OK)` rather than assumed, and resolved with + `shutil.which("stackward")` when it fails: on a normal install that + finds the very console script `sys.argv[0]` would have pointed to + when invoked the usual way. + """ + if getattr(sys, "frozen", False): + return sys.executable + argv0 = Path(sys.argv[0]).resolve() + if argv0.is_file() and os.access(argv0, os.X_OK): + return str(argv0) + found = shutil.which("stackward") + return found if found is not None else str(argv0) + + +def _hook_body(executable_path: str) -> str: + """The full text of the hook file: the marker, an edit-me-not notice, + and nothing that decides anything about a commit. `executable_path` is + embedded as a single-quoted, shell-escaped literal (safe even if it + contains a space) rather than interpolated into a double-quoted + string, which would let a `$`, backtick, or backslash in the path be + reinterpreted by the shell instead of taken literally. + """ + quoted = "'" + executable_path.replace("'", "'\\''") + "'" + return ( + "#!/bin/sh\n" + f"{_MARKER}\n" + "# Installed by `stackward hooks install` -- do not edit this file by\n" + "# hand; re-run that command instead (it is safe to run again, and\n" + "# picks up a newer stackward automatically). Everything this hook\n" + "# actually decides lives in `stackward pre-commit`, not here -- a\n" + "# rule added or fixed there reaches every clone through a normal\n" + "# stackward upgrade, with no change to this file at all.\n" + "set -eu\n" + "\n" + f"STACKWARD={quoted}\n" + 'if [ ! -x "$STACKWARD" ]; then\n' + ' STACKWARD="$(command -v stackward 2>/dev/null || true)"\n' + "fi\n" + 'if [ -z "$STACKWARD" ]; then\n' + ' echo "stackward: could not find the stackward executable" >&2\n' + ' echo " (checked the path recorded at install time, then PATH)" >&2\n' + " exit 1\n" + "fi\n" + "\n" + 'exec "$STACKWARD" pre-commit\n' + ) + + +def _is_stackward_hook(path: Path) -> bool: + """Whether the file at `path` is a hook this command wrote -- i.e. + carries `_MARKER` on a line of its own. Never raises: an unreadable or + binary existing file simply is not recognised as ours, which is the + correct, safe answer either way -- it gets backed up rather than + mistaken for a hook this command can overwrite in place.""" + try: + content = path.read_text() + except OSError: + return False + return any(line.strip() == _MARKER for line in content.splitlines()) + + +def _backup_path(hook_path: Path) -> Path: + """`.backup.`, microsecond-resolution so two + installs in the same repository within the same second cannot collide + and silently overwrite one backup with another.""" + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ") + return hook_path.with_name(f"{hook_path.name}.backup.{stamp}") + + +def _install_hook(hooks_dir: Path, executable_path: str) -> Path | None: + """Write the `pre-commit` hook into `hooks_dir`, creating the directory + if it does not yet exist (a `core.hooksPath` redirected to a directory + nothing has populated yet, most notably). + + Returns the path an existing *foreign* hook was backed up to, or + `None` when there was nothing to back up -- either no hook was present, + or the one present already carried `_MARKER` and was simply overwritten + in place. That distinction is what makes installing twice leave exactly + one hook and create no second backup: the second run finds its own + marker on the first run's hook and overwrites it directly, rather than + backing it up as if it were foreign. + + Always `chmod 0o755` after writing, explicitly -- never assumed from a + default `open()` mode, which a restrictive umask could leave non- + executable, and never inherited from a prior file at the same path. + """ + hooks_dir.mkdir(parents=True, exist_ok=True) + hook_path = hooks_dir / _HOOK_NAME + + backup_path: Path | None = None + if hook_path.exists() or hook_path.is_symlink(): + if not _is_stackward_hook(hook_path): + backup_path = _backup_path(hook_path) + hook_path.rename(backup_path) + + hook_path.write_text(_hook_body(executable_path)) + hook_path.chmod(0o755) + return backup_path + + +@fail_closed +def cmd_install_hooks(_args: argparse.Namespace) -> int: + """Entry point for `stackward hooks install`. + + Exit codes: **0** installed (fresh, updated in place, or an existing + foreign hook backed up first); **1** refused because the resolved hooks + directory is tracked by git (see the module docstring for why writing + there is unsafe); **2** could not even determine where to install -- + `git` missing, or the current directory not inside a git working tree. + Reusing `check_config.fail_closed` here for the same reason it wraps + `cmd_check_config` and `cmd_pre_commit`: an unanticipated exception + must not fall through to Python's own default exit code of 1, which + this tool reserves for a specific, different meaning everywhere else it + appears. + """ + try: + hooks_dir = _hooks_dir() + except InstallError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + if _is_tracked(hooks_dir): + print(f"error: hooks directory is tracked by git: {hooks_dir}", file=sys.stderr) + print( + "Installing here would commit a hook that execs a stackward binary " + "other clones may not have, blocking their commits. Chain from the " + "tracked hook instead: have it invoke `stackward pre-commit` itself " + "(with the same PATH fallback this command would have used) rather " + "than letting this command overwrite it.", + file=sys.stderr, + ) + return 1 + + executable = _running_executable() + hook_path = hooks_dir / _HOOK_NAME + backup_path = _install_hook(hooks_dir, executable) + if backup_path is not None: + print(f"Existing hook backed up to {backup_path}") + print(f"Installed pre-commit hook -> {hook_path}") + return 0 diff --git a/tests/test_install_hooks.py b/tests/test_install_hooks.py new file mode 100644 index 0000000..3fe35d8 --- /dev/null +++ b/tests/test_install_hooks.py @@ -0,0 +1,562 @@ +"""Tests for `hooks install` -- writing the `pre-commit` gate into a +repository's real hooks directory. + +Every test here uses a real temporary git repository, created with `git +init`. This command's entire reason to exist is a handful of git behaviours +that are easy to get wrong silently -- `core.hooksPath` redirection, a +tracked hooks directory, an existing foreign hook -- so, exactly as in +`test_pre_commit.py`, git is never mocked. +""" + +from __future__ import annotations + +import os +import shutil +import stat +import subprocess +import sys +from pathlib import Path + +import pytest + +from stackward.cli import main +from stackward.commands import install_hooks as install_hooks_module + +# --------------------------------------------------------------------------- +# Real-git-repository fixtures and helpers. +# --------------------------------------------------------------------------- + + +def _git(repo: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", *args], cwd=repo, capture_output=True, text=True, check=True + ) + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + """A real, minimal git repository with one initial commit.""" + path = tmp_path / "repo" + path.mkdir() + _git(path, "init", "-q") + _git(path, "config", "user.email", "test@example.com") + _git(path, "config", "user.name", "Test") + _git(path, "commit", "-q", "--allow-empty", "-m", "init") + return path + + +def install(repo: Path, monkeypatch) -> int: + monkeypatch.chdir(repo) + return main(["hooks", "install"]) + + +def backups(hooks_dir: Path) -> list[Path]: + return sorted(hooks_dir.glob("pre-commit.backup.*")) + + +def _real_stackward_executable() -> str: + """The actual installed `stackward` console script for this test + interpreter -- not a stub. Used only by the end-to-end tests below, + which prove the generated hook body really gets invoked by a real + `git commit`, not merely that a file landed at the expected path: a + file at the right path that git never actually runs is precisely the + "silent no-op" this command's own hooks-directory resolution exists to + prevent, and asserting on the file alone cannot catch that failure + mode.""" + candidate = Path(sys.executable).parent / "stackward" + if candidate.is_file() and os.access(candidate, os.X_OK): + return str(candidate) + found = shutil.which("stackward") + if found: + return found + pytest.skip("no installed `stackward` console script found for an end-to-end test") + + +@pytest.fixture +def real_executable(monkeypatch) -> str: + """Make `_install_hook` embed the real `stackward` console script + instead of whatever `sys.argv[0]` happens to be under pytest (its own + interpreter) -- see `_real_stackward_executable`.""" + executable = _real_stackward_executable() + monkeypatch.setattr(install_hooks_module, "_running_executable", lambda: executable) + return executable + + +# --------------------------------------------------------------------------- +# Default location: `.git/hooks`, resolved rather than hardcoded. +# --------------------------------------------------------------------------- + + +def test_default_location_installs_into_git_hooks(repo, monkeypatch, capsys): + assert install(repo, monkeypatch) == 0 + hook_path = repo / ".git" / "hooks" / "pre-commit" + assert hook_path.is_file() + assert install_hooks_module._MARKER in hook_path.read_text() + assert str(hook_path) in capsys.readouterr().out + + +def test_installed_hook_actually_blocks_a_real_commit_with_a_credential( + repo, monkeypatch, real_executable +): + """A file existing at the expected path is not proof git runs it -- + that gap is exactly the "silent no-op" the brief warns about. This + drives a real `git commit`, through the real `stackward` this test + interpreter has installed, against the real default hooks directory.""" + assert install(repo, monkeypatch) == 0 + + (repo / "Pulumi.dev.yaml").write_text("config:\n myproject:dbPassword: hunter2\n") + _git(repo, "add", "Pulumi.dev.yaml") + result = subprocess.run( + ["git", "commit", "-m", "add credential"], + cwd=repo, + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "plaintext credential" in result.stdout + result.stderr + + +def test_installed_hook_allows_a_real_clean_commit(repo, monkeypatch, real_executable): + assert install(repo, monkeypatch) == 0 + + (repo / "Pulumi.dev.yaml").write_text("name: myproject\n") + _git(repo, "add", "Pulumi.dev.yaml") + result = subprocess.run( + ["git", "commit", "-m", "add clean config"], + cwd=repo, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + + +# --------------------------------------------------------------------------- +# core.hooksPath redirection: never hardcode `.git/hooks`. +# --------------------------------------------------------------------------- + + +def test_redirected_hooks_path_installs_there_not_in_dot_git(repo, monkeypatch): + (repo / "myhooks").mkdir() + _git(repo, "config", "core.hooksPath", "myhooks") + + assert install(repo, monkeypatch) == 0 + + redirected_hook = repo / "myhooks" / "pre-commit" + default_hook = repo / ".git" / "hooks" / "pre-commit" + assert redirected_hook.is_file() + assert not default_hook.exists() + + +def test_redirected_hooks_path_directory_is_created_if_missing(repo, monkeypatch): + """The redirected directory need not exist yet -- e.g. a fresh clone + that has set core.hooksPath but never run any install step.""" + _git(repo, "config", "core.hooksPath", "myhooks") + assert not (repo / "myhooks").exists() + + assert install(repo, monkeypatch) == 0 + assert (repo / "myhooks" / "pre-commit").is_file() + + +def test_hooks_path_absolute_and_outside_the_worktree_is_treated_as_untracked( + tmp_path, repo, monkeypatch +): + """`git ls-files --error-unmatch` on a path outside the repository + fails with "is outside repository", a different git error than the + in-repo untracked case (`did not match any file(s)`) exercised + elsewhere in this file. `_is_tracked` treats any non-zero exit as "not + tracked" -- this proves that answer is still correct for this + differently-shaped git failure, not merely right by accident of a + single error message this suite happens to have checked.""" + external = tmp_path / "external-hooks" + external.mkdir() + _git(repo, "config", "core.hooksPath", str(external)) + + assert install(repo, monkeypatch) == 0 + assert (external / "pre-commit").is_file() + + +def test_redirected_hooks_path_hook_actually_runs_on_a_real_commit( + repo, monkeypatch, real_executable +): + """The specific case "don't hardcode `.git/hooks`" exists for: proves + the installed hook does anything at all when git is configured to + look somewhere else entirely.""" + (repo / "myhooks").mkdir() + _git(repo, "config", "core.hooksPath", "myhooks") + assert install(repo, monkeypatch) == 0 + + (repo / "Pulumi.dev.yaml").write_text("config:\n myproject:dbPassword: hunter2\n") + _git(repo, "add", "Pulumi.dev.yaml") + result = subprocess.run( + ["git", "commit", "-m", "add credential"], + cwd=repo, + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "plaintext credential" in result.stdout + result.stderr + + +# --------------------------------------------------------------------------- +# Refusal on a tracked hooks directory. +# --------------------------------------------------------------------------- + + +def test_refuses_when_hooks_directory_is_tracked_by_git(repo, monkeypatch, capsys): + (repo / "githooks").mkdir() + (repo / "githooks" / "pre-commit").write_text("#!/bin/sh\necho tracked\n") + _git(repo, "add", "githooks") + _git(repo, "commit", "-q", "-m", "commit shared hooks") + _git(repo, "config", "core.hooksPath", "githooks") + + original = (repo / "githooks" / "pre-commit").read_text() + assert install(repo, monkeypatch) == 1 + + captured = capsys.readouterr() + assert "tracked" in captured.err.lower() + assert "githooks" in captured.err + # Refused, not silently overwritten or backed up. + assert (repo / "githooks" / "pre-commit").read_text() == original + assert backups(repo / "githooks") == [] + + +def test_tracked_refusal_suggests_chaining_from_the_tracked_hook(repo, monkeypatch, capsys): + (repo / "githooks").mkdir() + (repo / "githooks" / "pre-commit").write_text("#!/bin/sh\n") + _git(repo, "add", "githooks") + _git(repo, "commit", "-q", "-m", "commit shared hooks") + _git(repo, "config", "core.hooksPath", "githooks") + + install(repo, monkeypatch) + err = capsys.readouterr().err + assert "chain" in err.lower() + assert "pre-commit" in err # names what to chain to: `stackward pre-commit` + + +# --------------------------------------------------------------------------- +# An existing foreign hook is backed up, never refused or overwritten blind. +# --------------------------------------------------------------------------- + + +def test_existing_foreign_hook_is_backed_up(repo, monkeypatch, capsys): + hooks_dir = repo / ".git" / "hooks" + hook_path = hooks_dir / "pre-commit" + foreign_content = "#!/bin/sh\necho this is somebody else's hook\n" + hook_path.write_text(foreign_content) + hook_path.chmod(0o755) + + assert install(repo, monkeypatch) == 0 + + found = backups(hooks_dir) + assert len(found) == 1 + assert found[0].read_text() == foreign_content + # The installed hook now carries this tool's marker. + assert install_hooks_module._MARKER in hook_path.read_text() + assert str(found[0]) in capsys.readouterr().out + + +def test_backup_filename_matches_hook_dot_backup_dot_timestamp(repo, monkeypatch): + hooks_dir = repo / ".git" / "hooks" + (hooks_dir / "pre-commit").write_text("#!/bin/sh\necho foreign\n") + + install(repo, monkeypatch) + + [backup] = backups(hooks_dir) + assert backup.name.startswith("pre-commit.backup.") + # The suffix is a real timestamp, not an arbitrary string. + stamp = backup.name.removeprefix("pre-commit.backup.") + assert stamp # non-empty + assert stamp[:8].isdigit() # YYYYMMDD at minimum + + +# --------------------------------------------------------------------------- +# Idempotence: installing twice leaves one hook and creates no second backup. +# --------------------------------------------------------------------------- + + +def test_installing_twice_over_a_foreign_hook_creates_only_one_backup(repo, monkeypatch): + hooks_dir = repo / ".git" / "hooks" + (hooks_dir / "pre-commit").write_text("#!/bin/sh\necho foreign\n") + + assert install(repo, monkeypatch) == 0 + assert install(repo, monkeypatch) == 0 + + assert len(backups(hooks_dir)) == 1 + assert (hooks_dir / "pre-commit").is_file() + + +def test_installing_twice_from_a_clean_repo_leaves_exactly_one_hook(repo, monkeypatch): + assert install(repo, monkeypatch) == 0 + assert install(repo, monkeypatch) == 0 + + hooks_dir = repo / ".git" / "hooks" + assert backups(hooks_dir) == [] + assert sum(1 for p in hooks_dir.iterdir() if p.name == "pre-commit") == 1 + + +def test_second_install_recognises_its_own_marker_and_overwrites_in_place( + repo, monkeypatch +): + """The mechanism idempotence depends on: a hook already carrying + `_MARKER` is treated as ours and overwritten directly, not backed up + as though it were foreign.""" + install(repo, monkeypatch) + hook_path = repo / ".git" / "hooks" / "pre-commit" + assert install_hooks_module._is_stackward_hook(hook_path) is True + + install(repo, monkeypatch) + assert backups(repo / ".git" / "hooks") == [] + + +# --------------------------------------------------------------------------- +# Mode 0o755, set explicitly. +# --------------------------------------------------------------------------- + + +def test_installed_hook_mode_is_0o755(repo, monkeypatch): + install(repo, monkeypatch) + hook_path = repo / ".git" / "hooks" / "pre-commit" + mode = stat.S_IMODE(hook_path.stat().st_mode) + assert mode == 0o755 + + +def test_hook_mode_is_0o755_even_when_replacing_a_non_executable_foreign_hook( + repo, monkeypatch +): + hooks_dir = repo / ".git" / "hooks" + hook_path = hooks_dir / "pre-commit" + hook_path.write_text("#!/bin/sh\necho foreign\n") + hook_path.chmod(0o644) + + install(repo, monkeypatch) + + mode = stat.S_IMODE(hook_path.stat().st_mode) + assert mode == 0o755 + + +# --------------------------------------------------------------------------- +# The hook body embeds an absolute path to the running executable. +# --------------------------------------------------------------------------- + + +def test_hook_body_contains_an_absolute_path(repo, monkeypatch): + install(repo, monkeypatch) + hook_path = repo / ".git" / "hooks" / "pre-commit" + body = hook_path.read_text() + + stackward_line = next( + line for line in body.splitlines() if line.startswith("STACKWARD=") + ) + quoted_path = stackward_line.removeprefix("STACKWARD=") + raw_path = quoted_path[1:-1].replace("'\\''", "'") # undo shell single-quoting + assert Path(raw_path).is_absolute() + + +def test_running_executable_returns_an_absolute_path(): + assert Path(install_hooks_module._running_executable()).is_absolute() + + +def test_running_executable_falls_back_to_path_when_argv0_is_not_executable( + tmp_path, monkeypatch +): + """`python -m stackward` sets `sys.argv[0]` to `.../stackward/__main__.py` + -- a plain source file, with no execute bit and no shebang. Embedding + that path verbatim would make the hook silently fall through to its + own `PATH` fallback on *every* run, which is exactly the dependency + this whole scheme exists to avoid relying on -- so a non-executable + `argv[0]` must be recognised and a real `PATH` lookup used instead.""" + non_executable = tmp_path / "__main__.py" + non_executable.write_text("# not executable\n") + monkeypatch.setattr(sys, "argv", [str(non_executable)]) + + fake_bin = tmp_path / "fake-bin" + fake_bin.mkdir() + real_stub = fake_bin / "stackward" + real_stub.write_text("#!/bin/sh\n") + real_stub.chmod(0o755) + monkeypatch.setenv("PATH", str(fake_bin)) + + assert install_hooks_module._running_executable() == str(real_stub) + + +def test_running_executable_falls_back_to_argv0_when_nothing_else_is_found( + tmp_path, monkeypatch +): + """No usable fallback exists either: better to embed the best guess + available (which the hook's own `PATH` lookup can still recover from + at runtime) than to raise here and block installation entirely.""" + non_executable = tmp_path / "__main__.py" + non_executable.write_text("# not executable\n") + monkeypatch.setattr(sys, "argv", [str(non_executable)]) + + empty_bin = tmp_path / "empty-bin" + empty_bin.mkdir() + monkeypatch.setenv("PATH", str(empty_bin)) + + assert install_hooks_module._running_executable() == str(non_executable.resolve()) + + +# --------------------------------------------------------------------------- +# The hook actually locates and execs `stackward pre-commit` -- proven by +# running the generated shell script directly, not merely inspecting text. +# --------------------------------------------------------------------------- + + +def _make_stub(path: Path, out_file: Path) -> None: + path.write_text( + "#!/bin/sh\n" + f"echo \"invoked with: $*\" > {out_file}\n" + "exit 0\n" + ) + path.chmod(0o755) + + +def test_hook_body_execs_the_embedded_absolute_path_with_pre_commit(tmp_path): + stub = tmp_path / "stackward-stub" + out_file = tmp_path / "out.txt" + _make_stub(stub, out_file) + + hook_script = tmp_path / "generated-hook" + hook_script.write_text(install_hooks_module._hook_body(str(stub))) + hook_script.chmod(0o755) + + result = subprocess.run([str(hook_script)], capture_output=True, text=True) + assert result.returncode == 0 + assert out_file.read_text().strip() == "invoked with: pre-commit" + + +def test_hook_body_falls_back_to_path_when_embedded_path_is_missing(tmp_path): + """A bare `exec stackward` from an environment with a minimal `PATH` + is what this fallback exists to avoid -- but when the embedded path + genuinely no longer exists (the binary moved, or this hook was + installed by a different clone's layout), falling back to `PATH` + keeps the hook working rather than failing with exit 127.""" + missing_path = tmp_path / "does" / "not" / "exist" / "stackward" + + fake_bin = tmp_path / "fake-bin" + fake_bin.mkdir() + out_file = tmp_path / "out.txt" + _make_stub(fake_bin / "stackward", out_file) + + hook_script = tmp_path / "generated-hook" + hook_script.write_text(install_hooks_module._hook_body(str(missing_path))) + hook_script.chmod(0o755) + + env = {"PATH": str(fake_bin)} + result = subprocess.run( + [str(hook_script)], capture_output=True, text=True, env=env + ) + assert result.returncode == 0 + assert out_file.read_text().strip() == "invoked with: pre-commit" + + +def test_hook_body_fails_clearly_when_nothing_is_found(tmp_path): + missing_path = tmp_path / "does" / "not" / "exist" / "stackward" + hook_script = tmp_path / "generated-hook" + hook_script.write_text(install_hooks_module._hook_body(str(missing_path))) + hook_script.chmod(0o755) + + empty_bin = tmp_path / "empty-bin" + empty_bin.mkdir() + result = subprocess.run( + [str(hook_script)], capture_output=True, text=True, env={"PATH": str(empty_bin)} + ) + assert result.returncode == 1 + assert "could not find the stackward executable" in result.stderr + + +# --------------------------------------------------------------------------- +# The marker line: fixed, and how "ours" is told apart from "foreign". +# --------------------------------------------------------------------------- + + +def test_marker_is_a_whole_line_in_the_generated_body(): + body = install_hooks_module._hook_body("/usr/bin/stackward") + assert install_hooks_module._MARKER in body.splitlines() + + +def test_a_hook_without_the_marker_is_not_recognised_as_ours(tmp_path): + foreign = tmp_path / "pre-commit" + foreign.write_text("#!/bin/sh\necho not stackward\n") + assert install_hooks_module._is_stackward_hook(foreign) is False + + +# --------------------------------------------------------------------------- +# Fail-closed: not a git repository, or git itself unavailable. +# --------------------------------------------------------------------------- + + +def test_outside_a_git_repository_exits_2(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + assert main(["hooks", "install"]) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err != "" + + +def test_git_executable_missing_exits_2(repo, monkeypatch, capsys): + real_run = subprocess.run + + def failing_run(args, **kwargs): + if args[:1] == ["git"]: + raise OSError("git not found (simulated)") + return real_run(args, **kwargs) + + monkeypatch.setattr(subprocess, "run", failing_run) + assert install(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "git not found" in captured.err + + +def test_git_rev_parse_failure_exits_2(repo, monkeypatch, capsys): + real_run = subprocess.run + + def failing_run(args, **kwargs): + if args[:3] == ["git", "rev-parse", "--git-path"]: + return subprocess.CompletedProcess( + args, returncode=128, stdout="", stderr="fatal: simulated failure\n" + ) + return real_run(args, **kwargs) + + monkeypatch.setattr(subprocess, "run", failing_run) + assert install(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "simulated failure" in captured.err + + +def test_unanticipated_exception_exits_2_not_1(repo, monkeypatch, capsys): + """Python's default exit code for an uncaught exception is 1 -- the + code `pre-commit` reserves for "a credential was found" elsewhere in + this tool. An unrelated bug here must not exit 1, which `@fail_closed` + (shared with `check_config`/`pre_commit`) is what prevents.""" + + def boom(_hooks_dir, _executable_path): + raise RuntimeError("unanticipated failure") + + monkeypatch.setattr(install_hooks_module, "_install_hook", boom) + assert install(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "RuntimeError" in captured.err + + +# --------------------------------------------------------------------------- +# Wiring. +# --------------------------------------------------------------------------- + + +def test_hooks_install_is_registered_and_dispatches(repo, monkeypatch): + assert install(repo, monkeypatch) == 0 + + +def test_hooks_with_no_subcommand_prints_help_and_exits_2(capsys): + assert main(["hooks"]) == 2 + assert "usage:" in capsys.readouterr().out + + +def test_unexpected_argument_is_a_usage_error_exiting_2(): + with pytest.raises(SystemExit) as exit_info: + main(["hooks", "install", "unexpected-argument"]) + assert exit_info.value.code == 2 From 5321d3ffb576ad9aaa85a08335ebdf6923f41285 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 04:21:57 -0700 Subject: [PATCH 14/44] fix: close the fail-open gap in _is_tracked, and stop the e2e tests skipping quietly in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of Task 5 found _is_tracked was the one place in this module that inverted its own fail-closed pattern: any non-zero `git ls-files --error-unmatch` exit was read as "not tracked", but exit 128 is also what git reports for a broad class of unrelated fatal errors (a corrupt index, a permissions problem) where whether the directory is tracked is genuinely unanswered. `_hooks_dir` resolving symlinks gives a real route to this: it can make the resolved path diverge from git's own repository root and turn a genuinely-tracked directory into one git reports as outside the repository. `_is_tracked` now keys off git's own reported text rather than the exit code alone: only "did not match any file" (the ordinary untracked case) and "is outside repository" (an absolute core.hooksPath outside the worktree) count as "not tracked". Anything else raises `InstallError`, mapped by `cmd_install_hooks` to the same exit 2 every other could-not-determine failure in this command already uses — consistent with `_run_git`'s own any-non-zero-means-cannot-proceed rule, which this was the sole exception to. Also: the real-git-commit end-to-end tests skip when no `stackward` console script is found for the test interpreter. That's fine locally, but would let CI go quietly green having lost exactly the coverage the rest of the review leaned on. The skip now fails loudly instead when the `CI` environment variable is set. Tests added (tests/test_install_hooks.py): - test_unrecognised_ls_files_failure_exits_2_rather_than_installing - test_is_tracked_raises_install_error_for_an_unrecognised_failure Both sabotage-verified against the pre-fix `return proc.returncode == 0` before this commit; the existing outside-worktree redirect test continues to pass unchanged. $ uv run pytest -q ........................................................................ [ 31%] ........................................................................ [ 62%] ........................................................................ [ 94%] ............. [100%] 229 passed in 2.18s Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/commands/install_hooks.py | 45 +++++++++++++-- tests/test_install_hooks.py | 73 +++++++++++++++++++++++-- 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/src/stackward/commands/install_hooks.py b/src/stackward/commands/install_hooks.py index faca9a1..69ec2f6 100644 --- a/src/stackward/commands/install_hooks.py +++ b/src/stackward/commands/install_hooks.py @@ -132,14 +132,41 @@ def _is_tracked(path: Path) -> bool: working directory happens to be. Git's own `.git/hooks`, unredirected, never matches: paths under `.git/` are not part of the tracked namespace at all, so this reliably distinguishes that default location - from a `core.hooksPath` deliberately pointed at a tracked directory.""" + from a `core.hooksPath` deliberately pointed at a tracked directory. + + A non-zero exit is deliberately *not* read as "not tracked" on its + own -- only two specific, recognised git failures are: the ordinary + in-repository case (`error: pathspec '...' did not match any + file(s) known to git`, exit 1) and a `core.hooksPath` that resolves + outside the repository entirely (`fatal: '...' is outside repository + at '...'`, exit 128 -- reachable because `_hooks_dir` resolves + symlinks, which can make the resolved path diverge from git's own + notion of the repository root). Both are matched on git's own + reported text, not the exit code alone, because exit 128 in + particular is shared with a broad class of unrelated fatal errors + (a corrupt index, a permissions problem) where whether the directory + is tracked is genuinely unanswered. Treating *any* of those as "safe + to write here" would be exactly the silent bypass of a mandated + refusal this function exists to prevent, and would make this the one + place in the module that inverts `_run_git`'s own rule of raising on + any non-zero exit rather than guessing -- so an unrecognised failure + raises `InstallError` here too, mapped by the caller to the same + could-not-determine exit code as every other git failure in this + command.""" proc = subprocess.run( ["git", "ls-files", "--error-unmatch", str(path)], capture_output=True, text=True, check=False, ) - return proc.returncode == 0 + if proc.returncode == 0: + return True + stderr = proc.stderr + if "did not match any file" in stderr or "is outside repository" in stderr: + return False + raise InstallError( + f"could not determine whether {path} is tracked by git: {stderr.strip()}" + ) def _running_executable() -> str: @@ -269,8 +296,10 @@ def cmd_install_hooks(_args: argparse.Namespace) -> int: Exit codes: **0** installed (fresh, updated in place, or an existing foreign hook backed up first); **1** refused because the resolved hooks directory is tracked by git (see the module docstring for why writing - there is unsafe); **2** could not even determine where to install -- - `git` missing, or the current directory not inside a git working tree. + there is unsafe); **2** could not even determine where to install, or + whether the resolved hooks directory is tracked -- `git` missing, the + current directory not inside a git working tree, or an unrecognised + `git ls-files` failure (see `_is_tracked`). Reusing `check_config.fail_closed` here for the same reason it wraps `cmd_check_config` and `cmd_pre_commit`: an unanticipated exception must not fall through to Python's own default exit code of 1, which @@ -283,7 +312,13 @@ def cmd_install_hooks(_args: argparse.Namespace) -> int: print(f"error: {exc}", file=sys.stderr) return 2 - if _is_tracked(hooks_dir): + try: + tracked = _is_tracked(hooks_dir) + except InstallError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + if tracked: print(f"error: hooks directory is tracked by git: {hooks_dir}", file=sys.stderr) print( "Installing here would commit a hook that execs a stackward binary " diff --git a/tests/test_install_hooks.py b/tests/test_install_hooks.py index 3fe35d8..d636a53 100644 --- a/tests/test_install_hooks.py +++ b/tests/test_install_hooks.py @@ -62,14 +62,27 @@ def _real_stackward_executable() -> str: file at the right path that git never actually runs is precisely the "silent no-op" this command's own hooks-directory resolution exists to prevent, and asserting on the file alone cannot catch that failure - mode.""" + mode. + + Skipping when nothing is found keeps a local run convenient (an + interpreter not launched through this project's own `uv`-managed venv + genuinely may not have one), but `uv sync` always produces + `.venv/bin/stackward`, so CI must never quietly go green having lost + exactly the coverage the rest of this test file's real-git-repository + approach leans on hardest. Skip locally; fail loudly under `CI`. + """ candidate = Path(sys.executable).parent / "stackward" if candidate.is_file() and os.access(candidate, os.X_OK): return str(candidate) found = shutil.which("stackward") if found: return found - pytest.skip("no installed `stackward` console script found for an end-to-end test") + message = ( + "no installed `stackward` console script found for an end-to-end test" + ) + if os.environ.get("CI"): + pytest.fail(f"{message} -- `uv sync` should have installed one") + pytest.skip(message) @pytest.fixture @@ -163,8 +176,8 @@ def test_hooks_path_absolute_and_outside_the_worktree_is_treated_as_untracked( """`git ls-files --error-unmatch` on a path outside the repository fails with "is outside repository", a different git error than the in-repo untracked case (`did not match any file(s)`) exercised - elsewhere in this file. `_is_tracked` treats any non-zero exit as "not - tracked" -- this proves that answer is still correct for this + elsewhere in this file. `_is_tracked` recognises both messages as + "not tracked" -- this proves that answer is still correct for this differently-shaped git failure, not merely right by accident of a single error message this suite happens to have checked.""" external = tmp_path / "external-hooks" @@ -175,6 +188,58 @@ def test_hooks_path_absolute_and_outside_the_worktree_is_treated_as_untracked( assert (external / "pre-commit").is_file() +def test_unrecognised_ls_files_failure_exits_2_rather_than_installing( + repo, monkeypatch, capsys +): + """`_is_tracked` must not treat *every* non-zero `git ls-files` exit + as "not tracked" -- only the two recognised, specifically-worded + failures. A `git ls-files` failure for an unrelated reason (a corrupt + index, a permissions problem -- simulated here) leaves the question + genuinely unanswered, and installing anyway would be exactly the + silent bypass of a mandated refusal `_is_tracked` exists to prevent. + This must exit 2 (could not determine), never 0 (installed) or 1 + (the different, definitive "is tracked" refusal).""" + real_run = subprocess.run + + def failing_run(args, **kwargs): + if args[:2] == ["git", "ls-files"]: + return subprocess.CompletedProcess( + args, + returncode=128, + stdout="", + stderr="fatal: index file corrupt (simulated)\n", + ) + return real_run(args, **kwargs) + + monkeypatch.setattr(subprocess, "run", failing_run) + assert install(repo, monkeypatch) == 2 + + captured = capsys.readouterr() + assert captured.out == "" + assert "index file corrupt" in captured.err + # Refused to guess either way: no hook was written. + assert not (repo / ".git" / "hooks" / "pre-commit").exists() + + +def test_is_tracked_raises_install_error_for_an_unrecognised_failure( + tmp_path, monkeypatch +): + """Direct unit test of the boundary `_is_tracked` itself draws, + independent of the command-level wiring covered above.""" + real_run = subprocess.run + + def failing_run(args, **kwargs): + if args[:2] == ["git", "ls-files"]: + return subprocess.CompletedProcess( + args, returncode=128, stdout="", stderr="fatal: something else\n" + ) + return real_run(args, **kwargs) + + monkeypatch.setattr(subprocess, "run", failing_run) + with pytest.raises(install_hooks_module.InstallError, match="something else"): + install_hooks_module._is_tracked(tmp_path) + + def test_redirected_hooks_path_hook_actually_runs_on_a_real_commit( repo, monkeypatch, real_executable ): From ab567ca5d49d43f8a2ade52fa8458fc362283ff2 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 08:54:00 -0700 Subject: [PATCH 15/44] feat: profile-scoped credential store, encrypted at rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate built so far reads files and resolves nothing. This is the store those credentials live in: one envelope per profile, Argon2id + AES-256-GCM, with the profile name bound as AAD so an envelope cannot be moved between profiles undetected. crypto.py is the envelope and nothing else — no file I/O, no profile knowledge — so the part that is hard to get right and easy to get subtly wrong reads in one sitting. store.py owns profiles, the two files, and the writes. Argon2id at m=64 MiB, t=3, p=4 (RFC 9106's second recommended option, ~60 ms here). Deliberately NOT the PBKDF2/1,000,000 that check-passphrase will need: that code must reproduce Pulumi's own envelope to verify a stack passphrase, which is a different job with an externally-imposed format. Do not unify them. Three properties worth naming, because each is a silent failure otherwise: - A store-level verifier envelope is opened before any profile envelope, so a mistyped password fails as a wrong password immediately rather than weeks later at first use. - Rotation builds the whole re-sealed document in memory and performs exactly one atomic write, so it re-seals every envelope or none. It iterates the credentials file rather than config's profile list: an envelope whose [profile.] table was deleted is unreachable but not gone, and rotating past it would destroy it. - Passwords are NFC-normalised. The same characters typed on two platforms can arrive as different UTF-8 bytes and derive different keys, giving a store that opens only on the machine that created it. This had to be settled now: adding normalisation after any store exists breaks every password that worked without it. Writes are tmp + fsync + fchmod + rename, with the mode set before the rename and not after — "after" is a window in which the finished file exists at its real path with the wrong permissions. Selection is --profile, STACKWARD_PROFILE, the repo config's profile, then default_profile, then an error. A source that names a profile which does not exist errors rather than falling through: running against a backend nobody asked for is the worst thing this tool could do quietly. backend_url is passed through byte-for-byte and never parsed, which is what lets s3://, gs://, azblob:// and file:// all work without this module knowing anything about any of them. The component form is carried as structured data; composing it into a URL belongs with the command that runs pulumi login. Tests are named for the property each proves, and the ones that matter were sabotage-verified rather than trusted for passing. Three of my own tests failed that check and were rewritten: the mode-ordering test was vacuous (mkstemp already creates at 0600), the all-or-nothing rotation test missed an incremental-write implementation, and my stated rationale for the KDF parameter bounds was empirically wrong — the load-bearing case is iterations, which raises nothing and simply runs, not memory_kib, which _derive already maps. Error messages are hardened against the file they read. The version and kdf fields are hand-editable and live in the one file expected to hold secrets, so crypto.describe_value shows numbers and None and reports anything else by type rather than echoing it back — the same rule check_config.describe_yaml_error follows, and public for the same reason: store.py reuses it instead of growing a second copy of the judgement. _require_credentials is the only branch where decrypted values are in scope when an exception is raised. It interpolates neither half of an entry: the payload is whatever was sealed, and a mapping built the wrong way round puts a credential in the key position, so "keys are just variable names" is not an assumption this branch can make. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/crypto.py | 319 +++++++++++++++++ src/stackward/store.py | 700 ++++++++++++++++++++++++++++++++++++++ tests/test_crypto.py | 294 ++++++++++++++++ tests/test_store.py | 732 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 2045 insertions(+) create mode 100644 src/stackward/crypto.py create mode 100644 src/stackward/store.py create mode 100644 tests/test_crypto.py create mode 100644 tests/test_store.py diff --git a/src/stackward/crypto.py b/src/stackward/crypto.py new file mode 100644 index 0000000..b2f5783 --- /dev/null +++ b/src/stackward/crypto.py @@ -0,0 +1,319 @@ +"""The sealed envelope: Argon2id + AES-256-GCM, and nothing else. + +This module knows how to turn bytes and a password into an envelope and back +again. It does not know what a profile is, where the store lives, or how a +password was obtained — `store.py` owns all of that. The split is deliberate: +the crypto here is the part that is hard to get right and easy to get subtly +wrong, so it is kept small enough to read in one sitting and testable without +touching a filesystem. + +**Why Argon2id and not PBKDF2.** This store interoperates with nothing. It is +read only by this tool, so it is free to use the modern memory-hard KDF rather +than a legacy one. `commands/check_passphrase.py` deliberately uses PBKDF2 with +1,000,000 iterations instead — not as an inconsistency, but because that code +must reproduce *Pulumi's* envelope to verify a stack passphrase, which is a +different job with a fixed, externally-imposed format. Do not unify them. + +**The envelope.** + + {"v": 1, + "kdf": {"name": "argon2id", "memory_kib": …, "iterations": …, "lanes": …}, + "salt": "", "nonce": "", "ct": ""} + +Two readings of the brief's "fields `v`, `kdf`, `salt`, `nonce`, `ct`, each +base64" are possible. The one taken here base64-encodes the three *byte-valued* +fields — the ones JSON cannot carry natively — and leaves `v` an integer and +`kdf` a structured object. Base64-encoding a version number defeats the purpose +of having one: the first thing a reader of a broken store needs is to see which +format version it is looking at, and `IjEi` does not tell them. `kdf` carries +its parameters rather than only a name so that a future change to the cost +parameters can still open envelopes written today; expressing that as a nested +object rather than as additional top-level fields keeps the envelope to the five +fields the brief names. + +**AAD.** Every seal binds an associated-data string, which `store.py` sets to +the profile name. AES-GCM authenticates it without encrypting it, so an envelope +lifted out of one profile's slot and dropped into another's fails to open even +under the correct password. That is a property of the tag, not of a check this +code performs, which is what makes it hard to accidentally remove. + +**Passwords are NFC-normalised before encoding.** A password containing a +non-ASCII character can be delivered as either a precomposed or a decomposed +sequence depending on the platform and input method — the same characters, +different UTF-8 bytes, a different derived key, and a store that opens on the +machine it was created on and nowhere else. Normalising costs one line and must +be decided before any store exists: adding it later would break every password +that worked without it. +""" + +from __future__ import annotations + +import base64 +import binascii +import json +import os +import unicodedata +from typing import Any + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.argon2 import Argon2id + +ENVELOPE_VERSION = 1 +KDF_NAME = "argon2id" + +# RFC 9106's second recommended option (t=3, m=64 MiB, p=4), which measures at +# roughly 60 ms here — small enough to sit in front of an interactive command, +# large enough to make an offline guessing attack on a stolen file expensive. +# Written into every new envelope; envelopes already on disk are opened with +# whatever parameters they carry. +ARGON2ID_MEMORY_KIB = 65536 +ARGON2ID_ITERATIONS = 3 +ARGON2ID_LANES = 4 + +KEY_BYTES = 32 # AES-256 +SALT_BYTES = 16 # RFC 9106 §4 +NONCE_BYTES = 12 # the GCM nonce length everything agrees on + +# Sanity bounds on parameters read back from a file, which is untrusted input +# even when it is the user's own: the file is hand-editable and this code must +# not turn a typo into a crash or a hang. +# +# These are NOT a security control, and describing them as downgrade protection +# would be wrong: changing a parameter in the file changes the derived key, so +# the GCM tag fails regardless, and the exposure this store addresses (a stolen +# disk, backup or tarball) does not include an attacker with write access to a +# live config directory. +# +# What earns them their place is the `iterations` ceiling specifically. An +# absurd `memory_kib` is survivable without a bound — `cryptography` answers it +# with a `MemoryError`, which `_derive` below maps like any other library +# rejection. An absurd `iterations` raises nothing at all: Argon2id simply runs, +# for as long as it is asked to, so a mistyped digit becomes a command that +# never returns and cannot be told apart from a hung tool. There is no exception +# to catch for that one, which is why it has to be refused before the derivation +# starts rather than handled after it. +_MIN_MEMORY_KIB = 8192 +_MAX_MEMORY_KIB = 1048576 # 1 GiB +_MIN_ITERATIONS = 1 +_MAX_ITERATIONS = 16 +_MIN_LANES = 1 +_MAX_LANES = 16 +_MIN_SALT_BYTES = 8 # `cryptography`'s own floor +_MAX_SALT_BYTES = 1024 + + +class CryptoError(Exception): + """Base for anything this module refuses to do. Never carries a plaintext, + a password, or any part of either — callers print these.""" + + +class EnvelopeError(CryptoError): + """The envelope is not something this module can attempt to open: a missing + or misshapen field, an unknown version or KDF, a parameter outside the + sanity bounds, undecodable base64. + + Distinct from `DecryptionError` on purpose. This one means "the input is + not an envelope"; that one means "it is an envelope and it did not open", + which is the branch a caller reports as a wrong password.""" + + +class DecryptionError(CryptoError): + """The GCM tag did not verify: a wrong password, a wrong AAD (an envelope + moved between profiles), or a modified ciphertext — indistinguishable by + construction, and deliberately so. + + Always raised `from` the underlying `InvalidTag`, so a test can prove the + failure came from the tag rather than from a length check or a comparison + this module performed itself.""" + + +def _normalise(password: str) -> bytes: + return unicodedata.normalize("NFC", password).encode("utf-8") + + +def describe_value(value: Any) -> str: + """Name a value read out of the store without echoing it. + + Envelope fields are hand-editable, and this module's messages are printed. + A number or `None` in a header field cannot carry a credential, so it is + shown; anything else is reported by type only. A user who pasted a + credential into `v` or `kdf.name` by mistake would otherwise have it read + back at them by the error that caught the mistake — the same rule + `commands/check_config.describe_yaml_error` follows for the same reason. + + Public (not `_`-prefixed) specifically so `store.py` can reuse it for the + version field of the credentials document itself, rather than growing a + second copy of the same judgement that would have to be kept in step + with this one by hand. + """ + if value is None or isinstance(value, (int, float)): + return repr(value) + return f"<{type(value).__name__}>" + + +def _b64(raw: bytes) -> str: + return base64.b64encode(raw).decode("ascii") + + +def _unb64(value: Any, field: str) -> bytes: + if not isinstance(value, str): + raise EnvelopeError(f"envelope field {field!r} must be a base64 string") + try: + # `validate=True` so that stray characters are rejected rather than + # silently discarded, which would let two distinct texts decode to the + # same bytes and make the envelope's encoding non-canonical. + return base64.b64decode(value, validate=True) + except (binascii.Error, ValueError) as exc: + raise EnvelopeError(f"envelope field {field!r} is not valid base64") from exc + + +def _require_int(data: dict[str, Any], key: str, low: int, high: int) -> int: + value = data.get(key) + # `bool` is an `int` subclass; `True` would otherwise read as the integer 1. + if not isinstance(value, int) or isinstance(value, bool): + raise EnvelopeError(f"kdf parameter {key!r} must be an integer") + if not low <= value <= high: + raise EnvelopeError(f"kdf parameter {key!r} must be between {low} and {high}") + return value + + +def _derive(password: str, salt: bytes, params: dict[str, int]) -> bytes: + """Argon2id, with the parameters the envelope carries. + + Everything reaching here has already passed the sanity bounds above, so a + failure from `cryptography` means this module's own validation and the + library's disagree. Map it to `EnvelopeError` rather than letting a raw + `ValueError`, `OverflowError` or `MemoryError` escape: a caller catching + `CryptoError` must not have to enumerate the library's exception types to + stay fail-closed. + """ + try: + return Argon2id( + salt=salt, + length=KEY_BYTES, + iterations=params["iterations"], + lanes=params["lanes"], + memory_cost=params["memory_kib"], + ).derive(_normalise(password)) + except (ValueError, OverflowError, MemoryError, TypeError) as exc: + raise EnvelopeError( + f"key derivation rejected the envelope's parameters ({type(exc).__name__})" + ) from exc + + +def seal(plaintext: bytes, password: str, aad: str) -> dict[str, Any]: + """Encrypt `plaintext` under `password`, binding `aad` into the tag. + + A fresh salt and a fresh nonce are drawn from `os.urandom` on every call and + never reused. That is what makes it safe to seal the same plaintext under + the same password repeatedly — as password rotation does across every + envelope in the store — without leaking that the plaintexts are equal, and + it is why nonce reuse (catastrophic for GCM) cannot arise here: no nonce is + ever derived from anything, so there is no scheme to get wrong. + """ + salt = os.urandom(SALT_BYTES) + nonce = os.urandom(NONCE_BYTES) + params = { + "memory_kib": ARGON2ID_MEMORY_KIB, + "iterations": ARGON2ID_ITERATIONS, + "lanes": ARGON2ID_LANES, + } + key = _derive(password, salt, params) + ciphertext = AESGCM(key).encrypt(nonce, plaintext, aad.encode("utf-8")) + return { + "v": ENVELOPE_VERSION, + "kdf": {"name": KDF_NAME, **params}, + "salt": _b64(salt), + "nonce": _b64(nonce), + "ct": _b64(ciphertext), + } + + +def unseal(envelope: Any, password: str, aad: str) -> bytes: + """Open an envelope, or raise. + + Raises `EnvelopeError` when the input is not a well-formed envelope this + version understands, and `DecryptionError` when it is but the tag does not + verify. There is no third outcome: this function never returns bytes it did + not authenticate, so a wrong password cannot yield garbage for a caller to + misinterpret as a credential. + """ + if not isinstance(envelope, dict): + raise EnvelopeError("envelope must be a JSON object") + + version = envelope.get("v") + if version != ENVELOPE_VERSION: + raise EnvelopeError( + f"unsupported envelope version {describe_value(version)} " + f"(this build writes and reads {ENVELOPE_VERSION})" + ) + + kdf = envelope.get("kdf") + if not isinstance(kdf, dict): + raise EnvelopeError("envelope field 'kdf' must be a JSON object") + if kdf.get("name") != KDF_NAME: + raise EnvelopeError( + f"unsupported kdf {describe_value(kdf.get('name'))} (expected {KDF_NAME!r})" + ) + params = { + "memory_kib": _require_int(kdf, "memory_kib", _MIN_MEMORY_KIB, _MAX_MEMORY_KIB), + "iterations": _require_int(kdf, "iterations", _MIN_ITERATIONS, _MAX_ITERATIONS), + "lanes": _require_int(kdf, "lanes", _MIN_LANES, _MAX_LANES), + } + + salt = _unb64(envelope.get("salt"), "salt") + if not _MIN_SALT_BYTES <= len(salt) <= _MAX_SALT_BYTES: + raise EnvelopeError( + f"envelope salt must be between {_MIN_SALT_BYTES} and " + f"{_MAX_SALT_BYTES} bytes, got {len(salt)}" + ) + nonce = _unb64(envelope.get("nonce"), "nonce") + if len(nonce) != NONCE_BYTES: + raise EnvelopeError( + f"envelope nonce must be {NONCE_BYTES} bytes, got {len(nonce)}" + ) + ciphertext = _unb64(envelope.get("ct"), "ct") + + key = _derive(password, salt, params) + try: + return AESGCM(key).decrypt(nonce, ciphertext, aad.encode("utf-8")) + except InvalidTag as exc: + raise DecryptionError( + "could not decrypt: wrong password, or the sealed data does not " + "belong here" + ) from exc + + +def seal_json(payload: Any, password: str, aad: str) -> dict[str, Any]: + """`seal` over a JSON-serialisable value. + + `sort_keys` so that the plaintext depends on the mapping's content and not + on the order a dict happened to be built in — the length of the ciphertext + already leaks the size of the payload, and there is no reason to let its + construction order leak too. + """ + return seal( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8"), + password, + aad, + ) + + +def unseal_json(envelope: Any, password: str, aad: str) -> Any: + """`unseal`, then parse the authenticated plaintext as JSON. + + Parsing only ever happens on bytes GCM has already authenticated, so the + JSON parser is never exposed to attacker-chosen input — an ordering that + matters and is easy to reverse by accident. + """ + plaintext = unseal(envelope, password, aad) + try: + return json.loads(plaintext) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + # Reachable only for an envelope this tool did not write, since the tag + # already proved it was sealed under this password and AAD. The message + # names the failure and nothing about the plaintext. + raise EnvelopeError( + f"decrypted content is not valid JSON ({type(exc).__name__})" + ) from exc diff --git a/src/stackward/store.py b/src/stackward/store.py new file mode 100644 index 0000000..bc7a445 --- /dev/null +++ b/src/stackward/store.py @@ -0,0 +1,700 @@ +"""The credential store: which backend, whose credentials, and where they live. + +A profile is a Pulumi state backend plus the credentials that unlock it. That +pairing is split across two files in `${XDG_CONFIG_HOME:-~/.config}/stackward/`: + + config TOML, mode 0644 — backend identity, and never a credential + credentials JSON, mode 0600 — one sealed envelope per profile + +The split is the point. `config` says *which* bucket, endpoint and region a +profile names; it is safe to read, diff, paste into a ticket, and back up. Only +`credentials` holds anything secret, so only it needs the restrictive mode, and +a user who wants to check what a profile points at never has to open the file +that would show them a key if they mistyped a command. + +**Everything else in this module follows from three refusals.** + +*Never guess a backend.* Selection runs `--profile`, then `STACKWARD_PROFILE`, +then the repository's own `profile =`, then `default_profile`, and then it +errors. There is no built-in default and no fallback: a tool that guesses which +backend it is pointed at can publish state, or a credential, to the wrong place. +A name that comes from a source but does not exist in `config` is an error too — +it never falls through to the next source, because "the profile you asked for is +missing" and "you asked for no profile" are different problems with different +fixes. + +*Never write a credential outside an envelope.* Values arrive as a mapping, go +into `crypto.seal_json`, and come back out only through `resolve_credentials`. +This module has no code path that prints one, logs one, or puts one in an +exception message; error text names profiles, files and key names only. + +*Never leave a half-written store.* Every write goes through `atomic_write`: +a temporary file in the same directory, `fsync`, the mode, then `rename`. The +mode is set before the rename and not after, because "after" is a window in +which the finished file exists at its real path with the wrong permissions. +Password rotation builds the entire re-sealed document in memory first and then +performs exactly one such write, so it re-seals every envelope or none — a +rotation that failed halfway through would leave a store whose profiles need two +different passwords, and no way to tell which is which. + +There is deliberately no CLI command here. This task's brief lists this module, +`crypto.py` and their tests; the commands that drive them (`login`, `exec`, +`credentials`) arrive with the tasks that own them, on top of this API. +""" + +from __future__ import annotations + +import json +import os +import re +import stat +import sys +import tempfile +import tomllib +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from .crypto import ( + CryptoError, + DecryptionError, + describe_value, + seal_json, + unseal_json, +) + +CONFIG_FILENAME = "config" +CREDENTIALS_FILENAME = "credentials" + +DIR_MODE = 0o700 +CONFIG_MODE = 0o644 +CREDENTIALS_MODE = 0o600 + +ENV_PROFILE = "STACKWARD_PROFILE" + +STORE_VERSION = 1 + +# The verifier is a store-level envelope holding a known plaintext, written when +# the store is initialised and opened before anything else. Without it a +# mistyped password at `credentials init` produces a store that looks fine and +# fails at first use, possibly weeks later and on a different machine; with it, +# a wrong password is reported as a wrong password, immediately, and is never +# confused with a corrupt profile envelope. +VERIFIER_KEY = "verifier" +VERIFIER_AAD = "stackward:verifier" +VERIFIER_PLAINTEXT = "stackward credential store" + +# A profile name is used verbatim as the AAD of its envelope, so the set of +# valid names must not contain the verifier's AAD — otherwise a profile could be +# created whose envelope is interchangeable with the verifier's. Excluding ':' +# from the charset is what keeps the two namespaces disjoint; it is a mechanism, +# not a convention, and `test_store.py` proves it by trying the collision. +# +# '.' is excluded too, for a different reason: TOML reads a dot in a table +# header as nesting, so `[profile.a.b]` declares a table `b` inside a table `a` +# and not a profile named `a.b`. Allowing dotted names would mean a name that +# has two spellings — one of which silently parses as something else — for no +# benefit. What remains is exactly the TOML bare-key charset, so every valid +# name is spellable as `[profile.]` with no quoting and no ambiguity. +_PROFILE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$") + +_TOP_LEVEL_KEYS = frozenset({"default_profile", "profile"}) +_PROFILE_KEYS = frozenset({"backend_url", "bucket", "prefix", "endpoint", "region"}) +_COMPONENT_KEYS = ("bucket", "prefix", "endpoint", "region") + + +class StoreError(Exception): + """Anything that stops the store from answering. Callers print these, so no + subclass ever interpolates a credential value into its message.""" + + +class ProfileError(StoreError): + """No profile could be selected, or the selected one does not exist. + + Separate from `StoreError` so a command can tell "you have not told me which + backend to use" — which a user fixes with `--profile` or a config edit — + apart from "the store itself is unusable".""" + + +class PasswordError(StoreError): + """The store password is wrong: the verifier envelope did not open. + + Raised in preference to letting a profile envelope's `DecryptionError` + surface, because the two mean different things to whoever reads them. This + one says "retype your password"; a failure on a profile envelope after the + verifier opened says the store itself is damaged or was tampered with.""" + + +@dataclass(frozen=True) +class Profile: + """One `[profile.]` table: a backend, in one of the two forms. + + `backend_url` is passed through **verbatim** and is never parsed, + normalised or reassembled by this tool — that is what lets `s3://`, + `gs://`, `azblob://` and `file://` all work without this module knowing + anything about any of them, and what keeps a scheme nobody has thought of + yet working too. + + The component form (`bucket`/`prefix`/`endpoint`/`region`) describes an + S3-compatible store in generic terms for users who would rather not + hand-assemble a URL. Composing those components *into* a URL is + deliberately not done here: the query-parameter spelling a Pulumi backend + expects is knowledge about Pulumi, not about profiles, and it belongs with + the command that runs `pulumi login`. This module carries the components as + structured data and stops there. + """ + + name: str + backend_url: str | None = None + bucket: str | None = None + prefix: str | None = None + endpoint: str | None = None + region: str | None = None + + @property + def has_components(self) -> bool: + return any(getattr(self, key) is not None for key in _COMPONENT_KEYS) + + +@dataclass(frozen=True) +class StoreConfig: + """A parsed `config` file: the default profile, and the profiles.""" + + default_profile: str | None = None + profiles: Mapping[str, Profile] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Locations and file handling +# --------------------------------------------------------------------------- + + +def store_dir(home: Path | None = None) -> Path: + """The store directory: `home` when given, otherwise `cli.config_home()`. + + `cli` is imported inside the function rather than at module scope. The + commands that will drive this store live under `commands/`, which `cli` + imports at *its* module scope — so a module-level `from .cli import + config_home` here would close a cycle (`cli` → `commands.x` → `store` → + `cli`) the moment such a command exists, and would fail on a name `cli` has + not defined yet at that point in its own execution. `config.py` avoids the + same cycle the same way; see `enforce_min_version` there. + """ + if home is not None: + return home + from .cli import config_home # local: breaks an import cycle + + return config_home() + + +def config_path(home: Path | None = None) -> Path: + return store_dir(home) / CONFIG_FILENAME + + +def credentials_path(home: Path | None = None) -> Path: + return store_dir(home) / CREDENTIALS_FILENAME + + +def warn_if_permissive(path: Path, allowed: int) -> None: + """Warn on stderr when `path` grants more than `allowed`. + + A warning and not an error: refusing to read a store because a `umask` or a + restored backup widened a mode would lock a user out of their own + credentials, and the value has already been exposed by then — refusing + afterwards protects nothing. The warning goes to stderr so it cannot + contaminate a command's stdout, which callers parse. + """ + try: + mode = stat.S_IMODE(path.stat().st_mode) + except OSError: + return # absent or unreadable: the caller's own read reports it properly + extra = mode & ~allowed + if extra: + print( + f"warning: {path} has mode {mode:04o}, which is more permissive " + f"than {allowed:04o}", + file=sys.stderr, + ) + + +def ensure_store_dir(home: Path | None = None) -> Path: + """Create the store directory if absent, and make sure it is 0700. + + `chmod` runs unconditionally rather than relying on `mkdir(mode=...)`, + whose result the process `umask` modifies — a `umask` of 0 would otherwise + leave a world-readable directory holding a credentials file. + """ + directory = store_dir(home) + try: + directory.mkdir(parents=True, exist_ok=True) + os.chmod(directory, DIR_MODE) + except OSError as exc: + raise StoreError(f"{directory}: cannot create store directory: {exc}") from exc + return directory + + +def atomic_write(path: Path, data: bytes, mode: int) -> None: + """Replace `path` with `data`, atomically, at exactly `mode`. + + A crash at any point leaves either the old file or the new one, never a + truncated one: the content is written to a temporary file in the *same* + directory (so the final step is a rename within one filesystem, which is + atomic — across filesystems it would be a copy, and a copy can be + interrupted), flushed and `fsync`ed so the bytes are on the medium before + anything points at them, and only then renamed into place. + + The mode is set on the file descriptor before the rename. Doing it after + would leave a window in which the completed file is visible at its real + path under whatever `mkstemp` and the `umask` produced. `mkstemp` already + creates at 0600, so that window would not currently be world-readable — but + the ordering is what makes that true regardless of the mode being asked for, + and it is the ordering, not the accident, that the next reader should rely + on. + + On any failure the temporary file is removed, so a failed write leaves + neither a damaged target nor debris beside it. + """ + directory = path.parent + handle, tmp_name = tempfile.mkstemp( + dir=directory, prefix=f".{path.name}.", suffix=".tmp" + ) + tmp = Path(tmp_name) + try: + with os.fdopen(handle, "wb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.fchmod(stream.fileno(), mode) + os.replace(tmp, path) + except BaseException: + # BaseException, not Exception: a KeyboardInterrupt between mkstemp and + # rename would otherwise leave a temporary file holding sealed + # credentials in the store directory forever. + tmp.unlink(missing_ok=True) + raise + + try: + # Make the rename itself durable. Best effort: not every platform + # supports opening a directory for fsync, and by this point the data is + # already in place — failing here would report an error for a write + # that succeeded. + fd = os.open(directory, os.O_RDONLY) + try: + os.fsync(fd) + finally: + os.close(fd) + except OSError: + pass + + +# --------------------------------------------------------------------------- +# `config` — backend identity +# --------------------------------------------------------------------------- + + +def validate_profile_name(name: str, source: str) -> str: + """Reject a name that is not spellable as a bare TOML key or that could + collide with a reserved AAD. `source` names where the value came from, so + the message points at the thing to edit.""" + if not name: + raise ProfileError(f"{source}: profile name is empty") + if not _PROFILE_NAME.match(name): + raise ProfileError( + f"{source}: invalid profile name {name!r}; names may contain " + "letters, digits, '_' and '-', and must start with a letter or digit" + ) + return name + + +def _require_str(table: Mapping[str, Any], key: str, where: str) -> str | None: + if key not in table: + return None + value = table[key] + if not isinstance(value, str): + raise StoreError(f"{where}.{key} must be a string") + return value + + +def _build_profile(name: str, raw: Any) -> Profile: + if not isinstance(raw, dict): + raise StoreError(f"profile.{name} must be a table") + where = f"profile.{name}" + + unknown = set(raw) - _PROFILE_KEYS + if unknown: + raise StoreError(f"unknown key {where}.{sorted(unknown)[0]}") + + profile = Profile( + name=name, + backend_url=_require_str(raw, "backend_url", where), + bucket=_require_str(raw, "bucket", where), + prefix=_require_str(raw, "prefix", where), + endpoint=_require_str(raw, "endpoint", where), + region=_require_str(raw, "region", where), + ) + + # Exactly one form. Both would leave two answers to "which backend?" with + # no rule for choosing; neither leaves none, and there is no default to fall + # back on. + if profile.backend_url is not None and profile.has_components: + raise StoreError( + f"{where}: set either backend_url or the component form " + "(bucket/prefix/endpoint/region), not both" + ) + if profile.backend_url is None and not profile.has_components: + raise StoreError( + f"{where}: needs a backend — either backend_url or the component " + "form (bucket/prefix/endpoint/region)" + ) + if profile.backend_url is None and profile.bucket is None: + raise StoreError(f"{where}: the component form requires bucket") + return profile + + +def load_store_config(home: Path | None = None) -> StoreConfig: + """Parse `config`, or return an empty one when the file does not exist. + + Absent is a normal state for a user who has not set the tool up yet; the + profile that cannot then be selected is reported by `select_profile`, which + can say what to do about it. Present-but-invalid raises — a `config` with a + typo must never be treated as though it were absent, since that would turn + a broken profile into a silently missing one. + """ + path = config_path(home) + if not path.exists(): + return StoreConfig() + + warn_if_permissive(path, CONFIG_MODE) + try: + with path.open("rb") as handle: + data = tomllib.load(handle) + except OSError as exc: + raise StoreError(f"{path}: cannot read: {exc}") from exc + except tomllib.TOMLDecodeError as exc: + # `config` holds backend identity and never a credential, so quoting + # tomllib's message — which may include the offending line — is safe + # here in a way it would not be for `credentials`. + raise StoreError(f"{path}: invalid TOML: {exc}") from exc + + try: + return _build_store_config(data) + except StoreError as exc: + raise StoreError(f"{path}: {exc}") from exc + + +def _build_store_config(data: Mapping[str, Any]) -> StoreConfig: + unknown = set(data) - _TOP_LEVEL_KEYS + if unknown: + raise StoreError(f"unknown key {sorted(unknown)[0]!r}") + + default_profile = _require_str(data, "default_profile", "config") + if default_profile is not None: + validate_profile_name(default_profile, "default_profile") + + raw_profiles = data.get("profile", {}) + if not isinstance(raw_profiles, dict): + raise StoreError("'profile' must be a table of tables") + + profiles: dict[str, Profile] = {} + for name, raw in raw_profiles.items(): + validate_profile_name(name, "config") + profiles[name] = _build_profile(name, raw) + + if default_profile is not None and default_profile not in profiles: + raise StoreError( + f"default_profile is {default_profile!r}, which has no " + f"[profile.{default_profile}] table" + ) + return StoreConfig(default_profile=default_profile, profiles=profiles) + + +# --------------------------------------------------------------------------- +# Profile selection +# --------------------------------------------------------------------------- + + +def select_profile( + explicit: str | None = None, + *, + config: StoreConfig, + repo_profile: str | None = None, + environ: Mapping[str, str] | None = None, +) -> Profile: + """Resolve which profile to use. First source that *speaks* wins. + + `--profile`, then `STACKWARD_PROFILE`, then the repository's own `profile =` + (the normal case — a repository's stacks live in one backend, which is a + fact about the repository and not about whoever's shell is running), then + `default_profile`, then an error naming every source that could have + answered. + + "Speaks" means *present*, not *valid*. A source that names a profile which + does not exist is an error, not a miss: falling through to the next source + would run the command against a different backend than the one asked for, + which is the single worst thing this tool could do quietly. An empty value + is likewise an error rather than a miss — `STACKWARD_PROFILE=""` in a CI + environment is a broken variable, not an absent one, and treating it as + absent would silently substitute the default. + """ + environ = os.environ if environ is None else environ + + sources: list[tuple[str, str | None]] = [ + ("--profile", explicit), + (ENV_PROFILE, environ.get(ENV_PROFILE)), + ("the repository's .stackward.toml 'profile'", repo_profile), + ("default_profile in the store config", config.default_profile), + ] + + for source, value in sources: + if value is None: + continue + name = validate_profile_name(value, source) + profile = config.profiles.get(name) + if profile is None: + raise ProfileError( + f"{source} selects profile {name!r}, which has no " + f"[profile.{name}] table in {CONFIG_FILENAME}" + ) + return profile + + raise ProfileError( + "no profile selected and there is no default: pass --profile, set " + f"{ENV_PROFILE}, set 'profile' in the repository's .stackward.toml, or " + f"set default_profile in {CONFIG_FILENAME}" + ) + + +# --------------------------------------------------------------------------- +# `credentials` — the sealed envelopes +# --------------------------------------------------------------------------- + + +def _read_document(home: Path | None = None) -> dict[str, Any]: + path = credentials_path(home) + warn_if_permissive(path, CREDENTIALS_MODE) + try: + raw = path.read_bytes() + except FileNotFoundError as exc: + raise StoreError( + f"{path}: no credential store here yet — initialise one first" + ) from exc + except OSError as exc: + raise StoreError(f"{path}: cannot read: {exc}") from exc + + try: + document = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + # Only the failure type, never the parser's message: the message quotes + # the offending text, and this is the one file that holds ciphertext + # the user may have pasted a plaintext into by mistake. + raise StoreError( + f"{path}: not a valid credential store ({type(exc).__name__})" + ) from exc + + if not isinstance(document, dict): + raise StoreError(f"{path}: not a valid credential store") + if document.get("v") != STORE_VERSION: + # `_describe`, not the raw value: this field is hand-editable and lives + # in the one file expected to hold secrets, so a credential pasted into + # it must not be read back out by the error that caught the mistake. + raise StoreError( + f"{path}: unsupported store version {describe_value(document.get('v'))} " + f"(this build reads {STORE_VERSION})" + ) + if not isinstance(document.get("profiles"), dict): + raise StoreError(f"{path}: not a valid credential store") + return document + + +def _write_document(document: Mapping[str, Any], home: Path | None = None) -> None: + ensure_store_dir(home) + payload = json.dumps(document, indent=2, sort_keys=True).encode("utf-8") + b"\n" + atomic_write(credentials_path(home), payload, CREDENTIALS_MODE) + + +def _check_password(document: Mapping[str, Any], password: str) -> None: + """Open the verifier, and fail with `PasswordError` if it does not. + + Everything that touches an envelope goes through here first, so a wrong + password is always reported as a wrong password — before any profile + envelope is attempted, and therefore before a decryption failure could be + mistaken for a damaged store. + """ + envelope = document.get(VERIFIER_KEY) + if envelope is None: + raise StoreError( + "credential store has no verifier: it was not written by this tool, " + "or it is damaged" + ) + try: + plaintext = unseal_json(envelope, password, VERIFIER_AAD) + except DecryptionError as exc: + raise PasswordError("wrong password for the credential store") from exc + except CryptoError as exc: + raise StoreError(f"credential store verifier is unusable: {exc}") from exc + + if plaintext != VERIFIER_PLAINTEXT: + # The GCM tag already proves the plaintext is the one sealed under this + # password and AAD, so this compares a value nothing else could have + # produced. It is kept because it is what makes the verifier a + # *known-plaintext* check rather than an implicit property of the tag, + # and because a future format change that broke the invariant would + # otherwise pass silently. + raise StoreError("credential store verifier did not match") + + +def _require_credentials(payload: Any, profile: str) -> dict[str, str]: + """A decrypted payload must be a flat mapping of names to strings. + + This is the one place in this module where decrypted credential values are + in scope at the moment an exception is raised, so the message below + interpolates only the profile name and a fixed string. + + Do not "improve" it by naming the offending entry. A key here would + ordinarily be an environment variable name and harmless — but the payload + is whatever was sealed, and a mapping built the wrong way round + (`{value: name}`) puts a credential in the key position. The safety of this + branch rests on interpolating neither half of an entry, not on an + assumption about which half is sensitive. + """ + if not isinstance(payload, dict): + raise StoreError(f"credentials for profile {profile!r} are malformed") + result: dict[str, str] = {} + for key, value in payload.items(): + if not isinstance(key, str) or not isinstance(value, str): + raise StoreError( + f"credentials for profile {profile!r}: every entry must be a " + "string name with a string value" + ) + result[key] = value + return result + + +def init_store(password: str, *, home: Path | None = None) -> None: + """Create the store directory and an empty `credentials` holding a verifier. + + Refuses to overwrite an existing store: doing so would discard every sealed + envelope in it, irrecoverably, in response to a command a user could plausibly + run twice. + """ + ensure_store_dir(home) + path = credentials_path(home) + if path.exists(): + raise StoreError(f"{path}: a credential store already exists here") + _write_document( + { + "v": STORE_VERSION, + VERIFIER_KEY: seal_json(VERIFIER_PLAINTEXT, password, VERIFIER_AAD), + "profiles": {}, + }, + home, + ) + + +def store_profiles(home: Path | None = None) -> list[str]: + """Names that have a sealed envelope, sorted. Reads no password and opens + nothing, so it is safe to call for a listing.""" + return sorted(_read_document(home)["profiles"]) + + +def set_credentials( + profile: str, + credentials: Mapping[str, str], + password: str, + *, + home: Path | None = None, +) -> None: + """Seal `credentials` as `profile`'s envelope, replacing any existing one. + + The password is checked against the verifier first, so a mistyped password + cannot add an envelope that nothing else in the store can open. + """ + validate_profile_name(profile, "profile") + for key, value in credentials.items(): + if not isinstance(key, str) or not isinstance(value, str): + raise StoreError("credentials must be a mapping of strings to strings") + + document = _read_document(home) + _check_password(document, password) + document["profiles"][profile] = seal_json(dict(credentials), password, profile) + _write_document(document, home) + + +def resolve_credentials( + profile: str, password: str, *, home: Path | None = None +) -> dict[str, str]: + """The bootstrap values for `profile`: names to values, decrypted. + + This is the function the `file` credential-store provider will register in + Task 11 — one name, a profile, a mapping back — so that adding an `env` or + a remote provider adapts this rather than rewriting it. It deliberately does + not know *which* names a caller wants: the environment variables a Pulumi + backend needs are the calling command's business, and naming them here would + put a vendor's vocabulary into the store. + + The password is not read from anywhere. Prompting, `getpass`, and any + environment fallback belong to the command; a library that could prompt + would be a library that can block a hook. + """ + document = _read_document(home) + _check_password(document, password) + + envelope = document["profiles"].get(profile) + if envelope is None: + raise ProfileError(f"profile {profile!r} has no credentials in the store") + + try: + payload = unseal_json(envelope, password, profile) + except DecryptionError as exc: + # The verifier already opened under this password, so the password is + # right and this envelope is not the one that belongs here — the AAD + # mismatch an envelope moved between profiles produces, or a modified + # ciphertext. Either way it is damage, not a typo. + raise StoreError( + f"profile {profile!r}: sealed data does not belong to this profile, " + "or has been modified" + ) from exc + except CryptoError as exc: + raise StoreError(f"profile {profile!r}: {exc}") from exc + + return _require_credentials(payload, profile) + + +def rotate_password( + old_password: str, new_password: str, *, home: Path | None = None +) -> None: + """Re-seal every envelope under `new_password`, or change nothing. + + The whole document is rebuilt in memory and written once, so a failure at + any point — a damaged envelope, an interrupted process, a full disk — + leaves the store exactly as it was, still opening under the old password. + A partial rotation would be the worst outcome available: a store whose + profiles need different passwords, with nothing recording which is which. + + Every envelope *in the credentials file* is re-sealed, not every profile in + `config`. An envelope whose `[profile.]` table has been removed is + unreachable but not gone, and rotating past it would quietly destroy it. + """ + document = _read_document(home) + _check_password(document, old_password) + + rotated: dict[str, Any] = {} + for name, envelope in document["profiles"].items(): + try: + payload = unseal_json(envelope, old_password, name) + except CryptoError as exc: + raise StoreError( + f"profile {name!r}: cannot re-seal, its envelope did not open " + f"({type(exc).__name__}); nothing has been changed" + ) from exc + rotated[name] = seal_json(payload, new_password, name) + + _write_document( + { + "v": STORE_VERSION, + VERIFIER_KEY: seal_json(VERIFIER_PLAINTEXT, new_password, VERIFIER_AAD), + "profiles": rotated, + }, + home, + ) diff --git a/tests/test_crypto.py b/tests/test_crypto.py new file mode 100644 index 0000000..b0c6c47 --- /dev/null +++ b/tests/test_crypto.py @@ -0,0 +1,294 @@ +"""Tests for the sealed envelope. + +Each test is named for the property it proves, and several are written +specifically so that they cannot pass for the wrong reason. The three that +matter most: + +- a wrong password must fail *through the GCM tag*, so the test asserts on the + underlying `InvalidTag` and not merely on "something was raised" — a length + check or a plaintext comparison would satisfy the weaker assertion while + leaving unauthenticated bytes reachable; +- an envelope must not open under a different AAD, which is the only evidence + that the AAD is genuinely bound rather than passed and ignored; +- the ciphertext must contain none of the plaintext, asserted against a known + marker value's bytes in three encodings, so that "we base64-encoded it and + called it sealed" fails loudly. + +No test prints a password or a plaintext on any path, including a failing one: +assertions are on absence and on exception types, never on captured secret text. +""" + +from __future__ import annotations + +import base64 +import json + +import pytest +from cryptography.exceptions import InvalidTag + +from stackward import crypto +from stackward.crypto import ( + ARGON2ID_ITERATIONS, + ARGON2ID_LANES, + ARGON2ID_MEMORY_KIB, + ENVELOPE_VERSION, + KDF_NAME, + KEY_BYTES, + NONCE_BYTES, + SALT_BYTES, + DecryptionError, + EnvelopeError, + seal, + seal_json, + unseal, + unseal_json, +) + +PASSWORD = "correct password for these tests" +OTHER_PASSWORD = "a different password entirely" +AAD = "profile-one" +OTHER_AAD = "profile-two" + +# A value chosen to be findable: if any part of it survives into the envelope, +# the absence assertions below will say so. +MARKER = b"marker-value-never-to-appear-in-ciphertext" + + +def test_seal_and_unseal_round_trip_returns_the_exact_plaintext(): + envelope = seal(MARKER, PASSWORD, AAD) + assert unseal(envelope, PASSWORD, AAD) == MARKER + + +def test_a_wrong_password_fails_through_the_gcm_tag_rather_than_returning_garbage(): + """The failure must come from authentication, not from a check this module + performs after the fact — otherwise unauthenticated plaintext exists + somewhere in the call, however briefly.""" + envelope = seal(MARKER, PASSWORD, AAD) + with pytest.raises(DecryptionError) as raised: + unseal(envelope, OTHER_PASSWORD, AAD) + assert isinstance(raised.value.__cause__, InvalidTag) + + +def test_an_envelope_sealed_under_one_profile_does_not_open_under_another(): + """The AAD is what stops an envelope being moved between profiles. A build + that accepted the `aad` argument and never passed it to AES-GCM would pass + the round-trip test above and fail here.""" + envelope = seal(MARKER, PASSWORD, AAD) + with pytest.raises(DecryptionError) as raised: + unseal(envelope, PASSWORD, OTHER_AAD) + assert isinstance(raised.value.__cause__, InvalidTag) + + +def test_the_envelope_contains_none_of_the_plaintext(): + envelope = json.dumps(seal(MARKER, PASSWORD, AAD)).encode("utf-8") + assert MARKER not in envelope + assert base64.b64encode(MARKER) not in envelope + assert MARKER.hex().encode("ascii") not in envelope + + +def test_the_envelope_contains_none_of_the_password(): + envelope = json.dumps(seal(MARKER, PASSWORD, AAD)).encode("utf-8") + assert PASSWORD.encode("utf-8") not in envelope + + +def test_every_seal_draws_a_fresh_salt_and_nonce(): + """Sealing the same plaintext twice must produce three different fields. + Equal ciphertexts would mean a reused nonce, which for GCM is not a + weakness but a break.""" + first = seal(MARKER, PASSWORD, AAD) + second = seal(MARKER, PASSWORD, AAD) + assert first["salt"] != second["salt"] + assert first["nonce"] != second["nonce"] + assert first["ct"] != second["ct"] + + +def test_a_modified_ciphertext_is_refused(): + envelope = seal(MARKER, PASSWORD, AAD) + raw = bytearray(base64.b64decode(envelope["ct"])) + raw[0] ^= 0x01 + envelope["ct"] = base64.b64encode(bytes(raw)).decode("ascii") + with pytest.raises(DecryptionError) as raised: + unseal(envelope, PASSWORD, AAD) + assert isinstance(raised.value.__cause__, InvalidTag) + + +def test_the_envelope_has_exactly_the_documented_fields(): + envelope = seal(MARKER, PASSWORD, AAD) + assert set(envelope) == {"v", "kdf", "salt", "nonce", "ct"} + assert envelope["v"] == ENVELOPE_VERSION + assert envelope["kdf"]["name"] == KDF_NAME + for field in ("salt", "nonce", "ct"): + assert isinstance(envelope[field], str) + base64.b64decode(envelope[field], validate=True) + + +def test_the_shipped_cost_parameters_are_the_documented_ones(): + """Pinned deliberately. `tests/test_store.py` lowers these for speed, so + without this test a production weakening would go unnoticed.""" + assert (ARGON2ID_MEMORY_KIB, ARGON2ID_ITERATIONS, ARGON2ID_LANES) == (65536, 3, 4) + assert (KEY_BYTES, SALT_BYTES, NONCE_BYTES) == (32, 16, 12) + + +def test_the_envelope_records_its_parameters_so_a_later_default_change_still_opens_it( + monkeypatch, +): + """The reason `kdf` carries parameters rather than only a name: raising the + cost for new envelopes must not orphan every envelope already on disk.""" + envelope = seal(MARKER, PASSWORD, AAD) + monkeypatch.setattr(crypto, "ARGON2ID_MEMORY_KIB", ARGON2ID_MEMORY_KIB * 2) + monkeypatch.setattr(crypto, "ARGON2ID_ITERATIONS", ARGON2ID_ITERATIONS + 1) + assert unseal(envelope, PASSWORD, AAD) == MARKER + assert seal(MARKER, PASSWORD, AAD)["kdf"]["memory_kib"] == ARGON2ID_MEMORY_KIB * 2 + + +def test_a_password_is_normalised_so_the_same_characters_open_the_same_envelope(): + """The same text entered on two platforms can arrive as two different byte + sequences — precomposed or decomposed. Without NFC normalisation the store + would open only on the machine that created it, and the failure would look + like a forgotten password.""" + # Written as escapes rather than as literal characters: an editor or a + # tool that normalised this source file would otherwise quietly turn + # this into a test of nothing. + precomposed = "passw\u00f6rd" # o-with-diaeresis, one code point + decomposed = "passwo\u0308rd" # o, then a combining diaeresis + assert precomposed != decomposed + envelope = seal(MARKER, precomposed, AAD) + assert unseal(envelope, decomposed, AAD) == MARKER + + +# --------------------------------------------------------------------------- +# Malformed input. `credentials` is hand-editable, so every one of these is +# reachable, and each must produce this module's own error rather than the +# library's — a caller catching `CryptoError` must not also have to enumerate +# `ValueError`, `OverflowError`, `TypeError` and `MemoryError` to stay closed. +# --------------------------------------------------------------------------- + + +def test_an_unsupported_envelope_version_is_refused(): + envelope = seal(MARKER, PASSWORD, AAD) + envelope["v"] = ENVELOPE_VERSION + 1 + with pytest.raises(EnvelopeError): + unseal(envelope, PASSWORD, AAD) + + +def test_an_unknown_kdf_is_refused(): + envelope = seal(MARKER, PASSWORD, AAD) + envelope["kdf"]["name"] = "something-else" + with pytest.raises(EnvelopeError): + unseal(envelope, PASSWORD, AAD) + + +@pytest.mark.parametrize( + ("key", "value"), + [ + ("memory_kib", 2**31), # `cryptography` answers this with a MemoryError + ("memory_kib", 1), + ("memory_kib", 0), + ("memory_kib", -1), + ("memory_kib", "65536"), + ("memory_kib", True), + ("memory_kib", 65536.0), + ("iterations", 0), + ("iterations", -1), + # The case the bounds exist for: without the ceiling this does not + # raise, it runs — the test hangs rather than failing. + ("iterations", 10**9), + ("lanes", 0), + ("lanes", 2**40), + ("lanes", None), + ], +) +def test_a_hand_edited_kdf_parameter_is_refused_before_it_reaches_the_library(key, value): + envelope = seal(MARKER, PASSWORD, AAD) + envelope["kdf"][key] = value + with pytest.raises(EnvelopeError): + unseal(envelope, PASSWORD, AAD) + + +@pytest.mark.parametrize("field", ["salt", "nonce", "ct"]) +def test_a_field_that_is_not_valid_base64_is_refused(field): + envelope = seal(MARKER, PASSWORD, AAD) + envelope[field] = "not base64!!" + with pytest.raises(EnvelopeError): + unseal(envelope, PASSWORD, AAD) + + +@pytest.mark.parametrize("field", ["salt", "nonce", "ct", "kdf", "v"]) +def test_a_missing_field_is_refused(field): + envelope = seal(MARKER, PASSWORD, AAD) + del envelope[field] + with pytest.raises(EnvelopeError): + unseal(envelope, PASSWORD, AAD) + + +def test_a_nonce_of_the_wrong_length_is_refused(): + envelope = seal(MARKER, PASSWORD, AAD) + envelope["nonce"] = base64.b64encode(b"\x00" * (NONCE_BYTES + 1)).decode("ascii") + with pytest.raises(EnvelopeError): + unseal(envelope, PASSWORD, AAD) + + +def test_a_salt_shorter_than_the_floor_is_refused(): + envelope = seal(MARKER, PASSWORD, AAD) + envelope["salt"] = base64.b64encode(b"\x00" * 4).decode("ascii") + with pytest.raises(EnvelopeError): + unseal(envelope, PASSWORD, AAD) + + +@pytest.mark.parametrize("envelope", ["a string", 17, None, ["a", "list"]]) +def test_something_that_is_not_an_envelope_at_all_is_refused(envelope): + with pytest.raises(EnvelopeError): + unseal(envelope, PASSWORD, AAD) + + +# --------------------------------------------------------------------------- +# The JSON layer +# --------------------------------------------------------------------------- + + +def test_seal_json_round_trips_a_mapping(): + payload = {"NAME_ONE": "value-one", "NAME_TWO": "value-two"} + envelope = seal_json(payload, PASSWORD, AAD) + assert unseal_json(envelope, PASSWORD, AAD) == payload + + +def test_seal_json_does_not_depend_on_the_order_a_mapping_was_built_in(): + first = seal_json({"A": "1", "B": "2"}, PASSWORD, AAD) + second = seal_json({"B": "2", "A": "1"}, PASSWORD, AAD) + assert len(base64.b64decode(first["ct"])) == len(base64.b64decode(second["ct"])) + assert unseal_json(first, PASSWORD, AAD) == unseal_json(second, PASSWORD, AAD) + + +def test_authenticated_plaintext_that_is_not_json_is_reported_rather_than_crashing(): + envelope = seal(b"\xff\xfe not json at all", PASSWORD, AAD) + with pytest.raises(EnvelopeError): + unseal_json(envelope, PASSWORD, AAD) + + +def test_unseal_json_does_not_parse_anything_the_tag_rejected(): + """Ordering property: JSON parsing must sit behind authentication, never in + front of it. A wrong password produces a decryption error, never a parse + error, because nothing is parsed at all.""" + envelope = seal_json({"NAME": "value"}, PASSWORD, AAD) + with pytest.raises(DecryptionError): + unseal_json(envelope, OTHER_PASSWORD, AAD) + + +def test_no_error_message_repeats_the_password_or_the_plaintext(): + envelope = seal(MARKER, PASSWORD, AAD) + messages: list[str] = [] + for broken in ( + {**envelope, "v": 99}, + {**envelope, "salt": "!!"}, + {**envelope, "kdf": {**envelope["kdf"], "memory_kib": 2**31}}, + envelope, + ): + try: + unseal(broken, OTHER_PASSWORD, OTHER_AAD) + except Exception as exc: # noqa: BLE001 - the messages are the subject + messages.append(str(exc)) + assert messages + for message in messages: + assert MARKER.decode("ascii") not in message + assert PASSWORD not in message + assert OTHER_PASSWORD not in message diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..332dbf0 --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,732 @@ +"""Tests for the credential store: profiles, file handling, and the store API. + +Two things these tests are careful about. + +*They never assert on a credential value being present in output.* A test that +proved a value was printed would itself print it on failure. Where a value must +not appear, the assertion is on absence; where a value must round-trip, it is +compared to a constant, not displayed. + +*Several are written to fail against the plausible wrong implementation rather +than merely to exercise the right one.* `--profile` naming a missing profile +must not fall through to `default_profile`; rotation must re-seal an envelope +whose config table has been deleted; an atomic write that fails must leave both +the original file and the directory as they were. Each of those is a bug a +passing "happy path" suite would not notice. + +The Argon2id cost is lowered here by an autouse fixture — these tests are about +store semantics, not about how expensive a guess is, and the shipped parameters +are pinned separately in `tests/test_crypto.py`. +""" + +from __future__ import annotations + +import base64 +import json +import os +import stat + +import pytest + +from stackward import crypto, store +from stackward.store import ( + CONFIG_MODE, + CREDENTIALS_MODE, + DIR_MODE, + ENV_PROFILE, + PasswordError, + Profile, + ProfileError, + StoreConfig, + StoreError, + atomic_write, + init_store, + load_store_config, + resolve_credentials, + rotate_password, + select_profile, + set_credentials, + store_profiles, +) + +PASSWORD = "the store password" +NEW_PASSWORD = "the rotated store password" +WRONG_PASSWORD = "not the store password" + +# Placeholder credential names and values. The names are arbitrary here on +# purpose: the store does not know which variables a backend needs, and naming +# real ones would put a vendor's vocabulary into a module that has none. +MARKER = "marker-credential-value-8f2c41" +CREDENTIALS = {"NAME_ONE": MARKER, "NAME_TWO": "second-placeholder-value"} + + +@pytest.fixture(autouse=True) +def cheap_kdf(monkeypatch): + """Lower the Argon2id cost for this module only. + + At the shipped parameters every seal costs about 60 ms, and this file + performs several hundred. The properties under test here — precedence, + atomicity, all-or-nothing rotation — do not depend on the cost, and + `test_crypto.py::test_the_shipped_cost_parameters_are_the_documented_ones` + is what stops this fixture hiding a production weakening. + """ + monkeypatch.setattr(crypto, "ARGON2ID_MEMORY_KIB", 8192) + monkeypatch.setattr(crypto, "ARGON2ID_ITERATIONS", 1) + monkeypatch.setattr(crypto, "ARGON2ID_LANES", 1) + + +@pytest.fixture +def home(tmp_path): + """An isolated store directory. Every store function takes `home`, so no + test depends on the real `XDG_CONFIG_HOME` or writes outside `tmp_path`.""" + return tmp_path / "stackward" + + +def write_config(home, text: str): + home.mkdir(parents=True, exist_ok=True) + path = home / "config" + path.write_text(text) + # At the required mode, so that these tests do not trip the permission + # warning as a side effect of whatever umask the suite runs under. The + # warning has its own tests below. + os.chmod(path, CONFIG_MODE) + return path + + +def config_with(*names: str, default: str | None = None) -> str: + lines = [] if default is None else [f'default_profile = "{default}"', ""] + for name in names: + lines += [f"[profile.{name}]", 'backend_url = "file:///placeholder"', ""] + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# `config`: backend identity +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "url", + [ + "s3://placeholder-bucket/placeholder-prefix", + "gs://placeholder-bucket", + "azblob://placeholder-container", + "file:///placeholder/path", + "s3://placeholder/prefix?endpoint=placeholder.invalid®ion=placeholder", + "some-future-scheme://placeholder", + ], +) +def test_a_backend_url_is_carried_through_byte_for_byte(home, url): + """Pass-through, not parsing. Any normalising or reassembling this module + did would show up here as a changed string, and would be the thing that + stopped an unanticipated scheme from working.""" + write_config(home, f"[profile.one]\nbackend_url = {json.dumps(url)}\n") + assert load_store_config(home).profiles["one"].backend_url == url + + +def test_the_component_form_is_carried_as_structured_data(home): + write_config( + home, + "[profile.one]\n" + 'bucket = "placeholder-bucket"\n' + 'prefix = "placeholder/prefix"\n' + 'endpoint = "https://placeholder.invalid"\n' + 'region = "placeholder-region"\n', + ) + profile = load_store_config(home).profiles["one"] + assert profile.backend_url is None + assert profile.bucket == "placeholder-bucket" + assert profile.prefix == "placeholder/prefix" + assert profile.endpoint == "https://placeholder.invalid" + assert profile.region == "placeholder-region" + + +def test_a_profile_carrying_both_forms_is_refused(home): + """Two answers to "which backend?" with no rule for choosing between them.""" + write_config( + home, + '[profile.one]\nbackend_url = "file:///placeholder"\nbucket = "placeholder"\n', + ) + with pytest.raises(StoreError): + load_store_config(home) + + +def test_a_profile_carrying_neither_form_is_refused(home): + """There is no default backend to fall back on, so an empty profile is an + error and not an inherited one.""" + write_config(home, "[profile.one]\n") + with pytest.raises(StoreError): + load_store_config(home) + + +def test_the_component_form_without_a_bucket_is_refused(home): + write_config(home, '[profile.one]\nregion = "placeholder-region"\n') + with pytest.raises(StoreError): + load_store_config(home) + + +def test_an_unknown_profile_key_is_refused_rather_than_ignored(home): + write_config( + home, + '[profile.one]\nbackend_url = "file:///placeholder"\nbucket_name = "typo"\n', + ) + with pytest.raises(StoreError): + load_store_config(home) + + +def test_a_default_profile_with_no_table_is_refused(home): + write_config(home, 'default_profile = "missing"\n' + config_with("one")) + with pytest.raises(StoreError): + load_store_config(home) + + +def test_an_absent_config_is_empty_rather_than_an_error(home): + """Not having set the tool up yet is a normal state. It is `select_profile` + that reports it, because only it can say what to do about it.""" + assert load_store_config(home) == StoreConfig() + + +def test_an_invalid_config_raises_rather_than_reading_as_absent(home): + """A typo must not degrade into "no profiles", which would turn a broken + backend into a silently missing one.""" + write_config(home, "[profile.one\nbackend_url = ") + with pytest.raises(StoreError): + load_store_config(home) + + +# --------------------------------------------------------------------------- +# Profile selection precedence +# --------------------------------------------------------------------------- + + +@pytest.fixture +def four_profiles(home): + write_config(home, config_with("chosen", "from_env", "from_repo", "fallback")) + return load_store_config(home) + + +def test_the_explicit_profile_wins_over_every_other_source(four_profiles): + selected = select_profile( + "chosen", + config=four_profiles, + repo_profile="from_repo", + environ={ENV_PROFILE: "from_env"}, + ) + assert selected.name == "chosen" + + +def test_the_environment_wins_when_no_explicit_profile_was_given(four_profiles): + selected = select_profile( + config=four_profiles, + repo_profile="from_repo", + environ={ENV_PROFILE: "from_env"}, + ) + assert selected.name == "from_env" + + +def test_the_repository_config_wins_over_the_default_profile(home): + write_config(home, config_with("from_repo", "fallback", default="fallback")) + selected = select_profile( + config=load_store_config(home), repo_profile="from_repo", environ={} + ) + assert selected.name == "from_repo" + + +def test_the_default_profile_is_the_last_resort(home): + write_config(home, config_with("fallback", default="fallback")) + selected = select_profile(config=load_store_config(home), environ={}) + assert selected.name == "fallback" + + +def test_no_profile_anywhere_is_an_error_and_never_a_guess(four_profiles): + with pytest.raises(ProfileError): + select_profile(config=four_profiles, environ={}) + + +def test_a_named_profile_that_does_not_exist_errors_instead_of_using_the_default(home): + """The case that separates "first source that *speaks* wins" from "first + source that *resolves* wins". Falling through here would run the command + against a backend nobody asked for — the worst thing this tool could do + quietly — and a loop that skipped invalid sources would pass every other + precedence test in this file. + """ + write_config(home, config_with("fallback", default="fallback")) + config = load_store_config(home) + for explicit, environ, repo_profile in ( + ("absent", {}, None), + (None, {ENV_PROFILE: "absent"}, None), + (None, {}, "absent"), + ): + with pytest.raises(ProfileError) as raised: + select_profile( + explicit, config=config, environ=environ, repo_profile=repo_profile + ) + assert "absent" in str(raised.value) + assert "fallback" not in str(raised.value) + + +def test_an_empty_environment_variable_is_an_error_rather_than_a_miss(home): + """A variable set to the empty string is a broken variable, not an absent + one. Skipping it would silently substitute the default.""" + write_config(home, config_with("fallback", default="fallback")) + with pytest.raises(ProfileError): + select_profile(config=load_store_config(home), environ={ENV_PROFILE: ""}) + + +def test_a_profile_name_that_would_collide_with_the_verifier_is_rejected(home): + """The verifier's AAD is `stackward:verifier`. Excluding ':' from the name + charset is the mechanism that keeps the two AAD namespaces disjoint, so a + profile envelope can never be interchangeable with the verifier's.""" + with pytest.raises(ProfileError): + store.validate_profile_name("stackward:verifier", "test") + + +@pytest.mark.parametrize( + "name", + [ + "with space", + "-leading-dash", + "with/slash", + "with:colon", + # TOML reads a dot in a table header as nesting, so `[profile.with.dot]` + # declares a table inside a table rather than this profile. + "with.dot", + "", + "wîth-å", + ], +) +def test_an_unspellable_profile_name_is_rejected(name): + with pytest.raises(ProfileError): + store.validate_profile_name(name, "test") + + +# --------------------------------------------------------------------------- +# File handling +# --------------------------------------------------------------------------- + + +def test_the_store_directory_is_created_private_whatever_the_umask(home): + previous = os.umask(0) + try: + store.ensure_store_dir(home) + finally: + os.umask(previous) + assert stat.S_IMODE(home.stat().st_mode) == DIR_MODE + + +def test_the_credentials_file_is_written_private_whatever_the_umask(home): + previous = os.umask(0) + try: + init_store(PASSWORD, home=home) + finally: + os.umask(previous) + assert stat.S_IMODE((home / "credentials").stat().st_mode) == CREDENTIALS_MODE + + +@pytest.mark.parametrize("mode", [CREDENTIALS_MODE, CONFIG_MODE]) +def test_the_mode_is_set_before_the_rename_not_after(home, monkeypatch, mode): + """Setting the mode after the rename leaves a window in which the finished + file is visible at its real path with whatever mode it was created with. + This records the mode of the temporary file at the moment of the rename. + + Both modes are checked because only one of them discriminates: `mkstemp` + already creates at 0600, so for the credentials file a mode set after the + rename would look identical and this test would prove nothing. 0644 is what + makes the ordering observable. + """ + home.mkdir(parents=True) + real_replace = os.replace + observed: list[int] = [] + + def spy(src, dst): + observed.append(stat.S_IMODE(os.stat(src).st_mode)) + return real_replace(src, dst) + + monkeypatch.setattr(os, "replace", spy) + atomic_write(home / "credentials", b"content", mode) + assert observed == [mode] + + +def test_a_failed_write_leaves_the_original_file_intact(home, monkeypatch): + target = home / "credentials" + home.mkdir(parents=True) + target.write_bytes(b"the original content") + + def boom(*_args, **_kwargs): + raise OSError("induced failure") + + monkeypatch.setattr(os, "fsync", boom) + with pytest.raises(OSError): + atomic_write(target, b"the replacement content", CREDENTIALS_MODE) + assert target.read_bytes() == b"the original content" + + +def test_a_failed_rename_leaves_the_original_file_intact(home, monkeypatch): + target = home / "credentials" + home.mkdir(parents=True) + target.write_bytes(b"the original content") + + def boom(*_args, **_kwargs): + raise OSError("induced failure") + + monkeypatch.setattr(os, "replace", boom) + with pytest.raises(OSError): + atomic_write(target, b"the replacement content", CREDENTIALS_MODE) + assert target.read_bytes() == b"the original content" + + +@pytest.mark.parametrize("failing", ["fsync", "replace"]) +def test_a_failed_write_leaves_no_temporary_file_behind(home, monkeypatch, failing): + """Debris in the store directory would be a sealed credentials file nobody + is tracking, kept at whatever mode `mkstemp` produced.""" + target = home / "credentials" + home.mkdir(parents=True) + target.write_bytes(b"the original content") + + def boom(*_args, **_kwargs): + raise OSError("induced failure") + + monkeypatch.setattr(os, failing, boom) + with pytest.raises(OSError): + atomic_write(target, b"the replacement content", CREDENTIALS_MODE) + assert [path.name for path in home.iterdir()] == ["credentials"] + + +@pytest.mark.parametrize( + ("filename", "allowed", "mode", "expected"), + [ + ("credentials", CREDENTIALS_MODE, 0o600, False), + ("credentials", CREDENTIALS_MODE, 0o640, True), + ("credentials", CREDENTIALS_MODE, 0o644, True), + ("config", CONFIG_MODE, 0o644, False), + ("config", CONFIG_MODE, 0o600, False), + ("config", CONFIG_MODE, 0o666, True), + ], +) +def test_a_more_permissive_mode_than_required_is_warned_about( + home, capsys, filename, allowed, mode, expected +): + home.mkdir(parents=True) + path = home / filename + path.write_text("") + os.chmod(path, mode) + store.warn_if_permissive(path, allowed) + captured = capsys.readouterr() + assert (str(path) in captured.err) is expected + assert captured.out == "" + + +# --------------------------------------------------------------------------- +# Sealing and resolving +# --------------------------------------------------------------------------- + + +@pytest.fixture +def initialised(home): + init_store(PASSWORD, home=home) + set_credentials("one", CREDENTIALS, PASSWORD, home=home) + return home + + +def test_credentials_round_trip_through_the_store(initialised): + assert resolve_credentials("one", PASSWORD, home=initialised) == CREDENTIALS + + +def test_the_credentials_file_holds_none_of_the_credential_bytes(initialised): + """A store that stringified or base64-encoded its values instead of sealing + them would pass the round-trip test above and fail here.""" + raw = (initialised / "credentials").read_bytes() + assert MARKER.encode("ascii") not in raw + assert MARKER.encode("ascii").hex().encode("ascii") not in raw + assert base64.b64encode(MARKER.encode("ascii")) not in raw + assert PASSWORD.encode("ascii") not in raw + + +def test_a_wrong_password_is_reported_as_a_wrong_password(initialised): + """What the store-level verifier buys: the failure names the actual problem + instead of surfacing as an unopenable profile.""" + with pytest.raises(PasswordError): + resolve_credentials("one", WRONG_PASSWORD, home=initialised) + + +def test_a_wrong_password_is_refused_before_any_profile_envelope_is_touched( + initialised, monkeypatch +): + """The verifier must be the first thing opened, or a mistyped password + would be indistinguishable from a damaged store.""" + set_credentials("two", CREDENTIALS, PASSWORD, home=initialised) + with pytest.raises(PasswordError): + set_credentials("three", CREDENTIALS, WRONG_PASSWORD, home=initialised) + assert store_profiles(initialised) == ["one", "two"] + + +def test_an_envelope_moved_between_profiles_does_not_open(initialised): + """The store-level consequence of the AAD binding: an attacker (or a + well-meaning hand edit) who copies one profile's envelope over another's + gets a refusal, not that profile's credentials under a different name.""" + path = initialised / "credentials" + document = json.loads(path.read_bytes()) + document["profiles"]["two"] = document["profiles"]["one"] + path.write_text(json.dumps(document)) + + assert resolve_credentials("one", PASSWORD, home=initialised) == CREDENTIALS + with pytest.raises(StoreError) as raised: + resolve_credentials("two", PASSWORD, home=initialised) + assert not isinstance(raised.value, PasswordError) + assert MARKER not in str(raised.value) + + +def test_a_profile_with_no_envelope_is_an_error_not_an_empty_mapping(initialised): + """Fail closed. Returning `{}` would let a caller run a command with no + credentials in the environment and blame the backend for the result.""" + with pytest.raises(ProfileError): + resolve_credentials("absent", PASSWORD, home=initialised) + + +def test_a_profile_in_config_without_credentials_selects_but_does_not_resolve(home): + """How the two files relate when a profile is in one and not the other. + `config` alone is enough to know which backend a profile names — which is + all `login` needs — so selection succeeds; resolving credentials that were + never sealed is an error naming the profile.""" + write_config(home, config_with("one")) + init_store(PASSWORD, home=home) + + assert select_profile("one", config=load_store_config(home), environ={}).name == "one" + with pytest.raises(ProfileError) as raised: + resolve_credentials("one", PASSWORD, home=home) + assert "one" in str(raised.value) + + +def test_resolving_before_the_store_exists_says_so(home): + with pytest.raises(StoreError): + resolve_credentials("one", PASSWORD, home=home) + + +def test_initialising_over_an_existing_store_is_refused(initialised): + """`init` twice is a plausible mistake; discarding every sealed envelope in + response to it is not a recoverable one.""" + with pytest.raises(StoreError): + init_store(PASSWORD, home=initialised) + assert resolve_credentials("one", PASSWORD, home=initialised) == CREDENTIALS + + +@pytest.mark.parametrize( + "payload", + [ + {"NAME": 42}, # a value that is not a string + {"NAME": [MARKER]}, + [MARKER], # not a mapping at all + MARKER, + # A mapping built the wrong way round, so the credential is the *key*. + # This is what makes naming the offending entry unsafe: the assumption + # that keys are harmless variable names is not one this branch can make. + {MARKER: 42}, + ], +) +def test_a_malformed_decrypted_payload_is_refused_without_echoing_it( + initialised, payload +): + """The only branch in the module where decrypted values are in scope when an + exception is raised — every other failure happens before anything is opened, + so an absence assertion there is trivially true. This one is not: the + payload is sealed under the right password and the right AAD, so it decrypts + successfully and *then* fails validation. Includes a mapping whose key is + the credential, which is why the message names neither half of an entry. + """ + path = initialised / "credentials" + document = json.loads(path.read_bytes()) + document["profiles"]["malformed"] = crypto.seal_json(payload, PASSWORD, "malformed") + path.write_text(json.dumps(document)) + + with pytest.raises(StoreError) as raised: + resolve_credentials("malformed", PASSWORD, home=initialised) + assert MARKER not in str(raised.value) + assert "malformed" in str(raised.value) + + +def test_a_damaged_store_file_is_refused_rather_than_read_as_empty(initialised): + (initialised / "credentials").write_text("{ not json") + with pytest.raises(StoreError): + resolve_credentials("one", PASSWORD, home=initialised) + + +def test_an_unsupported_store_version_is_refused(initialised): + path = initialised / "credentials" + document = json.loads(path.read_bytes()) + document["v"] = 99 + path.write_text(json.dumps(document)) + with pytest.raises(StoreError): + resolve_credentials("one", PASSWORD, home=initialised) + + +# --------------------------------------------------------------------------- +# Password rotation +# --------------------------------------------------------------------------- + + +def test_rotation_reseals_every_envelope(initialised): + set_credentials("two", {"NAME": "another-placeholder"}, PASSWORD, home=initialised) + rotate_password(PASSWORD, NEW_PASSWORD, home=initialised) + + assert resolve_credentials("one", NEW_PASSWORD, home=initialised) == CREDENTIALS + assert resolve_credentials("two", NEW_PASSWORD, home=initialised) == { + "NAME": "another-placeholder" + } + with pytest.raises(PasswordError): + resolve_credentials("one", PASSWORD, home=initialised) + + +def test_rotation_draws_fresh_salts_rather_than_reusing_the_old_ones(initialised): + before = json.loads((initialised / "credentials").read_bytes()) + rotate_password(PASSWORD, NEW_PASSWORD, home=initialised) + after = json.loads((initialised / "credentials").read_bytes()) + for section in (["verifier"], ["profiles", "one"]): + old, new = before, after + for key in section: + old, new = old[key], new[key] + assert old["salt"] != new["salt"] + assert old["nonce"] != new["nonce"] + + +def test_rotation_reseals_an_envelope_whose_config_table_is_gone(home): + """Rotation iterates the credentials file, not the profile list in `config`. + An implementation that walked `config` instead would silently and + irrecoverably destroy this envelope, and every other test here would pass.""" + write_config(home, config_with("one")) + init_store(PASSWORD, home=home) + set_credentials("one", CREDENTIALS, PASSWORD, home=home) + set_credentials("orphan", {"NAME": "orphan-placeholder"}, PASSWORD, home=home) + + rotate_password(PASSWORD, NEW_PASSWORD, home=home) + + assert store_profiles(home) == ["one", "orphan"] + assert resolve_credentials("orphan", NEW_PASSWORD, home=home) == { + "NAME": "orphan-placeholder" + } + + +def test_rotation_with_the_wrong_current_password_changes_nothing(initialised): + before = (initialised / "credentials").read_bytes() + with pytest.raises(PasswordError): + rotate_password(WRONG_PASSWORD, NEW_PASSWORD, home=initialised) + assert (initialised / "credentials").read_bytes() == before + + +def test_rotation_is_all_or_nothing_when_one_reseal_fails(initialised, monkeypatch): + """Induce a failure partway through and prove the store is untouched: the + same bytes, both envelopes still opening under the *old* password, neither + under the new one, and no debris beside the file. A partial rotation would + leave a store whose profiles need different passwords with nothing recording + which is which. + """ + set_credentials("two", {"NAME": "another-placeholder"}, PASSWORD, home=initialised) + before = (initialised / "credentials").read_bytes() + + real_seal_json = store.seal_json + + def failing_seal_json(payload, password, aad): + # Keyed on the profile, not on a call count. An implementation that + # wrote after each re-seal would already have persisted "one" by the + # time this fires, so the byte-comparison below catches it — a + # count-based trigger happened to fire before the first such write and + # let that implementation pass. + if aad == "two": + raise RuntimeError("induced failure partway through the rotation") + return real_seal_json(payload, password, aad) + + monkeypatch.setattr(store, "seal_json", failing_seal_json) + with pytest.raises(RuntimeError): + rotate_password(PASSWORD, NEW_PASSWORD, home=initialised) + monkeypatch.undo() + + assert (initialised / "credentials").read_bytes() == before + assert resolve_credentials("one", PASSWORD, home=initialised) == CREDENTIALS + assert resolve_credentials("two", PASSWORD, home=initialised) == { + "NAME": "another-placeholder" + } + with pytest.raises(PasswordError): + resolve_credentials("one", NEW_PASSWORD, home=initialised) + assert sorted(path.name for path in initialised.iterdir()) == ["credentials"] + + +def test_rotation_touches_the_file_exactly_once(initialised, monkeypatch): + """The mechanism behind all-or-nothing, asserted directly: build every + re-sealed envelope in memory, then write once. An implementation that saved + after each profile would be all-or-nothing only by luck about where a + failure landed, and the induced-failure test above can only observe the + failures it induces.""" + set_credentials("two", {"NAME": "another-placeholder"}, PASSWORD, home=initialised) + real_atomic_write = store.atomic_write + writes: list[str] = [] + + def counting_atomic_write(path, data, mode): + writes.append(path.name) + return real_atomic_write(path, data, mode) + + monkeypatch.setattr(store, "atomic_write", counting_atomic_write) + rotate_password(PASSWORD, NEW_PASSWORD, home=initialised) + assert writes == ["credentials"] + + +def test_rotation_stops_rather_than_dropping_an_envelope_it_cannot_open(initialised): + """An envelope that will not open must abort the rotation. Skipping it would + turn a damaged envelope into a deleted one.""" + path = initialised / "credentials" + document = json.loads(path.read_bytes()) + document["profiles"]["damaged"] = {**document["profiles"]["one"], "ct": "AAAA"} + path.write_text(json.dumps(document)) + before = path.read_bytes() + + with pytest.raises(StoreError): + rotate_password(PASSWORD, NEW_PASSWORD, home=initialised) + assert path.read_bytes() == before + + +# --------------------------------------------------------------------------- +# Location, and what never leaks +# --------------------------------------------------------------------------- + + +def test_the_store_lives_under_the_xdg_config_home(tmp_path, monkeypatch): + """`store_dir` defers to `cli.config_home` rather than re-deriving the XDG + base, so there is one answer to where this tool's files live.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + assert store.store_dir() == tmp_path / "stackward" + assert store.credentials_path() == tmp_path / "stackward" / "credentials" + assert store.config_path() == tmp_path / "stackward" / "config" + + +def test_no_error_message_from_any_failing_path_contains_a_credential( + initialised, capsys +): + """One assertion over every failure this module can produce. Any new branch + that interpolated a value would have to be added here to be seen — but a + branch that leaked one would already be caught by the sweep below.""" + path = initialised / "credentials" + document = json.loads(path.read_bytes()) + document["profiles"]["moved"] = document["profiles"]["one"] + path.write_text(json.dumps(document)) + + messages: list[str] = [] + for call in ( + lambda: resolve_credentials("one", WRONG_PASSWORD, home=initialised), + lambda: resolve_credentials("absent", PASSWORD, home=initialised), + lambda: resolve_credentials("moved", PASSWORD, home=initialised), + lambda: rotate_password(WRONG_PASSWORD, NEW_PASSWORD, home=initialised), + lambda: init_store(PASSWORD, home=initialised), + lambda: set_credentials("four", CREDENTIALS, WRONG_PASSWORD, home=initialised), + ): + with pytest.raises(StoreError) as raised: + call() + messages.append(str(raised.value)) + + captured = capsys.readouterr() + for text in [*messages, captured.out, captured.err]: + assert MARKER not in text + assert PASSWORD not in text + assert WRONG_PASSWORD not in text + + +def test_a_profile_dataclass_reports_which_form_it_carries(): + assert not Profile(name="one", backend_url="file:///placeholder").has_components + assert Profile(name="one", bucket="placeholder-bucket").has_components From c1e238e3a15379eefb05973a77539d0112b2812f Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 09:17:08 -0700 Subject: [PATCH 16/44] fix: raise the cryptography floor to 44.0, and refuse an empty password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six findings from task review. Two mattered. The dependency floor predated this work. pyproject pinned cryptography>=42.0, but Argon2id lives in cryptography.hazmat.primitives.kdf.argon2, which does not exist before 44.0 — established by installing 42.0.0, 43.0.0, 43.0.3, 44.0.0 and 45.0.0 into throwaway environments and importing, not from the changelog. The lock resolves 50.0.1, which is exactly why the suite passed and the diff hid it: any install not using the lock, onto an environment already carrying an older cryptography, gets an ImportError on `import stackward.crypto` — the module does not load at all. UnsupportedAlgorithm now joins the tuple _derive catches. Argon2id needs an OpenSSL 3.2+ backend and cryptography signals its absence with an exception inheriting straight from Exception, so on such a build it sailed past every `except CryptoError` in store.py as a raw traceback — on the one class of machine where nothing about the store works. _normalise also moves outside that try, so an absent password reads as this module refusing rather than as the library rejecting an envelope. An empty password sealed a real store, silently. Argon2id derives a perfectly good 32-byte key from b"" — verified against the pinned library — so init_store("") wrote a well-formed store that opens with no password at all, and rotate_password(old, "") did the same to an existing one, irreversibly. Strength is the prompting command's business; emptiness is not, because "" is indistinguishable from "nothing was supplied" — the same reasoning this module already applies one field over, where an empty STACKWARD_PROFILE is an error rather than a miss. Guarded in crypto._normalise, which sits under every seal and every open so no path can miss it, and again in store._require_password, which supplies the error type and says which of two passwords was missing. Sabotage shows both are load-bearing: without the store guard, rotation propagates a raw CryptoError instead of PasswordError. Whitespace-only is refused; nothing is stripped, since trimming would silently change what was typed. Also: `home` renamed to `directory` across store.py, because config_home() already appends /stackward and a caller passing an actual config home would have got /credentials; rotation now replaces only the two fields it owns instead of rebuilding the document from literals, so it no longer disagrees with set_credentials about what a document is; and two comments corrected that overclaimed — the name charset is a strict subset of TOML's bare-key charset, not exactly it, and the memory_kib bound has a fallback where the iterations ceiling has none. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- pyproject.toml | 9 +- src/stackward/crypto.py | 43 ++++- src/stackward/store.py | 127 +++++++++------ tests/test_crypto.py | 62 +++++++- tests/test_store.py | 341 ++++++++++++++++++++++++++-------------- uv.lock | 2 +- 6 files changed, 406 insertions(+), 178 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 31558e0..bed4123 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,14 @@ license = { text = "Apache-2.0" } # pull in grpcio and friends for no benefit. dependencies = [ "pyyaml>=6.0", - "cryptography>=42.0", + # 44.0 is the floor because it is the first release shipping + # `cryptography.hazmat.primitives.kdf.argon2`, which `stackward.crypto` + # imports at module scope. Established by installing each release into a + # throwaway environment and importing it — 42.0.0, 43.0.0 and 43.0.3 all + # raise ModuleNotFoundError, 44.0.0 imports and derives — not from the + # changelog and not from memory. Below this floor the credential store does + # not degrade at runtime: the module does not load at all. + "cryptography>=44.0", ] [project.scripts] diff --git a/src/stackward/crypto.py b/src/stackward/crypto.py index b2f5783..f4645e3 100644 --- a/src/stackward/crypto.py +++ b/src/stackward/crypto.py @@ -55,7 +55,7 @@ import unicodedata from typing import Any -from cryptography.exceptions import InvalidTag +from cryptography.exceptions import InvalidTag, UnsupportedAlgorithm from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.kdf.argon2 import Argon2id @@ -128,7 +128,27 @@ class DecryptionError(CryptoError): this module performed itself.""" +class EmptyPasswordError(CryptoError): + """No password was supplied. + + Argon2id derives a perfectly good 32-byte key from `b""`, so without this + check an empty password seals a real store that opens with no password at + all, silently and with no way to tell it apart from one that was protected. + Password *strength* is the prompting command's business; *emptiness* is not, + because `""` is indistinguishable from "nothing was supplied" — the same + reasoning `store.select_profile` applies to an empty `STACKWARD_PROFILE`.""" + + def _normalise(password: str) -> bytes: + """NFC-normalise and encode, refusing a password that is not one. + + The check lives here rather than at each store entry point so that no call + path can seal or open anything with an empty password by forgetting to ask. + Whitespace-only is refused too, and the password is *not* stripped: trimming + it would silently change what the user typed into something else. + """ + if not password.strip(): + raise EmptyPasswordError("a password is required") return unicodedata.normalize("NFC", password).encode("utf-8") @@ -187,7 +207,18 @@ def _derive(password: str, salt: bytes, params: dict[str, int]) -> bytes: `ValueError`, `OverflowError` or `MemoryError` escape: a caller catching `CryptoError` must not have to enumerate the library's exception types to stay fail-closed. + + `UnsupportedAlgorithm` is in that tuple for a case the others do not cover. + Argon2id needs an OpenSSL 3.2+ backend, and on an older one `cryptography` + raises it — inheriting straight from `Exception`, so it would otherwise sail + past every `except CryptoError` in `store.py` as a raw traceback on the one + class of machine where nothing about the store works. + + `_normalise` runs outside the `try` on purpose: an empty password is this + module refusing, not the library rejecting, and must not be recoded as an + envelope problem. """ + key_material = _normalise(password) try: return Argon2id( salt=salt, @@ -195,8 +226,14 @@ def _derive(password: str, salt: bytes, params: dict[str, int]) -> bytes: iterations=params["iterations"], lanes=params["lanes"], memory_cost=params["memory_kib"], - ).derive(_normalise(password)) - except (ValueError, OverflowError, MemoryError, TypeError) as exc: + ).derive(key_material) + except ( + ValueError, + OverflowError, + MemoryError, + TypeError, + UnsupportedAlgorithm, + ) as exc: raise EnvelopeError( f"key derivation rejected the envelope's parameters ({type(exc).__name__})" ) from exc diff --git a/src/stackward/store.py b/src/stackward/store.py index bc7a445..acaa7d6 100644 --- a/src/stackward/store.py +++ b/src/stackward/store.py @@ -59,6 +59,7 @@ from .crypto import ( CryptoError, DecryptionError, + EmptyPasswordError, describe_value, seal_json, unseal_json, @@ -95,8 +96,10 @@ # header as nesting, so `[profile.a.b]` declares a table `b` inside a table `a` # and not a profile named `a.b`. Allowing dotted names would mean a name that # has two spellings — one of which silently parses as something else — for no -# benefit. What remains is exactly the TOML bare-key charset, so every valid -# name is spellable as `[profile.]` with no quoting and no ambiguity. +# benefit. What remains is a strict subset of the TOML bare-key charset — TOML +# would also allow a leading '_' or '-', which this does not — so every valid +# name is spellable as `[profile.]` with no quoting and no ambiguity, +# but not every bare TOML key is a valid profile name. _PROFILE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$") _TOP_LEVEL_KEYS = frozenset({"default_profile", "profile"}) @@ -118,12 +121,13 @@ class ProfileError(StoreError): class PasswordError(StoreError): - """The store password is wrong: the verifier envelope did not open. + """The store password is unusable: absent, or wrong. - Raised in preference to letting a profile envelope's `DecryptionError` - surface, because the two mean different things to whoever reads them. This - one says "retype your password"; a failure on a profile envelope after the - verifier opened says the store itself is damaged or was tampered with.""" + Both mean "the password given cannot open this store", which is a single + problem to whoever reads it. Kept distinct from a profile envelope's + `DecryptionError`, which is a different one: this says "retype your + password", while a failure on a profile envelope *after* the verifier opened + says the store itself is damaged or was tampered with.""" @dataclass(frozen=True) @@ -170,8 +174,13 @@ class StoreConfig: # --------------------------------------------------------------------------- -def store_dir(home: Path | None = None) -> Path: - """The store directory: `home` when given, otherwise `cli.config_home()`. +def store_dir(directory: Path | None = None) -> Path: + """The store directory: `directory` when given, otherwise `cli.config_home()`. + + Named `directory`, not `home`: this is the store directory itself, the + thing `config_home()` returns with `/stackward` already appended. A + parameter called `home` invites a caller to pass a config home or + `Path.home()` and silently get `/credentials`. `cli` is imported inside the function rather than at module scope. The commands that will drive this store live under `commands/`, which `cli` @@ -181,19 +190,19 @@ def store_dir(home: Path | None = None) -> Path: not defined yet at that point in its own execution. `config.py` avoids the same cycle the same way; see `enforce_min_version` there. """ - if home is not None: - return home + if directory is not None: + return directory from .cli import config_home # local: breaks an import cycle return config_home() -def config_path(home: Path | None = None) -> Path: - return store_dir(home) / CONFIG_FILENAME +def config_path(directory: Path | None = None) -> Path: + return store_dir(directory) / CONFIG_FILENAME -def credentials_path(home: Path | None = None) -> Path: - return store_dir(home) / CREDENTIALS_FILENAME +def credentials_path(directory: Path | None = None) -> Path: + return store_dir(directory) / CREDENTIALS_FILENAME def warn_if_permissive(path: Path, allowed: int) -> None: @@ -218,20 +227,20 @@ def warn_if_permissive(path: Path, allowed: int) -> None: ) -def ensure_store_dir(home: Path | None = None) -> Path: +def ensure_store_dir(directory: Path | None = None) -> Path: """Create the store directory if absent, and make sure it is 0700. `chmod` runs unconditionally rather than relying on `mkdir(mode=...)`, whose result the process `umask` modifies — a `umask` of 0 would otherwise leave a world-readable directory holding a credentials file. """ - directory = store_dir(home) + resolved = store_dir(directory) try: - directory.mkdir(parents=True, exist_ok=True) - os.chmod(directory, DIR_MODE) + resolved.mkdir(parents=True, exist_ok=True) + os.chmod(resolved, DIR_MODE) except OSError as exc: - raise StoreError(f"{directory}: cannot create store directory: {exc}") from exc - return directory + raise StoreError(f"{resolved}: cannot create store directory: {exc}") from exc + return resolved def atomic_write(path: Path, data: bytes, mode: int) -> None: @@ -352,7 +361,7 @@ def _build_profile(name: str, raw: Any) -> Profile: return profile -def load_store_config(home: Path | None = None) -> StoreConfig: +def load_store_config(directory: Path | None = None) -> StoreConfig: """Parse `config`, or return an empty one when the file does not exist. Absent is a normal state for a user who has not set the tool up yet; the @@ -361,7 +370,7 @@ def load_store_config(home: Path | None = None) -> StoreConfig: typo must never be treated as though it were absent, since that would turn a broken profile into a silently missing one. """ - path = config_path(home) + path = config_path(directory) if not path.exists(): return StoreConfig() @@ -470,8 +479,8 @@ def select_profile( # --------------------------------------------------------------------------- -def _read_document(home: Path | None = None) -> dict[str, Any]: - path = credentials_path(home) +def _read_document(directory: Path | None = None) -> dict[str, Any]: + path = credentials_path(directory) warn_if_permissive(path, CREDENTIALS_MODE) try: raw = path.read_bytes() @@ -507,10 +516,22 @@ def _read_document(home: Path | None = None) -> dict[str, Any]: return document -def _write_document(document: Mapping[str, Any], home: Path | None = None) -> None: - ensure_store_dir(home) +def _write_document(document: Mapping[str, Any], directory: Path | None = None) -> None: + ensure_store_dir(directory) payload = json.dumps(document, indent=2, sort_keys=True).encode("utf-8") + b"\n" - atomic_write(credentials_path(home), payload, CREDENTIALS_MODE) + atomic_write(credentials_path(directory), payload, CREDENTIALS_MODE) + + +def _require_password(password: str, what: str = "a password") -> None: + """Refuse an absent password, as a `PasswordError` naming which one. + + `crypto._normalise` is the real guarantee — it sits under every seal and + every open, so no path can miss it. This exists on top of it only to phrase + the refusal in the store's own vocabulary and to say *which* password was + missing when a call takes two, which the crypto layer cannot know. + """ + if not password.strip(): + raise PasswordError(f"{what} is required") def _check_password(document: Mapping[str, Any], password: str) -> None: @@ -521,6 +542,7 @@ def _check_password(document: Mapping[str, Any], password: str) -> None: envelope is attempted, and therefore before a decryption failure could be mistaken for a damaged store. """ + _require_password(password) envelope = document.get(VERIFIER_KEY) if envelope is None: raise StoreError( @@ -531,6 +553,11 @@ def _check_password(document: Mapping[str, Any], password: str) -> None: plaintext = unseal_json(envelope, password, VERIFIER_AAD) except DecryptionError as exc: raise PasswordError("wrong password for the credential store") from exc + except EmptyPasswordError as exc: + # Unreachable while `_require_password` above stands, and mapped anyway: + # `EmptyPasswordError` is a `CryptoError`, so the clause below would + # otherwise recode "no password" as "the verifier is unusable". + raise PasswordError("a password is required") from exc except CryptoError as exc: raise StoreError(f"credential store verifier is unusable: {exc}") from exc @@ -571,15 +598,16 @@ def _require_credentials(payload: Any, profile: str) -> dict[str, str]: return result -def init_store(password: str, *, home: Path | None = None) -> None: +def init_store(password: str, *, directory: Path | None = None) -> None: """Create the store directory and an empty `credentials` holding a verifier. Refuses to overwrite an existing store: doing so would discard every sealed envelope in it, irrecoverably, in response to a command a user could plausibly run twice. """ - ensure_store_dir(home) - path = credentials_path(home) + _require_password(password) + ensure_store_dir(directory) + path = credentials_path(directory) if path.exists(): raise StoreError(f"{path}: a credential store already exists here") _write_document( @@ -588,14 +616,14 @@ def init_store(password: str, *, home: Path | None = None) -> None: VERIFIER_KEY: seal_json(VERIFIER_PLAINTEXT, password, VERIFIER_AAD), "profiles": {}, }, - home, + directory, ) -def store_profiles(home: Path | None = None) -> list[str]: +def store_profiles(directory: Path | None = None) -> list[str]: """Names that have a sealed envelope, sorted. Reads no password and opens nothing, so it is safe to call for a listing.""" - return sorted(_read_document(home)["profiles"]) + return sorted(_read_document(directory)["profiles"]) def set_credentials( @@ -603,7 +631,7 @@ def set_credentials( credentials: Mapping[str, str], password: str, *, - home: Path | None = None, + directory: Path | None = None, ) -> None: """Seal `credentials` as `profile`'s envelope, replacing any existing one. @@ -615,14 +643,14 @@ def set_credentials( if not isinstance(key, str) or not isinstance(value, str): raise StoreError("credentials must be a mapping of strings to strings") - document = _read_document(home) + document = _read_document(directory) _check_password(document, password) document["profiles"][profile] = seal_json(dict(credentials), password, profile) - _write_document(document, home) + _write_document(document, directory) def resolve_credentials( - profile: str, password: str, *, home: Path | None = None + profile: str, password: str, *, directory: Path | None = None ) -> dict[str, str]: """The bootstrap values for `profile`: names to values, decrypted. @@ -637,7 +665,7 @@ def resolve_credentials( environment fallback belong to the command; a library that could prompt would be a library that can block a hook. """ - document = _read_document(home) + document = _read_document(directory) _check_password(document, password) envelope = document["profiles"].get(profile) @@ -662,7 +690,7 @@ def resolve_credentials( def rotate_password( - old_password: str, new_password: str, *, home: Path | None = None + old_password: str, new_password: str, *, directory: Path | None = None ) -> None: """Re-seal every envelope under `new_password`, or change nothing. @@ -676,7 +704,8 @@ def rotate_password( `config`. An envelope whose `[profile.]` table has been removed is unreachable but not gone, and rotating past it would quietly destroy it. """ - document = _read_document(home) + _require_password(new_password, "the new password") + document = _read_document(directory) _check_password(document, old_password) rotated: dict[str, Any] = {} @@ -690,11 +719,11 @@ def rotate_password( ) from exc rotated[name] = seal_json(payload, new_password, name) - _write_document( - { - "v": STORE_VERSION, - VERIFIER_KEY: seal_json(VERIFIER_PLAINTEXT, new_password, VERIFIER_AAD), - "profiles": rotated, - }, - home, - ) + # Replace only the two fields this operation owns and keep the rest of the + # document as it was read. Rebuilding from a literal would silently drop any + # other top-level field, and would leave the two write paths disagreeing + # about what a document is — `set_credentials` preserves the whole thing, + # and rotation would be the one discarding. + document[VERIFIER_KEY] = seal_json(VERIFIER_PLAINTEXT, new_password, VERIFIER_AAD) + document["profiles"] = rotated + _write_document(document, directory) diff --git a/tests/test_crypto.py b/tests/test_crypto.py index b0c6c47..7d791a2 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -24,7 +24,7 @@ import json import pytest -from cryptography.exceptions import InvalidTag +from cryptography.exceptions import InvalidTag, UnsupportedAlgorithm from stackward import crypto from stackward.crypto import ( @@ -36,7 +36,9 @@ KEY_BYTES, NONCE_BYTES, SALT_BYTES, + CryptoError, DecryptionError, + EmptyPasswordError, EnvelopeError, seal, seal_json, @@ -181,7 +183,10 @@ def test_an_unknown_kdf_is_refused(): @pytest.mark.parametrize( ("key", "value"), [ - ("memory_kib", 2**31), # `cryptography` answers this with a MemoryError + # Rejected by _MAX_MEMORY_KIB before it reaches the library. Note this + # bound has a fallback: were it gone, `_derive` would still map the + # library's MemoryError. The `iterations` ceiling below does not. + ("memory_kib", 2**31), ("memory_kib", 1), ("memory_kib", 0), ("memory_kib", -1), @@ -292,3 +297,56 @@ def test_no_error_message_repeats_the_password_or_the_plaintext(): assert MARKER.decode("ascii") not in message assert PASSWORD not in message assert OTHER_PASSWORD not in message + + +# --------------------------------------------------------------------------- +# The password itself +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("password", ["", " ", "\t", "\n", " \t\n "]) +def test_an_absent_password_is_refused_rather_than_deriving_a_key_from_nothing(password): + """Argon2id derives a perfectly good 32-byte key from `b""` — verified + against the pinned library — so without this check an empty password seals + a real store that opens with no password at all, indistinguishably from one + that was protected. Strength is the prompting command's business; emptiness + is not, because "" cannot be told apart from "nothing was supplied". + """ + with pytest.raises(EmptyPasswordError): + seal(MARKER, password, AAD) + envelope = seal(MARKER, PASSWORD, AAD) + with pytest.raises(EmptyPasswordError): + unseal(envelope, password, AAD) + + +def test_an_absent_password_is_refused_as_a_crypto_error(): + """`EmptyPasswordError` must sit under `CryptoError`, or `store.py`'s + `except CryptoError` boundaries would not see it at all.""" + assert issubclass(EmptyPasswordError, CryptoError) + + +def test_a_password_is_refused_but_never_trimmed(): + """Whitespace-only is refused; a password with significant surrounding + whitespace is honoured exactly as typed. Stripping would silently change + what the user entered into something else.""" + padded = " " + PASSWORD + " " + envelope = seal(MARKER, padded, AAD) + assert unseal(envelope, padded, AAD) == MARKER + with pytest.raises(DecryptionError): + unseal(envelope, PASSWORD, AAD) + + +def test_a_backend_that_cannot_do_argon2id_fails_closed_as_a_crypto_error(monkeypatch): + """Argon2id needs an OpenSSL 3.2+ backend; on an older one `cryptography` + raises `UnsupportedAlgorithm`, which inherits straight from `Exception`. + Uncaught it would sail past every `except CryptoError` in `store.py` as a + raw traceback, on the one class of machine where nothing about the store + works. Simulated, because this build's backend does support it. + """ + + def unsupported(*_args, **_kwargs): + raise UnsupportedAlgorithm("no Argon2id in this backend") + + monkeypatch.setattr(crypto, "Argon2id", unsupported) + with pytest.raises(CryptoError): + seal(MARKER, PASSWORD, AAD) diff --git a/tests/test_store.py b/tests/test_store.py index 332dbf0..7758688 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -76,15 +76,15 @@ def cheap_kdf(monkeypatch): @pytest.fixture -def home(tmp_path): - """An isolated store directory. Every store function takes `home`, so no - test depends on the real `XDG_CONFIG_HOME` or writes outside `tmp_path`.""" +def store_directory(tmp_path): + """An isolated store directory. Every store function takes `directory`, so + no test depends on the real `XDG_CONFIG_HOME` or writes outside `tmp_path`.""" return tmp_path / "stackward" -def write_config(home, text: str): - home.mkdir(parents=True, exist_ok=True) - path = home / "config" +def write_config(store_directory, text: str): + store_directory.mkdir(parents=True, exist_ok=True) + path = store_directory / "config" path.write_text(text) # At the required mode, so that these tests do not trip the permission # warning as a side effect of whatever umask the suite runs under. The @@ -116,24 +116,24 @@ def config_with(*names: str, default: str | None = None) -> str: "some-future-scheme://placeholder", ], ) -def test_a_backend_url_is_carried_through_byte_for_byte(home, url): +def test_a_backend_url_is_carried_through_byte_for_byte(store_directory, url): """Pass-through, not parsing. Any normalising or reassembling this module did would show up here as a changed string, and would be the thing that stopped an unanticipated scheme from working.""" - write_config(home, f"[profile.one]\nbackend_url = {json.dumps(url)}\n") - assert load_store_config(home).profiles["one"].backend_url == url + write_config(store_directory, f"[profile.one]\nbackend_url = {json.dumps(url)}\n") + assert load_store_config(store_directory).profiles["one"].backend_url == url -def test_the_component_form_is_carried_as_structured_data(home): +def test_the_component_form_is_carried_as_structured_data(store_directory): write_config( - home, + store_directory, "[profile.one]\n" 'bucket = "placeholder-bucket"\n' 'prefix = "placeholder/prefix"\n' 'endpoint = "https://placeholder.invalid"\n' 'region = "placeholder-region"\n', ) - profile = load_store_config(home).profiles["one"] + profile = load_store_config(store_directory).profiles["one"] assert profile.backend_url is None assert profile.bucket == "placeholder-bucket" assert profile.prefix == "placeholder/prefix" @@ -141,57 +141,57 @@ def test_the_component_form_is_carried_as_structured_data(home): assert profile.region == "placeholder-region" -def test_a_profile_carrying_both_forms_is_refused(home): +def test_a_profile_carrying_both_forms_is_refused(store_directory): """Two answers to "which backend?" with no rule for choosing between them.""" write_config( - home, + store_directory, '[profile.one]\nbackend_url = "file:///placeholder"\nbucket = "placeholder"\n', ) with pytest.raises(StoreError): - load_store_config(home) + load_store_config(store_directory) -def test_a_profile_carrying_neither_form_is_refused(home): +def test_a_profile_carrying_neither_form_is_refused(store_directory): """There is no default backend to fall back on, so an empty profile is an error and not an inherited one.""" - write_config(home, "[profile.one]\n") + write_config(store_directory, "[profile.one]\n") with pytest.raises(StoreError): - load_store_config(home) + load_store_config(store_directory) -def test_the_component_form_without_a_bucket_is_refused(home): - write_config(home, '[profile.one]\nregion = "placeholder-region"\n') +def test_the_component_form_without_a_bucket_is_refused(store_directory): + write_config(store_directory, '[profile.one]\nregion = "placeholder-region"\n') with pytest.raises(StoreError): - load_store_config(home) + load_store_config(store_directory) -def test_an_unknown_profile_key_is_refused_rather_than_ignored(home): +def test_an_unknown_profile_key_is_refused_rather_than_ignored(store_directory): write_config( - home, + store_directory, '[profile.one]\nbackend_url = "file:///placeholder"\nbucket_name = "typo"\n', ) with pytest.raises(StoreError): - load_store_config(home) + load_store_config(store_directory) -def test_a_default_profile_with_no_table_is_refused(home): - write_config(home, 'default_profile = "missing"\n' + config_with("one")) +def test_a_default_profile_with_no_table_is_refused(store_directory): + write_config(store_directory, 'default_profile = "missing"\n' + config_with("one")) with pytest.raises(StoreError): - load_store_config(home) + load_store_config(store_directory) -def test_an_absent_config_is_empty_rather_than_an_error(home): +def test_an_absent_config_is_empty_rather_than_an_error(store_directory): """Not having set the tool up yet is a normal state. It is `select_profile` that reports it, because only it can say what to do about it.""" - assert load_store_config(home) == StoreConfig() + assert load_store_config(store_directory) == StoreConfig() -def test_an_invalid_config_raises_rather_than_reading_as_absent(home): +def test_an_invalid_config_raises_rather_than_reading_as_absent(store_directory): """A typo must not degrade into "no profiles", which would turn a broken backend into a silently missing one.""" - write_config(home, "[profile.one\nbackend_url = ") + write_config(store_directory, "[profile.one\nbackend_url = ") with pytest.raises(StoreError): - load_store_config(home) + load_store_config(store_directory) # --------------------------------------------------------------------------- @@ -200,9 +200,11 @@ def test_an_invalid_config_raises_rather_than_reading_as_absent(home): @pytest.fixture -def four_profiles(home): - write_config(home, config_with("chosen", "from_env", "from_repo", "fallback")) - return load_store_config(home) +def four_profiles(store_directory): + write_config( + store_directory, config_with("chosen", "from_env", "from_repo", "fallback") + ) + return load_store_config(store_directory) def test_the_explicit_profile_wins_over_every_other_source(four_profiles): @@ -224,17 +226,17 @@ def test_the_environment_wins_when_no_explicit_profile_was_given(four_profiles): assert selected.name == "from_env" -def test_the_repository_config_wins_over_the_default_profile(home): - write_config(home, config_with("from_repo", "fallback", default="fallback")) +def test_the_repository_config_wins_over_the_default_profile(store_directory): + write_config(store_directory, config_with("from_repo", "fallback", default="fallback")) selected = select_profile( - config=load_store_config(home), repo_profile="from_repo", environ={} + config=load_store_config(store_directory), repo_profile="from_repo", environ={} ) assert selected.name == "from_repo" -def test_the_default_profile_is_the_last_resort(home): - write_config(home, config_with("fallback", default="fallback")) - selected = select_profile(config=load_store_config(home), environ={}) +def test_the_default_profile_is_the_last_resort(store_directory): + write_config(store_directory, config_with("fallback", default="fallback")) + selected = select_profile(config=load_store_config(store_directory), environ={}) assert selected.name == "fallback" @@ -243,15 +245,17 @@ def test_no_profile_anywhere_is_an_error_and_never_a_guess(four_profiles): select_profile(config=four_profiles, environ={}) -def test_a_named_profile_that_does_not_exist_errors_instead_of_using_the_default(home): +def test_a_named_profile_that_does_not_exist_errors_instead_of_using_the_default( + store_directory, +): """The case that separates "first source that *speaks* wins" from "first source that *resolves* wins". Falling through here would run the command against a backend nobody asked for — the worst thing this tool could do quietly — and a loop that skipped invalid sources would pass every other precedence test in this file. """ - write_config(home, config_with("fallback", default="fallback")) - config = load_store_config(home) + write_config(store_directory, config_with("fallback", default="fallback")) + config = load_store_config(store_directory) for explicit, environ, repo_profile in ( ("absent", {}, None), (None, {ENV_PROFILE: "absent"}, None), @@ -265,15 +269,17 @@ def test_a_named_profile_that_does_not_exist_errors_instead_of_using_the_default assert "fallback" not in str(raised.value) -def test_an_empty_environment_variable_is_an_error_rather_than_a_miss(home): +def test_an_empty_environment_variable_is_an_error_rather_than_a_miss(store_directory): """A variable set to the empty string is a broken variable, not an absent one. Skipping it would silently substitute the default.""" - write_config(home, config_with("fallback", default="fallback")) + write_config(store_directory, config_with("fallback", default="fallback")) with pytest.raises(ProfileError): - select_profile(config=load_store_config(home), environ={ENV_PROFILE: ""}) + select_profile( + config=load_store_config(store_directory), environ={ENV_PROFILE: ""} + ) -def test_a_profile_name_that_would_collide_with_the_verifier_is_rejected(home): +def test_a_profile_name_that_would_collide_with_the_verifier_is_rejected(store_directory): """The verifier's AAD is `stackward:verifier`. Excluding ':' from the name charset is the mechanism that keeps the two AAD namespaces disjoint, so a profile envelope can never be interchangeable with the verifier's.""" @@ -305,26 +311,27 @@ def test_an_unspellable_profile_name_is_rejected(name): # --------------------------------------------------------------------------- -def test_the_store_directory_is_created_private_whatever_the_umask(home): +def test_the_store_directory_is_created_private_whatever_the_umask(store_directory): previous = os.umask(0) try: - store.ensure_store_dir(home) + store.ensure_store_dir(store_directory) finally: os.umask(previous) - assert stat.S_IMODE(home.stat().st_mode) == DIR_MODE + assert stat.S_IMODE(store_directory.stat().st_mode) == DIR_MODE -def test_the_credentials_file_is_written_private_whatever_the_umask(home): +def test_the_credentials_file_is_written_private_whatever_the_umask(store_directory): previous = os.umask(0) try: - init_store(PASSWORD, home=home) + init_store(PASSWORD, directory=store_directory) finally: os.umask(previous) - assert stat.S_IMODE((home / "credentials").stat().st_mode) == CREDENTIALS_MODE + mode = stat.S_IMODE((store_directory / "credentials").stat().st_mode) + assert mode == CREDENTIALS_MODE @pytest.mark.parametrize("mode", [CREDENTIALS_MODE, CONFIG_MODE]) -def test_the_mode_is_set_before_the_rename_not_after(home, monkeypatch, mode): +def test_the_mode_is_set_before_the_rename_not_after(store_directory, monkeypatch, mode): """Setting the mode after the rename leaves a window in which the finished file is visible at its real path with whatever mode it was created with. This records the mode of the temporary file at the moment of the rename. @@ -334,7 +341,7 @@ def test_the_mode_is_set_before_the_rename_not_after(home, monkeypatch, mode): rename would look identical and this test would prove nothing. 0644 is what makes the ordering observable. """ - home.mkdir(parents=True) + store_directory.mkdir(parents=True) real_replace = os.replace observed: list[int] = [] @@ -343,13 +350,13 @@ def spy(src, dst): return real_replace(src, dst) monkeypatch.setattr(os, "replace", spy) - atomic_write(home / "credentials", b"content", mode) + atomic_write(store_directory / "credentials", b"content", mode) assert observed == [mode] -def test_a_failed_write_leaves_the_original_file_intact(home, monkeypatch): - target = home / "credentials" - home.mkdir(parents=True) +def test_a_failed_write_leaves_the_original_file_intact(store_directory, monkeypatch): + target = store_directory / "credentials" + store_directory.mkdir(parents=True) target.write_bytes(b"the original content") def boom(*_args, **_kwargs): @@ -361,9 +368,9 @@ def boom(*_args, **_kwargs): assert target.read_bytes() == b"the original content" -def test_a_failed_rename_leaves_the_original_file_intact(home, monkeypatch): - target = home / "credentials" - home.mkdir(parents=True) +def test_a_failed_rename_leaves_the_original_file_intact(store_directory, monkeypatch): + target = store_directory / "credentials" + store_directory.mkdir(parents=True) target.write_bytes(b"the original content") def boom(*_args, **_kwargs): @@ -376,11 +383,13 @@ def boom(*_args, **_kwargs): @pytest.mark.parametrize("failing", ["fsync", "replace"]) -def test_a_failed_write_leaves_no_temporary_file_behind(home, monkeypatch, failing): +def test_a_failed_write_leaves_no_temporary_file_behind( + store_directory, monkeypatch, failing +): """Debris in the store directory would be a sealed credentials file nobody is tracking, kept at whatever mode `mkstemp` produced.""" - target = home / "credentials" - home.mkdir(parents=True) + target = store_directory / "credentials" + store_directory.mkdir(parents=True) target.write_bytes(b"the original content") def boom(*_args, **_kwargs): @@ -389,7 +398,7 @@ def boom(*_args, **_kwargs): monkeypatch.setattr(os, failing, boom) with pytest.raises(OSError): atomic_write(target, b"the replacement content", CREDENTIALS_MODE) - assert [path.name for path in home.iterdir()] == ["credentials"] + assert [path.name for path in store_directory.iterdir()] == ["credentials"] @pytest.mark.parametrize( @@ -404,10 +413,10 @@ def boom(*_args, **_kwargs): ], ) def test_a_more_permissive_mode_than_required_is_warned_about( - home, capsys, filename, allowed, mode, expected + store_directory, capsys, filename, allowed, mode, expected ): - home.mkdir(parents=True) - path = home / filename + store_directory.mkdir(parents=True) + path = store_directory / filename path.write_text("") os.chmod(path, mode) store.warn_if_permissive(path, allowed) @@ -422,14 +431,14 @@ def test_a_more_permissive_mode_than_required_is_warned_about( @pytest.fixture -def initialised(home): - init_store(PASSWORD, home=home) - set_credentials("one", CREDENTIALS, PASSWORD, home=home) - return home +def initialised(store_directory): + init_store(PASSWORD, directory=store_directory) + set_credentials("one", CREDENTIALS, PASSWORD, directory=store_directory) + return store_directory def test_credentials_round_trip_through_the_store(initialised): - assert resolve_credentials("one", PASSWORD, home=initialised) == CREDENTIALS + assert resolve_credentials("one", PASSWORD, directory=initialised) == CREDENTIALS def test_the_credentials_file_holds_none_of_the_credential_bytes(initialised): @@ -446,7 +455,7 @@ def test_a_wrong_password_is_reported_as_a_wrong_password(initialised): """What the store-level verifier buys: the failure names the actual problem instead of surfacing as an unopenable profile.""" with pytest.raises(PasswordError): - resolve_credentials("one", WRONG_PASSWORD, home=initialised) + resolve_credentials("one", WRONG_PASSWORD, directory=initialised) def test_a_wrong_password_is_refused_before_any_profile_envelope_is_touched( @@ -454,9 +463,9 @@ def test_a_wrong_password_is_refused_before_any_profile_envelope_is_touched( ): """The verifier must be the first thing opened, or a mistyped password would be indistinguishable from a damaged store.""" - set_credentials("two", CREDENTIALS, PASSWORD, home=initialised) + set_credentials("two", CREDENTIALS, PASSWORD, directory=initialised) with pytest.raises(PasswordError): - set_credentials("three", CREDENTIALS, WRONG_PASSWORD, home=initialised) + set_credentials("three", CREDENTIALS, WRONG_PASSWORD, directory=initialised) assert store_profiles(initialised) == ["one", "two"] @@ -469,9 +478,9 @@ def test_an_envelope_moved_between_profiles_does_not_open(initialised): document["profiles"]["two"] = document["profiles"]["one"] path.write_text(json.dumps(document)) - assert resolve_credentials("one", PASSWORD, home=initialised) == CREDENTIALS + assert resolve_credentials("one", PASSWORD, directory=initialised) == CREDENTIALS with pytest.raises(StoreError) as raised: - resolve_credentials("two", PASSWORD, home=initialised) + resolve_credentials("two", PASSWORD, directory=initialised) assert not isinstance(raised.value, PasswordError) assert MARKER not in str(raised.value) @@ -480,34 +489,37 @@ def test_a_profile_with_no_envelope_is_an_error_not_an_empty_mapping(initialised """Fail closed. Returning `{}` would let a caller run a command with no credentials in the environment and blame the backend for the result.""" with pytest.raises(ProfileError): - resolve_credentials("absent", PASSWORD, home=initialised) + resolve_credentials("absent", PASSWORD, directory=initialised) -def test_a_profile_in_config_without_credentials_selects_but_does_not_resolve(home): +def test_a_profile_in_config_without_credentials_selects_but_does_not_resolve( + store_directory, +): """How the two files relate when a profile is in one and not the other. `config` alone is enough to know which backend a profile names — which is all `login` needs — so selection succeeds; resolving credentials that were never sealed is an error naming the profile.""" - write_config(home, config_with("one")) - init_store(PASSWORD, home=home) + write_config(store_directory, config_with("one")) + init_store(PASSWORD, directory=store_directory) - assert select_profile("one", config=load_store_config(home), environ={}).name == "one" + config = load_store_config(store_directory) + assert select_profile("one", config=config, environ={}).name == "one" with pytest.raises(ProfileError) as raised: - resolve_credentials("one", PASSWORD, home=home) + resolve_credentials("one", PASSWORD, directory=store_directory) assert "one" in str(raised.value) -def test_resolving_before_the_store_exists_says_so(home): +def test_resolving_before_the_store_exists_says_so(store_directory): with pytest.raises(StoreError): - resolve_credentials("one", PASSWORD, home=home) + resolve_credentials("one", PASSWORD, directory=store_directory) def test_initialising_over_an_existing_store_is_refused(initialised): """`init` twice is a plausible mistake; discarding every sealed envelope in response to it is not a recoverable one.""" with pytest.raises(StoreError): - init_store(PASSWORD, home=initialised) - assert resolve_credentials("one", PASSWORD, home=initialised) == CREDENTIALS + init_store(PASSWORD, directory=initialised) + assert resolve_credentials("one", PASSWORD, directory=initialised) == CREDENTIALS @pytest.mark.parametrize( @@ -539,7 +551,7 @@ def test_a_malformed_decrypted_payload_is_refused_without_echoing_it( path.write_text(json.dumps(document)) with pytest.raises(StoreError) as raised: - resolve_credentials("malformed", PASSWORD, home=initialised) + resolve_credentials("malformed", PASSWORD, directory=initialised) assert MARKER not in str(raised.value) assert "malformed" in str(raised.value) @@ -547,7 +559,7 @@ def test_a_malformed_decrypted_payload_is_refused_without_echoing_it( def test_a_damaged_store_file_is_refused_rather_than_read_as_empty(initialised): (initialised / "credentials").write_text("{ not json") with pytest.raises(StoreError): - resolve_credentials("one", PASSWORD, home=initialised) + resolve_credentials("one", PASSWORD, directory=initialised) def test_an_unsupported_store_version_is_refused(initialised): @@ -556,7 +568,7 @@ def test_an_unsupported_store_version_is_refused(initialised): document["v"] = 99 path.write_text(json.dumps(document)) with pytest.raises(StoreError): - resolve_credentials("one", PASSWORD, home=initialised) + resolve_credentials("one", PASSWORD, directory=initialised) # --------------------------------------------------------------------------- @@ -565,20 +577,22 @@ def test_an_unsupported_store_version_is_refused(initialised): def test_rotation_reseals_every_envelope(initialised): - set_credentials("two", {"NAME": "another-placeholder"}, PASSWORD, home=initialised) - rotate_password(PASSWORD, NEW_PASSWORD, home=initialised) + set_credentials( + "two", {"NAME": "another-placeholder"}, PASSWORD, directory=initialised + ) + rotate_password(PASSWORD, NEW_PASSWORD, directory=initialised) - assert resolve_credentials("one", NEW_PASSWORD, home=initialised) == CREDENTIALS - assert resolve_credentials("two", NEW_PASSWORD, home=initialised) == { + assert resolve_credentials("one", NEW_PASSWORD, directory=initialised) == CREDENTIALS + assert resolve_credentials("two", NEW_PASSWORD, directory=initialised) == { "NAME": "another-placeholder" } with pytest.raises(PasswordError): - resolve_credentials("one", PASSWORD, home=initialised) + resolve_credentials("one", PASSWORD, directory=initialised) def test_rotation_draws_fresh_salts_rather_than_reusing_the_old_ones(initialised): before = json.loads((initialised / "credentials").read_bytes()) - rotate_password(PASSWORD, NEW_PASSWORD, home=initialised) + rotate_password(PASSWORD, NEW_PASSWORD, directory=initialised) after = json.loads((initialised / "credentials").read_bytes()) for section in (["verifier"], ["profiles", "one"]): old, new = before, after @@ -588,19 +602,21 @@ def test_rotation_draws_fresh_salts_rather_than_reusing_the_old_ones(initialised assert old["nonce"] != new["nonce"] -def test_rotation_reseals_an_envelope_whose_config_table_is_gone(home): +def test_rotation_reseals_an_envelope_whose_config_table_is_gone(store_directory): """Rotation iterates the credentials file, not the profile list in `config`. An implementation that walked `config` instead would silently and irrecoverably destroy this envelope, and every other test here would pass.""" - write_config(home, config_with("one")) - init_store(PASSWORD, home=home) - set_credentials("one", CREDENTIALS, PASSWORD, home=home) - set_credentials("orphan", {"NAME": "orphan-placeholder"}, PASSWORD, home=home) + write_config(store_directory, config_with("one")) + init_store(PASSWORD, directory=store_directory) + set_credentials("one", CREDENTIALS, PASSWORD, directory=store_directory) + set_credentials( + "orphan", {"NAME": "orphan-placeholder"}, PASSWORD, directory=store_directory + ) - rotate_password(PASSWORD, NEW_PASSWORD, home=home) + rotate_password(PASSWORD, NEW_PASSWORD, directory=store_directory) - assert store_profiles(home) == ["one", "orphan"] - assert resolve_credentials("orphan", NEW_PASSWORD, home=home) == { + assert store_profiles(store_directory) == ["one", "orphan"] + assert resolve_credentials("orphan", NEW_PASSWORD, directory=store_directory) == { "NAME": "orphan-placeholder" } @@ -608,7 +624,7 @@ def test_rotation_reseals_an_envelope_whose_config_table_is_gone(home): def test_rotation_with_the_wrong_current_password_changes_nothing(initialised): before = (initialised / "credentials").read_bytes() with pytest.raises(PasswordError): - rotate_password(WRONG_PASSWORD, NEW_PASSWORD, home=initialised) + rotate_password(WRONG_PASSWORD, NEW_PASSWORD, directory=initialised) assert (initialised / "credentials").read_bytes() == before @@ -619,7 +635,9 @@ def test_rotation_is_all_or_nothing_when_one_reseal_fails(initialised, monkeypat leave a store whose profiles need different passwords with nothing recording which is which. """ - set_credentials("two", {"NAME": "another-placeholder"}, PASSWORD, home=initialised) + set_credentials( + "two", {"NAME": "another-placeholder"}, PASSWORD, directory=initialised + ) before = (initialised / "credentials").read_bytes() real_seal_json = store.seal_json @@ -636,16 +654,16 @@ def failing_seal_json(payload, password, aad): monkeypatch.setattr(store, "seal_json", failing_seal_json) with pytest.raises(RuntimeError): - rotate_password(PASSWORD, NEW_PASSWORD, home=initialised) + rotate_password(PASSWORD, NEW_PASSWORD, directory=initialised) monkeypatch.undo() assert (initialised / "credentials").read_bytes() == before - assert resolve_credentials("one", PASSWORD, home=initialised) == CREDENTIALS - assert resolve_credentials("two", PASSWORD, home=initialised) == { + assert resolve_credentials("one", PASSWORD, directory=initialised) == CREDENTIALS + assert resolve_credentials("two", PASSWORD, directory=initialised) == { "NAME": "another-placeholder" } with pytest.raises(PasswordError): - resolve_credentials("one", NEW_PASSWORD, home=initialised) + resolve_credentials("one", NEW_PASSWORD, directory=initialised) assert sorted(path.name for path in initialised.iterdir()) == ["credentials"] @@ -655,7 +673,9 @@ def test_rotation_touches_the_file_exactly_once(initialised, monkeypatch): after each profile would be all-or-nothing only by luck about where a failure landed, and the induced-failure test above can only observe the failures it induces.""" - set_credentials("two", {"NAME": "another-placeholder"}, PASSWORD, home=initialised) + set_credentials( + "two", {"NAME": "another-placeholder"}, PASSWORD, directory=initialised + ) real_atomic_write = store.atomic_write writes: list[str] = [] @@ -664,7 +684,7 @@ def counting_atomic_write(path, data, mode): return real_atomic_write(path, data, mode) monkeypatch.setattr(store, "atomic_write", counting_atomic_write) - rotate_password(PASSWORD, NEW_PASSWORD, home=initialised) + rotate_password(PASSWORD, NEW_PASSWORD, directory=initialised) assert writes == ["credentials"] @@ -678,7 +698,7 @@ def test_rotation_stops_rather_than_dropping_an_envelope_it_cannot_open(initiali before = path.read_bytes() with pytest.raises(StoreError): - rotate_password(PASSWORD, NEW_PASSWORD, home=initialised) + rotate_password(PASSWORD, NEW_PASSWORD, directory=initialised) assert path.read_bytes() == before @@ -709,12 +729,14 @@ def test_no_error_message_from_any_failing_path_contains_a_credential( messages: list[str] = [] for call in ( - lambda: resolve_credentials("one", WRONG_PASSWORD, home=initialised), - lambda: resolve_credentials("absent", PASSWORD, home=initialised), - lambda: resolve_credentials("moved", PASSWORD, home=initialised), - lambda: rotate_password(WRONG_PASSWORD, NEW_PASSWORD, home=initialised), - lambda: init_store(PASSWORD, home=initialised), - lambda: set_credentials("four", CREDENTIALS, WRONG_PASSWORD, home=initialised), + lambda: resolve_credentials("one", WRONG_PASSWORD, directory=initialised), + lambda: resolve_credentials("absent", PASSWORD, directory=initialised), + lambda: resolve_credentials("moved", PASSWORD, directory=initialised), + lambda: rotate_password(WRONG_PASSWORD, NEW_PASSWORD, directory=initialised), + lambda: init_store(PASSWORD, directory=initialised), + lambda: set_credentials( + "four", CREDENTIALS, WRONG_PASSWORD, directory=initialised + ), ): with pytest.raises(StoreError) as raised: call() @@ -730,3 +752,78 @@ def test_no_error_message_from_any_failing_path_contains_a_credential( def test_a_profile_dataclass_reports_which_form_it_carries(): assert not Profile(name="one", backend_url="file:///placeholder").has_components assert Profile(name="one", bucket="placeholder-bucket").has_components + + +# --------------------------------------------------------------------------- +# An absent password, per entry point +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("password", ["", " ", "\t\n"]) +def test_init_store_refuses_an_absent_password_instead_of_sealing_with_nothing( + store_directory, password +): + """Argon2id derives a usable key from `b""`, so an unguarded `init_store("")` + writes a well-formed store that opens with no password at all — no error, no + warning, and no way to tell it from a protected one.""" + with pytest.raises(PasswordError): + init_store(password, directory=store_directory) + assert not (store_directory / "credentials").exists() + + +@pytest.mark.parametrize("password", ["", " ", "\t\n"]) +def test_set_credentials_refuses_an_absent_password(initialised, password): + with pytest.raises(PasswordError): + set_credentials("two", CREDENTIALS, password, directory=initialised) + assert store_profiles(initialised) == ["one"] + + +@pytest.mark.parametrize("password", ["", " ", "\t\n"]) +def test_resolve_credentials_refuses_an_absent_password(initialised, password): + with pytest.raises(PasswordError): + resolve_credentials("one", password, directory=initialised) + + +@pytest.mark.parametrize("password", ["", " ", "\t\n"]) +def test_rotation_refuses_an_absent_new_password_and_changes_nothing( + initialised, password +): + """The irreversible one: rotating *to* an empty password would leave a store + that opens with nothing, with the old password gone.""" + before = (initialised / "credentials").read_bytes() + with pytest.raises(PasswordError): + rotate_password(PASSWORD, password, directory=initialised) + assert (initialised / "credentials").read_bytes() == before + assert resolve_credentials("one", PASSWORD, directory=initialised) == CREDENTIALS + + +@pytest.mark.parametrize("password", ["", " ", "\t\n"]) +def test_rotation_refuses_an_absent_current_password(initialised, password): + with pytest.raises(PasswordError): + rotate_password(password, NEW_PASSWORD, directory=initialised) + + +def test_an_absent_password_is_not_reported_as_a_damaged_store(initialised): + """`EmptyPasswordError` is a `CryptoError`, so the broad clause in + `_check_password` would otherwise recode "no password" as "the verifier is + unusable" — a message that sends the reader after the wrong problem.""" + with pytest.raises(PasswordError) as raised: + resolve_credentials("one", "", directory=initialised) + assert "required" in str(raised.value) + + +def test_rotation_keeps_top_level_fields_it_does_not_own(initialised): + """Rotation replaces the verifier and the profiles and leaves the rest of + the document alone. Rebuilding it from a literal would silently drop any + other field, and would leave the two write paths disagreeing about what a + document is — `set_credentials` preserves the whole thing.""" + path = initialised / "credentials" + document = json.loads(path.read_bytes()) + document["a_field_a_later_version_added"] = {"kept": True} + path.write_text(json.dumps(document)) + + rotate_password(PASSWORD, NEW_PASSWORD, directory=initialised) + + after = json.loads(path.read_bytes()) + assert after["a_field_a_later_version_added"] == {"kept": True} + assert resolve_credentials("one", NEW_PASSWORD, directory=initialised) == CREDENTIALS diff --git a/uv.lock b/uv.lock index 774146a..e8c29a2 100644 --- a/uv.lock +++ b/uv.lock @@ -513,7 +513,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "cryptography", specifier = ">=42.0" }, + { name = "cryptography", specifier = ">=44.0" }, { name = "pyyaml", specifier = ">=6.0" }, ] From de4e84180878c73d12cb1256e0f486a3c6960ac9 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 10:02:38 -0700 Subject: [PATCH 17/44] feat: login, exec, shell -- run pulumi with a profile's credentials `pulumi login` persists only the backend URL; AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and PULUMI_CONFIG_PASSPHRASE are read from the environment of every subsequent `pulumi` process. `exec`/`shell` decrypt a profile and inject those three plus PULUMI_BACKEND_URL into a child process only -- inherited environment, never argv, never disk, never logged. `login` resolves a profile's backend URL (composing the component form into an S3 URL when given, passing `backend_url` through byte-for-byte otherwise) and runs `pulumi login `. Wires `select_profile`'s `repo_profile` parameter to the repository's own `.stackward.toml`, the precedence tier Task 6's report flagged as dead code until something supplied it. An explicit `--profile` skips parsing `.stackward.toml` entirely, so a broken repo config elsewhere cannot block an unambiguous explicit choice. Backend guard: before `exec`/`shell` (never `login`, which is what sets the state the guard checks against) runs a child, compares the resolved profile's backend URL against Pulumi's own persisted backend and refuses on mismatch, naming both. Read directly from `/credentials.json`'s `current` field rather than by shelling out to `pulumi` -- `pulumi whoami`, `whoami --json` and `about` were all verified, empirically, to contact the backend itself even for a read-only status query, which a pre-flight safety check must never depend on. The store password comes from `STACKWARD_PASSWORD` or an interactive `getpass` prompt, and never from `sys.stdin`: verified that without a `/dev/tty` pre-check, `getpass` falls back to reading (and echoing) `sys.stdin`, which is the child's stdin for `exec`/`shell`. Exit status is translated (`128 + signal` for a signal-terminated child, since `subprocess` represents that as a negative return code `sys.exit` cannot carry through intact) and propagated exactly, including through a real subprocess for both a non-zero exit and a signal kill. Introduces no new concurrent-write path over the credential store: this module only ever calls the read-only `load_store_config`/`resolve_credentials`, never `set_credentials`/`rotate_password`. 456 tests pass (368 before, 88 new), output pristine. Several properties verified by sabotage: mutating the backend guard away, the exit-status translation, the environment-overlay behavior, the repo-profile wiring, the tty pre-check, and the usage-error/profile-resolution ordering each broke a specific, named test; two of those sweeps found and fixed a genuinely vacuous test before being trusted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/cli.py | 20 + src/stackward/commands/session.py | 472 ++++++++++++ tests/test_session.py | 1188 +++++++++++++++++++++++++++++ 3 files changed, 1680 insertions(+) create mode 100644 src/stackward/commands/session.py create mode 100644 tests/test_session.py diff --git a/src/stackward/cli.py b/src/stackward/cli.py index b1d951e..c19bcaf 100644 --- a/src/stackward/cli.py +++ b/src/stackward/cli.py @@ -20,6 +20,7 @@ from .commands.check_config import cmd_check_config from .commands.install_hooks import cmd_install_hooks from .commands.pre_commit import cmd_pre_commit +from .commands.session import cmd_exec, cmd_login, cmd_shell from .config import find_repo_config # Commands that must keep working even when a repo demands a newer stackward @@ -134,6 +135,25 @@ def build_parser() -> argparse.ArgumentParser: ) hooks_install.set_defaults(func=cmd_install_hooks) + login = sub.add_parser("login", help="point pulumi at a profile's backend") + login.add_argument("--profile", help="profile to use (overrides selection precedence)") + login.set_defaults(func=cmd_login) + + exec_ = sub.add_parser( + "exec", help="run a command with a profile's credentials injected" + ) + exec_.add_argument("--profile", help="profile to use (overrides selection precedence)") + exec_.add_argument( + "argv", nargs="*", metavar="COMMAND", help="command to run, after --" + ) + exec_.set_defaults(func=cmd_exec) + + shell = sub.add_parser( + "shell", help="open $SHELL with a profile's credentials injected" + ) + shell.add_argument("--profile", help="profile to use (overrides selection precedence)") + shell.set_defaults(func=cmd_shell) + return parser diff --git a/src/stackward/commands/session.py b/src/stackward/commands/session.py new file mode 100644 index 0000000..5c38191 --- /dev/null +++ b/src/stackward/commands/session.py @@ -0,0 +1,472 @@ +"""`login`, `exec`, `shell`: point Pulumi at a profile's backend, and run +commands with that profile's credentials in the environment. + +**Why this module exists at all.** `pulumi login` persists only the backend +URL. `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `PULUMI_CONFIG_PASSPHRASE` +are read from the environment of *every subsequent* `pulumi` process -- `up`, +`preview`, `config get`, `stack ls`. An encrypted store only `stackward` can +read therefore breaks a plain `pulumi up` outright: `exec` is what makes the +store usable in practice, not an optional convenience layered on top of it. + +**Secrets exist only in the child's environment.** `exec` and `shell` decrypt +a profile, build one `dict` holding the caller's own environment plus four +names, and hand it to `subprocess.run` as `env=`. Nothing here writes a +credential to disk, logs one, or lets one reach `argv` -- the values travel +from the sealed store into the child's environment block and nowhere else. +Every error path in this module names a profile, a key, a file or a backend +URL, and never a credential value. + +**`shell` inherits, it does not replace.** Both `exec` and `shell` overlay +exactly four names onto a *copy* of the caller's own environment +(`PATH`, `HOME`, `TERM` and everything else the child would otherwise need +survive untouched); they never construct an environment from the four names +alone. `tests/test_session.py` asserts this by diffing parent and child +environments, not by counting keys in the child -- an implementation that +happened to also inherit the right ambient variables by accident would still +fail a diff-based test if it dropped or altered anything else. + +**The backend guard applies to `exec` and `shell`, never to `login`.** +`login` is the command that *sets* Pulumi's persisted backend; gating it on +matching that same state would make switching profiles impossible. The guard +instead sits in front of the two commands that run someone else's code with a +profile's credentials live in its environment: before doing that, this module +compares the resolved profile's backend URL against Pulumi's own persisted +`current` backend (see `_current_backend`) and refuses on any mismatch, naming +both. Without it, a second profile is merely a convenience -- a `pulumi` +invocation that does not go through `stackward exec` (a stray script, a +forgotten shell alias, a plugin) would still silently use whichever backend +the last `login` pointed at. + +**Reading Pulumi's current backend never touches the network.** +`pulumi whoami`, `pulumi about` and `pulumi whoami --json` were all tried +against a real (unreachable) S3 backend during development of this module, +and every one of them attempts to contact the backend itself -- exactly the +network dependency a pre-flight safety check must not have (a guard that can +hang or fail on network conditions unrelated to the mismatch it exists to +catch is not a guard). `pulumi login ` was also observed, empirically, +to write the URL verbatim to `/credentials.json`'s `current` +field with no network access at all for a `file://` backend. This module +therefore reads that field directly. `PULUMI_HOME` (default `~/.pulumi`) is +Pulumi's own documented override, not knowledge about any particular +deployment of it. + +**The store password is never read from `sys.stdin`.** `exec` decrypts a +profile before handing the *child's* stdin, stdout and stderr through +untouched -- a `stackward shell` session and a `stackward exec -- pulumi up` +that itself prompts for confirmation both depend on that descriptor being +free. `_read_store_password` checks `STACKWARD_PASSWORD` first (the +non-interactive path: CI has no controlling terminal to prompt against) and +otherwise prompts with `getpass.getpass`, which itself is verified, before +ever being called, to be able to open `/dev/tty` directly -- if it cannot, +this module raises rather than letting `getpass` fall back to its own +`sys.stdin` read, which would both consume bytes meant for the child and +echo them to the screen. `login` needs none of this: it resolves a backend +URL from `config` (plaintext, unencrypted) and never opens the credentials +store at all, which is also why a profile can be logged into before its +credentials have ever been set (see `store.py`'s own note on this). + +**Exit status is translated, not passed through raw.** `subprocess` +represents a child killed by a signal as a *negative* return code; handing +that negative number to `sys.exit` gets reduced modulo 256 at the OS boundary +and no longer identifies the signal. `_exit_status` converts it to the +`128 + signal` form a POSIX shell's own `$?` would report, which is the one +representation `sys.exit` can carry through intact. +""" + +from __future__ import annotations + +import argparse +import getpass +import json +import os +import shutil +import subprocess +import sys +from collections.abc import Mapping +from pathlib import Path +from urllib.parse import quote, urlencode + +from ..config import ConfigError, find_repo_config, load_config +from ..store import ( + Profile, + StoreError, + load_store_config, + resolve_credentials, + select_profile, +) +from .check_config import fail_closed + +# Read once, never printed: the non-interactive escape hatch for `exec`/ +# `shell` in an environment with no controlling terminal (CI, most notably). +# Never accepted as a CLI argument -- only ever an environment variable, per +# the same rule that keeps every other secret out of argv and out of `ps`. +ENV_STORE_PASSWORD = "STACKWARD_PASSWORD" + +# The three names `resolve_credentials` must return for a profile before +# `exec`/`shell` can run anything -- the exact vocabulary `pulumi` itself +# reads from a child process's environment (see the module docstring). Named +# here, not in `store.py`: the store deliberately does not know which +# variables a backend needs, so this is the one place in the codebase that +# does. +AWS_ACCESS_KEY_ID = "AWS_ACCESS_KEY_ID" +AWS_SECRET_ACCESS_KEY = "AWS_SECRET_ACCESS_KEY" +PULUMI_CONFIG_PASSPHRASE = "PULUMI_CONFIG_PASSPHRASE" +PULUMI_BACKEND_URL = "PULUMI_BACKEND_URL" + +CREDENTIAL_NAMES = (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, PULUMI_CONFIG_PASSPHRASE) + + +class SessionError(Exception): + """Something specific to this module stopped `login`/`exec`/`shell` from + proceeding: no store password was available, a decrypted profile was + missing a required credential name, a backend could not be determined, or + the resolved backend does not match Pulumi's current one. + + Never carries a credential value -- every message here names a profile, a + key name, a file path or a backend URL, never a secret.""" + + +# Errors this module's own `cmd_*` entry points recognise and report with a +# one-line message rather than falling through to `fail_closed`'s generic +# "could not run ()". `StoreError` alone already covers `ProfileError` +# and `PasswordError`, both of which subclass it. +_KNOWN_ERRORS = (StoreError, ConfigError, SessionError) + + +def _repo_profile() -> str | None: + """The repository's own `profile =`, from the nearest `.stackward.toml` + above the current working directory, or `None` when there is none. + + Wires the precedence tier `select_profile` has always accepted but that + nothing supplied until this module existed: without this, a repository + declaring its own backend -- the normal case, per `store.select_profile`'s + own docstring -- would silently fall through to `default_profile` instead. + """ + path = find_repo_config() + if path is None: + return None + return load_config(path).profile + + +def _resolve_profile(explicit: str | None, *, directory: Path | None = None) -> Profile: + """`select_profile`, with the repository tier wired in. + + `_repo_profile()` is skipped entirely when `explicit` is given: an + explicit `--profile` is the highest-precedence source and answers the + question completely on its own, so a `.stackward.toml` that happens to be + unparsable elsewhere in the repository must not stop it from working. + Every other source (`STACKWARD_PROFILE`, `default_profile`) still leaves + `.stackward.toml` parsed and validated -- see `config.py`'s own "invalid + is never treated as absent" rule, which this preserves for every case + where the repository tier could actually be consulted. + """ + config = load_store_config(directory) + repo_profile = None if explicit is not None else _repo_profile() + return select_profile(explicit, config=config, repo_profile=repo_profile) + + +def _compose_backend_url(profile: Profile) -> str: + """The profile's backend URL, ready to hand to `pulumi login`/inject as + `PULUMI_BACKEND_URL`. + + `backend_url` passes through **byte-for-byte** -- never normalised, + re-encoded or reordered -- so that whatever a user (or a future backend + scheme) wrote is exactly what `pulumi` receives. Composing the component + form into a URL is this module's job precisely because Task 6's store + deliberately left it undone (see `store.Profile`'s own docstring): the + query-parameter spelling below is a fact about Pulumi's S3-compatible + backend, not about a profile. + + The composed form is `s3://[/][?region=...&endpoint=... + &s3ForcePathStyle=true]`. `s3ForcePathStyle` is only ever added alongside + `endpoint`: virtual-hosted-style addressing (the default without a custom + endpoint) does not work against most S3-compatible services, which is + exactly the case an `endpoint` in the profile signals. An empty string for + `prefix`, `region` or `endpoint` is treated the same as absent, since + there is no way to distinguish "the operator wrote an empty prefix on + purpose" from "the key was left blank" and the safe reading for an + optional component is to omit it. + + Never called with a profile that has neither form: `store._build_profile` + already refuses to construct one. The check below exists anyway for a + `Profile` built by hand (as a test can), so this function fails closed + rather than raising an unrelated `AttributeError`. + """ + if profile.backend_url is not None: + return profile.backend_url + if not profile.bucket: + raise SessionError(f"profile {profile.name!r} has no backend configured") + + url = f"s3://{quote(profile.bucket, safe='')}" + if profile.prefix: + url += "/" + quote(profile.prefix, safe="/") + + params: dict[str, str] = {} + if profile.region: + params["region"] = profile.region + if profile.endpoint: + params["endpoint"] = profile.endpoint + params["s3ForcePathStyle"] = "true" + if params: + url += "?" + urlencode(params) + return url + + +def _pulumi_home() -> Path: + """`PULUMI_HOME`, or its documented default. Not environment-specific + knowledge -- this is Pulumi's own override, the same way `XDG_CONFIG_HOME` + is the platform's.""" + home = os.environ.get("PULUMI_HOME") + return Path(home) if home else Path.home() / ".pulumi" + + +def _current_backend() -> str | None: + """Pulumi's persisted backend URL, read directly from + `/credentials.json`'s `current` field -- never by shelling + out to `pulumi`, which (see the module docstring) contacts the backend + itself even for a read-only status query. + + Returns `None` only for the legitimate "never logged in" state: the file + does not exist, or exists but does not yet have a `current` entry. Any + other failure to read or parse it raises `SessionError` -- an unreadable + or malformed credentials file must not be silently treated as "nothing to + guard against", which is exactly the fail-open behaviour the backend guard + exists to prevent. + """ + path = _pulumi_home() / "credentials.json" + try: + raw = path.read_bytes() + except FileNotFoundError: + return None + except OSError as exc: + raise SessionError(f"cannot read {path}: {exc}") from exc + + try: + data = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise SessionError( + f"{path}: not a valid pulumi credentials file ({type(exc).__name__})" + ) from exc + if not isinstance(data, dict): + raise SessionError(f"{path}: not a valid pulumi credentials file") + + current = data.get("current") + if current is None: + return None + if not isinstance(current, str): + raise SessionError(f"{path}: 'current' field is not a string") + return current + + +def _check_backend_guard(resolved_url: str) -> None: + """Refuse when Pulumi's persisted backend does not match `resolved_url`, + naming both. Never logged into anything at all is not a mismatch -- it is + an absence, and there is nothing yet for `resolved_url` to conflict with; + `stackward login` (or a first `exec`/`shell`) is how it gets set.""" + current = _current_backend() + if current is not None and current != resolved_url: + raise SessionError( + "backend mismatch: this profile resolves to backend " + f"{resolved_url!r}, but pulumi is currently logged in to " + f"{current!r}. Run `stackward login` for this profile first." + ) + + +def _tty_available() -> bool: + """Whether `/dev/tty` can be opened directly, independent of `sys.stdin`. + + A dedicated, monkeypatchable seam rather than inlining the `os.open` call + in `_read_store_password`: it is what lets that function's "no interactive + terminal" branch be exercised without a real detached process, and it is + the exact question `getpass.getpass` itself answers first -- checking it + ourselves means we can refuse *before* `getpass` would otherwise fall back + to reading (and echoing) `sys.stdin`, which -- for `exec`/`shell` -- is the + child's stdin, not a spare input source of our own to consume. + """ + try: + fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY) + except OSError: + return False + os.close(fd) + return True + + +def _read_store_password() -> str: + """`STACKWARD_PASSWORD` if set, otherwise an interactive prompt. + + Never reads `sys.stdin`. The environment variable exists for the + non-interactive case `exec`/`shell` must support (a CI job running + `stackward exec -- pulumi up` has no controlling terminal to prompt + against); the prompt exists for everyone else, and refuses outright, + rather than degrading to a visible, stdin-consuming fallback, when + `_tty_available` says `/dev/tty` cannot be opened. + """ + from_env = os.environ.get(ENV_STORE_PASSWORD) + if from_env is not None: + return from_env + if not _tty_available(): + raise SessionError( + f"no store password available: set {ENV_STORE_PASSWORD}, or run " + "this command from an interactive terminal" + ) + return getpass.getpass("stackward credential store password: ") + + +def _require_credential_names(credentials: Mapping[str, str], profile: str) -> None: + """Refuse, naming the missing key names (never a value), when a + decrypted profile does not carry every name `exec`/`shell` must inject. + An envelope holding extra names beyond the three required ones is + accepted as-is; only those three are ever injected -- see `_child_env`.""" + missing = [name for name in CREDENTIAL_NAMES if name not in credentials] + if missing: + raise SessionError( + f"profile {profile!r} is missing required credential(s): " + f"{', '.join(missing)}" + ) + + +def _child_env(credentials: Mapping[str, str], backend_url: str) -> dict[str, str]: + """The caller's own environment, plus exactly the four session names. + + A copy of `os.environ`, never `os.environ` itself -- overlaying onto the + real mapping would leak the four names into every subsequent call in this + same process, including this one's own `fail_closed` error path.""" + env = dict(os.environ) + for name in CREDENTIAL_NAMES: + env[name] = credentials[name] + env[PULUMI_BACKEND_URL] = backend_url + return env + + +def _prepare_env( + explicit_profile: str | None, *, directory: Path | None = None +) -> dict[str, str]: + """Resolve the profile, enforce the backend guard, decrypt its + credentials and return the exact environment `exec`/`shell` must run + their child in. + + Raises `StoreError`, `ConfigError` or `SessionError` and returns nothing + partial: every step that can fail runs before `_child_env` builds + anything, so a caller either gets a complete environment or an exception, + never a dict missing one of the four names. + """ + profile = _resolve_profile(explicit_profile, directory=directory) + url = _compose_backend_url(profile) + _check_backend_guard(url) + password = _read_store_password() + credentials = resolve_credentials(profile.name, password, directory=directory) + _require_credential_names(credentials, profile.name) + return _child_env(credentials, url) + + +def _exit_status(returncode: int) -> int: + """A `subprocess` return code, translated to what a POSIX shell's `$?` + would report for the same child. + + A normal POSIX exit is already 0-255 (the kernel only ever reports 8 bits + of it) and passes through unchanged. A negative `returncode` is how + `subprocess` represents a POSIX child killed by a signal; `sys.exit` + cannot carry that negative number through intact (it is reduced modulo + 256 at the OS boundary, and no longer identifies the signal), so it is + translated to `128 + signal` -- the one representation that survives. + + This function does not special-case Windows, where a terminated + process's `returncode` can be a large *positive* `STATUS_*` value outside + 0-255: that value is returned unchanged, and is subject to whatever + `sys.exit` itself does with a number that large on that platform, the + same as it would be for any exit code this tool ever returns. + """ + if returncode < 0: + return 128 - returncode + return returncode + + +def _run_child(command: list[str], env: dict[str, str] | None = None) -> int: + """Run `command`, inheriting this process's stdin/stdout/stderr, and + return its translated exit status. + + `env=None` means "inherit the caller's environment unchanged", the same + meaning `subprocess.run` itself gives it -- used by `login`, which injects + nothing. `exec`/`shell` always pass an explicit environment built by + `_child_env`. + + A missing executable is reported by name (never by the full command line, + which could hold an argument value, though never a credential -- those + never reach argv at all) and mapped to exit code 2, the same "could not + run" code every other refusal in this module uses. + """ + try: + completed = subprocess.run(command, env=env) + except OSError as exc: + print(f"error: cannot run {command[0]!r}: {exc}", file=sys.stderr) + return 2 + return _exit_status(completed.returncode) + + +@fail_closed +def cmd_login(args: argparse.Namespace) -> int: + """Entry point for `stackward login`. + + Resolves the profile's backend URL and runs `pulumi login `. Never + opens the credentials store -- a profile can be logged into before its + credentials have been set at all (`store.py`'s own R3) -- and never + subject to the backend guard, which exists to protect the commands that + run with a profile's credentials live in the environment; `login` is the + command that *sets* what the guard checks against, so gating it on + itself would make switching profiles impossible. + """ + try: + profile = _resolve_profile(args.profile) + url = _compose_backend_url(profile) + except _KNOWN_ERRORS as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + pulumi = shutil.which("pulumi") + if pulumi is None: + print("error: pulumi executable not found on PATH", file=sys.stderr) + return 2 + return _run_child([pulumi, "login", url]) + + +@fail_closed +def cmd_exec(args: argparse.Namespace) -> int: + """Entry point for `stackward exec -- `. + + An empty command (nothing after `--`, or no `--` at all) is a usage + error, reported and returned as exit code 2 before anything is resolved + or decrypted -- the same code every other pre-flight refusal in this + module uses. + """ + if not args.argv: + print( + "error: exec requires a command: stackward exec -- ", + file=sys.stderr, + ) + return 2 + + try: + env = _prepare_env(args.profile) + except _KNOWN_ERRORS as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + return _run_child(args.argv, env) + + +@fail_closed +def cmd_shell(args: argparse.Namespace) -> int: + """Entry point for `stackward shell`: the same as `exec`, with the + user's `$SHELL` as the command.""" + shell_path = os.environ.get("SHELL") + if not shell_path: + print("error: $SHELL is not set", file=sys.stderr) + return 2 + + try: + env = _prepare_env(args.profile) + except _KNOWN_ERRORS as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + return _run_child([shell_path], env) diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..39273f0 --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,1188 @@ +"""Tests for `login`, `exec`, `shell`. + +Two things this file is careful about, for the same reasons `test_store.py` +states them: + +*No assertion depends on a credential value appearing in captured output.* +Where a value must not appear, the assertion is on absence in `capfd` output +(file-descriptor level, so a real child process's own writes are covered, not +only this process's `print` calls) -- never on a value being displayed. + +*Several tests exist to fail against a plausible wrong implementation, not +merely to exercise a right one.* The environment-diff tests fail against an +implementation that replaces the environment instead of overlaying four names +onto it; the backend-guard ordering test fails against an implementation that +prompts for a password before checking the guard; the repo-profile test fails +if `select_profile`'s `repo_profile` parameter is never wired to anything (the +gap `store.py`'s own report flagged as dead code). + +Every real child process spawned here is a single, environment-variable-driven +Python script (`_STUB`, written once per test by the `stub` fixture) rather +than `pulumi` itself: this module must never depend on the `pulumi` binary +being installed to prove exit-status, signal, or environment propagation -- +those are properties of *this* code, not of Pulumi's. +""" + +from __future__ import annotations + +import json +import os +import signal +import subprocess +import sys +from pathlib import Path + +import pytest + +from stackward import store +from stackward.cli import main +from stackward.commands import session +from stackward.commands.session import ( + AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY, + ENV_STORE_PASSWORD, + PULUMI_BACKEND_URL, + PULUMI_CONFIG_PASSPHRASE, + SessionError, +) +from stackward.store import Profile, ProfileError, StoreError + +PASSWORD = "the store password" +WRONG_PASSWORD = "not the store password" + +# Placeholder values only -- see GC4 in the task brief. Distinctive enough +# that an accidental substring match elsewhere in a test's own output would +# be implausible. +MARKER_KEY = "marker-access-key-1a2b3c4d" +MARKER_SECRET = "marker-secret-key-5e6f7a8b" +MARKER_PASSPHRASE = "marker-passphrase-9c0d1e2f" + +FULL_CREDENTIALS = { + AWS_ACCESS_KEY_ID: MARKER_KEY, + AWS_SECRET_ACCESS_KEY: MARKER_SECRET, + PULUMI_CONFIG_PASSPHRASE: MARKER_PASSPHRASE, +} +ALL_MARKERS = (MARKER_KEY, MARKER_SECRET, MARKER_PASSPHRASE) + + +@pytest.fixture(autouse=True) +def cheap_kdf(monkeypatch): + """Lower the Argon2id cost for this module only -- see `test_store.py`'s + identical fixture. This file performs several real seal/open round trips + per test (`init_store`, `set_credentials`, `resolve_credentials`).""" + from stackward import crypto + + monkeypatch.setattr(crypto, "ARGON2ID_MEMORY_KIB", 8192) + monkeypatch.setattr(crypto, "ARGON2ID_ITERATIONS", 1) + monkeypatch.setattr(crypto, "ARGON2ID_LANES", 1) + + +@pytest.fixture(autouse=True) +def no_real_pulumi_state(monkeypatch, tmp_path): + """Point `PULUMI_HOME` at an empty, per-test directory so no test in this + file can read or depend on this machine's real `~/.pulumi`.""" + monkeypatch.setenv("PULUMI_HOME", str(tmp_path / "pulumi-home")) + + +@pytest.fixture +def store_directory(tmp_path) -> Path: + return tmp_path / "stackward" + + +def write_store_config(directory: Path, text: str) -> Path: + directory.mkdir(parents=True, exist_ok=True) + path = directory / store.CONFIG_FILENAME + path.write_text(text) + path.chmod(store.CONFIG_MODE) + return path + + +def seed_profile( + directory: Path, + name: str, + *, + backend_url: str, + credentials: dict[str, str] | None = None, + password: str = PASSWORD, +) -> None: + """A store holding one profile in `config`, with `credentials` sealed + into it when given. Mirrors what a real `credentials init` + + `credentials set` (Task 8's sibling commands) would leave behind.""" + write_store_config(directory, f'[profile.{name}]\nbackend_url = "{backend_url}"\n') + if credentials is not None: + store.init_store(password, directory=directory) + store.set_credentials(name, credentials, password, directory=directory) + + +# --------------------------------------------------------------------------- +# A real child process, driven entirely by environment variables it reads +# itself -- never by argv, so it works identically as `exec`'s command or as +# `$SHELL` (which `shell` invokes with no arguments at all). +# --------------------------------------------------------------------------- + +_STUB = """\ +#!/usr/bin/env python3 +import json, os, sys + +dump_env_to = os.environ.get("STUB_DUMP_ENV_TO") +if dump_env_to: + with open(dump_env_to, "w") as f: + json.dump(dict(os.environ), f) + +dump_argv_to = os.environ.get("STUB_DUMP_ARGV_TO") +if dump_argv_to: + with open(dump_argv_to, "w") as f: + json.dump(sys.argv[1:], f) + +sig = os.environ.get("STUB_SIGNAL") +if sig: + os.kill(os.getpid(), int(sig)) + +sys.exit(int(os.environ.get("STUB_EXIT_CODE", "0"))) +""" + + +@pytest.fixture +def stub(tmp_path) -> Path: + path = tmp_path / "stub.py" + path.write_text(_STUB) + path.chmod(0o755) + return path + + +def fake_pulumi(monkeypatch, executable: Path) -> None: + """Make `shutil.which("pulumi")` resolve to `executable` -- `cmd_login`'s + only way of finding the real binary, and the seam every `login` test + uses to run the stub in its place instead.""" + monkeypatch.setattr( + session.shutil, "which", lambda name: str(executable) if name == "pulumi" else None + ) + + +# --------------------------------------------------------------------------- +# `_compose_backend_url` -- verbatim pass-through, and composition +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "url", + [ + "s3://placeholder-bucket/placeholder-prefix", + "gs://placeholder-bucket", + "azblob://placeholder-container", + "file:///placeholder/path", + "s3://placeholder?endpoint=placeholder.invalid®ion=placeholder-region", + "some-future-scheme://placeholder;weird=chars&more=1", + ], +) +def test_backend_url_passes_through_byte_for_byte(url): + """No normalisation, re-encoding or reordering -- whatever a profile + names is exactly what `login`/`exec`/`shell` use.""" + profile = Profile(name="p", backend_url=url) + assert session._compose_backend_url(profile) == url + + +def test_component_form_with_bucket_only(): + profile = Profile(name="p", bucket="placeholder-bucket") + assert session._compose_backend_url(profile) == "s3://placeholder-bucket" + + +def test_component_form_with_bucket_and_prefix(): + profile = Profile(name="p", bucket="placeholder-bucket", prefix="placeholder-prefix") + assert ( + session._compose_backend_url(profile) + == "s3://placeholder-bucket/placeholder-prefix" + ) + + +def test_component_form_with_region_only_adds_a_region_query_param(): + profile = Profile(name="p", bucket="placeholder-bucket", region="placeholder-region") + url = session._compose_backend_url(profile) + assert url == "s3://placeholder-bucket?region=placeholder-region" + + +def test_component_form_with_endpoint_forces_path_style(): + """A custom endpoint is exactly the case virtual-hosted-style addressing + does not work for, so `s3ForcePathStyle` is added alongside it.""" + profile = Profile(name="p", bucket="placeholder-bucket", endpoint="placeholder.invalid") + url = session._compose_backend_url(profile) + assert "endpoint=placeholder.invalid" in url + assert "s3ForcePathStyle=true" in url + + +def test_component_form_without_an_endpoint_never_adds_path_style(): + profile = Profile(name="p", bucket="placeholder-bucket", region="placeholder-region") + assert "s3ForcePathStyle" not in session._compose_backend_url(profile) + + +def test_component_form_combines_every_field(): + profile = Profile( + name="p", + bucket="placeholder-bucket", + prefix="placeholder-prefix", + region="placeholder-region", + endpoint="placeholder.invalid", + ) + url = session._compose_backend_url(profile) + assert url.startswith("s3://placeholder-bucket/placeholder-prefix?") + assert "region=placeholder-region" in url + assert "endpoint=placeholder.invalid" in url + assert "s3ForcePathStyle=true" in url + + +def test_component_form_percent_encodes_special_characters(): + profile = Profile(name="p", bucket="a bucket/weird", prefix="a prefix?with=chars") + url = session._compose_backend_url(profile) + # The bucket's own '/' must be encoded (it is not a path separator here); + # the prefix's '/' would be, but this prefix has none. + assert "a%20bucket%2Fweird" in url + assert "a%20prefix%3Fwith%3Dchars" in url + + +def test_an_empty_prefix_region_or_endpoint_is_treated_as_absent(): + profile = Profile(name="p", bucket="placeholder-bucket", prefix="", region="", endpoint="") + assert session._compose_backend_url(profile) == "s3://placeholder-bucket" + + +def test_a_profile_with_no_backend_at_all_fails_closed_naming_the_profile(): + """Unreachable through `store._build_profile`, which already refuses to + construct such a `Profile` -- covers a hand-built one instead, so this + function never raises a bare `AttributeError`.""" + profile = Profile(name="broken-profile") + with pytest.raises(SessionError, match="broken-profile"): + session._compose_backend_url(profile) + + +# --------------------------------------------------------------------------- +# Reading Pulumi's current backend, and the guard built on it +# --------------------------------------------------------------------------- + + +def test_pulumi_home_defaults_when_the_env_var_is_absent(monkeypatch): + monkeypatch.delenv("PULUMI_HOME", raising=False) + assert session._pulumi_home() == Path.home() / ".pulumi" + + +def test_pulumi_home_honours_the_env_var(tmp_path, monkeypatch): + monkeypatch.setenv("PULUMI_HOME", str(tmp_path / "custom")) + assert session._pulumi_home() == tmp_path / "custom" + + +def test_current_backend_is_none_when_never_logged_in(): + assert session._current_backend() is None + + +def test_current_backend_reads_the_current_field(monkeypatch, tmp_path): + home = tmp_path / "ph" + monkeypatch.setenv("PULUMI_HOME", str(home)) + home.mkdir() + (home / "credentials.json").write_text(json.dumps({"current": "file:///seen"})) + assert session._current_backend() == "file:///seen" + + +def test_current_backend_is_none_when_the_field_is_absent_from_valid_json( + monkeypatch, tmp_path +): + """A credentials file with no `current` entry yet is the same legitimate + "never logged in" state as no file at all -- not corruption.""" + home = tmp_path / "ph" + monkeypatch.setenv("PULUMI_HOME", str(home)) + home.mkdir() + (home / "credentials.json").write_text(json.dumps({"accounts": {}})) + assert session._current_backend() is None + + +def test_current_backend_raises_on_malformed_json(monkeypatch, tmp_path): + home = tmp_path / "ph" + monkeypatch.setenv("PULUMI_HOME", str(home)) + home.mkdir() + (home / "credentials.json").write_text("{not json") + with pytest.raises(SessionError): + session._current_backend() + + +def test_current_backend_raises_when_the_document_is_not_an_object(monkeypatch, tmp_path): + home = tmp_path / "ph" + monkeypatch.setenv("PULUMI_HOME", str(home)) + home.mkdir() + (home / "credentials.json").write_text("[1, 2, 3]") + with pytest.raises(SessionError): + session._current_backend() + + +def test_current_backend_raises_when_current_is_not_a_string(monkeypatch, tmp_path): + home = tmp_path / "ph" + monkeypatch.setenv("PULUMI_HOME", str(home)) + home.mkdir() + (home / "credentials.json").write_text(json.dumps({"current": 5})) + with pytest.raises(SessionError): + session._current_backend() + + +def test_current_backend_raises_on_an_unreadable_file_rather_than_treating_it_as_absent( + monkeypatch, tmp_path +): + """Fail closed: distinguishing "genuinely never logged in" (a missing + file) from "cannot tell" (any other read failure) is what stops the guard + itself from becoming a fail-open path.""" + home = tmp_path / "ph" + home.mkdir() + monkeypatch.setenv("PULUMI_HOME", str(home)) + (home / "credentials.json").write_text("{}") + + def boom(self, *a, **k): + raise PermissionError("simulated: cannot read") + + monkeypatch.setattr(Path, "read_bytes", boom) + with pytest.raises(SessionError): + session._current_backend() + + +def test_guard_allows_a_match(): + session._check_backend_guard("file:///same") # no current backend at all: fine + + +def test_guard_passes_when_never_logged_in_at_all(monkeypatch): + monkeypatch.setattr(session, "_current_backend", lambda: None) + session._check_backend_guard("file:///anything") # must not raise + + +def test_guard_passes_on_an_exact_match(monkeypatch): + monkeypatch.setattr(session, "_current_backend", lambda: "file:///same") + session._check_backend_guard("file:///same") # must not raise + + +def test_guard_refuses_a_mismatch_naming_both(monkeypatch): + monkeypatch.setattr(session, "_current_backend", lambda: "file:///other") + with pytest.raises(SessionError) as raised: + session._check_backend_guard("file:///mine") + message = str(raised.value) + assert "file:///mine" in message + assert "file:///other" in message + + +# --------------------------------------------------------------------------- +# The store password: env var, interactive prompt, and the tty refusal -- +# never `sys.stdin`. +# --------------------------------------------------------------------------- + + +class _PoisonedStdin: + """Raises if anything reads from it. `_read_store_password` must never + touch `sys.stdin` on any branch -- it is the child's, for `exec`/`shell`.""" + + def read(self, *a, **k): + raise AssertionError("_read_store_password touched sys.stdin") + + def readline(self, *a, **k): + raise AssertionError("_read_store_password touched sys.stdin") + + +@pytest.fixture(autouse=True) +def poisoned_stdin(monkeypatch): + monkeypatch.setattr(sys, "stdin", _PoisonedStdin()) + + +def test_password_from_env_var_is_used_without_prompting(monkeypatch): + monkeypatch.setenv(ENV_STORE_PASSWORD, "from-the-environment") + + def boom(*a, **k): + raise AssertionError("getpass.getpass must not be called when the env var is set") + + monkeypatch.setattr(session.getpass, "getpass", boom) + assert session._read_store_password() == "from-the-environment" + + +def test_password_prompts_interactively_when_a_tty_is_available(monkeypatch): + monkeypatch.delenv(ENV_STORE_PASSWORD, raising=False) + monkeypatch.setattr(session, "_tty_available", lambda: True) + monkeypatch.setattr(session.getpass, "getpass", lambda prompt="": "typed-password") + assert session._read_store_password() == "typed-password" + + +def test_password_refuses_rather_than_falling_back_to_stdin_when_no_tty(monkeypatch): + monkeypatch.delenv(ENV_STORE_PASSWORD, raising=False) + monkeypatch.setattr(session, "_tty_available", lambda: False) + + def boom(*a, **k): + raise AssertionError("getpass.getpass must not run without a tty") + + monkeypatch.setattr(session.getpass, "getpass", boom) + with pytest.raises(SessionError, match=ENV_STORE_PASSWORD): + session._read_store_password() + + +# --------------------------------------------------------------------------- +# Required credential names, and building the child environment +# --------------------------------------------------------------------------- + + +def test_all_three_names_present_is_accepted(): + session._require_credential_names(dict(FULL_CREDENTIALS), "p") # must not raise + + +def test_a_missing_name_is_named_but_no_value_is(): + partial = dict(FULL_CREDENTIALS) + del partial[AWS_SECRET_ACCESS_KEY] + with pytest.raises(SessionError) as raised: + session._require_credential_names(partial, "p") + message = str(raised.value) + assert AWS_SECRET_ACCESS_KEY in message + for marker in ALL_MARKERS: + assert marker not in message + + +def test_every_missing_name_is_named(): + with pytest.raises(SessionError) as raised: + session._require_credential_names({}, "p") + message = str(raised.value) + for name in (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, PULUMI_CONFIG_PASSPHRASE): + assert name in message + + +def test_extra_names_beyond_the_three_required_are_accepted(): + extra = dict(FULL_CREDENTIALS) + extra["SOME_OTHER_NAME"] = "whatever" + session._require_credential_names(extra, "p") # must not raise + + +def test_child_env_overlays_exactly_four_names_on_a_copy_of_the_parent(): + parent_before = dict(os.environ) + env = session._child_env(FULL_CREDENTIALS, "file:///backend") + + assert dict(os.environ) == parent_before # the real environment is untouched + + parent_keys = set(parent_before) + child_keys = set(env) + assert child_keys - parent_keys == { + AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY, + PULUMI_CONFIG_PASSPHRASE, + PULUMI_BACKEND_URL, + } + assert parent_keys - child_keys == set() # nothing dropped + for key in parent_keys: + assert env[key] == parent_before[key] # nothing else changed + assert env[AWS_ACCESS_KEY_ID] == MARKER_KEY + assert env[AWS_SECRET_ACCESS_KEY] == MARKER_SECRET + assert env[PULUMI_CONFIG_PASSPHRASE] == MARKER_PASSPHRASE + assert env[PULUMI_BACKEND_URL] == "file:///backend" + + +# --------------------------------------------------------------------------- +# Exit status translation, including a signal +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("code", [0, 1, 2, 42, 255]) +def test_exit_status_passes_a_normal_code_through_unchanged(code): + assert session._exit_status(code) == code + + +@pytest.mark.parametrize( + "returncode,expected", + [(-signal.SIGTERM, 128 + signal.SIGTERM), (-signal.SIGKILL, 128 + signal.SIGKILL)], +) +def test_exit_status_translates_a_negative_signal_return_code(returncode, expected): + assert session._exit_status(returncode) == expected + + +# --------------------------------------------------------------------------- +# `_run_child` against a real subprocess +# --------------------------------------------------------------------------- + + +def test_run_child_propagates_a_nonzero_exit_code(stub): + code = session._run_child([str(stub)], {**os.environ, "STUB_EXIT_CODE": "7"}) + assert code == 7 + + +def test_run_child_propagates_a_signal_terminated_child(stub): + code = session._run_child( + [str(stub)], {**os.environ, "STUB_SIGNAL": str(int(signal.SIGTERM))} + ) + assert code == 128 + signal.SIGTERM + + +def test_run_child_reports_a_missing_executable_without_a_traceback(tmp_path, capsys): + missing = tmp_path / "does-not-exist" + code = session._run_child([str(missing)]) + assert code == 2 + assert "does-not-exist" in capsys.readouterr().err + + +def test_run_child_passes_env_none_through_to_inherit_unchanged(monkeypatch, stub): + monkeypatch.setenv("STACKWARD_TEST_MARKER_VAR", "seen") + dump = None + + def fake_run(command, env=None): + nonlocal dump + dump = env + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(session.subprocess, "run", fake_run) + session._run_child([str(stub)]) + assert dump is None + + +# --------------------------------------------------------------------------- +# `_resolve_profile` -- every precedence branch, including the repo-config +# tier this task is responsible for wiring. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def repo(tmp_path) -> Path: + """A directory tree `find_repo_config` will search from, bounded by a + `.git` marker -- no real git repository is needed for that function.""" + root = tmp_path / "repo" + (root / ".git").mkdir(parents=True) + return root + + +def test_explicit_profile_resolves_regardless_of_other_sources(store_directory): + """`--profile` at the wiring level: `select_profile` itself already + proves (in `test_store.py`) that an explicit value wins over a competing + `repo_profile` string; `_resolve_profile` goes one step further and never + even calls `_repo_profile()` when `explicit` is given (see + `test_an_explicit_profile_bypasses_a_broken_repo_config`, which proves + that specifically) -- so there is no competing repository profile for + this test to write, and doing so would only look like it was proving + something it no longer does.""" + write_store_config(store_directory, '[profile.chosen]\nbackend_url = "file:///chosen"\n') + profile = session._resolve_profile("chosen", directory=store_directory) + assert profile.name == "chosen" + + +def test_env_var_wins_over_repo_and_default(store_directory, repo, monkeypatch): + write_store_config( + store_directory, + 'default_profile = "fallback"\n' + '[profile.fallback]\nbackend_url = "file:///fallback"\n' + '[profile.from_repo]\nbackend_url = "file:///from-repo"\n' + '[profile.from_env]\nbackend_url = "file:///from-env"\n', + ) + (repo / ".stackward.toml").write_text('profile = "from_repo"\n') + monkeypatch.chdir(repo) + monkeypatch.setenv(store.ENV_PROFILE, "from_env") + profile = session._resolve_profile(None, directory=store_directory) + assert profile.name == "from_env" + + +def test_the_repo_profile_tier_is_wired_and_beats_the_default( + store_directory, repo, monkeypatch +): + """The tier Task 6's own report flagged as dead code: `select_profile` + has always accepted `repo_profile`, but nothing supplied it until this + module read `.stackward.toml` itself. This is the normal case -- a + repository's stacks live in one backend -- so it must win over + `default_profile`, not merely be reachable.""" + write_store_config( + store_directory, + 'default_profile = "fallback"\n' + '[profile.fallback]\nbackend_url = "file:///fallback"\n' + '[profile.from_repo]\nbackend_url = "file:///from-repo"\n', + ) + (repo / ".stackward.toml").write_text('profile = "from_repo"\n') + monkeypatch.chdir(repo) + monkeypatch.delenv(store.ENV_PROFILE, raising=False) + profile = session._resolve_profile(None, directory=store_directory) + assert profile.name == "from_repo" + + +def test_default_profile_is_the_last_resort(store_directory, repo, monkeypatch): + write_store_config( + store_directory, + 'default_profile = "fallback"\n[profile.fallback]\nbackend_url = "file:///fallback"\n', + ) + monkeypatch.chdir(repo) # no .stackward.toml here at all + monkeypatch.delenv(store.ENV_PROFILE, raising=False) + profile = session._resolve_profile(None, directory=store_directory) + assert profile.name == "fallback" + + +def test_no_profile_anywhere_is_a_profile_error(store_directory, repo, monkeypatch): + write_store_config(store_directory, '[profile.unrelated]\nbackend_url = "file:///x"\n') + monkeypatch.chdir(repo) + monkeypatch.delenv(store.ENV_PROFILE, raising=False) + with pytest.raises(ProfileError): + session._resolve_profile(None, directory=store_directory) + + +def test_absent_repo_config_falls_through_cleanly_to_the_default( + store_directory, repo, monkeypatch +): + """No `.stackward.toml` at all is a normal state, not an error -- the + repo tier simply does not speak, and selection moves on.""" + write_store_config( + store_directory, + 'default_profile = "fallback"\n[profile.fallback]\nbackend_url = "file:///fallback"\n', + ) + monkeypatch.chdir(repo) + monkeypatch.delenv(store.ENV_PROFILE, raising=False) + assert session._repo_profile() is None + profile = session._resolve_profile(None, directory=store_directory) + assert profile.name == "fallback" + + +def test_an_explicit_profile_bypasses_a_broken_repo_config( + store_directory, repo, monkeypatch +): + """`--profile` is self-sufficient: it must not require an unrelated, + broken `.stackward.toml` elsewhere in the repository to be fixed first.""" + write_store_config(store_directory, '[profile.chosen]\nbackend_url = "file:///chosen"\n') + (repo / ".stackward.toml").write_text("[profile\nbroken = ") + monkeypatch.chdir(repo) + profile = session._resolve_profile("chosen", directory=store_directory) + assert profile.name == "chosen" + + +def test_a_malformed_repo_config_is_a_config_error_not_a_silent_miss(repo, monkeypatch): + """A `.stackward.toml` that fails to parse must not be treated the same + as no file at all -- that would turn a broken repo policy into a silently + missing one.""" + (repo / ".stackward.toml").write_text("[profile\nbroken = ") + monkeypatch.chdir(repo) + from stackward.config import ConfigError + + with pytest.raises(ConfigError): + session._repo_profile() + + +def test_a_malformed_repo_config_is_reported_by_login_as_a_could_not_run_error( + cli_store, repo, monkeypatch, capsys +): + seed_profile(cli_store, "staging", backend_url="file:///staging-backend") + (repo / ".stackward.toml").write_text("[profile\nbroken = ") + monkeypatch.chdir(repo) + + code = main(["login"]) + + assert code == 2 + assert "invalid TOML" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# `_prepare_env` -- the full pipeline `exec`/`shell` share, at the unit level +# --------------------------------------------------------------------------- + + +def test_prepare_env_happy_path_builds_the_expected_environment(store_directory, monkeypatch): + seed_profile( + store_directory, + "staging", + backend_url="file:///staging-backend", + credentials=FULL_CREDENTIALS, + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + env = session._prepare_env("staging", directory=store_directory) + assert env[AWS_ACCESS_KEY_ID] == MARKER_KEY + assert env[AWS_SECRET_ACCESS_KEY] == MARKER_SECRET + assert env[PULUMI_CONFIG_PASSPHRASE] == MARKER_PASSPHRASE + assert env[PULUMI_BACKEND_URL] == "file:///staging-backend" + + +def test_prepare_env_checks_the_backend_guard_before_reading_a_password( + store_directory, monkeypatch +): + """Ordering matters: a mismatch must be reported without ever prompting + for -- or requiring -- a password.""" + seed_profile( + store_directory, + "staging", + backend_url="file:///staging-backend", + credentials=FULL_CREDENTIALS, + ) + monkeypatch.setattr(session, "_current_backend", lambda: "file:///somewhere-else") + + def boom(): + raise AssertionError("password must not be read when the guard refuses") + + monkeypatch.setattr(session, "_read_store_password", boom) + with pytest.raises(SessionError, match="backend mismatch"): + session._prepare_env("staging", directory=store_directory) + + +def test_prepare_env_allows_a_matching_backend(store_directory, monkeypatch): + seed_profile( + store_directory, + "staging", + backend_url="file:///staging-backend", + credentials=FULL_CREDENTIALS, + ) + monkeypatch.setattr(session, "_current_backend", lambda: "file:///staging-backend") + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + env = session._prepare_env("staging", directory=store_directory) + assert env[PULUMI_BACKEND_URL] == "file:///staging-backend" + + +def test_prepare_env_wrong_password_raises_and_leaks_nothing(store_directory, monkeypatch): + seed_profile( + store_directory, + "staging", + backend_url="file:///staging-backend", + credentials=FULL_CREDENTIALS, + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, WRONG_PASSWORD) + with pytest.raises(StoreError) as raised: + session._prepare_env("staging", directory=store_directory) + message = str(raised.value) + assert WRONG_PASSWORD not in message + for marker in ALL_MARKERS: + assert marker not in message + + +def test_prepare_env_missing_credential_name_raises_naming_it(store_directory, monkeypatch): + incomplete = {AWS_ACCESS_KEY_ID: MARKER_KEY, AWS_SECRET_ACCESS_KEY: MARKER_SECRET} + seed_profile( + store_directory, "staging", backend_url="file:///staging-backend", credentials=incomplete + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + with pytest.raises(SessionError, match=PULUMI_CONFIG_PASSPHRASE): + session._prepare_env("staging", directory=store_directory) + + +def test_prepare_env_profile_with_no_credentials_set_raises(store_directory, monkeypatch): + """A profile in `config` with no envelope selects but cannot resolve -- + R3 from Task 6's store, exercised through this module's own pipeline.""" + seed_profile(store_directory, "staging", backend_url="file:///staging-backend") + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + with pytest.raises(StoreError): + session._prepare_env("staging", directory=store_directory) + + +# --------------------------------------------------------------------------- +# Full CLI wiring: `main([...])`, real subprocesses, `capfd`-level secrecy. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def cli_store(tmp_path, monkeypatch) -> Path: + """Points the real config-home lookup (`XDG_CONFIG_HOME`) at an isolated + directory, the way `cli.config_home` is actually consulted by every + `cmd_*` entry point -- as opposed to the `directory=` seam the unit tests + above use directly.""" + home = tmp_path / "xdg" + monkeypatch.setenv("XDG_CONFIG_HOME", str(home)) + return home / "stackward" + + +def test_exec_with_no_command_is_a_usage_error(monkeypatch, capsys): + assert main(["exec"]) == 2 + assert "requires a command" in capsys.readouterr().err + + +def test_exec_with_only_a_bare_separator_is_a_usage_error(capsys): + assert main(["exec", "--"]) == 2 + assert "requires a command" in capsys.readouterr().err + + +def test_exec_usage_error_is_reported_before_any_profile_resolution(monkeypatch, capsys): + """If the usage check ran after resolution instead of before it, this + would still exit 2 -- but via `fail_closed`'s generic catch-all around + the poisoned `_resolve_profile` below, with a *different* message. The + message assertion, not just the exit code, is what makes this + discriminate between the two orderings.""" + monkeypatch.setattr( + session, + "_resolve_profile", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError("profile resolution must not run before the usage check") + ), + ) + assert main(["exec"]) == 2 + err = capsys.readouterr().err + assert "requires a command" in err + assert "AssertionError" not in err + + +def test_shell_with_no_shell_env_var_errors(monkeypatch, capsys): + monkeypatch.delenv("SHELL", raising=False) + assert main(["shell"]) == 2 + assert "SHELL" in capsys.readouterr().err + + +def test_exec_runs_the_child_with_exactly_the_expected_environment_delta( + cli_store, monkeypatch, tmp_path, stub, capfd +): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + dump = tmp_path / "env.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump)) + parent_before = dict(os.environ) + + code = main(["exec", "--profile", "staging", "--", str(stub)]) + + assert code == 0 + child_env = json.loads(dump.read_text()) + parent_keys = set(parent_before) + child_keys = set(child_env) + assert child_keys - parent_keys == { + AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY, + PULUMI_CONFIG_PASSPHRASE, + PULUMI_BACKEND_URL, + } + assert parent_keys - child_keys == set() + for key in parent_keys - {"STUB_DUMP_ENV_TO"}: + assert child_env[key] == parent_before[key] + assert child_env[AWS_ACCESS_KEY_ID] == MARKER_KEY + assert child_env[AWS_SECRET_ACCESS_KEY] == MARKER_SECRET + assert child_env[PULUMI_CONFIG_PASSPHRASE] == MARKER_PASSPHRASE + assert child_env[PULUMI_BACKEND_URL] == "file:///staging-backend" + + captured = capfd.readouterr() + for marker in ALL_MARKERS: + assert marker not in captured.out + assert marker not in captured.err + + +def test_exec_passes_extra_flags_through_to_the_child_argv( + cli_store, monkeypatch, tmp_path, stub +): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + dump = tmp_path / "argv.json" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(dump)) + + code = main(["exec", "--profile", "staging", "--", str(stub), "up", "--yes"]) + + assert code == 0 + assert json.loads(dump.read_text()) == ["up", "--yes"] + + +def test_exec_dash_dash_help_runs_the_child_rather_than_printing_stackwards_help( + cli_store, monkeypatch, tmp_path, stub, capsys +): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + dump = tmp_path / "argv.json" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(dump)) + + code = main(["exec", "--profile", "staging", "--", str(stub), "--help"]) + + assert code == 0 + assert json.loads(dump.read_text()) == ["--help"] + assert "usage:" not in capsys.readouterr().out + + +def test_a_second_separator_survives_and_reaches_the_child( + cli_store, monkeypatch, tmp_path, stub +): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + dump = tmp_path / "argv.json" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(dump)) + + code = main(["exec", "--profile", "staging", "--", str(stub), "--", "pulumi"]) + + assert code == 0 + assert json.loads(dump.read_text()) == ["--", "pulumi"] + + +def test_exec_propagates_a_nonzero_exit_code_from_a_real_child( + cli_store, monkeypatch, stub, capfd +): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + monkeypatch.setenv("STUB_EXIT_CODE", "3") + + code = main(["exec", "--profile", "staging", "--", str(stub)]) + + assert code == 3 + captured = capfd.readouterr() + for marker in ALL_MARKERS: + assert marker not in captured.out + assert marker not in captured.err + + +def test_exec_propagates_a_signal_terminated_child(cli_store, monkeypatch, stub, capfd): + """The one path where decrypted credential values are genuinely live in + this process's memory at the moment the child dies -- the case a sweep + over only early-failing paths would miss entirely.""" + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + monkeypatch.setenv("STUB_SIGNAL", str(int(signal.SIGTERM))) + + code = main(["exec", "--profile", "staging", "--", str(stub)]) + + assert code == 128 + signal.SIGTERM + captured = capfd.readouterr() + for marker in ALL_MARKERS: + assert marker not in captured.out + assert marker not in captured.err + + +def test_exec_refuses_a_backend_mismatch_and_never_runs_the_child( + cli_store, monkeypatch, tmp_path, stub, capsys +): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + marker_file = tmp_path / "ran" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(marker_file)) + monkeypatch.setattr(session, "_current_backend", lambda: "file:///a-different-backend") + + code = main(["exec", "--profile", "staging", "--", str(stub)]) + + assert code == 2 + err = capsys.readouterr().err + assert "file:///staging-backend" in err + assert "file:///a-different-backend" in err + assert not marker_file.exists() + + +def test_exec_with_wrong_password_refuses_and_never_runs_the_child( + cli_store, monkeypatch, tmp_path, stub, capsys +): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, WRONG_PASSWORD) + marker_file = tmp_path / "ran" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(marker_file)) + + code = main(["exec", "--profile", "staging", "--", str(stub)]) + + assert code == 2 + captured = capsys.readouterr() + assert not marker_file.exists() + for marker in (WRONG_PASSWORD, *ALL_MARKERS): + assert marker not in captured.out + assert marker not in captured.err + + +def test_exec_with_no_password_available_refuses_and_never_runs_the_child( + cli_store, monkeypatch, tmp_path, stub, capsys +): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.delenv(ENV_STORE_PASSWORD, raising=False) + monkeypatch.setattr(session, "_tty_available", lambda: False) + marker_file = tmp_path / "ran" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(marker_file)) + + code = main(["exec", "--profile", "staging", "--", str(stub)]) + + assert code == 2 + assert not marker_file.exists() + captured = capsys.readouterr() + # Must be *this* module's own refusal, naming the escape hatch -- not + # `fail_closed`'s generic catch-all, which an unguarded `getpass` call + # could also route through by raising some other exception once it hits + # the poisoned stdin in this sandbox (no controlling terminal is ever + # available while running under pytest). + assert ENV_STORE_PASSWORD in captured.err + assert "RuntimeError" not in captured.err + assert "AssertionError" not in captured.err + for marker in ALL_MARKERS: + assert marker not in captured.out + assert marker not in captured.err + + +def test_exec_reports_an_unexpected_exception_via_fail_closed( + cli_store, monkeypatch, stub, capsys +): + """Proves `@fail_closed` is actually wired on `cmd_exec`: an + unanticipated exception must exit 2 with a type-only message, never a + traceback and never Python's own default exit code of 1.""" + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + + def boom(*a, **k): + raise RuntimeError("unanticipated") + + monkeypatch.setattr(session, "_compose_backend_url", boom) + code = main(["exec", "--profile", "staging", "--", str(stub)]) + assert code == 2 + err = capsys.readouterr().err + assert "RuntimeError" in err + assert "unanticipated" not in err + + +def test_exec_uses_the_wired_repo_profile_tier_end_to_end( + cli_store, monkeypatch, tmp_path, stub +): + seed_profile( + cli_store, "from_repo", backend_url="file:///from-repo-backend", credentials=FULL_CREDENTIALS + ) + repo = tmp_path / "repo" + (repo / ".git").mkdir(parents=True) + (repo / ".stackward.toml").write_text('profile = "from_repo"\n') + monkeypatch.chdir(repo) + monkeypatch.delenv(store.ENV_PROFILE, raising=False) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + dump = tmp_path / "env.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump)) + + code = main(["exec", "--", str(stub)]) + + assert code == 0 + child_env = json.loads(dump.read_text()) + assert child_env[PULUMI_BACKEND_URL] == "file:///from-repo-backend" + + +def test_shell_runs_stub_as_the_shell_with_the_expected_environment_delta( + cli_store, monkeypatch, tmp_path, stub +): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv("SHELL", str(stub)) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + dump = tmp_path / "env.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump)) + + code = main(["shell", "--profile", "staging"]) + + assert code == 0 + child_env = json.loads(dump.read_text()) + assert child_env[PULUMI_BACKEND_URL] == "file:///staging-backend" + assert child_env[AWS_ACCESS_KEY_ID] == MARKER_KEY + + +def test_shell_propagates_a_signal_terminated_child(cli_store, monkeypatch, stub, capfd): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv("SHELL", str(stub)) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + monkeypatch.setenv("STUB_SIGNAL", str(int(signal.SIGTERM))) + + code = main(["shell", "--profile", "staging"]) + + assert code == 128 + signal.SIGTERM + captured = capfd.readouterr() + for marker in ALL_MARKERS: + assert marker not in captured.out + assert marker not in captured.err + + +def test_shell_backend_mismatch_refuses(cli_store, monkeypatch, tmp_path, stub, capsys): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv("SHELL", str(stub)) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + monkeypatch.setattr(session, "_current_backend", lambda: "file:///elsewhere") + + code = main(["shell", "--profile", "staging"]) + + assert code == 2 + err = capsys.readouterr().err + assert "file:///staging-backend" in err + assert "file:///elsewhere" in err + + +# --------------------------------------------------------------------------- +# `login` +# --------------------------------------------------------------------------- + + +def test_login_runs_pulumi_login_with_the_resolved_url( + cli_store, monkeypatch, tmp_path, stub +): + seed_profile(cli_store, "staging", backend_url="file:///staging-backend") + dump = tmp_path / "argv.json" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(dump)) + fake_pulumi(monkeypatch, stub) + + code = main(["login", "--profile", "staging"]) + + assert code == 0 + assert json.loads(dump.read_text()) == ["login", "file:///staging-backend"] + + +def test_login_never_opens_the_credentials_store(cli_store, monkeypatch, tmp_path, stub): + """A profile can be logged into before its credentials have ever been + set -- `store.py`'s own R3, exercised here: no `credentials` file exists + at all, and `login` must still succeed.""" + seed_profile(cli_store, "staging", backend_url="file:///staging-backend") + assert not (cli_store / store.CREDENTIALS_FILENAME).exists() + fake_pulumi(monkeypatch, stub) + + code = main(["login", "--profile", "staging"]) + + assert code == 0 + assert not (cli_store / store.CREDENTIALS_FILENAME).exists() + + +def test_login_composes_the_component_form(cli_store, monkeypatch, tmp_path, stub): + write_store_config( + cli_store, + '[profile.staging]\nbucket = "placeholder-bucket"\nregion = "placeholder-region"\n', + ) + dump = tmp_path / "argv.json" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(dump)) + fake_pulumi(monkeypatch, stub) + + code = main(["login", "--profile", "staging"]) + + assert code == 0 + argv = json.loads(dump.read_text()) + assert argv[0] == "login" + assert argv[1] == "s3://placeholder-bucket?region=placeholder-region" + + +def test_login_reports_a_missing_pulumi_executable(cli_store, monkeypatch, capsys): + seed_profile(cli_store, "staging", backend_url="file:///staging-backend") + monkeypatch.setattr(session.shutil, "which", lambda name: None) + + code = main(["login", "--profile", "staging"]) + + assert code == 2 + assert "pulumi" in capsys.readouterr().err + + +def test_login_reports_profile_resolution_failure(cli_store, capsys): + code = main(["login", "--profile", "nonexistent"]) + assert code == 2 + assert "nonexistent" in capsys.readouterr().err + + +def test_login_propagates_a_nonzero_exit_code(cli_store, monkeypatch, stub): + seed_profile(cli_store, "staging", backend_url="file:///staging-backend") + monkeypatch.setenv("STUB_EXIT_CODE", "9") + fake_pulumi(monkeypatch, stub) + + code = main(["login", "--profile", "staging"]) + + assert code == 9 + + +def test_login_propagates_a_signal_terminated_child(cli_store, monkeypatch, stub): + seed_profile(cli_store, "staging", backend_url="file:///staging-backend") + monkeypatch.setenv("STUB_SIGNAL", str(int(signal.SIGTERM))) + fake_pulumi(monkeypatch, stub) + + code = main(["login", "--profile", "staging"]) + + assert code == 128 + signal.SIGTERM + + +def test_login_is_not_subject_to_the_backend_guard(cli_store, monkeypatch, tmp_path, stub): + """`login` is what *sets* the backend Pulumi is pointed at -- gating it on + matching that same state would make switching profiles impossible.""" + seed_profile(cli_store, "staging", backend_url="file:///staging-backend") + monkeypatch.setattr(session, "_current_backend", lambda: "file:///a-totally-different-place") + fake_pulumi(monkeypatch, stub) + + code = main(["login", "--profile", "staging"]) + + assert code == 0 From ee36dc8e99ea047014c9ac5a0b7e680e23e57855 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 10:25:38 -0700 Subject: [PATCH 18/44] fix: scrub the store password from children, and stop leaking a backend URL Two Important review findings, both closed. 1. STACKWARD_PASSWORD was inherited by exec/shell's child (and login's) via plain `dict(os.environ)`. The store password unlocks every profile, while what a child receives is scoped to one -- in the single-command form `STACKWARD_PASSWORD=x stackward exec -- pulumi up`, the user's intent is unambiguously "stackward only". New `_environ_without_store_password` scrubs it everywhere a child environment is built. The env-diff tests could not have caught this on their own: a set-difference assertion is satisfied by an inherited value already present on both sides, only by an added one -- fixed by asserting the removal directly (`parent_keys - child_keys == {ENV_STORE_PASSWORD}`, and its absence from the dumped child environment). 2. `login` put the backend URL in `pulumi login`'s argv (visible in `ps` for the call's lifetime), and the backend guard's mismatch message printed it unredacted. `pulumi login --help` documents `postgres://user:password@host/db` as a supported backend form, so a supported profile shape put a password in both places. Fixed both parts of the ruling: `login` now sets `PULUMI_BACKEND_URL` in the child's environment and invokes `pulumi login` with no positional URL argument -- verified, empirically, that this logs in identically for a `file://` backend with no network access. `_redact_url` replaces any `user:pass@` userinfo with `@` everywhere a backend URL is printed. Also fixed, per review: - `_current_backend` now checks this process's own `PULUMI_BACKEND_URL` before the persisted `credentials.json` -- Pulumi's CLI honours that variable over a stored login, so a value already exported into the caller's shell is what a stray `pulumi` command would actually use, and is what the guard's own stated purpose requires it to catch. - `_run_child` now catches `KeyboardInterrupt` (CPython's re-raised `SIGINT`, which `fail_closed` cannot catch -- it is a `BaseException`) and returns `128 + SIGINT`, the same exit status Python's own default handling already produced, but without the traceback that made `Ctrl-C` on `stackward exec -- pulumi up` read as a crash. - The guard's real-file/real-comparison joint now has an end-to-end test that writes a genuinely disagreeing `credentials.json`, not only each half mocking the other. - `test_guard_allows_a_match` deleted: its own comment admitted it tested the never-logged-in case, duplicating a test three lines below it under a name that promised something else. - Triple-copied `print(f"error: {exc}", ...); return 2` replaced by a `_report` helper. Not changed, per review: stale `AWS_SESSION_TOKEN`/`AWS_PROFILE` in the parent are not scrubbed (fails loudly as the wrong identity rather than silently; the brief mandates exactly four injected names, and removing a fifth ambient one is beyond this task). Carry-forward, recorded rather than fixed: every `_compose_backend_url` composition test uses a scheme-less endpoint, so percent-encoding a realistic `https://host:9000` endpoint is never exercised, and the composed URL was never verified against a live S3-compatible backend. 470 tests pass (456 before, 14 new/changed), output pristine. Every fix sabotage-verified: reverting each one independently breaks a specific, named test (one of them -- removing the KeyboardInterrupt catch -- kills the pytest run outright, the strongest possible signal). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/commands/session.py | 214 +++++++++++++++++++++------ tests/test_session.py | 238 ++++++++++++++++++++++++++++-- 2 files changed, 390 insertions(+), 62 deletions(-) diff --git a/src/stackward/commands/session.py b/src/stackward/commands/session.py index 5c38191..74c6e25 100644 --- a/src/stackward/commands/session.py +++ b/src/stackward/commands/session.py @@ -8,13 +8,21 @@ read therefore breaks a plain `pulumi up` outright: `exec` is what makes the store usable in practice, not an optional convenience layered on top of it. -**Secrets exist only in the child's environment.** `exec` and `shell` decrypt -a profile, build one `dict` holding the caller's own environment plus four -names, and hand it to `subprocess.run` as `env=`. Nothing here writes a -credential to disk, logs one, or lets one reach `argv` -- the values travel -from the sealed store into the child's environment block and nowhere else. -Every error path in this module names a profile, a key, a file or a backend -URL, and never a credential value. +**Secrets exist only in the child's environment -- and only the right ones.** +`exec` and `shell` decrypt a profile, build one `dict` holding the caller's +own environment plus four names, and hand it to `subprocess.run` as `env=`. +Nothing here writes a credential to disk, logs one, or lets one reach `argv`. +But "the caller's own environment" is not handed through unfiltered: +`STACKWARD_PASSWORD` -- the *store* password, which unlocks every profile, +not just the one being run with -- is scrubbed from every child's +environment, `login`'s included, by `_environ_without_store_password`. A +review of this module found the gap concretely: in the single-command form +`STACKWARD_PASSWORD=x stackward exec -- pulumi up`, the user's intent is +unambiguously "stackward only", and without scrubbing it, arbitrary +caller-supplied code would inherit a secret wider in scope than the one it +was actually invoked to receive. Every error path in this module names a +profile, a key, a file or a redacted backend URL, and never a credential +value. **`shell` inherits, it does not replace.** Both `exec` and `shell` overlay exactly four names onto a *copy* of the caller's own environment @@ -48,7 +56,26 @@ field with no network access at all for a `file://` backend. This module therefore reads that field directly. `PULUMI_HOME` (default `~/.pulumi`) is Pulumi's own documented override, not knowledge about any particular -deployment of it. +deployment of it. `PULUMI_BACKEND_URL`, when already present in *this* +process's own environment, is checked first and wins over the persisted +file: Pulumi's own CLI honours that variable over a stored login (`exec` +relies on exactly this to override `login`'s persisted state for a single +child), so a stray `PULUMI_BACKEND_URL` a user has exported into their shell +is what a bare `pulumi` command would actually use -- and is therefore what +the guard must compare against, not the file underneath it. + +**A backend URL is never put in argv, and is redacted wherever it is +printed.** Most schemes (`s3://`, `gs://`, `azblob://`, `file://`) carry no +secret, but `pulumi login --help` documents `postgres://user:password@host/db` +as a supported backend form, and Global Constraint 3 names `argv` visibility +explicitly. `login` therefore sets `PULUMI_BACKEND_URL` in `pulumi login`'s +environment and invokes it with **no** positional URL argument, rather than +`pulumi login ` -- verified, empirically, that a bare `pulumi login` +with only `PULUMI_BACKEND_URL` set logs in identically for a `file://` +backend, with no network access. The backend guard's mismatch message still +names both URLs (that is the point of it), but through `_redact_url`, which +replaces `user:pass@` with `@` wherever it appears in a printed +backend URL. **The store password is never read from `sys.stdin`.** `exec` decrypts a profile before handing the *child's* stdin, stdout and stderr through @@ -71,6 +98,15 @@ and no longer identifies the signal. `_exit_status` converts it to the `128 + signal` form a POSIX shell's own `$?` would report, which is the one representation `sys.exit` can carry through intact. + +**A `Ctrl-C` while the child is running prints no traceback.** CPython +re-raises `SIGINT` as `KeyboardInterrupt`, which `fail_closed` does not catch +(it inherits from `BaseException`, not `Exception`) -- an uncaught one +already exits 130 by Python's own default, so the exit status was never +wrong, but reaching the top of the process uncaught also prints a traceback, +which reads as a crash on `stackward exec -- pulumi up`'s single most common +interrupt path. `_run_child` catches it around the `subprocess.run` call and +returns `_exit_status(-signal.SIGINT)` -- the same 130, with nothing printed. """ from __future__ import annotations @@ -79,7 +115,9 @@ import getpass import json import os +import re import shutil +import signal import subprocess import sys from collections.abc import Mapping @@ -220,19 +258,56 @@ def _pulumi_home() -> Path: return Path(home) if home else Path.home() / ".pulumi" +# Matches the userinfo segment of a URL -- `scheme://user[:pass]@` -- so it +# can be replaced wherever a backend URL is printed. A regex over the raw +# string rather than a full URL parse: `backend_url` is passed through +# verbatim and is never validated as a well-formed URL elsewhere in this +# codebase, and this needs to redact whatever shape actually shows up, not +# only the shapes a stricter parser would accept. `[^/@]*` is what confines +# the match to the first `@` -- the userinfo segment cannot itself contain an +# unencoded `/` or `@` -- so a later `@` in a path or query string is left +# alone. +_USERINFO = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*://)[^/@]*@") + + +def _redact_url(url: str) -> str: + """`url`, with any `user[:pass]@` userinfo replaced by `@`. + + Most backend schemes (`s3://`, `gs://`, `azblob://`, `file://`) never + carry a secret in the URL itself, but `pulumi login --help` documents + `postgres://user:password@host/db` as a supported backend form that + does. This is the one function everywhere a backend URL is printed + passes through, so that scheme's password is never one of the values + that ends up on screen -- see the module docstring. + """ + return _USERINFO.sub(r"\1@", url, count=1) + + def _current_backend() -> str | None: - """Pulumi's persisted backend URL, read directly from - `/credentials.json`'s `current` field -- never by shelling - out to `pulumi`, which (see the module docstring) contacts the backend - itself even for a read-only status query. - - Returns `None` only for the legitimate "never logged in" state: the file - does not exist, or exists but does not yet have a `current` entry. Any - other failure to read or parse it raises `SessionError` -- an unreadable - or malformed credentials file must not be silently treated as "nothing to - guard against", which is exactly the fail-open behaviour the backend guard - exists to prevent. + """What a bare `pulumi` invocation -- one not run through this module's + own `exec`/`shell`, and so not handed `PULUMI_BACKEND_URL` by them -- + would use as its backend right now. + + Checks *this* process's own `PULUMI_BACKEND_URL` first: Pulumi's CLI + honours that variable over a persisted login, so a value a user has + already exported into their shell is what a stray `pulumi` command would + actually use, and is exactly the drift the guard exists to catch (see + the module docstring). Only when that is unset does this fall back to + `/credentials.json`'s `current` field -- read directly, + never by shelling out to `pulumi`, which (see the module docstring) + contacts the backend itself even for a read-only status query. + + Returns `None` only for the legitimate "nothing else says otherwise" + state: no `PULUMI_BACKEND_URL`, and either no credentials file or one + with no `current` entry yet. Any other failure to read or parse the file + raises `SessionError` -- an unreadable or malformed credentials file must + not be silently treated as "nothing to guard against", which is exactly + the fail-open behaviour the backend guard exists to prevent. """ + from_env = os.environ.get(PULUMI_BACKEND_URL) + if from_env: + return from_env + path = _pulumi_home() / "credentials.json" try: raw = path.read_bytes() @@ -260,15 +335,17 @@ def _current_backend() -> str | None: def _check_backend_guard(resolved_url: str) -> None: """Refuse when Pulumi's persisted backend does not match `resolved_url`, - naming both. Never logged into anything at all is not a mismatch -- it is - an absence, and there is nothing yet for `resolved_url` to conflict with; - `stackward login` (or a first `exec`/`shell`) is how it gets set.""" + naming both (redacted -- see `_redact_url`). Never logged into anything + at all is not a mismatch -- it is an absence, and there is nothing yet + for `resolved_url` to conflict with; `stackward login` (or a first + `exec`/`shell`) is how it gets set.""" current = _current_backend() if current is not None and current != resolved_url: raise SessionError( "backend mismatch: this profile resolves to backend " - f"{resolved_url!r}, but pulumi is currently logged in to " - f"{current!r}. Run `stackward login` for this profile first." + f"{_redact_url(resolved_url)!r}, but pulumi is currently logged " + f"in to {_redact_url(current)!r}. Run `stackward login` for " + "this profile first." ) @@ -325,13 +402,28 @@ def _require_credential_names(credentials: Mapping[str, str], profile: str) -> N ) -def _child_env(credentials: Mapping[str, str], backend_url: str) -> dict[str, str]: - """The caller's own environment, plus exactly the four session names. +def _environ_without_store_password() -> dict[str, str]: + """A copy of the caller's own environment with `STACKWARD_PASSWORD` + removed. - A copy of `os.environ`, never `os.environ` itself -- overlaying onto the - real mapping would leak the four names into every subsequent call in this - same process, including this one's own `fail_closed` error path.""" + Shared by `_child_env` (`exec`/`shell`) and `login`'s own child + environment. The store password unlocks *every* profile in the store, + while what either command hands to a child is scoped to one profile + (`exec`/`shell`) or to nothing secret at all (`login`) -- inheriting it + into arbitrary caller-supplied code, or even into `pulumi` itself, widens + that scope for no reason any of the three commands need. A copy, never + `os.environ` itself: mutating the real mapping here would affect every + later call in this same process. + """ env = dict(os.environ) + env.pop(ENV_STORE_PASSWORD, None) + return env + + +def _child_env(credentials: Mapping[str, str], backend_url: str) -> dict[str, str]: + """The caller's own environment, minus the store password, plus exactly + the four session names.""" + env = _environ_without_store_password() for name in CREDENTIAL_NAMES: env[name] = credentials[name] env[PULUMI_BACKEND_URL] = backend_url @@ -386,47 +478,79 @@ def _run_child(command: list[str], env: dict[str, str] | None = None) -> int: return its translated exit status. `env=None` means "inherit the caller's environment unchanged", the same - meaning `subprocess.run` itself gives it -- used by `login`, which injects - nothing. `exec`/`shell` always pass an explicit environment built by - `_child_env`. + meaning `subprocess.run` itself gives it. No current caller in this + module actually relies on that default -- `login` passes an explicit + environment too now, with `STACKWARD_PASSWORD` scrubbed even though + `pulumi login` never needs it, for the same reason `exec`/`shell` do -- + but it is kept as `_run_child`'s own general-purpose meaning rather than + coupled to what today's callers happen to do. A missing executable is reported by name (never by the full command line, which could hold an argument value, though never a credential -- those never reach argv at all) and mapped to exit code 2, the same "could not run" code every other refusal in this module uses. + + A `Ctrl-C` reaching this process while the child runs surfaces here as + `KeyboardInterrupt` (CPython's own re-raising of `SIGINT`) rather than as + a return code `subprocess` reports -- `fail_closed` does not catch it + (`BaseException`, not `Exception`), so left alone it would propagate to + the top of the process and print a traceback before Python's own default + handling exits 130. Caught here and translated the same way a + signal-killed child is, for the same exit status with nothing printed. """ try: completed = subprocess.run(command, env=env) except OSError as exc: print(f"error: cannot run {command[0]!r}: {exc}", file=sys.stderr) return 2 + except KeyboardInterrupt: + return _exit_status(-signal.SIGINT) return _exit_status(completed.returncode) +def _report(exc: Exception) -> int: + """Print `exc`'s message as `error: ...` on stderr and return exit code + 2 -- the "could not run" code every pre-flight refusal in `cmd_login`, + `cmd_exec` and `cmd_shell` uses. `exc` must be one of `_KNOWN_ERRORS`: + none of those ever carry a credential value in their message, which is + not a property of `Exception` in general -- an unanticipated exception + is `fail_closed`'s job, not this function's.""" + print(f"error: {exc}", file=sys.stderr) + return 2 + + @fail_closed def cmd_login(args: argparse.Namespace) -> int: """Entry point for `stackward login`. - Resolves the profile's backend URL and runs `pulumi login `. Never - opens the credentials store -- a profile can be logged into before its - credentials have been set at all (`store.py`'s own R3) -- and never - subject to the backend guard, which exists to protect the commands that - run with a profile's credentials live in the environment; `login` is the - command that *sets* what the guard checks against, so gating it on - itself would make switching profiles impossible. + Resolves the profile's backend URL and runs `pulumi login` with it set + as `PULUMI_BACKEND_URL` in the child's environment -- never as a + positional argument. `pulumi login --help` documents + `postgres://user:password@host/db` as a supported backend form, and an + argv value is visible in `ps` for the life of the call; verified, + empirically, that a bare `pulumi login` (no positional URL) honours + `PULUMI_BACKEND_URL` identically to the positional form, for a `file://` + backend with no network access. Never opens the credentials store -- a + profile can be logged into before its credentials have been set at all + (`store.py`'s own R3) -- and never subject to the backend guard, which + exists to protect the commands that run with a profile's credentials + live in the environment; `login` is the command that *sets* what the + guard checks against, so gating it on itself would make switching + profiles impossible. """ try: profile = _resolve_profile(args.profile) url = _compose_backend_url(profile) except _KNOWN_ERRORS as exc: - print(f"error: {exc}", file=sys.stderr) - return 2 + return _report(exc) pulumi = shutil.which("pulumi") if pulumi is None: print("error: pulumi executable not found on PATH", file=sys.stderr) return 2 - return _run_child([pulumi, "login", url]) + env = _environ_without_store_password() + env[PULUMI_BACKEND_URL] = url + return _run_child([pulumi, "login"], env) @fail_closed @@ -448,8 +572,7 @@ def cmd_exec(args: argparse.Namespace) -> int: try: env = _prepare_env(args.profile) except _KNOWN_ERRORS as exc: - print(f"error: {exc}", file=sys.stderr) - return 2 + return _report(exc) return _run_child(args.argv, env) @@ -466,7 +589,6 @@ def cmd_shell(args: argparse.Namespace) -> int: try: env = _prepare_env(args.profile) except _KNOWN_ERRORS as exc: - print(f"error: {exc}", file=sys.stderr) - return 2 + return _report(exc) return _run_child([shell_path], env) diff --git a/tests/test_session.py b/tests/test_session.py index 39273f0..a65b9d2 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -338,10 +338,6 @@ def boom(self, *a, **k): session._current_backend() -def test_guard_allows_a_match(): - session._check_backend_guard("file:///same") # no current backend at all: fine - - def test_guard_passes_when_never_logged_in_at_all(monkeypatch): monkeypatch.setattr(session, "_current_backend", lambda: None) session._check_backend_guard("file:///anything") # must not raise @@ -361,6 +357,128 @@ def test_guard_refuses_a_mismatch_naming_both(monkeypatch): assert "file:///other" in message +def test_guard_message_redacts_userinfo_in_both_urls(monkeypatch): + """The guard's "naming both" message must never print a password that + happened to be embedded in a backend URL (`postgres://user:pass@host/db` + is a documented Pulumi backend form).""" + monkeypatch.setattr( + session, "_current_backend", lambda: "postgres://u:current-secret@host/db" + ) + with pytest.raises(SessionError) as raised: + session._check_backend_guard("postgres://u:resolved-secret@host/db") + message = str(raised.value) + assert "current-secret" not in message + assert "resolved-secret" not in message + assert "@host/db" in message + + +def test_exec_refuses_against_a_real_disagreeing_credentials_file_on_disk( + cli_store, monkeypatch, tmp_path, stub, capsys +): + """The guard's two halves -- reading a real file, and comparing against + it -- proven together, not each in isolation against a mock of the + other.""" + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + # `no_real_pulumi_state` (autouse) already points PULUMI_HOME here. + home = Path(os.environ["PULUMI_HOME"]) + home.mkdir(parents=True, exist_ok=True) + (home / "credentials.json").write_text( + json.dumps({"current": "file:///a-real-disagreement"}) + ) + marker_file = tmp_path / "ran" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(marker_file)) + + code = main(["exec", "--profile", "staging", "--", str(stub)]) + + assert code == 2 + err = capsys.readouterr().err + assert "file:///staging-backend" in err + assert "file:///a-real-disagreement" in err + assert not marker_file.exists() + + +# --------------------------------------------------------------------------- +# `_redact_url` +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "url", + [ + "s3://placeholder-bucket/placeholder-prefix", + "file:///placeholder/path", + "gs://placeholder-bucket", + ], +) +def test_redact_url_leaves_a_userinfo_free_url_unchanged(url): + assert session._redact_url(url) == url + + +def test_redact_url_replaces_userinfo_with_a_placeholder(): + url = "postgres://dbuser:super-secret@db.invalid:5432/state" + redacted = session._redact_url(url) + assert "super-secret" not in redacted + assert "dbuser" not in redacted + assert redacted == "postgres://@db.invalid:5432/state" + + +def test_redact_url_handles_a_username_with_no_password(): + url = "postgres://dbuser@db.invalid:5432/state" + assert session._redact_url(url) == "postgres://@db.invalid:5432/state" + + +def test_redact_url_only_touches_the_first_at_sign(): + """A later `@` -- in a path or query string, say -- is not userinfo and + must survive.""" + url = "postgres://dbuser:secret@db.invalid:5432/state?note=a@b" + redacted = session._redact_url(url) + assert "secret" not in redacted + assert redacted.endswith("?note=a@b") + + +# --------------------------------------------------------------------------- +# `_current_backend` honouring an already-exported `PULUMI_BACKEND_URL` +# --------------------------------------------------------------------------- + + +def test_current_backend_prefers_an_exported_env_var_over_the_persisted_file( + monkeypatch, tmp_path +): + """Pulumi's own CLI honours `PULUMI_BACKEND_URL` over a persisted login, + so a value already exported into the caller's shell is what a stray + `pulumi` command would actually use -- and is what the guard must catch, + even when the persisted file says something else entirely.""" + home = tmp_path / "ph" + home.mkdir() + monkeypatch.setenv("PULUMI_HOME", str(home)) + (home / "credentials.json").write_text(json.dumps({"current": "file:///from-the-file"})) + monkeypatch.setenv(PULUMI_BACKEND_URL, "file:///from-the-env-var") + assert session._current_backend() == "file:///from-the-env-var" + + +def test_current_backend_falls_back_to_the_file_when_the_env_var_is_absent( + monkeypatch, tmp_path +): + home = tmp_path / "ph" + home.mkdir() + monkeypatch.setenv("PULUMI_HOME", str(home)) + (home / "credentials.json").write_text(json.dumps({"current": "file:///from-the-file"})) + monkeypatch.delenv(PULUMI_BACKEND_URL, raising=False) + assert session._current_backend() == "file:///from-the-file" + + +def test_current_backend_treats_an_empty_env_var_as_absent(monkeypatch, tmp_path): + home = tmp_path / "ph" + home.mkdir() + monkeypatch.setenv("PULUMI_HOME", str(home)) + (home / "credentials.json").write_text(json.dumps({"current": "file:///from-the-file"})) + monkeypatch.setenv(PULUMI_BACKEND_URL, "") + assert session._current_backend() == "file:///from-the-file" + + # --------------------------------------------------------------------------- # The store password: env var, interactive prompt, and the tty refusal -- # never `sys.stdin`. @@ -525,6 +643,38 @@ def fake_run(command, env=None): assert dump is None +def test_run_child_catches_a_keyboard_interrupt_and_returns_the_sigint_status(monkeypatch): + """`fail_closed` cannot catch this -- `KeyboardInterrupt` inherits from + `BaseException`, not `Exception` -- so `_run_child` must, or a `Ctrl-C` + during `stackward exec -- pulumi up` prints a raw traceback.""" + + def fake_run(command, env=None): + raise KeyboardInterrupt() + + monkeypatch.setattr(session.subprocess, "run", fake_run) + assert session._run_child(["irrelevant"]) == 128 + signal.SIGINT + + +def test_exec_prints_no_traceback_on_a_keyboard_interrupt( + cli_store, monkeypatch, stub, capsys +): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + + def fake_run(command, env=None): + raise KeyboardInterrupt() + + monkeypatch.setattr(session.subprocess, "run", fake_run) + code = main(["exec", "--profile", "staging", "--", str(stub)]) + + assert code == 128 + signal.SIGINT + captured = capsys.readouterr() + assert "Traceback" not in captured.err + assert "KeyboardInterrupt" not in captured.err + + # --------------------------------------------------------------------------- # `_resolve_profile` -- every precedence branch, including the repo-config # tier this task is responsible for wiring. @@ -825,16 +975,21 @@ def test_exec_runs_the_child_with_exactly_the_expected_environment_delta( PULUMI_CONFIG_PASSPHRASE, PULUMI_BACKEND_URL, } - assert parent_keys - child_keys == set() - for key in parent_keys - {"STUB_DUMP_ENV_TO"}: + # The one name that must be *dropped*, not merely left alone: the store + # master password unlocks every profile, not just this one, and must + # never reach caller-supplied code. + assert parent_keys - child_keys == {ENV_STORE_PASSWORD} + assert ENV_STORE_PASSWORD not in child_env + for key in parent_keys - {"STUB_DUMP_ENV_TO", ENV_STORE_PASSWORD}: assert child_env[key] == parent_before[key] assert child_env[AWS_ACCESS_KEY_ID] == MARKER_KEY assert child_env[AWS_SECRET_ACCESS_KEY] == MARKER_SECRET assert child_env[PULUMI_CONFIG_PASSPHRASE] == MARKER_PASSPHRASE assert child_env[PULUMI_BACKEND_URL] == "file:///staging-backend" + assert PASSWORD not in child_env.values() captured = capfd.readouterr() - for marker in ALL_MARKERS: + for marker in (*ALL_MARKERS, PASSWORD): assert marker not in captured.out assert marker not in captured.err @@ -1095,18 +1250,26 @@ def test_shell_backend_mismatch_refuses(cli_store, monkeypatch, tmp_path, stub, # --------------------------------------------------------------------------- -def test_login_runs_pulumi_login_with_the_resolved_url( +def test_login_runs_pulumi_login_with_the_url_via_env_not_argv( cli_store, monkeypatch, tmp_path, stub ): + """The URL reaches `pulumi login` as `PULUMI_BACKEND_URL`, never as a + positional argument -- some backend forms (`postgres://user:pass@host/db`) + can carry a plaintext password in the URL itself, and argv is visible in + `ps` for the life of the call.""" seed_profile(cli_store, "staging", backend_url="file:///staging-backend") - dump = tmp_path / "argv.json" - monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(dump)) + argv_dump = tmp_path / "argv.json" + env_dump = tmp_path / "env.json" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(env_dump)) fake_pulumi(monkeypatch, stub) code = main(["login", "--profile", "staging"]) assert code == 0 - assert json.loads(dump.read_text()) == ["login", "file:///staging-backend"] + assert json.loads(argv_dump.read_text()) == ["login"] + child_env = json.loads(env_dump.read_text()) + assert child_env[PULUMI_BACKEND_URL] == "file:///staging-backend" def test_login_never_opens_the_credentials_store(cli_store, monkeypatch, tmp_path, stub): @@ -1128,16 +1291,59 @@ def test_login_composes_the_component_form(cli_store, monkeypatch, tmp_path, stu cli_store, '[profile.staging]\nbucket = "placeholder-bucket"\nregion = "placeholder-region"\n', ) - dump = tmp_path / "argv.json" - monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(dump)) + dump = tmp_path / "env.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump)) + fake_pulumi(monkeypatch, stub) + + code = main(["login", "--profile", "staging"]) + + assert code == 0 + child_env = json.loads(dump.read_text()) + assert child_env[PULUMI_BACKEND_URL] == "s3://placeholder-bucket?region=placeholder-region" + + +def test_login_scrubs_the_store_password_from_pulumis_environment( + cli_store, monkeypatch, tmp_path, stub +): + """`login` never needs the store password -- it never opens the + credentials store -- but it must not hand one along to `pulumi` anyway, + for the same reason `exec`/`shell` scrub it.""" + seed_profile(cli_store, "staging", backend_url="file:///staging-backend") + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + dump = tmp_path / "env.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump)) fake_pulumi(monkeypatch, stub) code = main(["login", "--profile", "staging"]) assert code == 0 - argv = json.loads(dump.read_text()) - assert argv[0] == "login" - assert argv[1] == "s3://placeholder-bucket?region=placeholder-region" + child_env = json.loads(dump.read_text()) + assert ENV_STORE_PASSWORD not in child_env + assert PASSWORD not in child_env.values() + + +def test_login_never_puts_a_userinfo_bearing_url_in_argv_or_stderr( + cli_store, monkeypatch, tmp_path, stub, capsys +): + """A `postgres://user:password@host/db` profile -- a form + `pulumi login --help` documents -- must never put its password where + `ps` or a printed message could show it.""" + secret_url = "postgres://dbuser:super-secret-password@db.invalid:5432/state" + seed_profile(cli_store, "staging", backend_url=secret_url) + argv_dump = tmp_path / "argv.json" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + fake_pulumi(monkeypatch, stub) + + code = main(["login", "--profile", "staging"]) + + assert code == 0 + argv = json.loads(argv_dump.read_text()) + assert argv == ["login"] + for text in argv: + assert "super-secret-password" not in text + captured = capsys.readouterr() + assert "super-secret-password" not in captured.out + assert "super-secret-password" not in captured.err def test_login_reports_a_missing_pulumi_executable(cli_store, monkeypatch, capsys): From f929f6aed5a0abe973ddfb9688d6a33441970c10 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 10:41:53 -0700 Subject: [PATCH 19/44] fix: close two test-isolation gaps found in re-review 1. test_child_env_overlays_exactly_four_names_on_a_copy_of_the_parent never injected STACKWARD_PASSWORD into the parent environment, so its difference-based assertions could not distinguish "scrubbed" from "was never there" -- the same shape of gap already fixed in the end-to-end exec test, missed here. Worse than stale: for anyone running the suite with STACKWARD_PASSWORD exported (this feature's own documented non-interactive path), the real difference becomes {STACKWARD_PASSWORD}, the equality assertion fails, and the following per-key value-equality loop raises KeyError on the popped key. Fixed the same way as the end-to-end test: inject the password, assert the difference is exactly {ENV_STORE_PASSWORD}, assert its absence from the child, skip it in the value-equality loop, and assert the password value itself is absent from every child value. 2. The autouse no_real_pulumi_state fixture isolated PULUMI_HOME only. Since _current_backend() now consults PULUMI_BACKEND_URL first (this task's own earlier fix), every end-to-end test exercising the real _current_backend() silently depended on the invoking shell's ambient PULUMI_BACKEND_URL being unset -- reopening the isolation guarantee the fixture's own docstring states. Reproduced concretely: with PULUMI_BACKEND_URL exported in the real shell and the old fixture restored, 25 tests in this file fail. Fixed by clearing PULUMI_BACKEND_URL in the autouse fixture itself; dropped the one now-redundant per-test delenv. Both sabotage-verified: reverting either fix independently reproduces the described failure (the second reproduced against the real ambient environment, not merely a simulated one). 470 tests pass, output pristine, no new tests added -- both fixes correct existing tests' blind spots rather than adding coverage. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- tests/test_session.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/test_session.py b/tests/test_session.py index a65b9d2..e8e396b 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -79,9 +79,12 @@ def cheap_kdf(monkeypatch): @pytest.fixture(autouse=True) def no_real_pulumi_state(monkeypatch, tmp_path): - """Point `PULUMI_HOME` at an empty, per-test directory so no test in this - file can read or depend on this machine's real `~/.pulumi`.""" + """Point `PULUMI_HOME` at an empty, per-test directory, and clear + `PULUMI_BACKEND_URL`, so no test in this file can read or depend on this + machine's real Pulumi state -- `_current_backend()` consults both, in + that order, once `PULUMI_BACKEND_URL` is set.""" monkeypatch.setenv("PULUMI_HOME", str(tmp_path / "pulumi-home")) + monkeypatch.delenv(PULUMI_BACKEND_URL, raising=False) @pytest.fixture @@ -462,11 +465,12 @@ def test_current_backend_prefers_an_exported_env_var_over_the_persisted_file( def test_current_backend_falls_back_to_the_file_when_the_env_var_is_absent( monkeypatch, tmp_path ): + # PULUMI_BACKEND_URL is already absent -- the autouse `no_real_pulumi_state` + # fixture clears it for every test in this file. home = tmp_path / "ph" home.mkdir() monkeypatch.setenv("PULUMI_HOME", str(home)) (home / "credentials.json").write_text(json.dumps({"current": "file:///from-the-file"})) - monkeypatch.delenv(PULUMI_BACKEND_URL, raising=False) assert session._current_backend() == "file:///from-the-file" @@ -564,7 +568,12 @@ def test_extra_names_beyond_the_three_required_are_accepted(): session._require_credential_names(extra, "p") # must not raise -def test_child_env_overlays_exactly_four_names_on_a_copy_of_the_parent(): +def test_child_env_overlays_exactly_four_names_on_a_copy_of_the_parent(monkeypatch): + # Injected, not merely assumed absent: this is the exact non-interactive + # path (`STACKWARD_PASSWORD=x stackward exec -- ...`) the scrubbing + # protects, and a difference-based assertion that never puts the name in + # the parent to begin with cannot tell "scrubbed" from "was never there". + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) parent_before = dict(os.environ) env = session._child_env(FULL_CREDENTIALS, "file:///backend") @@ -578,13 +587,16 @@ def test_child_env_overlays_exactly_four_names_on_a_copy_of_the_parent(): PULUMI_CONFIG_PASSPHRASE, PULUMI_BACKEND_URL, } - assert parent_keys - child_keys == set() # nothing dropped - for key in parent_keys: + # The one name that must be *dropped*, not merely left alone. + assert parent_keys - child_keys == {ENV_STORE_PASSWORD} + assert ENV_STORE_PASSWORD not in env + for key in parent_keys - {ENV_STORE_PASSWORD}: assert env[key] == parent_before[key] # nothing else changed assert env[AWS_ACCESS_KEY_ID] == MARKER_KEY assert env[AWS_SECRET_ACCESS_KEY] == MARKER_SECRET assert env[PULUMI_CONFIG_PASSPHRASE] == MARKER_PASSPHRASE assert env[PULUMI_BACKEND_URL] == "file:///backend" + assert PASSWORD not in env.values() # --------------------------------------------------------------------------- From d3869c93cc5a99b2c22ea249dd1566e5af1769f1 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 11:11:33 -0700 Subject: [PATCH 20/44] feat: set-secrets -- publish declared stack secrets into pulumi config Reads `[secrets."".secret]` from `.stackward.toml` (config path -> logical name), resolves each name against the process environment and `[secrets.source].files` (env first, later files override earlier ones, `{stack}` substituted), and publishes resolved values with `pulumi config set --secret --path `, value on stdin -- never in argv. An unresolved name is skipped, never cleared; a path listed under `[required]` turns an unresolved skip into a failure without aborting the rest of the run. `[[secrets."".drift_pairs]]` compares a local bootstrap value against the published value at whichever path a "managed" logical name resolves to (read with `pulumi config get`, which decrypts), warning only on a genuine, readable difference. Only `secret` is published; `plaintext` and `unmanaged` share the same manifest shape but are read and left untouched, per the brief's own `--secret`-only wording -- see the task report for the full reasoning and the spec document that corroborates it. `LocalSecretSource.resolve(name) -> str | None` is written to the shape Task 11's `SecretSource` interface will register providers against, so that task adapts this module rather than rewriting it. 58 new tests (528 total), including sabotage-verified coverage of: the value reaching a real child process via stdin and never argv; skip-if- unset vs. required-and-unset; a timeout in the middle of three entries not aborting the other two; drift warning only on a genuine, readable difference (and the trailing-newline-from-`config get` bug that a naive test stub would otherwise hide); and a `pulumi` failure never echoing the value it was given. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/cli.py | 12 + src/stackward/commands/set_secrets.py | 649 +++++++++++++++++++ tests/test_set_secrets.py | 863 ++++++++++++++++++++++++++ 3 files changed, 1524 insertions(+) create mode 100644 src/stackward/commands/set_secrets.py create mode 100644 tests/test_set_secrets.py diff --git a/src/stackward/cli.py b/src/stackward/cli.py index c19bcaf..edc8075 100644 --- a/src/stackward/cli.py +++ b/src/stackward/cli.py @@ -21,6 +21,7 @@ from .commands.install_hooks import cmd_install_hooks from .commands.pre_commit import cmd_pre_commit from .commands.session import cmd_exec, cmd_login, cmd_shell +from .commands.set_secrets import cmd_set_secrets from .config import find_repo_config # Commands that must keep working even when a repo demands a newer stackward @@ -154,6 +155,17 @@ def build_parser() -> argparse.ArgumentParser: shell.add_argument("--profile", help="profile to use (overrides selection precedence)") shell.set_defaults(func=cmd_shell) + set_secrets = sub.add_parser( + "set-secrets", help="publish declared stack secrets into pulumi config" + ) + set_secrets.add_argument("--stack", help="stack name, forwarded to pulumi") + set_secrets.add_argument( + "--dry-run", + action="store_true", + help="report what would be set without running pulumi", + ) + set_secrets.set_defaults(func=cmd_set_secrets) + return parser diff --git a/src/stackward/commands/set_secrets.py b/src/stackward/commands/set_secrets.py new file mode 100644 index 0000000..9e9292f --- /dev/null +++ b/src/stackward/commands/set_secrets.py @@ -0,0 +1,649 @@ +"""`set-secrets`: publish a stack's declared secrets from local sources into +Pulumi's config. + +**The manifest maps a config path to a *logical name*; where the value comes +from is a separate concern.** `.stackward.toml` declares, per project +directory, a `[secrets."".secret]` table of ` = ` pairs — this module resolves each name to a value (highest precedence +first: the process environment, then `[secrets.source].files` in file order, +later files overriding earlier ones) and publishes it. Nothing here decides +*how* a name becomes a value beyond that local, file-and-environment scheme: +`LocalSecretSource.resolve` is written to the one-method shape a future +`SecretSource` interface (Task 11's `dotenv`/`env` providers) will register, +so that interface adapts this module rather than rewriting it. + +**Only `[secrets.""].secret` is ever published.** The manifest's sibling +tables, `plaintext` and `unmanaged`, share the same `path = name` shape but +this command does not resolve or act on either: `plaintext` names paths the +credential-scanning gate should treat as intentionally unencrypted, and +`unmanaged` names paths this tool does not touch at all. Every requirement +this module implements — the `--secret` flag, stdin delivery, drift pairing — +names `secret` specifically; inventing a second `pulumi config set --path` +form for `plaintext` would be scope this task was not asked for. If that +reading is wrong, publishing `plaintext` is a small addition to +`_publish_entries` below, not a redesign. + +**A value never reaches `argv`.** `pulumi config set --secret --path ` +is invoked with the resolved value on **stdin**, never as a positional +argument — a command line is visible in `ps` and in shell history for the +life of the call, the same concern Task 7's `login` already had to solve for +a backend URL. `pulumi config get --path ` (drift detection's read) has +no such argument at all, so it carries no value on either channel. + +**A name whose value is unset is skipped, never cleared.** This module has no +code path that ever removes a Pulumi config value; it only ever calls +`config set` for a name it was actually given a non-empty value for. A value +that resolves to the empty string is treated identically to one that did not +resolve at all — an environment variable exported as `""` is not a value +worth publishing, and publishing it would silently clear whatever secret was +there before, which is exactly the destructive surprise the brief calls out. + +**`[required]` promotes "skipped" to "failed".** A path named there — see +`_normalize_required` for the two shapes this module accepts, and why — must +resolve to a non-empty value or the run reports a non-zero exit. It does not, +however, abort the loop: a timeout, a missing `pulumi` binary, and an +unresolved required path are all the same class of per-entry failure (see +`_publish_entries`), and every one of them lets every other declared entry +still get its own chance to be set. A rotation that stops partway and reports +success would be worse than one that finishes and reports which entries did +not land — the summary printed by `cmd_set_secrets` exists to say exactly +that, by name, every time. + +**Drift detection never prints a value, and silence is a real, load-bearing +state — not merely "nothing to report".** `[[secrets."".drift_pairs]]` +declares a `bootstrap` and a `managed` logical name; the check compares the +*local* value of `bootstrap` (resolved the same way any other entry is) +against the *published* value at whichever config path `managed` names in +`secret` (read with `pulumi config get`, which decrypts — the list form, +`pulumi config`, renders every secret as `[secret]` without decrypting and +exits 0 under any passphrase, so it cannot answer this question at all). Three +states are silent on purpose, not merely "no branch happened to fire": the +local `bootstrap` value not resolving (nothing to compare against), +`managed`'s published value not being readable (a timeout, a non-zero exit, a +missing `pulumi`), and the two values actually agreeing. Only an *unreadable* +name — `managed` naming zero or more than one declared secret, which is a +manifest problem rather than a state-of-the-world one — warns, since that one +is worth a human's attention regardless of what the values turn out to be. +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from .. import paths +from ..config import Config, ConfigError, find_repo_config, load_config +from ..store import warn_if_permissive +from .check_config import fail_closed + +# How long any single `pulumi` invocation (a `config set` or a `config get`) +# is allowed to run before this module gives up on it and reports that one +# entry as failed. A module-level variable, not a function default, so a test +# can lower it (`monkeypatch.setattr`) to exercise the timeout path without +# actually waiting anywhere near this long. +DEFAULT_TIMEOUT_SECONDS = 30.0 + +# The two quote characters `_parse_env_file` strips one matched pair of, and +# nothing else — this is a `KEY=VALUE` file, not a shell, and no other +# escaping is attempted. +_QUOTE_CHARS = ('"', "'") + +_EXPORT_PREFIX = "export " + + +class SetSecretsError(Exception): + """Something about the manifest, the source files, or an invocation of + `pulumi` stops `set-secrets` from proceeding for reasons distinct from + "the value was not there" (that is a skip, not an error — see the module + docstring). + + Never carries a credential value: every message here names a config path, + a logical name, a file, or a `pulumi` exit code, never anything read out + of an envelope or an environment variable. + """ + + +@dataclass(frozen=True) +class DriftPair: + """One declared `[[secrets."".drift_pairs]]` entry: two *logical + names*, never config paths themselves. `managed`'s config path is found + by inverting `ProjectManifest.secret` at check time (see `_check_drift`), + not stored here — the manifest is the one place that mapping lives, and + keeping a second copy of it in a `DriftPair` would let the two disagree + silently if `secret` ever changed shape. + """ + + bootstrap: str + managed: str + + +@dataclass(frozen=True) +class ProjectManifest: + """One `[secrets.""]` table, parsed to only what this module acts + on: `secret` (config path -> logical name, the only thing ever + published), `required` (a subset of `secret`'s paths that must resolve), + and `drift_pairs`. `plaintext` and `unmanaged` are read by `config.py`'s + shape check and never reach this dataclass at all — see the module + docstring for why this command does not act on either. + """ + + secret: dict[str, str] = field(default_factory=dict) + required: frozenset[str] = frozenset() + drift_pairs: tuple[DriftPair, ...] = () + + +@dataclass(frozen=True) +class EntryOutcome: + """What happened to one `secret` entry, for both the per-line report and + the closing summary. `reason` is `None` exactly when `status` is `"set"` + or `"would_set"` — every other status names why, in words that never + include a value.""" + + path: str + name: str + status: str # "set" | "would_set" | "skipped" | "failed" + reason: str | None = None + + +class LocalSecretSource: + """Resolves a logical name to a value, or `None` — the environment first, + then each parsed source file, later files overriding earlier ones. + + This is deliberately the one-method shape ("a name in, a value or `None` + out") that Task 11's `SecretSource` interface will register `dotenv` and + `env` implementations against. Building resolution this way now means + that interface *adapts* this class — wraps it, or replaces its + construction — rather than needing to rewrite the call sites in + `_publish_entries`/`_check_drift` that already only ever call + `.resolve(name)`. + + Presence, not truthiness, decides precedence: an environment variable + exported as `""` still wins over a file that also defines the same name, + because that is what "the process environment first" means. It is + `_publish_entries` and `_check_drift` — not this class — that decide an + empty resolved value is unusable; a resolver returning `None` and one + returning `""` are different facts, and only the caller (which knows + whether the empty case is a skip or a "nothing to compare" for drift) + should be the one to treat them the same. + """ + + def __init__( + self, environ: Mapping[str, str], file_values: list[dict[str, str]] + ) -> None: + self._environ = environ + # In the order declared in `[secrets.source].files`; `resolve` walks + # this in reverse so a later file overrides an earlier one. + self._file_values = file_values + + def resolve(self, name: str) -> str | None: + if name in self._environ: + return self._environ[name] + for values in reversed(self._file_values): + if name in values: + return values[name] + return None + + +# --------------------------------------------------------------------------- +# Manifest parsing — `config.py` validates only that `secrets.` is a +# table; everything below this line is this module's own semantics. +# --------------------------------------------------------------------------- + + +def _parse_path_name_table(raw: Any, where: str) -> dict[str, str]: + if raw is None: + return {} + if not isinstance(raw, dict): + raise SetSecretsError(f"{where} must be a table") + result: dict[str, str] = {} + for path, name in raw.items(): + if not isinstance(name, str): + raise SetSecretsError(f"{where}.{path!r} must map to a string logical name") + result[path] = name + return result + + +def _normalize_required(raw: Any, where: str) -> frozenset[str]: + """`required` is accepted as either a list of path strings (`required = + ["a.b"]`, the form the brief's own wording — "paths *listed* under + `[required]`" — reads most naturally as canonical) or a table whose keys + are the paths (`[secrets."".required]`), so that a manifest author + who reaches for a TOML sub-table instead of an array is not met with a + parse error over a shape this module can accept for free. Documented as + a ruling, not settled by anything in the brief or `config.py`: the list + form is what this module's own tests and the manifest examples in this + file's docstring use as the one true spelling. + """ + if raw is None: + return frozenset() + if isinstance(raw, list): + if not all(isinstance(item, str) for item in raw): + raise SetSecretsError(f"{where} must be a list of path strings") + return frozenset(raw) + if isinstance(raw, dict): + return frozenset(raw.keys()) + raise SetSecretsError(f"{where} must be a list, or a table, of config paths") + + +def _parse_drift_pairs(raw: Any, where: str) -> tuple[DriftPair, ...]: + if raw is None: + return () + if not isinstance(raw, list): + raise SetSecretsError(f"{where} must be a list of tables") + pairs: list[DriftPair] = [] + for index, entry in enumerate(raw): + if not isinstance(entry, dict): + raise SetSecretsError(f"{where}[{index}] must be a table") + bootstrap = entry.get("bootstrap") + managed = entry.get("managed") + if not isinstance(bootstrap, str) or not isinstance(managed, str): + raise SetSecretsError( + f"{where}[{index}] needs string 'bootstrap' and 'managed' entries" + ) + pairs.append(DriftPair(bootstrap=bootstrap, managed=managed)) + return tuple(pairs) + + +def parse_project_manifest(raw: dict[str, Any], project: str) -> ProjectManifest: + """Build a `ProjectManifest` from `config.secrets[project]`. + + Every config path this module will actually publish or forward to + `pulumi` is validated with `paths.parse` here, at load time — this is + the one path grammar the whole tool shares (see `paths.py`), and using it + as the validator is what "do not invent string handling" means in + practice. `plaintext` and `unmanaged` keys are never parsed this way: + this module never forwards either to `pulumi`, and `unmanaged` keys may + legitimately hold a wildcard shape (`".*"`) that is not a `--path` + at all — see the module docstring. + """ + secret = _parse_path_name_table(raw.get("secret"), f"secrets.{project!r}.secret") + for path in secret: + try: + paths.parse(path) + except ValueError as exc: + raise SetSecretsError( + f"secrets.{project!r}.secret: invalid config path {path!r}: {exc}" + ) from exc + + required = _normalize_required(raw.get("required"), f"secrets.{project!r}.required") + unknown = required - set(secret) + if unknown: + raise SetSecretsError( + f"secrets.{project!r}.required names {sorted(unknown)[0]!r}, which " + "is not declared under 'secret'" + ) + + drift_pairs = _parse_drift_pairs( + raw.get("drift_pairs"), f"secrets.{project!r}.drift_pairs" + ) + + return ProjectManifest(secret=secret, required=required, drift_pairs=drift_pairs) + + +def select_project(config: Config, repo_root: Path, cwd: Path) -> str: + """Which `[secrets.""]` table this invocation publishes. + + A single declared project (besides the reserved `source` table) is used + unconditionally — "a single-project repository uses one table" per the + brief, with no need to make that table's name match anything about + `cwd`. With more than one, the project is the repository-root-relative + directory of `cwd` itself; `find_repo_config` always searches upward + *from* `cwd`, so `cwd` is guaranteed to be at or below `repo_root` and + `os.path.relpath` never has to represent an escape above it. + """ + candidates = sorted(name for name in config.secrets if name != "source") + if not candidates: + raise SetSecretsError("no [secrets.\"\"] table declared in .stackward.toml") + if len(candidates) == 1: + return candidates[0] + + relative = os.path.relpath(cwd.resolve(), repo_root.resolve()) + project = "." if relative == "." else relative.replace(os.sep, "/") + if project in candidates: + return project + raise SetSecretsError( + f"cannot tell which project's secrets to publish from {cwd}; declared " + f"projects: {', '.join(candidates)}" + ) + + +# --------------------------------------------------------------------------- +# Value sources: the process environment, and `[secrets.source].files` +# --------------------------------------------------------------------------- + + +def substitute_stack(filename: str, stack: str | None) -> str: + """`{stack}` in `filename`, replaced with `stack` — or a `SetSecretsError` + naming the file when the manifest needs a stack this invocation was never + given one for. A plain `.replace`, not `str.format`: a source filename is + not a template a manifest author is expected to write other `{...}` + placeholders into, and `.format` would raise its own confusing error on + one that happened to contain an unrelated brace. + """ + if "{stack}" not in filename: + return filename + if stack is None: + raise SetSecretsError( + f"secrets.source.files entry {filename!r} needs {{stack}} substituted, " + "but no --stack was given" + ) + return filename.replace("{stack}", stack) + + +def resolve_source_files(config: Config, repo_root: Path, stack: str | None) -> list[Path]: + """`[secrets.source].files`, each `{stack}`-substituted and made absolute + against `repo_root` when not already absolute. A missing `[secrets.source]` + table, or one with no `files`, resolves to an empty list — value + resolution then falls back to the process environment alone, which is a + normal, working configuration, not an error.""" + source = config.secrets.get("source", {}) + if not isinstance(source, dict): + raise SetSecretsError("secrets.source must be a table") + + raw_files = source.get("files", []) + if not isinstance(raw_files, list) or not all(isinstance(f, str) for f in raw_files): + raise SetSecretsError("secrets.source.files must be a list of strings") + + resolved: list[Path] = [] + for raw in raw_files: + name = substitute_stack(raw, stack) + candidate = Path(name) + resolved.append(candidate if candidate.is_absolute() else repo_root / candidate) + return resolved + + +def parse_env_file(path: Path) -> dict[str, str]: + """Parse one `KEY=VALUE` file: blank lines and `#` comments are ignored, a + leading `export ` is stripped from the key side, and exactly one matched + pair of surrounding quotes is stripped from the value. + + Warns on stderr (via `store.warn_if_permissive`, the same message shape + used for the credential store's own files) when `path` is more permissive + than `0o600` — this file can hold a bootstrap secret in plaintext on disk, + so a mode that lets another local user read it is worth flagging, even + though refusing to read it outright would only lock a user out of their + own secrets after the exposure already happened. + + A non-blank, non-comment line with no `=` is a `SetSecretsError` naming + the file and line number, never the line's own text — this is fail-closed + on a genuinely malformed bootstrap file rather than silently skipping a + line that was probably meant to define something. + """ + warn_if_permissive(path, 0o600) + try: + text = path.read_text() + except OSError as exc: + raise SetSecretsError(f"{path}: cannot read: {exc}") from exc + + values: dict[str, str] = {} + for line_number, raw_line in enumerate(text.splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith(_EXPORT_PREFIX): + line = line[len(_EXPORT_PREFIX) :] + if "=" not in line: + raise SetSecretsError(f"{path}:{line_number}: not a KEY=VALUE line") + key, _, value = line.partition("=") + key = key.strip() + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in _QUOTE_CHARS: + value = value[1:-1] + if not key: + raise SetSecretsError(f"{path}:{line_number}: empty key") + values[key] = value + return values + + +# --------------------------------------------------------------------------- +# Talking to `pulumi` +# --------------------------------------------------------------------------- + + +def _pulumi_config_set( + pulumi: str, + path: str, + value: str, + *, + secret: bool, + stack: str | None, + timeout: float, +) -> None: + """`pulumi config set [--secret] --path `, `value` on **stdin** — + never as a positional argument, which would put it in `argv` and + therefore in `ps` output and shell history for the life of the call. + + Raises `subprocess.TimeoutExpired` and `OSError` (a missing `pulumi` + binary, among other things) unchanged, for `_publish_entries` to + translate into a per-entry failure without this function needing to know + that vocabulary. A non-zero exit is instead raised here as + `SetSecretsError` naming only the exit code — `pulumi`'s own stdout and + stderr are captured and never included in any message or re-printed: + they are not this module's to vouch for as free of the very value it + just piped to this process on stdin. + """ + args = [pulumi, "config", "set"] + if secret: + args.append("--secret") + args += ["--path", path] + if stack: + args += ["--stack", stack] + completed = subprocess.run(args, input=value.encode(), timeout=timeout, capture_output=True) + if completed.returncode != 0: + raise SetSecretsError(f"pulumi exited {completed.returncode}") + + +def _pulumi_config_get( + pulumi: str, path: str, *, stack: str | None, timeout: float +) -> str | None: + """The published, decrypted value at `path`, or `None` when it could not + be read — a non-zero exit, a timeout, or a missing `pulumi` binary all + collapse to the same `None` here, on purpose: drift detection treats + every one of those as "nothing to compare", silently, per the module + docstring. Never raises, and never prints its own stdout — the decrypted + value is returned to the caller and nowhere else. + + `pulumi config` (the list form) is not usable for this: it renders every + secret as `[secret]` **without** decrypting, and exits 0 under any + passphrase at all — a caller could not tell "the values agree" from "this + command told me nothing." Only `pulumi config get --path ` + decrypts, which is why this function -- and not the list form -- is what + drift detection is built on. + """ + args = [pulumi, "config", "get", "--path", path] + if stack: + args += ["--stack", stack] + try: + completed = subprocess.run(args, timeout=timeout, capture_output=True, text=True) + except (subprocess.TimeoutExpired, OSError): + return None + if completed.returncode != 0: + return None + # `pulumi config get` terminates its output with a trailing newline; + # stripping it is what makes the comparison in `_check_drift` compare the + # actual value rather than failing on every equal pair. + return completed.stdout.rstrip("\n") + + +# --------------------------------------------------------------------------- +# Publishing and drift detection +# --------------------------------------------------------------------------- + + +def _publish_entries( + manifest: ProjectManifest, + source: LocalSecretSource, + *, + pulumi: str | None, + stack: str | None, + dry_run: bool, + timeout: float, +) -> list[EntryOutcome]: + """Walk `manifest.secret` in its declared order and resolve, then (unless + `dry_run`) publish, each entry — one `EntryOutcome` per entry, regardless + of whether it succeeded. + + Every failure mode here — an unresolved required path, a `pulumi` timeout, + a missing `pulumi` binary, a non-zero `pulumi` exit — is recorded on its + own `EntryOutcome` and the loop continues to the next entry rather than + raising: a half-finished rotation that stops on the first failure and + exits non-zero has already left every entry after it unset, and reports + that failure no differently than one that finishes and simply fails to + report which entries did not land. The order entries are declared in + `manifest.secret` (a `dict`, so insertion order — the order the TOML was + written in) is preserved throughout, since it is what makes "the failing + entry was in the middle" a meaningful, reproducible scenario to test. + """ + outcomes: list[EntryOutcome] = [] + for path, name in manifest.secret.items(): + value = source.resolve(name) + if not value: + if path in manifest.required: + outcomes.append(EntryOutcome(path, name, "failed", "required but unresolved")) + else: + outcomes.append(EntryOutcome(path, name, "skipped", "unresolved")) + continue + + if dry_run: + outcomes.append(EntryOutcome(path, name, "would_set", None)) + continue + + if pulumi is None: + outcomes.append( + EntryOutcome(path, name, "failed", "pulumi executable not found on PATH") + ) + continue + + try: + _pulumi_config_set(pulumi, path, value, secret=True, stack=stack, timeout=timeout) + except subprocess.TimeoutExpired: + outcomes.append(EntryOutcome(path, name, "failed", "pulumi timed out")) + except OSError as exc: + outcomes.append(EntryOutcome(path, name, "failed", f"cannot run pulumi: {exc}")) + except SetSecretsError as exc: + outcomes.append(EntryOutcome(path, name, "failed", str(exc))) + else: + outcomes.append(EntryOutcome(path, name, "set", None)) + return outcomes + + +def _config_path_for_name(secret: dict[str, str], name: str) -> str | None: + """The one config path in `secret` mapping to `name`, or `None` when zero + or more than one do — `managed` in a drift pair must name exactly one + declared secret for `pulumi config get` to have anything to read.""" + matches = [path for path, mapped in secret.items() if mapped == name] + return matches[0] if len(matches) == 1 else None + + +def _check_drift( + manifest: ProjectManifest, + source: LocalSecretSource, + *, + pulumi: str | None, + stack: str | None, + timeout: float, +) -> None: + """Warn, on stderr, for each declared drift pair whose local `bootstrap` + value and published `managed` value disagree — see the module docstring + for exactly which states are silent on purpose. Never called under + `--dry-run`: drift detection reads `pulumi config get`, which is a real + invocation, and dry-run's contract is to run nothing at all.""" + if pulumi is None: + return + for pair in manifest.drift_pairs: + bootstrap_value = source.resolve(pair.bootstrap) + if not bootstrap_value: + continue + + managed_path = _config_path_for_name(manifest.secret, pair.managed) + if managed_path is None: + print( + f"warning: drift pair {pair.bootstrap!r}/{pair.managed!r}: " + f"{pair.managed!r} does not name exactly one declared secret", + file=sys.stderr, + ) + continue + + published = _pulumi_config_get(pulumi, managed_path, stack=stack, timeout=timeout) + if published is None: + continue + + if published != bootstrap_value: + print( + f"warning: drift: {pair.bootstrap!r} (bootstrap) and " + f"{pair.managed!r} (managed, at {managed_path!r}) differ", + file=sys.stderr, + ) + + +def _print_outcome(outcome: EntryOutcome) -> None: + if outcome.status == "set": + print(f"set {outcome.path!r} ({outcome.name})") + elif outcome.status == "would_set": + print(f"would set {outcome.path!r} ({outcome.name})") + elif outcome.status == "skipped": + print(f"skip {outcome.path!r} ({outcome.name}): {outcome.reason}") + else: + print(f"error: {outcome.path!r} ({outcome.name}): {outcome.reason}", file=sys.stderr) + + +@fail_closed +def cmd_set_secrets(args: argparse.Namespace) -> int: + """Entry point for `stackward set-secrets`. + + Exit 0 when every declared entry was set or validly skipped; exit 2 when + anything was not set that should have been (a required-but-unresolved + path, a `pulumi` timeout, a missing `pulumi` binary, a non-zero `pulumi` + exit) or the manifest/config could not be loaded at all. Never exit 1 — + this tool reserves that code for `check-config`'s "a credential was + found," a different claim this command never makes. + """ + try: + config_path = find_repo_config() + if config_path is None: + raise SetSecretsError("no .stackward.toml found above the current directory") + config = load_config(config_path) + repo_root = config_path.parent + project = select_project(config, repo_root, Path.cwd()) + manifest = parse_project_manifest(config.secrets[project], project) + files = resolve_source_files(config, repo_root, args.stack) + file_values = [parse_env_file(f) for f in files if f.exists()] + source = LocalSecretSource(os.environ, file_values) + except (ConfigError, SetSecretsError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + pulumi = None if args.dry_run else shutil.which("pulumi") + + outcomes = _publish_entries( + manifest, + source, + pulumi=pulumi, + stack=args.stack, + dry_run=args.dry_run, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + for outcome in outcomes: + _print_outcome(outcome) + + if not args.dry_run: + _check_drift( + manifest, source, pulumi=pulumi, stack=args.stack, timeout=DEFAULT_TIMEOUT_SECONDS + ) + + failed = [outcome for outcome in outcomes if outcome.status == "failed"] + set_count = sum(1 for outcome in outcomes if outcome.status in ("set", "would_set")) + skipped_count = sum(1 for outcome in outcomes if outcome.status == "skipped") + print(f"summary: {set_count} set, {skipped_count} skipped, {len(failed)} failed") + if failed: + named = ", ".join(f"{outcome.path!r} ({outcome.name})" for outcome in failed) + print(f"not set: {named}", file=sys.stderr) + return 2 + return 0 diff --git a/tests/test_set_secrets.py b/tests/test_set_secrets.py new file mode 100644 index 0000000..f9c39be --- /dev/null +++ b/tests/test_set_secrets.py @@ -0,0 +1,863 @@ +"""Tests for `set-secrets`. + +Two things every test in this file is careful about, for the reasons +`test_session.py` states them: + +*No assertion depends on a credential value appearing in captured output.* +Every place a real value could plausibly leak — a successful publish, a +failed one, a drift mismatch, a `pulumi` failure that echoes something odd on +its own stdout/stderr — has a test that asserts the value's absence from +`capfd`-level output (real child process writes included, not only this +process's own `print` calls), never a test that merely checks the call +succeeded. + +*Several tests exist to fail against a plausible wrong implementation, not +merely to exercise a right one.* The stdin/argv test uses a real child +process that dumps its own `sys.argv` and `sys.stdin` to separate files, +rather than inspecting a mocked `subprocess.run` call, because asserting on +the `input=` keyword only proves the call was *constructed* with the value on +stdin, not that a real child process could actually read it there. The +timeout test puts the failing entry in the *middle* of three, because +checking only the exit code and the summary text would pass an +implementation that aborts on the first failure, provided that failure +happened to be the last entry. +""" + +from __future__ import annotations + +import json +import textwrap +from pathlib import Path + +import pytest + +from stackward.cli import main +from stackward.commands import set_secrets +from stackward.commands.set_secrets import ( + DriftPair, + LocalSecretSource, + ProjectManifest, + SetSecretsError, + _config_path_for_name, + _normalize_required, + _parse_drift_pairs, + _publish_entries, + parse_env_file, + parse_project_manifest, + select_project, + substitute_stack, +) +from stackward.config import Config, find_repo_config, load_config + +# Placeholder values only -- see GC4 in the task brief. Distinctive enough +# that an accidental substring match elsewhere in captured output would be +# implausible. +MARKER_DB_PASSWORD = "marker-db-password-1a2b3c4d" +MARKER_PUBLISHED = "marker-published-value-9c0d1e2f" +MARKER_BOOTSTRAP = "marker-bootstrap-value-3f4e5d6c" + + +# --------------------------------------------------------------------------- +# A real `pulumi` stand-in, driven by environment variables -- never a mock +# of `subprocess.run`, so the stdin/argv test proves a real child process can +# read what this module sent it. +# --------------------------------------------------------------------------- + +_PULUMI_STUB = textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json, os, sys, time + + argv = sys.argv[1:] + + + def arg_after(flag): + if flag in argv: + i = argv.index(flag) + if i + 1 < len(argv): + return argv[i + 1] + return None + + + dump_argv_to = os.environ.get("STUB_DUMP_ARGV_TO") + if dump_argv_to: + with open(dump_argv_to, "a") as f: + f.write(json.dumps(argv) + "\\n") + + path = arg_after("--path") + + if "set" in argv: + data = sys.stdin.buffer.read() + dump_stdin_to = os.environ.get("STUB_DUMP_STDIN_TO") + if dump_stdin_to: + with open(dump_stdin_to, "ab") as f: + f.write(data + b"\\n") + slow_path = os.environ.get("STUB_SLOW_PATH") + if slow_path and path == slow_path: + time.sleep(float(os.environ.get("STUB_SLEEP_SECONDS", "5"))) + echo = os.environ.get("STUB_SET_ECHO") + if echo: + sys.stderr.write(echo) + sys.exit(int(os.environ.get("STUB_SET_EXIT_CODE", "0"))) + + if "get" in argv: + table = json.loads(os.environ.get("STUB_GET_VALUES", "{}")) + exit_code = int(os.environ.get("STUB_GET_EXIT_CODE", "0")) + if path in table: + # A real `pulumi config get` terminates its output with a + # trailing newline -- reproduced here on purpose, so a + # comparison that forgot to strip it would fire on every equal + # pair instead of staying silent. + sys.stdout.write(table[path] + "\\n") + sys.exit(exit_code) + + sys.exit(1) + """ +) + + +@pytest.fixture +def stub_pulumi(tmp_path) -> Path: + path = tmp_path / "pulumi-stub.py" + path.write_text(_PULUMI_STUB) + path.chmod(0o755) + return path + + +def fake_pulumi(monkeypatch, executable: Path | None) -> None: + """Make `shutil.which("pulumi")` resolve to `executable`, or to nothing + at all when `executable` is `None` -- the seam every end-to-end test in + this file uses in place of the real binary.""" + monkeypatch.setattr( + set_secrets.shutil, + "which", + lambda name: str(executable) if (executable and name == "pulumi") else None, + ) + + +@pytest.fixture(autouse=True) +def fast_timeout(monkeypatch): + """A short default timeout for every test in this file -- the real + default (`DEFAULT_TIMEOUT_SECONDS = 30.0`) would make the timeout test + below take thirty real seconds for no benefit.""" + monkeypatch.setattr(set_secrets, "DEFAULT_TIMEOUT_SECONDS", 0.3) + + +# --------------------------------------------------------------------------- +# A repo tree `find_repo_config` will search from -- mirrors +# `test_session.py`'s own `repo` fixture. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def repo(tmp_path) -> Path: + root = tmp_path / "repo" + (root / ".git").mkdir(parents=True) + return root + + +def write_repo_config(repo: Path, text: str) -> Path: + path = repo / ".stackward.toml" + path.write_text(text) + return path + + +def load_repo_config(repo: Path) -> Config: + path = find_repo_config(repo) + assert path is not None + return load_config(path) + + +# --------------------------------------------------------------------------- +# `parse_env_file` +# --------------------------------------------------------------------------- + + +def test_parse_env_file_ignores_blanks_and_comments(tmp_path): + path = tmp_path / ".env" + path.write_text("\n# a comment\n\nDB_PASSWORD=" + MARKER_DB_PASSWORD + "\n") + path.chmod(0o600) + assert parse_env_file(path) == {"DB_PASSWORD": MARKER_DB_PASSWORD} + + +def test_parse_env_file_strips_leading_export(tmp_path): + path = tmp_path / ".env" + path.write_text(f"export DB_PASSWORD={MARKER_DB_PASSWORD}\n") + path.chmod(0o600) + assert parse_env_file(path) == {"DB_PASSWORD": MARKER_DB_PASSWORD} + + +@pytest.mark.parametrize("quote", ['"', "'"]) +def test_parse_env_file_strips_one_matched_pair_of_quotes(tmp_path, quote): + path = tmp_path / ".env" + path.write_text(f"DB_PASSWORD={quote}{MARKER_DB_PASSWORD}{quote}\n") + path.chmod(0o600) + assert parse_env_file(path) == {"DB_PASSWORD": MARKER_DB_PASSWORD} + + +def test_parse_env_file_only_strips_one_matched_pair(tmp_path): + """A doubly-quoted value keeps its inner pair -- only *one* layer is + stripped, per the brief's own wording.""" + path = tmp_path / ".env" + path.write_text('DB_PASSWORD=""nested""\n') + path.chmod(0o600) + assert parse_env_file(path) == {"DB_PASSWORD": '"nested"'} + + +def test_parse_env_file_warns_on_a_permissive_mode(tmp_path, capfd): + path = tmp_path / ".env" + path.write_text(f"DB_PASSWORD={MARKER_DB_PASSWORD}\n") + path.chmod(0o644) + parse_env_file(path) + err = capfd.readouterr().err + assert str(path) in err + assert "0644" in err or "644" in err + assert MARKER_DB_PASSWORD not in err + + +def test_parse_env_file_no_warning_at_exactly_0600(tmp_path, capfd): + path = tmp_path / ".env" + path.write_text(f"DB_PASSWORD={MARKER_DB_PASSWORD}\n") + path.chmod(0o600) + parse_env_file(path) + assert capfd.readouterr().err == "" + + +def test_parse_env_file_malformed_line_raises(tmp_path): + path = tmp_path / ".env" + path.write_text("this is not key=value... wait\nDB_PASSWORD\n") + path.chmod(0o600) + with pytest.raises(SetSecretsError, match=r"\.env:2"): + parse_env_file(path) + + +def test_parse_env_file_unreadable_path_raises(tmp_path): + with pytest.raises(SetSecretsError): + parse_env_file(tmp_path / "does-not-exist.env") + + +# --------------------------------------------------------------------------- +# `substitute_stack` +# --------------------------------------------------------------------------- + + +def test_substitute_stack_passes_through_a_filename_with_no_placeholder(): + assert substitute_stack(".env", None) == ".env" + assert substitute_stack(".env", "prod") == ".env" + + +def test_substitute_stack_replaces_the_placeholder(): + assert substitute_stack(".env.{stack}.local", "prod") == ".env.prod.local" + + +def test_substitute_stack_raises_when_no_stack_was_given(): + with pytest.raises(SetSecretsError, match=r"\.env\.\{stack\}"): + substitute_stack(".env.{stack}", None) + + +# --------------------------------------------------------------------------- +# `_normalize_required` +# --------------------------------------------------------------------------- + + +def test_normalize_required_accepts_a_list_of_paths(): + assert _normalize_required(["a.b", "c.d"], "x") == frozenset({"a.b", "c.d"}) + + +def test_normalize_required_accepts_a_table_of_paths(): + assert _normalize_required({"a.b": True, "c.d": "anything"}, "x") == frozenset( + {"a.b", "c.d"} + ) + + +def test_normalize_required_absent_is_empty(): + assert _normalize_required(None, "x") == frozenset() + + +def test_normalize_required_rejects_a_list_of_non_strings(): + with pytest.raises(SetSecretsError): + _normalize_required([1, 2], "x") + + +def test_normalize_required_rejects_other_shapes(): + with pytest.raises(SetSecretsError): + _normalize_required("a.b", "x") + + +# --------------------------------------------------------------------------- +# `_parse_drift_pairs` +# --------------------------------------------------------------------------- + + +def test_parse_drift_pairs_builds_pairs(): + raw = [{"bootstrap": "BOOT", "managed": "MANAGED"}] + assert _parse_drift_pairs(raw, "x") == (DriftPair(bootstrap="BOOT", managed="MANAGED"),) + + +def test_parse_drift_pairs_absent_is_empty(): + assert _parse_drift_pairs(None, "x") == () + + +def test_parse_drift_pairs_rejects_a_missing_field(): + with pytest.raises(SetSecretsError): + _parse_drift_pairs([{"bootstrap": "BOOT"}], "x") + + +def test_parse_drift_pairs_rejects_a_non_list(): + with pytest.raises(SetSecretsError): + _parse_drift_pairs({"bootstrap": "BOOT", "managed": "MANAGED"}, "x") + + +# --------------------------------------------------------------------------- +# `parse_project_manifest` +# --------------------------------------------------------------------------- + + +def test_parse_project_manifest_builds_the_secret_table(): + raw = {"secret": {"db.password": "DB_PASSWORD"}} + manifest = parse_project_manifest(raw, ".") + assert manifest.secret == {"db.password": "DB_PASSWORD"} + assert manifest.required == frozenset() + assert manifest.drift_pairs == () + + +def test_parse_project_manifest_rejects_a_malformed_config_path(): + """`paths.parse` is the one grammar every config path in the manifest is + validated with -- a key using invalid path syntax must fail here, at load + time, rather than surfacing later as a `pulumi` invocation error.""" + raw = {"secret": {"a[": "SOME_NAME"}} + with pytest.raises(SetSecretsError): + parse_project_manifest(raw, ".") + + +def test_parse_project_manifest_does_not_validate_unmanaged_or_plaintext_keys(): + """`unmanaged` may legitimately hold a wildcard shape that is not a + `--path` at all -- see the module docstring -- so it must never be run + through `paths.parse`.""" + raw = { + "secret": {"db.password": "DB_PASSWORD"}, + "plaintext": {"registry.user": "REGISTRY_USER"}, + "unmanaged": {"legacy.*": "reason, not a logical name"}, + } + manifest = parse_project_manifest(raw, ".") + assert manifest.secret == {"db.password": "DB_PASSWORD"} + + +def test_parse_project_manifest_required_path_not_in_secret_raises(): + raw = {"secret": {"db.password": "DB_PASSWORD"}, "required": ["nonexistent.path"]} + with pytest.raises(SetSecretsError, match="nonexistent.path"): + parse_project_manifest(raw, ".") + + +def test_parse_project_manifest_required_path_in_secret_is_accepted(): + raw = {"secret": {"db.password": "DB_PASSWORD"}, "required": ["db.password"]} + manifest = parse_project_manifest(raw, ".") + assert manifest.required == frozenset({"db.password"}) + + +def test_parse_project_manifest_secret_value_must_be_a_string(): + with pytest.raises(SetSecretsError): + parse_project_manifest({"secret": {"db.password": 123}}, ".") + + +# --------------------------------------------------------------------------- +# `select_project` +# --------------------------------------------------------------------------- + + +def test_select_project_uses_the_single_declared_table_regardless_of_cwd(repo): + write_repo_config( + repo, + '[secrets.source]\nfiles = []\n\n[secrets."."]\nsecret = { "a.b" = "NAME" }\n', + ) + config = load_repo_config(repo) + unrelated = repo / "not-a-declared-project" + unrelated.mkdir() + assert select_project(config, repo, unrelated) == "." + + +def test_select_project_multi_table_selects_by_cwd(repo): + write_repo_config( + repo, + '[secrets."."]\nsecret = { "a.b" = "NAME" }\n\n' + '[secrets."infra/foo"]\nsecret = { "c.d" = "OTHER" }\n', + ) + config = load_repo_config(repo) + subdir = repo / "infra" / "foo" + subdir.mkdir(parents=True) + assert select_project(config, repo, subdir) == "infra/foo" + assert select_project(config, repo, repo) == "." + + +def test_select_project_multi_table_no_match_raises_naming_candidates(repo): + write_repo_config( + repo, + '[secrets."."]\nsecret = { "a.b" = "NAME" }\n\n' + '[secrets."infra/foo"]\nsecret = { "c.d" = "OTHER" }\n', + ) + config = load_repo_config(repo) + elsewhere = repo / "elsewhere" + elsewhere.mkdir() + with pytest.raises(SetSecretsError, match="infra/foo"): + select_project(config, repo, elsewhere) + + +def test_select_project_with_no_declared_projects_raises(): + config = Config(secrets={"source": {"files": []}}) + with pytest.raises(SetSecretsError): + select_project(config, Path("/tmp"), Path("/tmp")) + + +# --------------------------------------------------------------------------- +# `LocalSecretSource` -- precedence +# --------------------------------------------------------------------------- + + +def test_resolves_a_name_present_in_only_one_file(): + source = LocalSecretSource({}, [{"NAME": "from-file-1"}]) + assert source.resolve("NAME") == "from-file-1" + + +def test_later_file_overrides_an_earlier_one(): + source = LocalSecretSource( + {}, [{"NAME": "from-file-1"}, {"NAME": "from-file-2"}] + ) + assert source.resolve("NAME") == "from-file-2" + + +def test_environment_wins_over_every_file(): + source = LocalSecretSource( + {"NAME": "from-env"}, [{"NAME": "from-file-1"}, {"NAME": "from-file-2"}] + ) + assert source.resolve("NAME") == "from-env" + + +def test_full_precedence_chain_across_env_and_two_files(): + """One test, three sources, three distinct names -- proving each tier + actually wins where it should, not merely that the highest tier beats a + single lower one.""" + source = LocalSecretSource( + {"ONLY_ENV": "env-value", "IN_ALL_THREE": "env-wins"}, + [ + {"ONLY_FILE_1": "file-1-value", "IN_ALL_THREE": "file-1-loses", "IN_BOTH_FILES": "file-1-loses"}, + {"IN_ALL_THREE": "file-2-loses", "IN_BOTH_FILES": "file-2-wins"}, + ], + ) + assert source.resolve("ONLY_ENV") == "env-value" + assert source.resolve("ONLY_FILE_1") == "file-1-value" + assert source.resolve("IN_BOTH_FILES") == "file-2-wins" + assert source.resolve("IN_ALL_THREE") == "env-wins" + + +def test_unresolvable_name_returns_none(): + source = LocalSecretSource({}, [{"OTHER": "value"}]) + assert source.resolve("NAME") is None + + +# --------------------------------------------------------------------------- +# `_config_path_for_name` +# --------------------------------------------------------------------------- + + +def test_config_path_for_name_finds_the_one_match(): + assert _config_path_for_name({"a.b": "NAME"}, "NAME") == "a.b" + + +def test_config_path_for_name_is_none_when_absent(): + assert _config_path_for_name({"a.b": "OTHER"}, "NAME") is None + + +def test_config_path_for_name_is_none_when_ambiguous(): + assert _config_path_for_name({"a.b": "NAME", "c.d": "NAME"}, "NAME") is None + + +# --------------------------------------------------------------------------- +# `_publish_entries` -- skip-if-unset, without touching `pulumi` at all +# --------------------------------------------------------------------------- + + +def test_unset_entry_is_skipped_and_pulumi_is_never_invoked(monkeypatch): + manifest = ProjectManifest(secret={"a.b": "UNSET_NAME"}) + source = LocalSecretSource({}, []) + + def poison(*args, **kwargs): + raise AssertionError("pulumi must not be invoked for an unresolved entry") + + monkeypatch.setattr(set_secrets, "_pulumi_config_set", poison) + outcomes = _publish_entries( + manifest, source, pulumi="/usr/bin/pulumi", stack=None, dry_run=False, timeout=1.0 + ) + assert len(outcomes) == 1 + assert outcomes[0].status == "skipped" + + +def test_empty_string_value_is_treated_as_unset(monkeypatch): + """An environment variable exported as `""` must not be published -- that + would clear whatever secret is already there, the destructive surprise + the brief calls out by name.""" + manifest = ProjectManifest(secret={"a.b": "EMPTY_NAME"}) + source = LocalSecretSource({"EMPTY_NAME": ""}, []) + + def poison(*args, **kwargs): + raise AssertionError("pulumi must not be invoked for an empty value") + + monkeypatch.setattr(set_secrets, "_pulumi_config_set", poison) + outcomes = _publish_entries( + manifest, source, pulumi="/usr/bin/pulumi", stack=None, dry_run=False, timeout=1.0 + ) + assert outcomes[0].status == "skipped" + + +def test_required_and_unset_is_a_failure_not_a_skip(): + manifest = ProjectManifest(secret={"a.b": "NAME"}, required=frozenset({"a.b"})) + source = LocalSecretSource({}, []) + outcomes = _publish_entries( + manifest, source, pulumi=None, stack=None, dry_run=False, timeout=1.0 + ) + assert outcomes[0].status == "failed" + assert "required" in outcomes[0].reason + + +# --------------------------------------------------------------------------- +# End-to-end via `main()`, with a real `pulumi` stand-in. +# --------------------------------------------------------------------------- + + +def test_no_repo_config_is_a_clean_failure(repo, monkeypatch, capfd): + monkeypatch.chdir(repo) + assert main(["set-secrets"]) == 2 + err = capfd.readouterr().err + assert "error:" in err + assert "Traceback" not in err + + +def test_dry_run_never_invokes_a_subprocess(repo, monkeypatch, capfd): + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + + def poison(*args, **kwargs): + raise AssertionError("--dry-run must never invoke a subprocess") + + monkeypatch.setattr(set_secrets.subprocess, "run", poison) + assert main(["set-secrets", "--dry-run"]) == 0 + out = capfd.readouterr().out + assert "would set" in out + assert "db.password" in out + assert MARKER_DB_PASSWORD not in out + + +def test_dry_run_reports_a_required_unresolved_path_as_a_failure(repo, monkeypatch, capfd): + write_repo_config( + repo, + '[secrets."."]\n' + 'secret = { "db.password" = "DB_PASSWORD" }\n' + 'required = ["db.password"]\n', + ) + monkeypatch.chdir(repo) + monkeypatch.delenv("DB_PASSWORD", raising=False) + assert main(["set-secrets", "--dry-run"]) == 2 + err = capfd.readouterr().err + assert "db.password" in err + + +def test_value_reaches_the_subprocess_via_stdin_and_never_via_argv( + repo, monkeypatch, capfd, stub_pulumi, tmp_path +): + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + fake_pulumi(monkeypatch, stub_pulumi) + + argv_dump = tmp_path / "argv.log" + stdin_dump = tmp_path / "stdin.log" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + monkeypatch.setenv("STUB_DUMP_STDIN_TO", str(stdin_dump)) + + assert main(["set-secrets"]) == 0 + + argv_calls = [json.loads(line) for line in argv_dump.read_text().splitlines()] + assert len(argv_calls) == 1 + call_argv = argv_calls[0] + assert "--secret" in call_argv + assert "--path" in call_argv and "db.password" in call_argv + assert not any(MARKER_DB_PASSWORD in arg for arg in call_argv) + + stdin_content = stdin_dump.read_bytes() + assert MARKER_DB_PASSWORD.encode() in stdin_content + + captured = capfd.readouterr() + assert MARKER_DB_PASSWORD not in captured.out + assert MARKER_DB_PASSWORD not in captured.err + + +def test_stack_flag_is_forwarded_to_pulumi(repo, monkeypatch, capfd, stub_pulumi, tmp_path): + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + fake_pulumi(monkeypatch, stub_pulumi) + + argv_dump = tmp_path / "argv.log" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + + assert main(["set-secrets", "--stack", "placeholder-stack"]) == 0 + call_argv = json.loads(argv_dump.read_text().splitlines()[0]) + assert "--stack" in call_argv + assert "placeholder-stack" in call_argv + + captured = capfd.readouterr() + assert MARKER_DB_PASSWORD not in captured.out + assert MARKER_DB_PASSWORD not in captured.err + + +def test_stack_flag_omitted_when_not_given(repo, monkeypatch, capfd, stub_pulumi, tmp_path): + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + fake_pulumi(monkeypatch, stub_pulumi) + + argv_dump = tmp_path / "argv.log" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + + assert main(["set-secrets"]) == 0 + call_argv = json.loads(argv_dump.read_text().splitlines()[0]) + assert "--stack" not in call_argv + + captured = capfd.readouterr() + assert MARKER_DB_PASSWORD not in captured.out + assert MARKER_DB_PASSWORD not in captured.err + + +def test_required_unresolved_path_fails_the_run(repo, monkeypatch, stub_pulumi, capfd): + write_repo_config( + repo, + '[secrets."."]\n' + 'secret = { "db.password" = "DB_PASSWORD" }\n' + 'required = ["db.password"]\n', + ) + monkeypatch.chdir(repo) + monkeypatch.delenv("DB_PASSWORD", raising=False) + fake_pulumi(monkeypatch, stub_pulumi) + + assert main(["set-secrets"]) == 2 + err = capfd.readouterr().err + assert "db.password" in err + + +def test_a_missing_pulumi_binary_fails_that_entry_and_is_reported(repo, monkeypatch, capfd): + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + fake_pulumi(monkeypatch, None) + + assert main(["set-secrets"]) == 2 + captured = capfd.readouterr() + assert "not found on PATH" in captured.err + assert "db.password" in captured.err + assert MARKER_DB_PASSWORD not in captured.out + assert MARKER_DB_PASSWORD not in captured.err + + +def test_a_timeout_in_the_middle_does_not_abort_the_remaining_entries( + repo, monkeypatch, capfd, stub_pulumi, tmp_path +): + """Three entries, the timing-out one in the middle, in a fixed, known + declaration order -- proving the first and third entries both still get + set, not merely that the run finishes and reports *a* failure.""" + write_repo_config( + repo, + '[secrets."."]\n' + "secret = { " + '"a.first" = "NAME_FIRST", ' + '"b.slow" = "NAME_SLOW", ' + '"c.last" = "NAME_LAST" ' + "}\n", + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("NAME_FIRST", "value-first") + monkeypatch.setenv("NAME_SLOW", "value-slow") + monkeypatch.setenv("NAME_LAST", "value-last") + fake_pulumi(monkeypatch, stub_pulumi) + + stdin_dump = tmp_path / "stdin.log" + monkeypatch.setenv("STUB_DUMP_STDIN_TO", str(stdin_dump)) + monkeypatch.setenv("STUB_SLOW_PATH", "b.slow") + # fast_timeout sets DEFAULT_TIMEOUT_SECONDS to 0.3s; sleep well past it. + monkeypatch.setenv("STUB_SLEEP_SECONDS", "2") + + assert main(["set-secrets"]) == 2 + out, err = (lambda c: (c.out, c.err))(capfd.readouterr()) + assert "set 'a.first'" in out + assert "set 'c.last'" in out + assert "b.slow" in err + assert "timed out" in err + assert "not set:" in err and "b.slow" in err.split("not set:")[1] + + # The two entries either side of the slow one really did reach a real + # child process on stdin -- not merely that this module *believes* they + # were set. (The slow entry's own value also reaches the stub's stdin + # before it sleeps and is killed on timeout -- a real subprocess cannot + # un-receive what it was already sent, so that is not asserted here; what + # matters is that this module never treats that entry as having landed.) + stdin_lines = stdin_dump.read_bytes().splitlines() + assert b"value-first" in stdin_lines + assert b"value-last" in stdin_lines + + +def test_a_pulumi_failure_never_echoes_the_value_it_was_given( + repo, monkeypatch, capfd, stub_pulumi +): + """Even when `pulumi` itself misbehaves and writes something odd to its + own stderr, this module must not relay it -- it is not this module's to + vouch for as free of the value it just sent on stdin.""" + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + fake_pulumi(monkeypatch, stub_pulumi) + monkeypatch.setenv("STUB_SET_EXIT_CODE", "1") + monkeypatch.setenv("STUB_SET_ECHO", f"pulumi choked on {MARKER_DB_PASSWORD}") + + assert main(["set-secrets"]) == 2 + captured = capfd.readouterr() + assert MARKER_DB_PASSWORD not in captured.out + assert MARKER_DB_PASSWORD not in captured.err + + +# --------------------------------------------------------------------------- +# Drift detection +# --------------------------------------------------------------------------- + + +_DRIFT_MANIFEST = ( + '[secrets."."]\n' + 'secret = { "managed.path" = "MANAGED_NAME" }\n\n' + '[[secrets.".".drift_pairs]]\n' + 'bootstrap = "BOOTSTRAP_NAME"\n' + 'managed = "MANAGED_NAME"\n' +) + +_AMBIGUOUS_DRIFT_MANIFEST = ( + '[secrets."."]\n' + "secret = { " + '"a.path" = "MANAGED_NAME", ' + '"b.path" = "MANAGED_NAME" ' + "}\n\n" + '[[secrets.".".drift_pairs]]\n' + 'bootstrap = "BOOTSTRAP_NAME"\n' + 'managed = "MANAGED_NAME"\n' +) + + +def test_drift_warns_when_the_values_differ(repo, monkeypatch, capfd, stub_pulumi): + write_repo_config(repo, _DRIFT_MANIFEST) + monkeypatch.chdir(repo) + monkeypatch.setenv("MANAGED_NAME", MARKER_PUBLISHED) + monkeypatch.setenv("BOOTSTRAP_NAME", MARKER_BOOTSTRAP) + fake_pulumi(monkeypatch, stub_pulumi) + monkeypatch.setenv( + "STUB_GET_VALUES", json.dumps({"managed.path": "a-different-published-value"}) + ) + + assert main(["set-secrets"]) == 0 + captured = capfd.readouterr() + assert "drift" in captured.err + assert "BOOTSTRAP_NAME" in captured.err + assert "MANAGED_NAME" in captured.err + assert "managed.path" in captured.err + assert MARKER_PUBLISHED not in captured.err + assert MARKER_BOOTSTRAP not in captured.err + assert MARKER_PUBLISHED not in captured.out + assert MARKER_BOOTSTRAP not in captured.out + + +def test_drift_is_silent_when_the_values_agree(repo, monkeypatch, capfd, stub_pulumi): + write_repo_config(repo, _DRIFT_MANIFEST) + monkeypatch.chdir(repo) + monkeypatch.setenv("MANAGED_NAME", MARKER_PUBLISHED) + monkeypatch.setenv("BOOTSTRAP_NAME", MARKER_PUBLISHED) + fake_pulumi(monkeypatch, stub_pulumi) + monkeypatch.setenv("STUB_GET_VALUES", json.dumps({"managed.path": MARKER_PUBLISHED})) + + assert main(["set-secrets"]) == 0 + captured = capfd.readouterr() + assert "drift" not in captured.err + assert "warning" not in captured.err + + +def test_drift_is_silent_when_the_published_value_is_unreadable( + repo, monkeypatch, capfd, stub_pulumi +): + write_repo_config(repo, _DRIFT_MANIFEST) + monkeypatch.chdir(repo) + monkeypatch.setenv("MANAGED_NAME", MARKER_PUBLISHED) + monkeypatch.setenv("BOOTSTRAP_NAME", MARKER_BOOTSTRAP) + fake_pulumi(monkeypatch, stub_pulumi) + monkeypatch.setenv("STUB_GET_EXIT_CODE", "1") + + assert main(["set-secrets"]) == 0 + captured = capfd.readouterr() + assert captured.err == "" + + +def test_drift_warns_when_the_managed_name_is_ambiguous(repo, monkeypatch, capfd, stub_pulumi): + write_repo_config(repo, _AMBIGUOUS_DRIFT_MANIFEST) + monkeypatch.chdir(repo) + monkeypatch.setenv("BOOTSTRAP_NAME", MARKER_BOOTSTRAP) + fake_pulumi(monkeypatch, stub_pulumi) + + assert main(["set-secrets"]) == 0 + captured = capfd.readouterr() + assert "MANAGED_NAME" in captured.err + assert MARKER_BOOTSTRAP not in captured.err + + +def test_drift_is_never_checked_under_dry_run(repo, monkeypatch, capfd, stub_pulumi, tmp_path): + write_repo_config(repo, _DRIFT_MANIFEST) + monkeypatch.chdir(repo) + monkeypatch.setenv("MANAGED_NAME", MARKER_PUBLISHED) + monkeypatch.setenv("BOOTSTRAP_NAME", "a-different-value") + fake_pulumi(monkeypatch, stub_pulumi) + monkeypatch.setenv( + "STUB_GET_VALUES", json.dumps({"managed.path": "a-different-published-value"}) + ) + argv_dump = tmp_path / "argv.log" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + + assert main(["set-secrets", "--dry-run"]) == 0 + assert not argv_dump.exists() + captured = capfd.readouterr() + assert "drift" not in captured.err + + +def test_drift_is_silent_when_the_bootstrap_value_does_not_resolve( + repo, monkeypatch, capfd, stub_pulumi +): + write_repo_config(repo, _DRIFT_MANIFEST) + monkeypatch.chdir(repo) + monkeypatch.setenv("MANAGED_NAME", MARKER_PUBLISHED) + monkeypatch.delenv("BOOTSTRAP_NAME", raising=False) + fake_pulumi(monkeypatch, stub_pulumi) + monkeypatch.setenv( + "STUB_GET_VALUES", json.dumps({"managed.path": "a-different-published-value"}) + ) + + assert main(["set-secrets"]) == 0 + assert capfd.readouterr().err == "" From fcdacd0729c81430532b55b35b40eca4be955ed7 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 11:24:18 -0700 Subject: [PATCH 21/44] fix: publish plaintext entries, and collapse [required] to one shape Two requirements changes from the coordinator, applied before review. Ruling 1 -- `[secrets.""].plaintext` entries are now published, not just parsed and left inert. `ProjectManifest` gains a `plaintext` table; `_pulumi_config_set` takes a `mode` ("secret" | "plaintext") instead of a bare `secret: bool` and emits `--secret` or `--plaintext` accordingly, one code path and one stdin delivery mechanism for both. `_publish_entries` walks `secret` then `plaintext`, each in its own declared order; `required` now checks membership across both tables. `unmanaged` is unchanged -- still documentation-only. Ruling 2 -- `[required]` now has exactly one accepted shape: [secrets."".required] paths = ["a.b", "c.d"] `_normalize_required` (which accepted a bare list or a keys-as-paths table) is replaced by `_parse_required`, which rejects both of those former shapes with a named error instead of silently reinterpreting either as valid -- consistent with `config.py`'s own "invalid is never treated as absent" and "unknown key is named and rejected" rules. 9 new tests (537 total): plaintext published with --plaintext and never --secret; secret never published with --plaintext; both tables live in one run; the new [required] shape's unit-level acceptance/rejection cases; and an end-to-end test proving the old bare-list [required] shape is now rejected at load time rather than silently tolerated -- the first version of that last test was itself vacuous (the tolerated old shape happened to produce the same exit code for its chosen manifest) and was strengthened, after sabotage exposed the gap, to assert on the actual distinguishing signal: a load-time rejection never reaches the publish loop's closing summary line at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/commands/set_secrets.py | 205 +++++++++++++++---------- tests/test_set_secrets.py | 211 +++++++++++++++++++++++--- 2 files changed, 312 insertions(+), 104 deletions(-) diff --git a/src/stackward/commands/set_secrets.py b/src/stackward/commands/set_secrets.py index 9e9292f..f289eff 100644 --- a/src/stackward/commands/set_secrets.py +++ b/src/stackward/commands/set_secrets.py @@ -12,23 +12,30 @@ `SecretSource` interface (Task 11's `dotenv`/`env` providers) will register, so that interface adapts this module rather than rewriting it. -**Only `[secrets.""].secret` is ever published.** The manifest's sibling -tables, `plaintext` and `unmanaged`, share the same `path = name` shape but -this command does not resolve or act on either: `plaintext` names paths the -credential-scanning gate should treat as intentionally unencrypted, and -`unmanaged` names paths this tool does not touch at all. Every requirement -this module implements — the `--secret` flag, stdin delivery, drift pairing — -names `secret` specifically; inventing a second `pulumi config set --path` -form for `plaintext` would be scope this task was not asked for. If that -reading is wrong, publishing `plaintext` is a small addition to -`_publish_entries` below, not a redesign. - -**A value never reaches `argv`.** `pulumi config set --secret --path ` -is invoked with the resolved value on **stdin**, never as a positional -argument — a command line is visible in `ps` and in shell history for the -life of the call, the same concern Task 7's `login` already had to solve for -a backend URL. `pulumi config get --path ` (drift detection's read) has -no such argument at all, so it carries no value on either channel. +**Both `[secrets.""].secret` and `.plaintext` are published; `unmanaged` +is not.** The three sibling tables share the same `path = name` shape, but +they are not interchangeable: the split between `secret` and `plaintext` *is* +the declaration of whether a value is a credential, and a declared table that +this command silently never acts on would make that declaration meaningless +— a manifest author adding a `plaintext` entry with nothing telling them it +does nothing is exactly the failure mode this project treats as worse than +either "reject it" or "act on it." `secret` entries are published with +`pulumi config set --secret --path `; `plaintext` entries with +`pulumi config set --plaintext --path ` — same resolution, same +skip-if-unset semantics, same delivery on stdin, in `_pulumi_config_set`'s one +code path (see below). `unmanaged` stays inert: it exists to record a path +deliberately *not* managed by this tool, together with a reason — a +"documentation-only" table by design, not an oversight. + +**A value never reaches `argv`.** `pulumi config set --secret|--plaintext +--path ` is invoked with the resolved value on **stdin**, never as a +positional argument — a command line is visible in `ps` and in shell history +for the life of the call, the same concern Task 7's `login` already had to +solve for a backend URL. Stdin is used for `plaintext` too, even though its +values are not confidential: keeping one delivery mechanism for both is +simpler than forking a second one for the entries that do not strictly need +it. `pulumi config get --path ` (drift detection's read) has no such +argument at all, so it carries no value on either channel. **A name whose value is unset is skipped, never cleared.** This module has no code path that ever removes a Pulumi config value; it only ever calls @@ -38,11 +45,13 @@ worth publishing, and publishing it would silently clear whatever secret was there before, which is exactly the destructive surprise the brief calls out. -**`[required]` promotes "skipped" to "failed".** A path named there — see -`_normalize_required` for the two shapes this module accepts, and why — must -resolve to a non-empty value or the run reports a non-zero exit. It does not, -however, abort the loop: a timeout, a missing `pulumi` binary, and an -unresolved required path are all the same class of per-entry failure (see +**`[required]` promotes "skipped" to "failed".** A path named there — under +`[secrets."".required]`'s `paths` list, the **one** canonical shape this +module accepts; see `_parse_required` for why a second, alternate spelling is +refused rather than tolerated — must resolve to a non-empty value (as either +a `secret` or a `plaintext` entry) or the run reports a non-zero exit. It +does not, however, abort the loop: a timeout, a missing `pulumi` binary, and +an unresolved required path are all the same class of per-entry failure (see `_publish_entries`), and every one of them lets every other declared entry still get its own chance to be set. A rotation that stops partway and reports success would be worse than one that finishes and reports which entries did @@ -127,14 +136,15 @@ class DriftPair: @dataclass(frozen=True) class ProjectManifest: """One `[secrets.""]` table, parsed to only what this module acts - on: `secret` (config path -> logical name, the only thing ever - published), `required` (a subset of `secret`'s paths that must resolve), - and `drift_pairs`. `plaintext` and `unmanaged` are read by `config.py`'s - shape check and never reach this dataclass at all — see the module - docstring for why this command does not act on either. + on: `secret` and `plaintext` (each config path -> logical name; both are + published, see the module docstring), `required` (a subset of `secret`'s + and `plaintext`'s paths that must resolve), and `drift_pairs`. + `unmanaged` is read by `config.py`'s shape check and never reaches this + dataclass at all — it is documentation-only, by design. """ secret: dict[str, str] = field(default_factory=dict) + plaintext: dict[str, str] = field(default_factory=dict) required: frozenset[str] = frozenset() drift_pairs: tuple[DriftPair, ...] = () @@ -210,26 +220,46 @@ def _parse_path_name_table(raw: Any, where: str) -> dict[str, str]: return result -def _normalize_required(raw: Any, where: str) -> frozenset[str]: - """`required` is accepted as either a list of path strings (`required = - ["a.b"]`, the form the brief's own wording — "paths *listed* under - `[required]`" — reads most naturally as canonical) or a table whose keys - are the paths (`[secrets."".required]`), so that a manifest author - who reaches for a TOML sub-table instead of an array is not met with a - parse error over a shape this module can accept for free. Documented as - a ruling, not settled by anything in the brief or `config.py`: the list - form is what this module's own tests and the manifest examples in this - file's docstring use as the one true spelling. +# The only key `[secrets."".required]` recognises. A second, alternate +# spelling of "these paths are required" (a bare `required = [...]` array, or +# a table whose own keys are the required paths) is deliberately not accepted +# alongside this one: a manifest format with two shapes for the same thing +# means a typo in one can silently read as the *other* shape's "nothing +# required" instead of as an error — an ambiguity a fail-closed check must +# not have. See `_parse_required`. +_REQUIRED_KEYS = frozenset({"paths"}) + + +def _parse_required(raw: Any, where: str) -> frozenset[str]: + """The **one** canonical `[secrets."".required]` shape: a table with + exactly one key, `paths`, holding a list of config-path strings — + + [secrets."".required] + paths = ["a.b", "c.d"] + + Absent entirely is a normal state (nothing required, an empty + `frozenset`). Present but any other shape — a bare `required = [...]` + array, a table whose own keys are the paths, an unrecognised key inside + `[required]` — is a hard `SetSecretsError` naming `where`, never silently + reinterpreted as one of the shapes this module used to also accept: this + is `config.py`'s own "invalid is never treated as absent" rule, and its + own "an unknown key is named and rejected" rule, both carried over here + for the same reason they hold there. """ if raw is None: return frozenset() - if isinstance(raw, list): - if not all(isinstance(item, str) for item in raw): - raise SetSecretsError(f"{where} must be a list of path strings") - return frozenset(raw) - if isinstance(raw, dict): - return frozenset(raw.keys()) - raise SetSecretsError(f"{where} must be a list, or a table, of config paths") + if not isinstance(raw, dict): + raise SetSecretsError( + f"{where} must be a table with a 'paths' list, e.g. " + f'[{where}]\npaths = ["a.b"]' + ) + unknown = set(raw) - _REQUIRED_KEYS + if unknown: + raise SetSecretsError(f"unknown key {where}.{sorted(unknown)[0]}") + raw_paths = raw.get("paths", []) + if not isinstance(raw_paths, list) or not all(isinstance(item, str) for item in raw_paths): + raise SetSecretsError(f"{where}.paths must be a list of path strings") + return frozenset(raw_paths) def _parse_drift_pairs(raw: Any, where: str) -> tuple[DriftPair, ...]: @@ -255,36 +285,41 @@ def parse_project_manifest(raw: dict[str, Any], project: str) -> ProjectManifest """Build a `ProjectManifest` from `config.secrets[project]`. Every config path this module will actually publish or forward to - `pulumi` is validated with `paths.parse` here, at load time — this is + `pulumi` — every key in `secret` and in `plaintext`, since both are + published — is validated with `paths.parse` here, at load time; this is the one path grammar the whole tool shares (see `paths.py`), and using it as the validator is what "do not invent string handling" means in - practice. `plaintext` and `unmanaged` keys are never parsed this way: - this module never forwards either to `pulumi`, and `unmanaged` keys may - legitimately hold a wildcard shape (`".*"`) that is not a `--path` - at all — see the module docstring. + practice. `unmanaged` keys are never parsed this way: this module never + forwards them to `pulumi`, and they may legitimately hold a wildcard + shape (`".*"`) that is not a `--path` at all — see the module + docstring. """ secret = _parse_path_name_table(raw.get("secret"), f"secrets.{project!r}.secret") - for path in secret: - try: - paths.parse(path) - except ValueError as exc: - raise SetSecretsError( - f"secrets.{project!r}.secret: invalid config path {path!r}: {exc}" - ) from exc - - required = _normalize_required(raw.get("required"), f"secrets.{project!r}.required") - unknown = required - set(secret) + plaintext = _parse_path_name_table(raw.get("plaintext"), f"secrets.{project!r}.plaintext") + for table_name, table in (("secret", secret), ("plaintext", plaintext)): + for path in table: + try: + paths.parse(path) + except ValueError as exc: + raise SetSecretsError( + f"secrets.{project!r}.{table_name}: invalid config path {path!r}: {exc}" + ) from exc + + required = _parse_required(raw.get("required"), f"secrets.{project!r}.required") + unknown = required - set(secret) - set(plaintext) if unknown: raise SetSecretsError( - f"secrets.{project!r}.required names {sorted(unknown)[0]!r}, which " - "is not declared under 'secret'" + f"secrets.{project!r}.required.paths names {sorted(unknown)[0]!r}, which " + "is not declared under 'secret' or 'plaintext'" ) drift_pairs = _parse_drift_pairs( raw.get("drift_pairs"), f"secrets.{project!r}.drift_pairs" ) - return ProjectManifest(secret=secret, required=required, drift_pairs=drift_pairs) + return ProjectManifest( + secret=secret, plaintext=plaintext, required=required, drift_pairs=drift_pairs + ) def select_project(config: Config, repo_root: Path, cwd: Path) -> str: @@ -407,18 +442,29 @@ def parse_env_file(path: Path) -> dict[str, str]: # --------------------------------------------------------------------------- +# The two publishable manifest tables, and the `pulumi config set` flag each +# one is published with. `_publish_entries` never calls `_pulumi_config_set` +# with a `mode` outside this mapping's keys. +_MODE_FLAGS = {"secret": "--secret", "plaintext": "--plaintext"} + + def _pulumi_config_set( pulumi: str, path: str, value: str, *, - secret: bool, + mode: str, stack: str | None, timeout: float, ) -> None: - """`pulumi config set [--secret] --path `, `value` on **stdin** — - never as a positional argument, which would put it in `argv` and - therefore in `ps` output and shell history for the life of the call. + """`pulumi config set --secret|--plaintext --path `, `value` on + **stdin** — never as a positional argument, which would put it in `argv` + and therefore in `ps` output and shell history for the life of the call. + `mode` (`"secret"` or `"plaintext"`, via `_MODE_FLAGS`) picks the flag; + the two are mutually exclusive on one invocation, and a `secret` entry is + never sent with `--plaintext` or vice versa — each entry carries its own + table's mode all the way from `manifest.secret`/`manifest.plaintext` + through to this call. Raises `subprocess.TimeoutExpired` and `OSError` (a missing `pulumi` binary, among other things) unchanged, for `_publish_entries` to @@ -429,10 +475,7 @@ def _pulumi_config_set( they are not this module's to vouch for as free of the very value it just piped to this process on stdin. """ - args = [pulumi, "config", "set"] - if secret: - args.append("--secret") - args += ["--path", path] + args = [pulumi, "config", "set", _MODE_FLAGS[mode], "--path", path] if stack: args += ["--stack", stack] completed = subprocess.run(args, input=value.encode(), timeout=timeout, capture_output=True) @@ -486,9 +529,10 @@ def _publish_entries( dry_run: bool, timeout: float, ) -> list[EntryOutcome]: - """Walk `manifest.secret` in its declared order and resolve, then (unless - `dry_run`) publish, each entry — one `EntryOutcome` per entry, regardless - of whether it succeeded. + """Walk every `secret` entry (in declared order), then every `plaintext` + entry (in declared order), resolving and then (unless `dry_run`) + publishing each one with its own table's mode — one `EntryOutcome` per + entry, regardless of whether it succeeded. Every failure mode here — an unresolved required path, a `pulumi` timeout, a missing `pulumi` binary, a non-zero `pulumi` exit — is recorded on its @@ -496,13 +540,16 @@ def _publish_entries( raising: a half-finished rotation that stops on the first failure and exits non-zero has already left every entry after it unset, and reports that failure no differently than one that finishes and simply fails to - report which entries did not land. The order entries are declared in - `manifest.secret` (a `dict`, so insertion order — the order the TOML was - written in) is preserved throughout, since it is what makes "the failing - entry was in the middle" a meaningful, reproducible scenario to test. + report which entries did not land. The order entries are declared within + each table (a `dict`, so insertion order — the order the TOML was written + in) is preserved throughout, since it is what makes "the failing entry + was in the middle" a meaningful, reproducible scenario to test. """ + entries = [(path, name, "secret") for path, name in manifest.secret.items()] + entries += [(path, name, "plaintext") for path, name in manifest.plaintext.items()] + outcomes: list[EntryOutcome] = [] - for path, name in manifest.secret.items(): + for path, name, mode in entries: value = source.resolve(name) if not value: if path in manifest.required: @@ -522,7 +569,7 @@ def _publish_entries( continue try: - _pulumi_config_set(pulumi, path, value, secret=True, stack=stack, timeout=timeout) + _pulumi_config_set(pulumi, path, value, mode=mode, stack=stack, timeout=timeout) except subprocess.TimeoutExpired: outcomes.append(EntryOutcome(path, name, "failed", "pulumi timed out")) except OSError as exc: diff --git a/tests/test_set_secrets.py b/tests/test_set_secrets.py index f9c39be..89d4c0d 100644 --- a/tests/test_set_secrets.py +++ b/tests/test_set_secrets.py @@ -39,8 +39,8 @@ ProjectManifest, SetSecretsError, _config_path_for_name, - _normalize_required, _parse_drift_pairs, + _parse_required, _publish_entries, parse_env_file, parse_project_manifest, @@ -256,32 +256,46 @@ def test_substitute_stack_raises_when_no_stack_was_given(): # --------------------------------------------------------------------------- -# `_normalize_required` +# `_parse_required` -- the one canonical `[required]` shape # --------------------------------------------------------------------------- -def test_normalize_required_accepts_a_list_of_paths(): - assert _normalize_required(["a.b", "c.d"], "x") == frozenset({"a.b", "c.d"}) +def test_parse_required_accepts_the_canonical_paths_table(): + assert _parse_required({"paths": ["a.b", "c.d"]}, "x") == frozenset({"a.b", "c.d"}) -def test_normalize_required_accepts_a_table_of_paths(): - assert _normalize_required({"a.b": True, "c.d": "anything"}, "x") == frozenset( - {"a.b", "c.d"} - ) +def test_parse_required_absent_is_empty(): + assert _parse_required(None, "x") == frozenset() + + +def test_parse_required_rejects_a_bare_list(): + """The shape this module used to also accept (`required = [...]`) is now + a hard error, not a silently-tolerated alternate spelling.""" + with pytest.raises(SetSecretsError, match="x"): + _parse_required(["a.b", "c.d"], "x") + +def test_parse_required_rejects_a_table_whose_own_keys_are_the_paths(): + """The other shape this module used to also accept + (`[secrets."".required]` with the paths as the table's own keys) is + now a hard error too -- 'paths' is the only recognised key.""" + with pytest.raises(SetSecretsError, match="a.b"): + _parse_required({"a.b": True, "c.d": "anything"}, "x") -def test_normalize_required_absent_is_empty(): - assert _normalize_required(None, "x") == frozenset() +def test_parse_required_rejects_an_unknown_key_alongside_paths(): + with pytest.raises(SetSecretsError, match="extra"): + _parse_required({"paths": ["a.b"], "extra": "oops"}, "x") -def test_normalize_required_rejects_a_list_of_non_strings(): + +def test_parse_required_rejects_a_paths_list_of_non_strings(): with pytest.raises(SetSecretsError): - _normalize_required([1, 2], "x") + _parse_required({"paths": [1, 2]}, "x") -def test_normalize_required_rejects_other_shapes(): +def test_parse_required_rejects_a_non_table(): with pytest.raises(SetSecretsError): - _normalize_required("a.b", "x") + _parse_required("a.b", "x") # --------------------------------------------------------------------------- @@ -317,11 +331,18 @@ def test_parse_project_manifest_builds_the_secret_table(): raw = {"secret": {"db.password": "DB_PASSWORD"}} manifest = parse_project_manifest(raw, ".") assert manifest.secret == {"db.password": "DB_PASSWORD"} + assert manifest.plaintext == {} assert manifest.required == frozenset() assert manifest.drift_pairs == () -def test_parse_project_manifest_rejects_a_malformed_config_path(): +def test_parse_project_manifest_builds_the_plaintext_table(): + raw = {"plaintext": {"registry.user": "REGISTRY_USER"}} + manifest = parse_project_manifest(raw, ".") + assert manifest.plaintext == {"registry.user": "REGISTRY_USER"} + + +def test_parse_project_manifest_rejects_a_malformed_config_path_in_secret(): """`paths.parse` is the one grammar every config path in the manifest is validated with -- a key using invalid path syntax must fail here, at load time, rather than surfacing later as a `pulumi` invocation error.""" @@ -330,31 +351,55 @@ def test_parse_project_manifest_rejects_a_malformed_config_path(): parse_project_manifest(raw, ".") -def test_parse_project_manifest_does_not_validate_unmanaged_or_plaintext_keys(): +def test_parse_project_manifest_rejects_a_malformed_config_path_in_plaintext(): + """`plaintext` is published too now, so its keys are validated exactly + like `secret`'s.""" + raw = {"plaintext": {"a[": "SOME_NAME"}} + with pytest.raises(SetSecretsError): + parse_project_manifest(raw, ".") + + +def test_parse_project_manifest_does_not_validate_unmanaged_keys(): """`unmanaged` may legitimately hold a wildcard shape that is not a `--path` at all -- see the module docstring -- so it must never be run - through `paths.parse`.""" + through `paths.parse`, unlike `secret` and `plaintext`.""" raw = { "secret": {"db.password": "DB_PASSWORD"}, - "plaintext": {"registry.user": "REGISTRY_USER"}, "unmanaged": {"legacy.*": "reason, not a logical name"}, } manifest = parse_project_manifest(raw, ".") assert manifest.secret == {"db.password": "DB_PASSWORD"} -def test_parse_project_manifest_required_path_not_in_secret_raises(): - raw = {"secret": {"db.password": "DB_PASSWORD"}, "required": ["nonexistent.path"]} +def test_parse_project_manifest_required_path_not_in_secret_or_plaintext_raises(): + raw = { + "secret": {"db.password": "DB_PASSWORD"}, + "required": {"paths": ["nonexistent.path"]}, + } with pytest.raises(SetSecretsError, match="nonexistent.path"): parse_project_manifest(raw, ".") def test_parse_project_manifest_required_path_in_secret_is_accepted(): - raw = {"secret": {"db.password": "DB_PASSWORD"}, "required": ["db.password"]} + raw = { + "secret": {"db.password": "DB_PASSWORD"}, + "required": {"paths": ["db.password"]}, + } manifest = parse_project_manifest(raw, ".") assert manifest.required == frozenset({"db.password"}) +def test_parse_project_manifest_required_path_in_plaintext_is_accepted(): + """`required` now ranges over both publishable tables, since both are + actually published.""" + raw = { + "plaintext": {"registry.user": "REGISTRY_USER"}, + "required": {"paths": ["registry.user"]}, + } + manifest = parse_project_manifest(raw, ".") + assert manifest.required == frozenset({"registry.user"}) + + def test_parse_project_manifest_secret_value_must_be_a_string(): with pytest.raises(SetSecretsError): parse_project_manifest({"secret": {"db.password": 123}}, ".") @@ -554,8 +599,9 @@ def test_dry_run_reports_a_required_unresolved_path_as_a_failure(repo, monkeypat write_repo_config( repo, '[secrets."."]\n' - 'secret = { "db.password" = "DB_PASSWORD" }\n' - 'required = ["db.password"]\n', + 'secret = { "db.password" = "DB_PASSWORD" }\n\n' + '[secrets.".".required]\n' + 'paths = ["db.password"]\n', ) monkeypatch.chdir(repo) monkeypatch.delenv("DB_PASSWORD", raising=False) @@ -597,6 +643,87 @@ def test_value_reaches_the_subprocess_via_stdin_and_never_via_argv( assert MARKER_DB_PASSWORD not in captured.err +def test_a_plaintext_entry_is_published_with_plaintext_and_not_secret( + repo, monkeypatch, capfd, stub_pulumi, tmp_path +): + """`plaintext` entries are published too (Ruling 1) -- with + `--plaintext`, never `--secret`. Still delivered on stdin: one code path + for both tables, per the ruling.""" + write_repo_config( + repo, + '[secrets."."]\nplaintext = { "registry.user" = "REGISTRY_USER" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("REGISTRY_USER", "a-plaintext-value") + fake_pulumi(monkeypatch, stub_pulumi) + + argv_dump = tmp_path / "argv.log" + stdin_dump = tmp_path / "stdin.log" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + monkeypatch.setenv("STUB_DUMP_STDIN_TO", str(stdin_dump)) + + assert main(["set-secrets"]) == 0 + + call_argv = json.loads(argv_dump.read_text().splitlines()[0]) + assert "--plaintext" in call_argv + assert "--secret" not in call_argv + assert "--path" in call_argv and "registry.user" in call_argv + + assert b"a-plaintext-value" in stdin_dump.read_bytes() + + +def test_a_secret_entry_is_never_published_with_plaintext( + repo, monkeypatch, stub_pulumi, tmp_path +): + """The converse of the test above: a `secret`-table entry always carries + `--secret` and never `--plaintext`, even now that both tables are + published through the same `_pulumi_config_set` code path.""" + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + fake_pulumi(monkeypatch, stub_pulumi) + + argv_dump = tmp_path / "argv.log" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + + assert main(["set-secrets"]) == 0 + call_argv = json.loads(argv_dump.read_text().splitlines()[0]) + assert "--secret" in call_argv + assert "--plaintext" not in call_argv + + +def test_secret_and_plaintext_entries_are_both_published_in_one_run( + repo, monkeypatch, stub_pulumi, tmp_path +): + """Both tables are live at once, each with its own flag -- not merely + that either works in isolation.""" + write_repo_config( + repo, + '[secrets."."]\n' + 'secret = { "db.password" = "DB_PASSWORD" }\n' + 'plaintext = { "registry.user" = "REGISTRY_USER" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + monkeypatch.setenv("REGISTRY_USER", "a-plaintext-value") + fake_pulumi(monkeypatch, stub_pulumi) + + argv_dump = tmp_path / "argv.log" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + + assert main(["set-secrets"]) == 0 + calls = [json.loads(line) for line in argv_dump.read_text().splitlines()] + assert len(calls) == 2 + + secret_call = next(c for c in calls if "db.password" in c) + plaintext_call = next(c for c in calls if "registry.user" in c) + assert "--secret" in secret_call and "--plaintext" not in secret_call + assert "--plaintext" in plaintext_call and "--secret" not in plaintext_call + + def test_stack_flag_is_forwarded_to_pulumi(repo, monkeypatch, capfd, stub_pulumi, tmp_path): write_repo_config( repo, @@ -644,8 +771,9 @@ def test_required_unresolved_path_fails_the_run(repo, monkeypatch, stub_pulumi, write_repo_config( repo, '[secrets."."]\n' - 'secret = { "db.password" = "DB_PASSWORD" }\n' - 'required = ["db.password"]\n', + 'secret = { "db.password" = "DB_PASSWORD" }\n\n' + '[secrets.".".required]\n' + 'paths = ["db.password"]\n', ) monkeypatch.chdir(repo) monkeypatch.delenv("DB_PASSWORD", raising=False) @@ -656,6 +784,39 @@ def test_required_unresolved_path_fails_the_run(repo, monkeypatch, stub_pulumi, assert "db.password" in err +def test_a_non_canonical_required_shape_is_rejected_not_silently_ignored( + repo, monkeypatch, capfd +): + """Ruling 2: `required = [...]` (the bare-array shape this module used to + also accept) is now a hard load-time error -- specifically *not* silently + read as "nothing required". + + The exit code alone cannot distinguish "rejected at load time" from "the + old shape was quietly tolerated": in this exact manifest the tolerated + reading would still end up treating 'db.password' as required (the list + happens to contain a valid path), still fail to resolve it, and still + exit 2 through the ordinary per-entry failure path -- printing a + "summary:" line on the way. A genuine load-time rejection never reaches + that loop at all, so *that* absence, not the exit code, is the property + this test actually has to prove -- the same trap the brief warns several + of this task's tests are prone to. + """ + write_repo_config( + repo, + '[secrets."."]\n' + 'secret = { "db.password" = "DB_PASSWORD" }\n' + 'required = ["db.password"]\n', + ) + monkeypatch.chdir(repo) + monkeypatch.delenv("DB_PASSWORD", raising=False) + + assert main(["set-secrets"]) == 2 + captured = capfd.readouterr() + assert "summary:" not in captured.out + assert "error:" in captured.err + assert "paths" in captured.err + + def test_a_missing_pulumi_binary_fails_that_entry_and_is_reported(repo, monkeypatch, capfd): write_repo_config( repo, From 5ba29540c0b571342d1d3a5faac268f23ad000e9 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 11:41:55 -0700 Subject: [PATCH 22/44] fix: reject secret/plaintext overlap, cover source-file resolution, fix drift edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task review findings (Spec fail, quality needs-fixes), all five addressed. Important 1 -- a path declared in both `secret` and `plaintext` was unvalidated. `_publish_entries` builds secret items then plaintext items, so an overlapping path published twice with the `--plaintext` write landing last and winning: a declared credential ending up unencrypted. `parse_project_manifest` now raises `SetSecretsError` naming the path when `set(secret) & set(plaintext)` is non-empty, mirroring the existing `required` cross-table check. Important 2 -- `resolve_source_files` had zero direct test coverage; the brief's own "precedence across the environment and two files" requirement was only exercised through hand-built `LocalSecretSource` dicts, never through the manifest-to-disk path that actually implements it. Added seven unit tests plus one end-to-end test through `main()` that reads two real files (the second overriding a name from the first) with `--stack` substitution wired in and a third file genuinely absent, verified via the stub `pulumi`'s own stdin -- and a separate test for the absolute-path branch. Also fixed: `_check_drift` now inverts over `secret ∪ plaintext` (both are published, so a `managed` name in `plaintext` no longer wrongly hits the "ambiguous" branch); `_pulumi_config_get` uses `.removesuffix("\n")` instead of `.rstrip("\n")`, which was eating a published value's own trailing newline and firing spurious drift warnings; `ProjectManifest`'s docstring no longer claims `config.py` validates `unmanaged`'s shape (it doesn't -- this module defers that, and simply chooses not to look at it). 13 new tests (550 total), each sabotage-verified against the defect it covers. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/commands/set_secrets.py | 77 ++++++--- tests/test_set_secrets.py | 217 ++++++++++++++++++++++++++ 2 files changed, 272 insertions(+), 22 deletions(-) diff --git a/src/stackward/commands/set_secrets.py b/src/stackward/commands/set_secrets.py index f289eff..8be947f 100644 --- a/src/stackward/commands/set_secrets.py +++ b/src/stackward/commands/set_secrets.py @@ -63,16 +63,18 @@ declares a `bootstrap` and a `managed` logical name; the check compares the *local* value of `bootstrap` (resolved the same way any other entry is) against the *published* value at whichever config path `managed` names in -`secret` (read with `pulumi config get`, which decrypts — the list form, -`pulumi config`, renders every secret as `[secret]` without decrypting and -exits 0 under any passphrase, so it cannot answer this question at all). Three -states are silent on purpose, not merely "no branch happened to fire": the -local `bootstrap` value not resolving (nothing to compare against), -`managed`'s published value not being readable (a timeout, a non-zero exit, a -missing `pulumi`), and the two values actually agreeing. Only an *unreadable* -name — `managed` naming zero or more than one declared secret, which is a -manifest problem rather than a state-of-the-world one — warns, since that one -is worth a human's attention regardless of what the values turn out to be. +`secret` **or** `plaintext` (both are published now, per Ruling 1 — the +inversion is over their disjoint union; see `_check_drift`), read with +`pulumi config get`, which decrypts — the list form, `pulumi config`, renders +every secret as `[secret]` without decrypting and exits 0 under any +passphrase, so it cannot answer this question at all. Three states are +silent on purpose, not merely "no branch happened to fire": the local +`bootstrap` value not resolving (nothing to compare against), `managed`'s +published value not being readable (a timeout, a non-zero exit, a missing +`pulumi`), and the two values actually agreeing. Only an *unreadable* name — +`managed` naming zero or more than one published entry, which is a manifest +problem rather than a state-of-the-world one — warns, since that one is +worth a human's attention regardless of what the values turn out to be. """ from __future__ import annotations @@ -139,8 +141,10 @@ class ProjectManifest: on: `secret` and `plaintext` (each config path -> logical name; both are published, see the module docstring), `required` (a subset of `secret`'s and `plaintext`'s paths that must resolve), and `drift_pairs`. - `unmanaged` is read by `config.py`'s shape check and never reaches this - dataclass at all — it is documentation-only, by design. + `unmanaged` is never parsed by this module at all — `config.py` validates + only that the *project* table itself is a table and defers every key + inside it, `unmanaged` included, to here; this module simply chooses not + to look at it, since it is documentation-only, by design. """ secret: dict[str, str] = field(default_factory=dict) @@ -305,6 +309,22 @@ def parse_project_manifest(raw: dict[str, Any], project: str) -> ProjectManifest f"secrets.{project!r}.{table_name}: invalid config path {path!r}: {exc}" ) from exc + # The split between `secret` and `plaintext` *is* the declaration of + # whether a value is a credential (see the module docstring) -- a path in + # both tables is a contradiction in that declaration, not a preference to + # resolve one way or the other. Left unchecked, `_publish_entries` + # publishes `secret` first and `plaintext` second, so the `--plaintext` + # write would win and a value its author explicitly declared a + # credential would end up unencrypted in Pulumi's config -- silently, in + # the more dangerous direction. Refused here instead, at load time, + # naming the offending path. + overlap = set(secret) & set(plaintext) + if overlap: + raise SetSecretsError( + f"secrets.{project!r}: {sorted(overlap)[0]!r} is declared under both " + "'secret' and 'plaintext' -- a path must be exactly one" + ) + required = _parse_required(raw.get("required"), f"secrets.{project!r}.required") unknown = required - set(secret) - set(plaintext) if unknown: @@ -509,10 +529,14 @@ def _pulumi_config_get( return None if completed.returncode != 0: return None - # `pulumi config get` terminates its output with a trailing newline; - # stripping it is what makes the comparison in `_check_drift` compare the - # actual value rather than failing on every equal pair. - return completed.stdout.rstrip("\n") + # `pulumi config get` terminates its output with exactly one trailing + # newline; stripping it is what makes the comparison in `_check_drift` + # compare the actual value rather than failing on every equal pair. + # `removesuffix`, not `rstrip`, is what "exactly one" requires: `rstrip` + # would also eat every newline of a published value that legitimately + # ends in one (or more), silently changing what is being compared and + # firing a spurious warning over a difference that was never real. + return completed.stdout.removesuffix("\n") # --------------------------------------------------------------------------- @@ -581,11 +605,15 @@ def _publish_entries( return outcomes -def _config_path_for_name(secret: dict[str, str], name: str) -> str | None: - """The one config path in `secret` mapping to `name`, or `None` when zero - or more than one do — `managed` in a drift pair must name exactly one - declared secret for `pulumi config get` to have anything to read.""" - matches = [path for path, mapped in secret.items() if mapped == name] +def _config_path_for_name(entries: dict[str, str], name: str) -> str | None: + """The one config path in `entries` mapping to `name`, or `None` when + zero or more than one do — `managed` in a drift pair must name exactly + one *published* entry for `pulumi config get` to have anything to read. + `entries` is `_check_drift`'s merge of `secret` and `plaintext` — both + are published now (Ruling 1), so `managed` may legitimately name either + one, and inverting only `secret` would wrongly warn on a `managed` name + that lives in `plaintext`.""" + matches = [path for path, mapped in entries.items() if mapped == name] return matches[0] if len(matches) == 1 else None @@ -604,12 +632,17 @@ def _check_drift( invocation, and dry-run's contract is to run nothing at all.""" if pulumi is None: return + # `secret` and `plaintext` are guaranteed disjoint by + # `parse_project_manifest`, so this merge cannot silently drop or shadow + # an entry from either side -- `managed` may name a path in either table, + # since both are published (Ruling 1). + published = {**manifest.secret, **manifest.plaintext} for pair in manifest.drift_pairs: bootstrap_value = source.resolve(pair.bootstrap) if not bootstrap_value: continue - managed_path = _config_path_for_name(manifest.secret, pair.managed) + managed_path = _config_path_for_name(published, pair.managed) if managed_path is None: print( f"warning: drift pair {pair.bootstrap!r}/{pair.managed!r}: " diff --git a/tests/test_set_secrets.py b/tests/test_set_secrets.py index 89d4c0d..d1f9e1a 100644 --- a/tests/test_set_secrets.py +++ b/tests/test_set_secrets.py @@ -44,6 +44,7 @@ _publish_entries, parse_env_file, parse_project_manifest, + resolve_source_files, select_project, substitute_stack, ) @@ -255,6 +256,53 @@ def test_substitute_stack_raises_when_no_stack_was_given(): substitute_stack(".env.{stack}", None) +# --------------------------------------------------------------------------- +# `resolve_source_files` +# --------------------------------------------------------------------------- + + +def test_resolve_source_files_absent_source_table_is_an_empty_list(tmp_path): + assert resolve_source_files(Config(secrets={}), tmp_path, None) == [] + + +def test_resolve_source_files_absent_files_key_is_an_empty_list(tmp_path): + config = Config(secrets={"source": {}}) + assert resolve_source_files(config, tmp_path, None) == [] + + +def test_resolve_source_files_rejects_a_non_list_files_value(tmp_path): + config = Config(secrets={"source": {"files": "not-a-list"}}) + with pytest.raises(SetSecretsError): + resolve_source_files(config, tmp_path, None) + + +def test_resolve_source_files_rejects_a_files_list_of_non_strings(tmp_path): + config = Config(secrets={"source": {"files": [1, 2]}}) + with pytest.raises(SetSecretsError): + resolve_source_files(config, tmp_path, None) + + +def test_resolve_source_files_resolves_a_relative_entry_against_repo_root(tmp_path): + config = Config(secrets={"source": {"files": [".env"]}}) + assert resolve_source_files(config, tmp_path, None) == [tmp_path / ".env"] + + +def test_resolve_source_files_leaves_an_absolute_entry_unchanged(tmp_path): + absolute = tmp_path / "elsewhere" / ".env" + config = Config(secrets={"source": {"files": [str(absolute)]}}) + # A repo root that is NOT an ancestor of `absolute` -- proves the + # absolute entry is not joined onto it at all, not merely that the join + # happens to produce the same path by coincidence. + unrelated_root = tmp_path / "a-different-repo-root" + assert resolve_source_files(config, unrelated_root, None) == [absolute] + + +def test_resolve_source_files_substitutes_stack_per_entry(tmp_path): + config = Config(secrets={"source": {"files": [".env.{stack}.local"]}}) + resolved = resolve_source_files(config, tmp_path, "placeholder-stack") + assert resolved == [tmp_path / ".env.placeholder-stack.local"] + + # --------------------------------------------------------------------------- # `_parse_required` -- the one canonical `[required]` shape # --------------------------------------------------------------------------- @@ -400,6 +448,19 @@ def test_parse_project_manifest_required_path_in_plaintext_is_accepted(): assert manifest.required == frozenset({"registry.user"}) +def test_parse_project_manifest_rejects_a_path_declared_in_both_secret_and_plaintext(): + """The split between `secret` and `plaintext` *is* the declaration of + whether a value is a credential -- a path in both tables is a + contradiction in that declaration, and must be refused rather than + silently resolved toward the more dangerous (plaintext) reading.""" + raw = { + "secret": {"db.password": "DB_PASSWORD"}, + "plaintext": {"db.password": "DB_PASSWORD_AGAIN"}, + } + with pytest.raises(SetSecretsError, match="db.password"): + parse_project_manifest(raw, ".") + + def test_parse_project_manifest_secret_value_must_be_a_string(): with pytest.raises(SetSecretsError): parse_project_manifest({"secret": {"db.password": 123}}, ".") @@ -576,6 +637,73 @@ def test_no_repo_config_is_a_clean_failure(repo, monkeypatch, capfd): assert "Traceback" not in err +def test_source_files_are_read_from_disk_through_the_full_manifest_pipeline( + repo, monkeypatch, stub_pulumi, tmp_path +): + """`resolve_source_files` is real logic -- default-to-empty, type + validation, `{stack}` substitution per entry, repo-root-relative + resolution -- and this is the one test that exercises it through the + actual manifest-to-disk path, rather than through hand-built + `LocalSecretSource` dicts (as `test_full_precedence_chain_across_env_and_ + two_files` does for the resolver itself). One test closes four gaps at + once: two *real* files on disk where the second overrides a name from + the first; `--stack` supplied so `{stack}` substitution is exercised as + it is actually wired, not just as a standalone function call; a third + declared file that is genuinely absent from disk, proving the + silent-skip ruling end-to-end; and the result is read off a real child + process's stdin, not this module's own belief about what it resolved. + """ + write_repo_config( + repo, + '[secrets.source]\n' + 'files = [".env.base", ".env.{stack}.local", ".env.absent"]\n\n' + '[secrets."."]\n' + 'secret = { "db.password" = "DB_PASSWORD" }\n', + ) + (repo / ".env.base").write_text("DB_PASSWORD=from-base-file\n") + (repo / ".env.base").chmod(0o600) + (repo / ".env.placeholder-stack.local").write_text("DB_PASSWORD=from-stack-local-file\n") + (repo / ".env.placeholder-stack.local").chmod(0o600) + # ".env.absent" is deliberately never created. + + monkeypatch.chdir(repo) + monkeypatch.delenv("DB_PASSWORD", raising=False) + fake_pulumi(monkeypatch, stub_pulumi) + + stdin_dump = tmp_path / "stdin.log" + monkeypatch.setenv("STUB_DUMP_STDIN_TO", str(stdin_dump)) + + assert main(["set-secrets", "--stack", "placeholder-stack"]) == 0 + + stdin_content = stdin_dump.read_bytes() + assert b"from-stack-local-file" in stdin_content + assert b"from-base-file" not in stdin_content + + +def test_an_absolute_source_file_path_is_read_regardless_of_repo_root( + repo, monkeypatch, stub_pulumi, tmp_path +): + outside = tmp_path / "outside-the-repo" / ".env" + outside.parent.mkdir(parents=True) + outside.write_text("DB_PASSWORD=from-an-absolute-path\n") + outside.chmod(0o600) + + write_repo_config( + repo, + f'[secrets.source]\nfiles = ["{outside}"]\n\n' + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.delenv("DB_PASSWORD", raising=False) + fake_pulumi(monkeypatch, stub_pulumi) + + stdin_dump = tmp_path / "stdin.log" + monkeypatch.setenv("STUB_DUMP_STDIN_TO", str(stdin_dump)) + + assert main(["set-secrets"]) == 0 + assert b"from-an-absolute-path" in stdin_dump.read_bytes() + + def test_dry_run_never_invokes_a_subprocess(repo, monkeypatch, capfd): write_repo_config( repo, @@ -817,6 +945,34 @@ def test_a_non_canonical_required_shape_is_rejected_not_silently_ignored( assert "paths" in captured.err +def test_a_path_declared_in_both_secret_and_plaintext_is_rejected_not_downgraded( + repo, monkeypatch, capfd, stub_pulumi +): + """End-to-end version of the overlap check: without it, `_publish_entries` + would publish 'db.password' twice -- `--secret` first, `--plaintext` + last, with the last write winning -- leaving a value its author declared + a credential sitting unencrypted in Pulumi's config. This must fail + before either write happens, not merely end up correct by luck of write + order.""" + write_repo_config( + repo, + '[secrets."."]\n' + 'secret = { "db.password" = "DB_PASSWORD" }\n' + 'plaintext = { "db.password" = "DB_PASSWORD_AGAIN" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + monkeypatch.setenv("DB_PASSWORD_AGAIN", MARKER_DB_PASSWORD) + fake_pulumi(monkeypatch, stub_pulumi) + + assert main(["set-secrets"]) == 2 + captured = capfd.readouterr() + assert "summary:" not in captured.out + assert "db.password" in captured.err + assert MARKER_DB_PASSWORD not in captured.out + assert MARKER_DB_PASSWORD not in captured.err + + def test_a_missing_pulumi_binary_fails_that_entry_and_is_reported(repo, monkeypatch, capfd): write_repo_config( repo, @@ -926,6 +1082,17 @@ def test_a_pulumi_failure_never_echoes_the_value_it_was_given( 'managed = "MANAGED_NAME"\n' ) +# `managed` lives in `plaintext`, not `secret` -- proves the inversion in +# `_check_drift` covers both publishable tables (Also-fix 3), not just +# `secret`. +_PLAINTEXT_MANAGED_DRIFT_MANIFEST = ( + '[secrets."."]\n' + 'plaintext = { "managed.path" = "MANAGED_NAME" }\n\n' + '[[secrets.".".drift_pairs]]\n' + 'bootstrap = "BOOTSTRAP_NAME"\n' + 'managed = "MANAGED_NAME"\n' +) + def test_drift_warns_when_the_values_differ(repo, monkeypatch, capfd, stub_pulumi): write_repo_config(repo, _DRIFT_MANIFEST) @@ -963,6 +1130,33 @@ def test_drift_is_silent_when_the_values_agree(repo, monkeypatch, capfd, stub_pu assert "warning" not in captured.err +def test_drift_comparison_removes_only_the_clis_own_trailing_newline( + repo, monkeypatch, capfd, stub_pulumi +): + """Also-fix 4: a published value that itself legitimately ends in a + newline must still compare equal to a local value that also does -- + `.removesuffix("\\n")` removes exactly the one trailing newline `pulumi + config get` itself appends, where `.rstrip("\\n")` would also eat the + value's own, firing a spurious warning over a difference that was never + real. The stub appends its own trailing newline on top of the value + (simulating the CLI), so this only passes if exactly one newline is + stripped, not every one.""" + write_repo_config(repo, _DRIFT_MANIFEST) + monkeypatch.chdir(repo) + value_ending_in_newline = "value-with-its-own-trailing-newline\n" + monkeypatch.setenv("MANAGED_NAME", value_ending_in_newline) + monkeypatch.setenv("BOOTSTRAP_NAME", value_ending_in_newline) + fake_pulumi(monkeypatch, stub_pulumi) + monkeypatch.setenv( + "STUB_GET_VALUES", json.dumps({"managed.path": value_ending_in_newline}) + ) + + assert main(["set-secrets"]) == 0 + captured = capfd.readouterr() + assert "drift" not in captured.err + assert "warning" not in captured.err + + def test_drift_is_silent_when_the_published_value_is_unreadable( repo, monkeypatch, capfd, stub_pulumi ): @@ -990,6 +1184,29 @@ def test_drift_warns_when_the_managed_name_is_ambiguous(repo, monkeypatch, capfd assert MARKER_BOOTSTRAP not in captured.err +def test_drift_finds_a_managed_name_declared_in_plaintext(repo, monkeypatch, capfd, stub_pulumi): + """Also-fix 3: `_check_drift` inverts over `secret ∪ plaintext`, since + both are published now (Ruling 1). Before the fix, this exact manifest + -- `managed` correctly and unambiguously declared, just under + `plaintext` -- would wrongly hit the "does not name exactly one declared + secret" branch instead of comparing the values at all.""" + write_repo_config(repo, _PLAINTEXT_MANAGED_DRIFT_MANIFEST) + monkeypatch.chdir(repo) + monkeypatch.setenv("MANAGED_NAME", "a-plaintext-value") + monkeypatch.setenv("BOOTSTRAP_NAME", MARKER_BOOTSTRAP) + fake_pulumi(monkeypatch, stub_pulumi) + monkeypatch.setenv( + "STUB_GET_VALUES", json.dumps({"managed.path": "a-different-published-value"}) + ) + + assert main(["set-secrets"]) == 0 + captured = capfd.readouterr() + assert "does not name exactly one" not in captured.err + assert "drift" in captured.err + assert "managed.path" in captured.err + assert MARKER_BOOTSTRAP not in captured.err + + def test_drift_is_never_checked_under_dry_run(repo, monkeypatch, capfd, stub_pulumi, tmp_path): write_repo_config(repo, _DRIFT_MANIFEST) monkeypatch.chdir(repo) From 0ba3353e08ed36bf7c0352426410c099841814a3 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 11:53:56 -0700 Subject: [PATCH 23/44] fix: stop a variable-name collision from crashing the second drift pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review regression: `_check_drift`'s pre-loop `published` (the merged `secret ∪ plaintext` dict, added when Finding 3 was fixed) shared its name with the loop's own `published` (`_pulumi_config_get`'s `str | None` result, pre-existing and untouched by that fix). The first drift pair evaluated correctly; the second and every later pair called `_config_path_for_name` with the previous iteration's `str` instead of the dict and raised `AttributeError`. User-visible effect: `_check_drift` runs after `_publish_entries` has already made every `pulumi config set` call and printed every outcome, so a run where everything published successfully still exited 2 via `fail_closed`'s generic catch, with the closing `summary:` line -- exactly what the operator needs at that moment -- never printed. Fix: rename the pre-loop binding to `published_entries`; the loop's own `published = _pulumi_config_get(...)` is untouched. Also corrects `DriftPair`'s docstring, which still named `ProjectManifest.secret` alone as the inversion source after Finding 3 changed it to `secret ∪ plaintext`. Regression test: two drift pairs, both unambiguously resolvable and both genuinely differing (so a pass requires both warnings to actually fire, not merely "no crash"), reproduced against the pre-fix source with the exact reported failure shape before being fixed. 551 tests pass (550 before, 1 new). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/commands/set_secrets.py | 20 +++++---- tests/test_set_secrets.py | 60 +++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/src/stackward/commands/set_secrets.py b/src/stackward/commands/set_secrets.py index 8be947f..cc85089 100644 --- a/src/stackward/commands/set_secrets.py +++ b/src/stackward/commands/set_secrets.py @@ -125,10 +125,11 @@ class SetSecretsError(Exception): class DriftPair: """One declared `[[secrets."".drift_pairs]]` entry: two *logical names*, never config paths themselves. `managed`'s config path is found - by inverting `ProjectManifest.secret` at check time (see `_check_drift`), - not stored here — the manifest is the one place that mapping lives, and - keeping a second copy of it in a `DriftPair` would let the two disagree - silently if `secret` ever changed shape. + by inverting `ProjectManifest.secret` **and** `.plaintext` together (both + are published — Ruling 1) at check time (see `_check_drift`), not stored + here — the manifest is the one place that mapping lives, and keeping a + second copy of it in a `DriftPair` would let the two disagree silently if + `secret`/`plaintext` ever changed shape. """ bootstrap: str @@ -635,14 +636,19 @@ def _check_drift( # `secret` and `plaintext` are guaranteed disjoint by # `parse_project_manifest`, so this merge cannot silently drop or shadow # an entry from either side -- `managed` may name a path in either table, - # since both are published (Ruling 1). - published = {**manifest.secret, **manifest.plaintext} + # since both are published (Ruling 1). Named distinctly from the loop's + # own `published` local below (`pulumi config get`'s decrypted result, + # rebound on every iteration): the two used to share a name, which meant + # the *second* and every later drift pair called `_config_path_for_name` + # with a `str` left over from the previous iteration instead of this + # dict, raising `AttributeError` rather than evaluating the pair at all. + published_entries = {**manifest.secret, **manifest.plaintext} for pair in manifest.drift_pairs: bootstrap_value = source.resolve(pair.bootstrap) if not bootstrap_value: continue - managed_path = _config_path_for_name(published, pair.managed) + managed_path = _config_path_for_name(published_entries, pair.managed) if managed_path is None: print( f"warning: drift pair {pair.bootstrap!r}/{pair.managed!r}: " diff --git a/tests/test_set_secrets.py b/tests/test_set_secrets.py index d1f9e1a..e5e7cc2 100644 --- a/tests/test_set_secrets.py +++ b/tests/test_set_secrets.py @@ -1093,6 +1093,26 @@ def test_a_pulumi_failure_never_echoes_the_value_it_was_given( 'managed = "MANAGED_NAME"\n' ) +# Two drift pairs, each unambiguously resolvable -- regression coverage for +# the `published` variable-shadowing bug: a pre-loop `published` (the merged +# path->name dict) and the loop's own `published` (`_pulumi_config_get`'s +# `str | None` result) used to share one name, so the second and every later +# pair called `_config_path_for_name` with a leftover `str` instead of the +# dict and crashed with `AttributeError`. +_TWO_DRIFT_PAIRS_MANIFEST = ( + '[secrets."."]\n' + "secret = { " + '"managed.path.one" = "MANAGED_NAME_ONE", ' + '"managed.path.two" = "MANAGED_NAME_TWO" ' + "}\n\n" + '[[secrets.".".drift_pairs]]\n' + 'bootstrap = "BOOTSTRAP_NAME_ONE"\n' + 'managed = "MANAGED_NAME_ONE"\n\n' + '[[secrets.".".drift_pairs]]\n' + 'bootstrap = "BOOTSTRAP_NAME_TWO"\n' + 'managed = "MANAGED_NAME_TWO"\n' +) + def test_drift_warns_when_the_values_differ(repo, monkeypatch, capfd, stub_pulumi): write_repo_config(repo, _DRIFT_MANIFEST) @@ -1207,6 +1227,46 @@ def test_drift_finds_a_managed_name_declared_in_plaintext(repo, monkeypatch, cap assert MARKER_BOOTSTRAP not in captured.err +def test_a_second_drift_pair_is_evaluated_and_the_run_reaches_its_summary( + repo, monkeypatch, capfd, stub_pulumi +): + """Regression test for the `published` variable-shadowing bug: a + pre-loop `published` (the merged `secret ∪ plaintext` dict) and the + loop's own `published` (`_pulumi_config_get`'s result) shared one name, + so every drift pair after the first called `_config_path_for_name` with + a leftover `str` and crashed with `AttributeError` -- which `fail_closed` + then turned into a bare exit 2 *after* every `pulumi config set` call had + already succeeded and been printed, with the closing `summary:` line + never reached at all. Two pairs, both unambiguously resolvable and both + genuinely differing (so both warnings must fire, not just "no crash"), + proves the second pair is actually evaluated rather than merely not + crashing on it by accident. + """ + write_repo_config(repo, _TWO_DRIFT_PAIRS_MANIFEST) + monkeypatch.chdir(repo) + monkeypatch.setenv("MANAGED_NAME_ONE", "local-value-one") + monkeypatch.setenv("MANAGED_NAME_TWO", "local-value-two") + monkeypatch.setenv("BOOTSTRAP_NAME_ONE", "local-value-one") + monkeypatch.setenv("BOOTSTRAP_NAME_TWO", "local-value-two") + fake_pulumi(monkeypatch, stub_pulumi) + monkeypatch.setenv( + "STUB_GET_VALUES", + json.dumps( + { + "managed.path.one": "published-value-one-differs", + "managed.path.two": "published-value-two-differs", + } + ), + ) + + assert main(["set-secrets"]) == 0 + captured = capfd.readouterr() + assert "AttributeError" not in captured.err + assert "MANAGED_NAME_ONE" in captured.err + assert "MANAGED_NAME_TWO" in captured.err + assert "summary:" in captured.out + + def test_drift_is_never_checked_under_dry_run(repo, monkeypatch, capfd, stub_pulumi, tmp_path): write_repo_config(repo, _DRIFT_MANIFEST) monkeypatch.chdir(repo) From 4ba9f6b6132848e7589335605baae09c96a5f2ce Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 12:19:15 -0700 Subject: [PATCH 24/44] feat: add check-passphrase command (Task 9) Prove a passphrase actually decrypts a stack, which no ordinary `pulumi` command can answer -- `pulumi config` renders secrets as `[secret]` without decrypting and exits 0 under any passphrase, including a wrong one. `stackward check-passphrase STACK...` reads the candidate passphrase from stdin (falling back to an echo-free `getpass` prompt when stdin is a terminal, never as a CLI argument), runs `pulumi stack export --stack ` for each named stack, and reproduces Pulumi's own passphrase-based secrets-provider check against `deployment.secrets_providers.state.salt`: PBKDF2-HMAC-SHA256 at 1,000,000 iterations deriving a 32-byte key (Pulumi's own fixed scheme, deliberately not crypto.py's Argon2id), then AES-GCM, expecting the literal plaintext `b"pulumi"`. Reports accepted/rejected per stack and a SHA-256 fingerprint of the passphrase (never the passphrase itself); exits 0 only if every named stack accepted. Fails closed: a malformed salt string, an unparseable export, or a missing `deployment`/`secrets_providers`/`state`/`salt` field (e.g. a stack using a non-passphrase secrets provider) is reported as an error, never "rejected" or "accepted". Explicit byte-length checks on the salt/nonce/ciphertext guard against a subtle misclassification: `cryptography`'s `AESGCM.decrypt` raises `InvalidTag` -- not a distinguishable error -- for a too-short ciphertext or an unexpected-but-still-valid nonce length, which without these checks would be misreported as "rejected" instead of the malformed input it actually is. The format (v1::v1::, standard/padded base64, 8-byte salt, 12-byte nonce) was confirmed empirically against a real `pulumi stack export` from a throwaway local-backend stack, not assumed -- that exact vector is frozen as a test fixture, since a round trip against this module's own encryption would prove only self-consistency, never interoperability with Pulumi's actual format. 38 new tests in tests/test_check_passphrase.py; 589 passed, up from 551. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/cli.py | 8 + src/stackward/commands/check_passphrase.py | 429 +++++++++++++++ tests/test_check_passphrase.py | 581 +++++++++++++++++++++ 3 files changed, 1018 insertions(+) create mode 100644 src/stackward/commands/check_passphrase.py create mode 100644 tests/test_check_passphrase.py diff --git a/src/stackward/cli.py b/src/stackward/cli.py index edc8075..01f3f14 100644 --- a/src/stackward/cli.py +++ b/src/stackward/cli.py @@ -18,6 +18,7 @@ from . import __version__ from .commands.check_config import cmd_check_config +from .commands.check_passphrase import cmd_check_passphrase from .commands.install_hooks import cmd_install_hooks from .commands.pre_commit import cmd_pre_commit from .commands.session import cmd_exec, cmd_login, cmd_shell @@ -166,6 +167,13 @@ def build_parser() -> argparse.ArgumentParser: ) set_secrets.set_defaults(func=cmd_set_secrets) + check_passphrase = sub.add_parser( + "check-passphrase", + help="prove a passphrase decrypts one or more stacks (never as an argument)", + ) + check_passphrase.add_argument("stacks", nargs="+", metavar="STACK") + check_passphrase.set_defaults(func=cmd_check_passphrase) + return parser diff --git a/src/stackward/commands/check_passphrase.py b/src/stackward/commands/check_passphrase.py new file mode 100644 index 0000000..dbb9e20 --- /dev/null +++ b/src/stackward/commands/check_passphrase.py @@ -0,0 +1,429 @@ +"""`check-passphrase`: prove a passphrase actually decrypts a stack. + +**Why this exists.** `pulumi config` (the list form) renders every secret as +`[secret]` **without decrypting**, and exits 0 under any passphrase at all, +including a flatly wrong one — `set_secrets.py`'s own `_pulumi_config_get` +docstring makes the same point about why drift detection cannot use it +either. There is no `pulumi` subcommand that answers "is this passphrase +right" directly. The only way to actually prove one is to reproduce the +decryption Pulumi itself would perform against the stack's own exported +state, which is what this module does. It is a verifier, not an alternative +convenience wrapper around some other `pulumi` command — if an easier +`pulumi` invocation could answer this question, this file would not exist. + +**Where the ciphertext comes from.** `pulumi stack export --stack ` +prints the stack's deployment state as JSON. For a stack using Pulumi's +built-in passphrase-based secrets provider (the default for a self-managed, +non-Service backend), that document carries, at +`deployment.secrets_providers.state.salt`, a string of the form + + v1::v1:: + +which decodes to an 8-byte salt, a 12-byte AES-GCM nonce, and a ciphertext +whose last 16 bytes are the GCM authentication tag. Decrypting that +ciphertext (no associated data) under the key PBKDF2 derives from the +candidate passphrase and that salt yields the literal plaintext `b"pulumi"` +if and only if the passphrase is the one the stack was created with. That +literal string is a canary Pulumi itself writes for exactly this purpose — +this module does not invent the check, it reproduces it. + +Every field name and byte length above (`v1:`-prefixed, standard/padded +base64, 8-byte salt, 12-byte nonce, no AAD, PBKDF2-HMAC-SHA256 at 1,000,000 +iterations, a 32-byte key, plaintext `b"pulumi"`) was confirmed empirically +against a real `pulumi stack export` from a throwaway local-backend stack +created for this purpose, decrypted successfully with the passphrase that +created it and rejected with a different one — not taken on faith from +memory or from reading Pulumi's source. `tests/test_check_passphrase.py` +freezes that exact salt string as a fixture for the same reason: a round +trip against this module's own encryption would prove only self-consistency, +never that the format matches Pulumi's. + +**Why PBKDF2 here and Argon2id in `crypto.py`.** This is deliberate, not an +oversight — see the parameter definitions below for the reason, and +`crypto.py`'s own module docstring, which names this module for the same +reason from the other side. Do not unify them: this module is reproducing a +fixed, externally-imposed format it does not control, and has no freedom to +pick a stronger KDF even though one exists elsewhere in this codebase. + +**Fail-closed, not three-valued-but-secretly-two.** A stack is reported as +exactly one of `accepted`, `rejected`, or an error — never conflated. A +malformed salt string, a `pulumi stack export` that fails or times out, a +document missing `deployment`, `secrets_providers`, `state`, or `salt` (for +instance because the stack uses a cloud KMS-based secrets provider instead +of a passphrase, which carries no `salt` field at all), or output that is +not valid JSON are all errors — this module cannot tell whether the +passphrase would have worked, and reporting "rejected" for any of them would +be a false negative wearing the shape of a real answer. This is what "fail +closed" concretely means for this command, not merely a slogan: every one of +those conditions raises `CheckPassphraseError`, which the per-stack loop in +`cmd_check_passphrase` reports as `error`, never as `accepted` or `rejected`. + +**The passphrase is never a command-line argument.** It is read as one line +from `sys.stdin` by default — the expected shape is a pipe, e.g. a value +drawn from a password manager or a candidate list — falling back to an +echo-free `getpass.getpass` prompt when `sys.stdin` is a terminal (nothing +piped in, a human at a keyboard). This is the mirror image of +`session.py`'s `_read_store_password`, not a reuse of its exact mechanism, +and deliberately so: `session.py` must never read `sys.stdin` because that +descriptor belongs to the child process it is about to run (`pulumi up`, +etc.) and reading even one byte of it here would be consumed before the +child ever saw it. This command spawns no interactive child — `pulumi stack +export` never reads from its own stdin — so `sys.stdin` is this command's to +use, and checking `sys.stdin.isatty()` directly (rather than `session.py`'s +`/dev/tty`-opened-independently check) is the correct question to ask here: +it is the exact fallback condition the task names, and it decides "was +something piped in" using the same descriptor that will actually be read. + +**The fingerprint identifies a candidate, not the store.** It is the first +16 hex characters of SHA-256 over the exact UTF-8 bytes handed to PBKDF2 — +not NFC-normalised, unlike `crypto.py`'s stored-password handling, because +there is nothing here to keep byte-for-byte reproducible across sessions; +the fingerprint only has to agree with what was actually derived *this run*, +so it is computed from the identical bytes rather than a canonicalised +form that could disagree with them. + +**Exit codes are 0 or 2, never 1** — `check-config`'s docstring and +`set_secrets.py`'s both reserve exit code 1 exclusively for "a credential +was found," a claim this command does not make. 0 means every named stack +accepted; 2 means at least one did not (rejected, or could not be checked). +""" + +from __future__ import annotations + +import argparse +import base64 +import binascii +import getpass +import hashlib +import json +import shutil +import subprocess +import sys +from typing import Any + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +from .check_config import fail_closed + +# Pulumi's own KDF, reproduced exactly -- NOT `crypto.py`'s Argon2id. +# +# This is not an inconsistency to "fix": `crypto.py`'s store interoperates +# with nothing, so it is free to use the modern memory-hard KDF. This module +# is reproducing *Pulumi's* envelope format in order to verify a stack's own +# ciphertext, which is a fixed, externally-imposed scheme this module has no +# freedom to change -- PBKDF2-HMAC-SHA256 at 1,000,000 iterations, deriving +# a 32-byte AES-256 key, confirmed against a real `pulumi stack export` (see +# the module docstring). Changing either number here would make this module +# derive a key Pulumi itself does not, and every real stack would reject +# even its own correct passphrase. +PBKDF2_ITERATIONS = 1_000_000 +KEY_BYTES = 32 + +# Byte lengths Pulumi's own encoding fixes, confirmed against a real export +# (see the module docstring) rather than assumed: +# - the salt is `make([]byte, 8)` on the Go side; +# - the nonce is AES-GCM's own standard 12-byte size; +# - the ciphertext carries a 16-byte GCM tag appended, so it can never be +# shorter than that. +# These are enforced explicitly, before any decryption is attempted, because +# `cryptography`'s `AESGCM.decrypt` raises `InvalidTag` -- not a `ValueError` +# -- for a too-short ciphertext or for a nonce of an unexpected-but-still- +# valid length. Without these checks a malformed salt string would be +# misreported as "rejected" (a wrong passphrase) instead of the error it +# actually is, which is exactly what fail-closed forbids: see the module +# docstring's "Fail-closed" section. +_SALT_BYTES = 8 +_NONCE_BYTES = 12 +_MIN_CIPHERTEXT_BYTES = 16 + +# The literal plaintext Pulumi encrypts as its own passphrase canary. +PULUMI_TEST_PLAINTEXT = b"pulumi" + +# How long a single `pulumi stack export` is allowed to run before this +# module gives up on that one stack and reports it as an error -- a module +# level constant (not a function default) so a test can lower it with +# `monkeypatch.setattr`, matching `set_secrets.DEFAULT_TIMEOUT_SECONDS`. +DEFAULT_TIMEOUT_SECONDS = 30.0 + + +class CheckPassphraseError(Exception): + """A stack could not be checked at all: a malformed salt string, a + `pulumi stack export` that failed, timed out, or returned something + that is not parseable JSON, or an exported document missing + `deployment`, `secrets_providers`, `state`, or `salt`. + + Never means "the passphrase was wrong" -- that is `check_salt` returning + `False`, a distinct outcome the per-stack loop never conflates with this + one. Never carries the passphrase or any part of it: every message here + names a stack, a byte length, a `pulumi` exit code, or a JSON field -- + never a credential value. + """ + + +def fingerprint(passphrase: str) -> str: + """First 16 hex characters of SHA-256 over `passphrase`'s UTF-8 bytes -- + the same bytes `check_salt` hands to PBKDF2, not an NFC-normalised or + otherwise adjusted form (contrast `crypto.py`'s stored passwords, which + are normalised because they must stay reproducible across sessions on + different platforms; this value only has to match what was actually + derived in this one run). + + Lets two candidate passphrases be told apart in a log safely: the + fingerprint reveals nothing about the passphrase itself (SHA-256 is not + invertible, and this is a truncated fingerprint of a hash, never the + hash used as a key or a proof of knowledge), but two different + passphrases fingerprint differently with overwhelming probability, and + the same passphrase always fingerprints the same way. + """ + return hashlib.sha256(passphrase.encode("utf-8")).hexdigest()[:16] + + +def _derive_key(passphrase: bytes, salt: bytes) -> bytes: + """PBKDF2-HMAC-SHA256, Pulumi's own parameters -- see `PBKDF2_ITERATIONS` + for why these numbers and not `crypto.py`'s Argon2id ones.""" + return PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=KEY_BYTES, + salt=salt, + iterations=PBKDF2_ITERATIONS, + ).derive(passphrase) + + +def _parse_salt(salt_field: str) -> tuple[bytes, bytes, bytes]: + """Decode `v1::v1::` into + `(salt, nonce, ciphertext)`, or raise `CheckPassphraseError` naming what + is wrong -- never a raw `ValueError` or `binascii.Error`, and never a + guess at bytes that were not actually there. + + Base64 never contains `:`, so a plain `str.split(":")` producing exactly + five parts is a safe, unambiguous parse of this format -- it is also + exactly how Pulumi's own two-part construction (`v1:` joined with + `v1::`) round-trips through one string. + + The byte-length checks after decoding are not optional hardening: see + `_SALT_BYTES`'s comment for why skipping them would let a malformed + salt masquerade as a wrong passphrase instead of the error it is. + """ + parts = salt_field.split(":") + if len(parts) != 5 or parts[0] != "v1" or parts[2] != "v1": + raise CheckPassphraseError( + "malformed secrets provider salt (expected " + "'v1::v1::')" + ) + try: + salt = base64.b64decode(parts[1], validate=True) + nonce = base64.b64decode(parts[3], validate=True) + ciphertext = base64.b64decode(parts[4], validate=True) + except (binascii.Error, ValueError) as exc: + raise CheckPassphraseError( + "malformed secrets provider salt: invalid base64" + ) from exc + + if len(salt) != _SALT_BYTES: + raise CheckPassphraseError( + f"malformed secrets provider salt: expected a {_SALT_BYTES}-byte " + f"salt, got {len(salt)}" + ) + if len(nonce) != _NONCE_BYTES: + raise CheckPassphraseError( + f"malformed secrets provider salt: expected a {_NONCE_BYTES}-byte " + f"nonce, got {len(nonce)}" + ) + if len(ciphertext) < _MIN_CIPHERTEXT_BYTES: + raise CheckPassphraseError( + "malformed secrets provider salt: ciphertext shorter than the " + f"{_MIN_CIPHERTEXT_BYTES}-byte GCM tag ({len(ciphertext)} bytes)" + ) + return salt, nonce, ciphertext + + +def check_salt(salt_field: str, passphrase: str) -> bool: + """`True` if `passphrase` opens `salt_field` (Pulumi's own encoded + secrets-provider state), `False` if it is well-formed but a *different* + passphrase opened it. + + Raises `CheckPassphraseError` when `salt_field` itself is not something + this function can attempt to open at all -- never returns `False` for + that case, which is what would make a malformed export indistinguishable + from a genuinely wrong passphrase in the per-stack report. + """ + if not isinstance(salt_field, str): + raise CheckPassphraseError("secrets provider salt must be a string") + salt, nonce, ciphertext = _parse_salt(salt_field) + key = _derive_key(passphrase.encode("utf-8"), salt) + try: + plaintext = AESGCM(key).decrypt(nonce, ciphertext, None) + except InvalidTag: + return False + return plaintext == PULUMI_TEST_PLAINTEXT + + +def _read_passphrase() -> str: + """The candidate passphrase: one line from `sys.stdin` by default, or an + echo-free `getpass` prompt when `sys.stdin` is a terminal. + + See the module docstring's "The passphrase is never a command-line + argument" section for why the branch condition is `sys.stdin.isatty()` + itself, rather than `session.py`'s independent `/dev/tty`-opened check: + this command, unlike `session.py`, is free to read `sys.stdin`, so the + literal question -- was something piped into *this* descriptor -- is the + right one to ask. + + Raises `CheckPassphraseError` when stdin is not a terminal and reaches + EOF with nothing on it at all, rather than silently proceeding with an + empty string a caller almost certainly did not intend. An explicit empty + line (as opposed to no line at all) is not treated the same way: `""` is + a passphrase a caller can deliberately test, however unlikely to open + anything. + """ + if sys.stdin.isatty(): + return getpass.getpass("passphrase to test: ") + line = sys.stdin.readline() + if not line: + raise CheckPassphraseError("no passphrase provided on stdin") + return line.rstrip("\r\n") + + +def _pulumi_stack_export(pulumi: str, stack: str, *, timeout: float) -> dict[str, Any]: + """`pulumi stack export --stack `, parsed as a JSON object. + + `stdin=subprocess.DEVNULL`: this process has already read the + passphrase from its own `sys.stdin` (or nothing, on the `getpass` + branch), and `pulumi` must never inherit either any leftover piped bytes + meant for this process, or -- on the interactive branch -- this + process's controlling terminal, which would let a `pulumi` that decided + to prompt for something hang against a human who is not expecting it. + + Output is captured and parsed as bytes, not decoded via `text=True`: + `json.loads` accepts bytes directly, and doing it this way turns + undecodable output into this function's own `CheckPassphraseError` + rather than a `UnicodeDecodeError` that `fail_closed` would report with + a generic message instead of a stack-specific one. + + Raises `CheckPassphraseError` for a timeout, a missing `pulumi` binary, + a non-zero exit, or output that does not parse as a JSON object -- + never includes `pulumi`'s own stdout or stderr in the message, which is + not this module's data to vouch for as free of anything sensitive. + """ + try: + completed = subprocess.run( + [pulumi, "stack", "export", "--stack", stack], + stdin=subprocess.DEVNULL, + timeout=timeout, + capture_output=True, + ) + except subprocess.TimeoutExpired as exc: + raise CheckPassphraseError("pulumi stack export timed out") from exc + except OSError as exc: + raise CheckPassphraseError(f"cannot run pulumi: {exc}") from exc + + if completed.returncode != 0: + raise CheckPassphraseError( + f"pulumi stack export exited {completed.returncode}" + ) + try: + document = json.loads(completed.stdout) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise CheckPassphraseError( + "pulumi stack export did not return valid JSON" + ) from exc + if not isinstance(document, dict): + raise CheckPassphraseError("exported stack state is not a JSON object") + return document + + +def _extract_salt(document: dict[str, Any]) -> str: + """`deployment.secrets_providers.state.salt` out of a parsed export, or + `CheckPassphraseError` naming exactly which level is missing or the + wrong shape. + + A stack using a non-passphrase secrets provider (a cloud KMS URL, or the + Pulumi Service's own managed encryption) has no `salt` field at all -- + see the module docstring's "Fail-closed" section for why that is an + error for this command, on the same footing as any other malformed or + incomplete export, and never a silent "rejected" or "accepted". + """ + deployment = document.get("deployment") + if not isinstance(deployment, dict): + raise CheckPassphraseError("exported state has no 'deployment' object") + providers = deployment.get("secrets_providers") + if not isinstance(providers, dict): + raise CheckPassphraseError( + "exported state has no 'secrets_providers' object -- this stack " + "may not use a passphrase-based secrets provider" + ) + state = providers.get("state") + if not isinstance(state, dict): + raise CheckPassphraseError( + "exported state has no 'secrets_providers.state' object" + ) + salt = state.get("salt") + if not isinstance(salt, str): + raise CheckPassphraseError( + "exported state has no 'secrets_providers.state.salt' string" + ) + return salt + + +def _check_stack(pulumi: str | None, stack: str, passphrase: str, *, timeout: float) -> bool: + """`True`/`False` for `stack`, or `CheckPassphraseError` naming why it + could not be checked at all -- a missing `pulumi` binary included, on + the same footing as every other could-not-run condition this function + can raise.""" + if pulumi is None: + raise CheckPassphraseError("pulumi executable not found on PATH") + document = _pulumi_stack_export(pulumi, stack, timeout=timeout) + salt_field = _extract_salt(document) + return check_salt(salt_field, passphrase) + + +@fail_closed +def cmd_check_passphrase(args: argparse.Namespace) -> int: + """Entry point for `stackward check-passphrase STACK...`. + + Prints the passphrase's fingerprint once, then one `accepted` / + `rejected` / `error: ` line per named stack -- a could-not-check + stack does not abort the run, so a real answer on one stack is never + hidden behind a problem on another (the same reasoning + `cmd_check_config` applies to its own per-file loop). + + Exit 0 only if every named stack accepted; 2 otherwise, whether that is + because a stack rejected the passphrase or because a stack could not be + checked at all -- this command reserves exit 1 for nothing, since + `check-config` and `set_secrets` both already reserve it exclusively + for "a credential was found," a claim this command never makes. + """ + try: + passphrase = _read_passphrase() + except CheckPassphraseError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + print(f"passphrase fingerprint: {fingerprint(passphrase)}") + + pulumi = shutil.which("pulumi") + all_accepted = True + for stack in args.stacks: + try: + accepted = _check_stack( + pulumi, stack, passphrase, timeout=DEFAULT_TIMEOUT_SECONDS + ) + except CheckPassphraseError as exc: + print(f"{stack}: error: {exc}", file=sys.stderr) + all_accepted = False + continue + except Exception as exc: # noqa: BLE001 - fail closed on anything unanticipated + print( + f"{stack}: error: could not check ({type(exc).__name__})", + file=sys.stderr, + ) + all_accepted = False + continue + print(f"{stack}: {'accepted' if accepted else 'rejected'}") + if not accepted: + all_accepted = False + + return 0 if all_accepted else 2 diff --git a/tests/test_check_passphrase.py b/tests/test_check_passphrase.py new file mode 100644 index 0000000..f6cab21 --- /dev/null +++ b/tests/test_check_passphrase.py @@ -0,0 +1,581 @@ +"""Tests for `check-passphrase`. + +**The interoperability proof is a frozen, real `pulumi`-generated vector, not +a round trip against this module's own code.** `REAL_PULUMI_SALT_FIELD` +below was captured once from an actual `pulumi stack export`, against a +throwaway local (`file://`) backend created solely to produce it -- a +placeholder project, stack, and passphrase, per GC4, none of it real. A test +that only encrypted and then decrypted with this module's own functions +would prove nothing beyond internal self-consistency: if this module's +understanding of the byte layout, the base64 variant, or the KDF parameters +were wrong in some way that happened to be wrong *consistently*, such a test +would still pass. `test_check_salt_accepts_a_real_pulumi_generated_vector` +below cannot pass that way -- it can only pass if this module's decryption +genuinely matches what Pulumi itself produced. + +A second, independently-written PBKDF2+AES-GCM helper +(`_encrypt_pulumi_style`) is used for the malformed-input and +wrong-passphrase fixtures a fixed real vector cannot conveniently produce on +demand -- it calls `cryptography` primitives directly and never reuses this +module's own `_derive_key`/`check_salt`, so it does not share a bug with the +code it is testing. + +**No test prints the passphrase, at the Python level or the real child +process's own stdout/stderr.** Assertions about absence are made against +`capsys`-captured output for pure-Python paths and `capfd`-captured output +(file-descriptor level, covering the real stub `pulumi` child process's own +writes) for anything that goes through `main()`. + +**Several tests exist to fail against a plausible wrong implementation, not +merely to exercise a right one** -- most importantly the too-short-ciphertext +and wrong-length-nonce tests: `cryptography`'s `AESGCM.decrypt` raises +`InvalidTag`, not `ValueError`, for both, so an implementation that skips the +explicit byte-length checks would silently report "rejected" instead of the +error this is required to be. Each is written to fail on such an +implementation and to pass only once the explicit checks are present. +""" + +from __future__ import annotations + +import base64 +import json +import os +import sys +import textwrap +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +from stackward.cli import main +from stackward.commands import check_passphrase +from stackward.commands.check_passphrase import ( + CheckPassphraseError, + check_salt, + fingerprint, + _extract_salt, + _read_passphrase, +) + +# Placeholder values only -- see GC4. Distinctive enough that an accidental +# substring match elsewhere in captured output would be implausible. +MARKER_PASSPHRASE = "marker-passphrase-7c8d9e0f" +WRONG_PASSPHRASE = "marker-wrong-passphrase-1a2b3c" + +# --------------------------------------------------------------------------- +# A real vector, captured once from an actual `pulumi stack export` against +# a throwaway local backend -- see the module docstring. Reproduced here: +# +# $ pulumi login file:///tmp/scratch-backend +# $ pulumi stack init placeholder-stack --non-interactive +# $ PULUMI_CONFIG_PASSPHRASE=placeholder-passphrase-marker \ +# pulumi config set --secret placeholder.key placeholder-value +# $ pulumi stack export +# -> deployment.secrets_providers.state.salt +# --------------------------------------------------------------------------- +REAL_PULUMI_SALT_FIELD = "v1:qs/zMEaMbWE=:v1:iMv8Ne7bNxnv6rkP:vGQfE+/D8kOTOH8eT1DTPj68sddAoA==" +REAL_PULUMI_PASSPHRASE = "placeholder-passphrase-marker" + + +def _encrypt_pulumi_style(passphrase: str, plaintext: bytes = b"pulumi") -> str: + """Build a `v1::v1::` string the same way + Pulumi's own passphrase secrets provider does -- see the module + docstring for why this is written independently of + `check_passphrase._derive_key`/`check_salt` rather than reusing them. + """ + salt = os.urandom(8) + nonce = os.urandom(12) + key = PBKDF2HMAC( + algorithm=hashes.SHA256(), length=32, salt=salt, iterations=1_000_000 + ).derive(passphrase.encode("utf-8")) + ciphertext = AESGCM(key).encrypt(nonce, plaintext, None) + b64 = lambda raw: base64.b64encode(raw).decode("ascii") # noqa: E731 + return f"v1:{b64(salt)}:v1:{b64(nonce)}:{b64(ciphertext)}" + + +def _with_field(salt_field: str, index: int, raw_bytes: bytes) -> str: + """`salt_field` with the base64 field at `index` (1=salt, 3=nonce, + 4=ciphertext) replaced by `raw_bytes`, re-encoded -- for building + deliberately malformed variants of an otherwise well-formed vector.""" + parts = salt_field.split(":") + parts[index] = base64.b64encode(raw_bytes).decode("ascii") + return ":".join(parts) + + +# --------------------------------------------------------------------------- +# The interoperability proof. +# --------------------------------------------------------------------------- + + +def test_check_salt_accepts_a_real_pulumi_generated_vector(): + """The one test that cannot pass on self-consistency alone -- see the + module docstring.""" + assert check_salt(REAL_PULUMI_SALT_FIELD, REAL_PULUMI_PASSPHRASE) is True + + +def test_check_salt_rejects_a_wrong_passphrase_against_the_real_vector(): + assert check_salt(REAL_PULUMI_SALT_FIELD, "not the right passphrase") is False + + +# --------------------------------------------------------------------------- +# The synthesised-salt proof the brief names explicitly, independent of +# whether a real `pulumi` binary is available to generate one. +# --------------------------------------------------------------------------- + + +def test_check_salt_accepts_a_synthesised_salt_the_known_passphrase_opens(): + salt_field = _encrypt_pulumi_style(MARKER_PASSPHRASE) + assert check_salt(salt_field, MARKER_PASSPHRASE) is True + + +def test_check_salt_rejects_a_wrong_passphrase_against_a_synthesised_salt(): + salt_field = _encrypt_pulumi_style(MARKER_PASSPHRASE) + assert check_salt(salt_field, WRONG_PASSPHRASE) is False + + +# --------------------------------------------------------------------------- +# Fail-closed: malformed input is an error, never "rejected" or "accepted". +# --------------------------------------------------------------------------- + + +def test_a_salt_field_missing_the_v1_prefix_errors(): + with pytest.raises(CheckPassphraseError): + check_salt("bogus:AAAA:v1:BBBB:CCCC", MARKER_PASSPHRASE) + + +def test_a_salt_field_with_the_wrong_number_of_parts_errors(): + with pytest.raises(CheckPassphraseError): + check_salt("v1:AAAA:v1:BBBB", MARKER_PASSPHRASE) + + +def test_a_salt_field_with_invalid_base64_errors(): + with pytest.raises(CheckPassphraseError): + check_salt("v1:not-valid-base64!!!:v1:BBBB:CCCC", MARKER_PASSPHRASE) + + +def test_a_non_string_salt_field_errors_rather_than_crashing(): + with pytest.raises(CheckPassphraseError): + check_salt(12345, MARKER_PASSPHRASE) # type: ignore[arg-type] + + +def test_a_wrong_length_salt_errors_rather_than_being_silently_derived_from(): + base = _encrypt_pulumi_style(MARKER_PASSPHRASE) + mutated = _with_field(base, 1, b"\x00" * 4) # real salt is 8 bytes + with pytest.raises(CheckPassphraseError): + check_salt(mutated, MARKER_PASSPHRASE) + + +def test_a_wrong_length_nonce_errors_rather_than_being_reported_as_rejected(): + """`AESGCM.decrypt` tolerates a 16-byte nonce without raising -- it just + fails the tag -- so this can only pass if `check_salt` validates the + nonce length itself before ever calling `AESGCM.decrypt`. An + implementation missing that check would report this case as "rejected" + (a wrong passphrase) instead of the malformed-input error it actually + is, which fail-closed forbids.""" + base = _encrypt_pulumi_style(MARKER_PASSPHRASE) + mutated = _with_field(base, 3, b"\x00" * 16) # real nonce is 12 bytes + with pytest.raises(CheckPassphraseError): + check_salt(mutated, MARKER_PASSPHRASE) + + +def test_a_ciphertext_shorter_than_the_gcm_tag_errors_rather_than_being_reported_as_rejected(): + """`AESGCM.decrypt` raises `InvalidTag` -- not a distinguishable error -- + for a ciphertext too short to even hold a tag, so the length must be + validated before decryption is attempted at all. Without that check this + case is indistinguishable from a wrong passphrase and would be reported + as "rejected" instead of the malformed-input error it actually is.""" + base = _encrypt_pulumi_style(MARKER_PASSPHRASE) + mutated = _with_field(base, 4, b"\x00" * 5) # shorter than the 16-byte tag + with pytest.raises(CheckPassphraseError): + check_salt(mutated, MARKER_PASSPHRASE) + + +# --------------------------------------------------------------------------- +# The fingerprint. +# --------------------------------------------------------------------------- + + +def test_fingerprint_is_16_hex_characters(): + fp = fingerprint(MARKER_PASSPHRASE) + assert len(fp) == 16 + int(fp, 16) # raises ValueError if it is not hex + + +def test_fingerprint_is_stable_for_the_same_passphrase(): + assert fingerprint(MARKER_PASSPHRASE) == fingerprint(MARKER_PASSPHRASE) + + +def test_fingerprint_differs_for_different_passphrases(): + """The whole point of a fingerprint is telling two candidates apart -- + a constant or a collision-prone implementation would defeat that even + while satisfying "16 hex characters" and "stable".""" + assert fingerprint(MARKER_PASSPHRASE) != fingerprint(WRONG_PASSPHRASE) + + +def test_fingerprint_never_contains_the_passphrase_itself(): + assert MARKER_PASSPHRASE not in fingerprint(MARKER_PASSPHRASE) + + +# --------------------------------------------------------------------------- +# Reading the passphrase -- stdin by default, `getpass` when stdin is a tty, +# never a command-line argument (there is no code path that could accept +# one -- `build_parser` gives `check-passphrase` no such option, and this +# section proves `_read_passphrase` never touches anything but `sys.stdin` +# or `getpass.getpass`). +# --------------------------------------------------------------------------- + + +class _FakeStdin: + """A minimal stand-in for `sys.stdin`: `isatty()` reports a fixed value, + and `readline()` returns each of `lines` in turn, then `""` (EOF).""" + + def __init__(self, isatty: bool, lines: list[str] | None = None): + self._isatty = isatty + self._lines = list(lines or []) + + def isatty(self) -> bool: + return self._isatty + + def readline(self, *a, **k) -> str: + return self._lines.pop(0) if self._lines else "" + + +class _PoisonedReadline: + """`isatty()` reports a real terminal; `readline()` raises if called -- + proves `_read_passphrase` never reads `sys.stdin` on the tty branch.""" + + def isatty(self) -> bool: + return True + + def readline(self, *a, **k): + raise AssertionError("_read_passphrase touched stdin on the tty branch") + + +def test_read_passphrase_reads_a_line_from_piped_stdin(monkeypatch): + monkeypatch.setattr(sys, "stdin", _FakeStdin(isatty=False, lines=[f"{MARKER_PASSPHRASE}\n"])) + + def boom(*a, **k): + raise AssertionError("getpass.getpass must not run when stdin is not a tty") + + monkeypatch.setattr(check_passphrase.getpass, "getpass", boom) + assert _read_passphrase() == MARKER_PASSPHRASE + + +def test_read_passphrase_strips_only_the_line_terminator(monkeypatch): + """Trailing whitespace that is not the line terminator is preserved -- + trimming it would silently change what the caller actually piped in.""" + monkeypatch.setattr( + sys, "stdin", _FakeStdin(isatty=False, lines=[f"{MARKER_PASSPHRASE} \r\n"]) + ) + assert _read_passphrase() == f"{MARKER_PASSPHRASE} " + + +def test_read_passphrase_raises_on_empty_stdin(monkeypatch): + monkeypatch.setattr(sys, "stdin", _FakeStdin(isatty=False, lines=[])) + with pytest.raises(CheckPassphraseError): + _read_passphrase() + + +def test_read_passphrase_falls_back_to_getpass_when_stdin_is_a_tty(monkeypatch): + monkeypatch.setattr(sys, "stdin", _PoisonedReadline()) + monkeypatch.setattr(check_passphrase.getpass, "getpass", lambda prompt="": MARKER_PASSPHRASE) + assert _read_passphrase() == MARKER_PASSPHRASE + + +def test_read_passphrase_never_reads_stdin_on_the_tty_branch(monkeypatch): + """`_PoisonedReadline` itself asserts this, but this test names the + property directly: the interactive branch must go through `getpass` + only, with no fallback read of `sys.stdin`.""" + monkeypatch.setattr(sys, "stdin", _PoisonedReadline()) + monkeypatch.setattr(check_passphrase.getpass, "getpass", lambda prompt="": "typed") + assert _read_passphrase() == "typed" # would have raised via readline() otherwise + + +# --------------------------------------------------------------------------- +# `_extract_salt`: the pure, subprocess-free half of reading an export. +# --------------------------------------------------------------------------- + + +def test_extract_salt_reads_the_nested_field(): + document = { + "deployment": {"secrets_providers": {"state": {"salt": REAL_PULUMI_SALT_FIELD}}} + } + assert _extract_salt(document) == REAL_PULUMI_SALT_FIELD + + +def test_extract_salt_errors_when_deployment_is_missing(): + with pytest.raises(CheckPassphraseError): + _extract_salt({}) + + +def test_extract_salt_errors_when_secrets_providers_is_missing(): + """The case of a stack using a non-passphrase secrets provider (a cloud + KMS URL, or the Pulumi Service's own managed encryption): no `salt` + field exists to check at all, which fail-closed treats as an error for + this stack, never a silent "rejected" or "accepted" -- see the module + docstring.""" + with pytest.raises(CheckPassphraseError): + _extract_salt({"deployment": {}}) + + +def test_extract_salt_errors_when_state_is_missing(): + with pytest.raises(CheckPassphraseError): + _extract_salt({"deployment": {"secrets_providers": {}}}) + + +def test_extract_salt_errors_when_salt_is_missing(): + with pytest.raises(CheckPassphraseError): + _extract_salt({"deployment": {"secrets_providers": {"state": {}}}}) + + +def test_extract_salt_errors_when_salt_is_not_a_string(): + with pytest.raises(CheckPassphraseError): + _extract_salt( + {"deployment": {"secrets_providers": {"state": {"salt": 12345}}}} + ) + + +# --------------------------------------------------------------------------- +# End-to-end, through `main()`, against a real stub `pulumi` process -- +# never a mocked `subprocess.run` -- so a leak through the real child's own +# stdout/stderr, or through its actual `argv`, would be caught the same way +# `test_set_secrets.py`'s stdin/argv test is. +# --------------------------------------------------------------------------- + +_PULUMI_STUB = textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json, os, sys, time + + argv = sys.argv[1:] + + dump_argv_to = os.environ.get("STUB_DUMP_ARGV_TO") + if dump_argv_to: + with open(dump_argv_to, "a") as f: + f.write(json.dumps(argv) + "\\n") + + + def arg_after(flag): + if flag in argv: + i = argv.index(flag) + if i + 1 < len(argv): + return argv[i + 1] + return None + + + stack = arg_after("--stack") + + slow_stack = os.environ.get("STUB_SLOW_STACK") + if slow_stack and stack == slow_stack: + time.sleep(float(os.environ.get("STUB_SLEEP_SECONDS", "5"))) + + exit_codes = json.loads(os.environ.get("STUB_EXIT_CODES", "{}")) + if stack in exit_codes: + sys.stderr.write(os.environ.get("STUB_ECHO", "")) + sys.exit(int(exit_codes[stack])) + + exports = json.loads(os.environ.get("STUB_EXPORTS", "{}")) + sys.stdout.write(exports.get(stack, os.environ.get("STUB_DEFAULT_EXPORT", "{}"))) + sys.exit(0) + """ +) + + +@pytest.fixture +def stub_pulumi(tmp_path) -> Path: + path = tmp_path / "pulumi-stub.py" + path.write_text(_PULUMI_STUB) + path.chmod(0o755) + return path + + +def fake_pulumi(monkeypatch, executable: Path | None) -> None: + monkeypatch.setattr( + check_passphrase.shutil, + "which", + lambda name: str(executable) if (executable and name == "pulumi") else None, + ) + + +def feed_stdin(monkeypatch, passphrase: str) -> None: + monkeypatch.setattr( + check_passphrase.sys, "stdin", _FakeStdin(isatty=False, lines=[f"{passphrase}\n"]) + ) + + +@pytest.fixture(autouse=True) +def fast_timeout(monkeypatch): + """The real default (30s) would make the timeout test below take thirty + real seconds for no benefit.""" + monkeypatch.setattr(check_passphrase, "DEFAULT_TIMEOUT_SECONDS", 0.3) + + +def _export_with_salt(salt_field: str) -> str: + return json.dumps( + {"deployment": {"secrets_providers": {"type": "passphrase", "state": {"salt": salt_field}}}} + ) + + +def test_a_correct_passphrase_is_accepted_end_to_end(stub_pulumi, monkeypatch, capsys): + salt_field = _encrypt_pulumi_style(MARKER_PASSPHRASE) + monkeypatch.setenv("STUB_EXPORTS", json.dumps({"stack-a": _export_with_salt(salt_field)})) + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, MARKER_PASSPHRASE) + + assert main(["check-passphrase", "stack-a"]) == 0 + out = capsys.readouterr().out + assert "stack-a: accepted" in out + assert "passphrase fingerprint:" in out + + +def test_a_wrong_passphrase_is_rejected_and_exits_non_zero(stub_pulumi, monkeypatch, capsys): + salt_field = _encrypt_pulumi_style(MARKER_PASSPHRASE) + monkeypatch.setenv("STUB_EXPORTS", json.dumps({"stack-a": _export_with_salt(salt_field)})) + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, WRONG_PASSPHRASE) + + assert main(["check-passphrase", "stack-a"]) == 2 + assert "stack-a: rejected" in capsys.readouterr().out + + +def test_every_named_stack_must_accept_for_exit_zero(stub_pulumi, monkeypatch, capsys): + good = _encrypt_pulumi_style(MARKER_PASSPHRASE) + bad = _encrypt_pulumi_style(WRONG_PASSPHRASE) + monkeypatch.setenv( + "STUB_EXPORTS", + json.dumps( + {"stack-a": _export_with_salt(good), "stack-b": _export_with_salt(bad)} + ), + ) + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, MARKER_PASSPHRASE) + + assert main(["check-passphrase", "stack-a", "stack-b"]) == 2 + out = capsys.readouterr().out + assert "stack-a: accepted" in out + assert "stack-b: rejected" in out + + +def test_all_stacks_accepted_is_the_only_way_to_exit_zero(stub_pulumi, monkeypatch, capsys): + good = _encrypt_pulumi_style(MARKER_PASSPHRASE) + monkeypatch.setenv( + "STUB_EXPORTS", + json.dumps( + {"stack-a": _export_with_salt(good), "stack-b": _export_with_salt(good)} + ), + ) + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, MARKER_PASSPHRASE) + + assert main(["check-passphrase", "stack-a", "stack-b"]) == 0 + out = capsys.readouterr().out + assert "stack-a: accepted" in out + assert "stack-b: accepted" in out + + +def test_a_missing_pulumi_binary_is_reported_as_error_not_rejected(monkeypatch, capsys): + fake_pulumi(monkeypatch, None) + feed_stdin(monkeypatch, MARKER_PASSPHRASE) + + assert main(["check-passphrase", "stack-a"]) == 2 + err = capsys.readouterr().err + assert "stack-a: error:" in err + assert "rejected" not in err + + +def test_a_nonzero_pulumi_exit_is_reported_as_error_not_rejected(stub_pulumi, monkeypatch, capsys): + monkeypatch.setenv("STUB_EXIT_CODES", json.dumps({"stack-a": 1})) + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, MARKER_PASSPHRASE) + + assert main(["check-passphrase", "stack-a"]) == 2 + captured = capsys.readouterr() + assert "stack-a: error:" in captured.err + assert "stack-a: rejected" not in captured.out + + +def test_a_stack_lacking_a_secrets_provider_is_reported_as_error(stub_pulumi, monkeypatch, capsys): + monkeypatch.setenv("STUB_EXPORTS", json.dumps({"stack-a": json.dumps({"deployment": {}})})) + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, MARKER_PASSPHRASE) + + assert main(["check-passphrase", "stack-a"]) == 2 + assert "stack-a: error:" in capsys.readouterr().err + + +def test_invalid_json_from_pulumi_is_reported_as_error(stub_pulumi, monkeypatch, capsys): + monkeypatch.setenv("STUB_EXPORTS", json.dumps({"stack-a": "not json at all"})) + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, MARKER_PASSPHRASE) + + assert main(["check-passphrase", "stack-a"]) == 2 + assert "stack-a: error:" in capsys.readouterr().err + + +def test_a_pulumi_timeout_is_reported_as_error_without_aborting_other_stacks( + stub_pulumi, monkeypatch, capsys +): + good = _encrypt_pulumi_style(MARKER_PASSPHRASE) + monkeypatch.setenv("STUB_SLOW_STACK", "stack-slow") + monkeypatch.setenv("STUB_EXPORTS", json.dumps({"stack-fast": _export_with_salt(good)})) + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, MARKER_PASSPHRASE) + + assert main(["check-passphrase", "stack-slow", "stack-fast"]) == 2 + captured = capsys.readouterr() + assert "stack-slow: error:" in captured.err + assert "stack-fast: accepted" in captured.out + + +def test_no_passphrase_provided_on_stdin_errors_before_any_stack_is_checked(monkeypatch): + monkeypatch.setattr(check_passphrase.sys, "stdin", _FakeStdin(isatty=False, lines=[])) + + def boom(*a, **k): + raise AssertionError("no stack should be checked without a passphrase") + + monkeypatch.setattr(check_passphrase, "_check_stack", boom) + assert main(["check-passphrase", "stack-a"]) == 2 + + +# --------------------------------------------------------------------------- +# The passphrase never appears anywhere in output -- Python-level `print`s +# (capsys) or the real stub child process's own writes (capfd) -- across +# every reachable per-stack outcome, and never reaches the child's `argv`. +# --------------------------------------------------------------------------- + + +def test_the_passphrase_never_appears_in_output_on_any_outcome(stub_pulumi, monkeypatch, capfd): + good = _encrypt_pulumi_style(MARKER_PASSPHRASE) + monkeypatch.setenv( + "STUB_EXPORTS", + json.dumps({"stack-accept": _export_with_salt(good), "stack-error": "not json"}), + ) + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, MARKER_PASSPHRASE) + main(["check-passphrase", "stack-accept", "stack-error"]) + + combined = "".join(capfd.readouterr()) + assert MARKER_PASSPHRASE not in combined + + # A rejected outcome too, in a separate run against a fresh capture. + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, WRONG_PASSPHRASE) + main(["check-passphrase", "stack-accept"]) + combined_reject = "".join(capfd.readouterr()) + assert MARKER_PASSPHRASE not in combined_reject + assert WRONG_PASSPHRASE not in combined_reject + + +def test_the_passphrase_never_reaches_pulumis_argv(stub_pulumi, monkeypatch, tmp_path): + good = _encrypt_pulumi_style(MARKER_PASSPHRASE) + monkeypatch.setenv("STUB_EXPORTS", json.dumps({"stack-a": _export_with_salt(good)})) + argv_dump = tmp_path / "argv.jsonl" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, MARKER_PASSPHRASE) + + main(["check-passphrase", "stack-a"]) + + assert MARKER_PASSPHRASE not in argv_dump.read_text() From 393c2d7a455ba61f3ff6bac0adeabfbe0d6a33d5 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 12:54:56 -0700 Subject: [PATCH 25/44] feat: the model net -- declared secrets as a graph, checked against the index The gate now unions the key-name heuristic with a second net built from a repository's own `json_schema_extra={"secret": True}` marks. A shipped binary cannot import the consuming repo's classes, so the declarations cross that boundary as data. The artifact is a graph, not a list of path patterns. The counterexample is concrete: a model with `token` marked and `children: list[Node]` yields the pattern list `['token']` -- any generator needs a cycle guard -- while the data it describes is unbounded. The flat form fails open exactly where it must fail closed. `nets/model.py` walks the graph and the document together, so recursion and mutual recursion work by construction. Coverage is prefix-based (a marked field covers everything beneath it, annotation irrelevant) and structural (a dict key may contain `.` or `[`, so the walk never builds or re-parses a path string to decide anything). `bootstrap/regen.py` runs under the repository's interpreter -- shipped as data, never imported, which is what keeps pydantic test-only. It refuses any unmarked field whose annotation could hold a model but is not walkable rather than emitting nothing for it; the refusal names the field and both remedies. Freshness: `check-config` reads the artifact from the git index, not the worktree, and compares recorded git blob ids in one `git ls-files -s`. Sources come from an MRO walk (an inherited mark in another file) and from an identity scan for `Annotated` aliases (a mark whose alias is defined elsewhere); an out-of-repo contributor is pinned by the dependency lockfile. The boundary is stated rather than overclaimed: blob ids catch modification of a recorded source, and CI regeneration is what catches addition. Any failure to load exits 2, never 1 -- a fail-closed refusal must not be filed under "a credential was found". `model_net = "none"` is a declared mode and is never reached as a fallback. Also: `is_encrypted`/`is_empty` become public in `nets/heuristic.py` so both nets share one leaf decision; `check.declared_paths_fn` lands in config as the escape hatch for a non-standard marking convention. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- .github/workflows/release.yml | 9 +- README.md | 1 + src/stackward/bootstrap/__init__.py | 38 + src/stackward/bootstrap/regen.py | 515 +++++++++++ src/stackward/cli.py | 7 + src/stackward/commands/check_config.py | 70 +- src/stackward/commands/sync_declared.py | 318 +++++++ src/stackward/config.py | 17 + src/stackward/nets/heuristic.py | 24 +- src/stackward/nets/model.py | 651 +++++++++++++ tests/test_config.py | 35 + tests/test_heuristic.py | 2 +- tests/test_model_net.py | 1132 +++++++++++++++++++++++ 13 files changed, 2795 insertions(+), 24 deletions(-) create mode 100644 src/stackward/bootstrap/__init__.py create mode 100644 src/stackward/bootstrap/regen.py create mode 100644 src/stackward/commands/sync_declared.py create mode 100644 src/stackward/nets/model.py create mode 100644 tests/test_model_net.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d72a0b1..84f6369 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,8 +59,15 @@ jobs: set -euo pipefail python -m pip install --quiet --upgrade pip python -m pip install --quiet . pyinstaller + # `bootstrap/regen.py` is shipped as DATA, not as a module: it is + # piped to the consuming repository's own interpreter, and nothing + # in stackward imports it — which is what keeps pydantic out of the + # bundle. PyInstaller's import analysis therefore never sees it, so + # it has to be added explicitly. python -m PyInstaller --noconfirm --clean --onedir \ - --name stackward --paths src --distpath dist --workpath build entry.py + --name stackward --paths src --distpath dist --workpath build \ + --add-data "src/stackward/bootstrap/regen.py:stackward/bootstrap" \ + entry.py - name: It must actually run # A bundle that builds is not a bundle that works. doctor performs a diff --git a/README.md b/README.md index f337e08..3c3480d 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ diff. `stackward` exists to make that mistake hard. | `login` | Point Pulumi at a profile's backend | | `set-secrets` | Publish stack secrets from a declared manifest, values on stdin | | `check-passphrase` | Verify a passphrase actually decrypts a stack, rather than merely exiting 0 | +| `sync-declared-secrets` | Regenerate the committed graph of secrets a repo's own models declare | ## Design commitments diff --git a/src/stackward/bootstrap/__init__.py b/src/stackward/bootstrap/__init__.py new file mode 100644 index 0000000..9af32a0 --- /dev/null +++ b/src/stackward/bootstrap/__init__.py @@ -0,0 +1,38 @@ +"""Source that is shipped to be *run by another interpreter*, not imported. + +`stackward` may run as a frozen binary carrying its own interpreter, which +cannot import the consuming repository's classes — so the one step that must +import them, generating the declared-secrets artifact, runs under the +repository's own interpreter instead (`[python]` in `.stackward.toml`). That +step needs pydantic; `stackward` itself must never import or bundle it. + +The two requirements together are what this package exists for. `regen.py` is +a real, standalone module — readable, lintable, and directly runnable — but +`stackward` only ever reads it as **text** and pipes it to the repository's +interpreter. Nothing in `stackward` imports it, so PyInstaller's analysis +never follows it, and pydantic never enters the bundle. + +Because it is data rather than an imported module, the frozen build has to +carry it explicitly: `.github/workflows/release.yml` passes +`--add-data src/stackward/bootstrap/regen.py:stackward/bootstrap`, and +`generator_source` reads it back through `importlib.resources`, which +resolves both an ordinary installation and a PyInstaller bundle. That +round trip was verified against a real frozen bundle rather than assumed. +""" + +from __future__ import annotations + +import importlib.resources + +GENERATOR_NAME = "regen.py" + + +def generator_source() -> str: + """The generator program's text, for the repository's interpreter. + + The single accessor for it, used by `commands.sync_declared` and by the + tests alike — a test that read the file by path instead would assert + nothing about the frozen build, where that path does not exist. + """ + resource = importlib.resources.files(__package__).joinpath(GENERATOR_NAME) + return resource.read_text(encoding="utf-8") diff --git a/src/stackward/bootstrap/regen.py b/src/stackward/bootstrap/regen.py new file mode 100644 index 0000000..5608498 --- /dev/null +++ b/src/stackward/bootstrap/regen.py @@ -0,0 +1,515 @@ +"""Generate the declared-secrets graph. Runs under the *repository's* +interpreter, never under `stackward`'s own. + +This program is never imported by `stackward`. `commands.sync_declared` reads +it as text (see `bootstrap.generator_source`) and pipes it to the interpreter +named by `[python]` in `.stackward.toml`, which is the only interpreter that +can import the repository's models. That is what keeps pydantic a test-only +dependency of `stackward` itself: the half that needs pydantic runs somewhere +else, and only JSON crosses back. + +It reads a request as JSON in `argv[1]` and writes the graph as JSON to the +file that request names (see `main` for why not stdout). It resolves no +credentials, reads no secret store and contacts nothing — it imports declared +classes and inspects their fields. + +**It refuses rather than emitting nothing.** Silently skipping an annotation +this program cannot walk is the fail-open the whole model-net design exists +to prevent: the artifact would look complete while covering less than the +repository declared. So an *unmarked* field whose annotation could hold a +model but is not one of the walkable forms is an error naming the field and +the two ways to clear it. A *marked* field's annotation is never examined at +all — coverage is prefix-based, so everything beneath a mark is covered +whatever its type. + +The walkable forms are exactly: a `BaseModel` subclass, `list[X]`, +`dict[str, X]`, `Optional[X]` / `X | None`, and any annotation that provably +cannot hold a model (`str`, `int`, an `Enum`, `Literal[...]`, `list[str]`, +`Sequence[str]`, and so on — these become leaves, with nothing beneath them +to declare). Everything else is refused: `Union[A, B]`, `Sequence[A]`, +`Mapping[str, A]`, `set[A]`, `tuple[A, ...]`, a bare `dict` or `list`, `Any` +and `object` — the last four because "could this hold a model?" has no +answer for them, and an unanswerable question must not be answered "no". +""" + +from __future__ import annotations + +import inspect +import json +import sys +import types +import typing +from pathlib import Path +from typing import Any, NoReturn + +# Containers written without parameters. Whether one holds a model is not +# knowable from the annotation, so an unmarked field annotated with one is +# refused rather than guessed at. +_BARE_CONTAINERS = (dict, list, set, frozenset, tuple) + +# Union spellings: `typing.Union[...]` and PEP 604's `X | Y`, which +# `get_origin` reports as `types.UnionType` rather than `typing.Union`. +_UNION_ORIGINS = (typing.Union, types.UnionType) + + +class Unwalkable(Exception): + """An annotation, alias form or model this program refuses to guess at.""" + + +def _die(message: str) -> NoReturn: + print(f"error: {message}", file=sys.stderr) + raise SystemExit(1) + + +def _strip_annotated(annotation: Any) -> Any: + """`Annotated[X, ...]` down to `X`. + + pydantic already strips the top-level `Annotated` off `FieldInfo.annotation` + (which is how a mark carried by an alias defined in another module lands + on the field at all), but a nested one — `list[Annotated[X, ...]]` — is + still there, so the walk strips as it descends rather than once at entry. + """ + while hasattr(annotation, "__metadata__") and hasattr(annotation, "__origin__"): + annotation = annotation.__origin__ + return annotation + + +def _is_model(annotation: Any) -> bool: + from pydantic import BaseModel + + return isinstance(annotation, type) and issubclass(annotation, BaseModel) + + +def _model_id(model: type) -> str: + """`module:QualName` — stable, readable, and meaningful in a diff of the + committed artifact, which synthetic indices would not be.""" + return f"{model.__module__}:{model.__qualname__}" + + +def _may_hold_model(annotation: Any) -> bool: + """Could a value described by `annotation` contain a declared model? + + True also when the answer is *unknown* — `Any`, `object`, a bare + container, an unresolved forward reference, an annotation form this + program does not recognise. That direction is the whole point: an + unanswerable question is answered "yes, refuse", never "no, emit + nothing". + """ + annotation = _strip_annotated(annotation) + if annotation is None or annotation is type(None) or annotation is Ellipsis: + return False + if annotation is Any or annotation is object: + return True + if isinstance(annotation, (str, typing.ForwardRef)): + # An unresolved forward reference: the model was never rebuilt, so + # what it refers to cannot be inspected here. + return True + if _is_model(annotation): + return True + if annotation in _BARE_CONTAINERS: + return True + + origin = typing.get_origin(annotation) + if origin is typing.Literal: + # Literal's arguments are *values*, not types; none can be a model. + return False + args = typing.get_args(annotation) + if origin is not None: + # A parameterised generic: recurse. An unparameterised one + # (`typing.Dict`) has an origin but no arguments, and is as unknowable + # as a bare `dict`. + return True if not args else any(_may_hold_model(arg) for arg in args) + if isinstance(annotation, (list, tuple)): + # `Callable[[A], B]` carries its parameter types in a list. + return any(_may_hold_model(item) for item in annotation) + if isinstance(annotation, type): + return False + return True + + +class _GraphBuilder: + """Walks declared classes into the graph the matcher reads. + + Holds the model worklist so recursion and mutual recursion terminate: + a model already in `models` is referenced by id and not re-walked, which + is safe precisely because the artifact is a graph — the matcher follows + the reference as many times as the *data* requires. + """ + + def __init__(self, is_secret: Any) -> None: + self.is_secret = is_secret + self.models: dict[str, dict[str, dict[str, Any]]] = {} + self.classes: dict[str, type] = {} + + def add(self, model: type) -> str: + model_id = _model_id(model) + if model_id in self.models: + return model_id + # Registered before its fields are walked, so a self-reference + # resolves to this same id instead of recursing forever. + self.models[model_id] = {} + self.classes[model_id] = model + self.models[model_id] = self._fields(model, model_id) + return model_id + + def _fields(self, model: type, model_id: str) -> dict[str, dict[str, Any]]: + fields: dict[str, dict[str, Any]] = {} + marked = self.is_secret(model) + for name, info in model.model_fields.items(): + where = f"{model_id}.{name}" + entry: dict[str, Any] = {} + if name in marked: + # A marked field's annotation is never examined: coverage is + # prefix-based, so everything beneath it is covered whatever + # it is, and refusing an unwalkable annotation there would + # refuse a declaration that is already complete. + entry["secret"] = True + else: + child = self._resolve(info.annotation, where) + if child is not None: + entry["child"] = child + for key in _data_keys(name, info, where): + if key in fields: + raise Unwalkable( + f"{where}: data key {key!r} is claimed by more than one " + "field; give the fields distinct names or aliases" + ) + if entry: + # A field with neither a mark nor a model beneath it is + # omitted rather than written as `{}`: the matcher treats + # "absent" and "declares nothing" identically, and a model + # with fifty scalar fields would otherwise bury the marks — + # the only thing this artifact exists to carry — under + # forty-nine empty objects in every review of it. + fields[key] = entry + return fields + + def _resolve(self, annotation: Any, where: str) -> dict[str, Any] | None: + """One annotation, as a graph node — or `None` when nothing beneath it + is a declared model, which is the common case and is written as the + absence of a `child` entry.""" + annotation = _strip_annotated(annotation) + if annotation is None or annotation is type(None): + return None + if _is_model(annotation): + return {"kind": "model", "model": self.add(annotation)} + + origin = typing.get_origin(annotation) + args = typing.get_args(annotation) + + if origin in _UNION_ORIGINS: + present = [a for a in args if a is not type(None)] + if len(present) == 1: + # `Optional[X]` / `X | None`: one real member, so there is + # exactly one thing to walk. `None` itself is not a mapping + # and has nothing beneath it. + return self._resolve(present[0], where) + if any(_may_hold_model(member) for member in present): + raise Unwalkable( + f"{where}: a union of more than one type cannot be walked, " + "because which member a value belongs to is not knowable " + "from the annotation" + ) + return None + if origin is list and len(args) == 1: + item = self._resolve(args[0], where) + return None if item is None else {"kind": "list", "item": item} + if origin is dict and len(args) == 2 and _strip_annotated(args[0]) is str: + value = self._resolve(args[1], where) + return None if value is None else {"kind": "dict", "value": value} + + if _may_hold_model(annotation): + raise Unwalkable( + f"{where}: this annotation may hold a declared model but is not " + "one this tool can walk. Walkable forms are a model, list[X], " + "dict[str, X] and Optional[X]. Either narrow the annotation to " + "one of those, or mark the field " + "(json_schema_extra={'secret': True}), which covers everything " + "beneath it whatever its type" + ) + return None + + +def _data_keys(name: str, info: Any, where: str) -> list[str]: + """Every key this field may appear under in a stack config document. + + The field's own name plus any string alias. Keying only by field name + would miss an aliased field silently, which is a fail-open; keying by + every name it could carry over-matches at worst, and an over-match is + suppressed by the same encrypted/empty rules as any other leaf. + + A non-string `validation_alias` (`AliasChoices`, `AliasPath`) is refused, + marked field included: without a determinable data key there is no + position at which to apply coverage at all, and a mark that covers + nothing is worse than one that does not exist. + """ + keys = [name] + for attribute in ("alias", "validation_alias", "serialization_alias"): + value = getattr(info, attribute, None) + if value is None: + continue + if not isinstance(value, str): + raise Unwalkable( + f"{where}: {attribute} is not a plain string, so the key this " + "field appears under is not knowable; use a string alias" + ) + if value not in keys: + keys.append(value) + return keys + + +def _standard_marks(model: type) -> set[str]: + """This tool's published marking convention: + `json_schema_extra={"secret": True}`. + + A callable `json_schema_extra` is refused rather than ignored — its result + depends on a schema-generation context that does not exist here, so + whether the field is marked cannot be determined, and "cannot determine" + must not become "not marked". + """ + marked: set[str] = set() + for name, info in model.model_fields.items(): + extra = getattr(info, "json_schema_extra", None) + if extra is None: + continue + if callable(extra): + raise Unwalkable( + f"{_model_id(model)}.{name}: json_schema_extra is a callable, so " + "whether this field is marked cannot be determined here; use a " + "plain dict, or declare check.declared_paths_fn" + ) + if isinstance(extra, dict) and extra.get("secret") is True: + marked.add(name) + return marked + + +def _custom_marks(function: Any) -> Any: + """`check.declared_paths_fn` as a marking-convention override. + + Called as `fn(model_class)` and must return the *field names* that model + marks as secret. Field names, not paths: the graph is what makes + recursion work, and a hook returning flat paths would reintroduce exactly + the enumeration this design replaced — so a returned string containing + `.` or `[` is refused rather than interpreted. A name that is not a field + of the model is refused too, since a typo that marked nothing would be + indistinguishable from a field deliberately left unmarked. + """ + + def marks(model: type) -> set[str]: + try: + returned = function(model) + except Exception as exc: # noqa: BLE001 - the hook is repository code + raise Unwalkable( + f"{_model_id(model)}: check.declared_paths_fn raised " + f"{type(exc).__name__}: {exc}" + ) from exc + names: set[str] = set() + for item in returned: + if not isinstance(item, str): + raise Unwalkable( + f"{_model_id(model)}: check.declared_paths_fn returned a " + f"{type(item).__name__}; it must return field names" + ) + if "." in item or "[" in item: + raise Unwalkable( + f"{_model_id(model)}: check.declared_paths_fn returned " + f"{item!r}; it must return field names, not paths" + ) + if item not in model.model_fields: + raise Unwalkable( + f"{_model_id(model)}: check.declared_paths_fn returned " + f"{item!r}, which is not a field of this model" + ) + names.add(item) + return names + + return marks + + +def _import_object(target: str, what: str) -> Any: + module_name, _, attribute = target.partition(":") + try: + module = __import__(module_name, fromlist=["*"]) + except Exception as exc: # noqa: BLE001 - repository code, any failure + raise Unwalkable( + f"{what} {target!r}: cannot import {module_name!r}: " + f"{type(exc).__name__}: {exc}" + ) from exc + try: + return getattr(module, attribute) + except AttributeError as exc: + raise Unwalkable( + f"{what} {target!r}: {module_name!r} has no {attribute!r}" + ) from exc + + +def _annotated_aliases(model: type) -> list[Any]: + """Every `Annotated[...]` object appearing in `model`'s own annotations. + + Collected from the *raw* hints rather than from `FieldInfo`, because + pydantic resolves an alias's mark onto the field and discards the wrapper + — which is exactly why an alias defined in another module can change a + mark with the model's own file untouched. The objects collected here are + matched by identity against module globals in `_collect_sources`, which + is what finds the module that defines the alias. + """ + try: + hints = typing.get_type_hints(model, include_extras=True) + except Exception as exc: # noqa: BLE001 - repository code, any failure + raise Unwalkable( + f"{_model_id(model)}: annotations cannot be resolved " + f"({type(exc).__name__}: {exc}), so the modules contributing them " + "cannot be recorded; a model whose sources cannot be recorded " + "cannot be checked for staleness" + ) from exc + + found: list[Any] = [] + seen: set[int] = set() + + def visit(annotation: Any) -> None: + if id(annotation) in seen: + return + seen.add(id(annotation)) + if hasattr(annotation, "__metadata__"): + found.append(annotation) + for argument in typing.get_args(annotation): + visit(argument) + + for hint in hints.values(): + visit(hint) + return found + + +def _collect_sources( + classes: list[type], extra_objects: list[Any], repo_root: Path +) -> tuple[list[str], list[str]]: + """Every file and out-of-repo module that contributed a mark. + + Two mechanisms, because a mark can arrive two ways that neither one alone + sees: + + - the **MRO** of every reachable model, so a mark inherited from a base + class in another file is recorded. `getsourcefile(cls)` alone gives + only the subclass's own file, which is the case this closes. + - **identity** of every `Annotated` alias used by those models, matched + against the globals of every imported module. An alias is a module-level + object, and the annotation resolves to that very object, so the module + holding it can be found this way and no other — nothing on the resolved + field records where the alias was written. + + `pydantic.BaseModel` itself is excluded from the MRO walk. Including it + would make pydantic an out-of-repo contributor for *every* repository, + and so make a tracked lockfile a universal precondition, for no signal: + the marks this tool reads are its own convention, not pydantic's. + + A module with no `__file__` (a builtin, a namespace package, this program + itself) is skipped: there is nothing to record a blob id for. That is a + stated boundary of the blob-id layer, not a claim it does not exist. + """ + from pydantic import BaseModel + + files: set[str] = set() + external: set[str] = set() + + def record(source: Any, module_name: str) -> None: + if not source: + return + path = Path(source).resolve() + try: + path.relative_to(repo_root) + except ValueError: + external.add(module_name) + return + files.add(str(path)) + + for model in classes: + for base in model.__mro__: + if base is BaseModel or not issubclass(base, BaseModel): + continue + try: + source = inspect.getsourcefile(base) + except TypeError: + source = None + record(source, base.__module__) + + wanted = {id(obj) for model in classes for obj in _annotated_aliases(model)} + wanted.update(id(obj) for obj in extra_objects) + if wanted: + for module_name, module in list(sys.modules.items()): + if module is None or module_name == "__main__": + continue + namespace = getattr(module, "__dict__", None) + if not isinstance(namespace, dict): + continue + if any(id(value) in wanted for value in list(namespace.values())): + record(getattr(module, "__file__", None), module_name) + + return sorted(files), sorted(external) + + +def generate(request: dict[str, Any]) -> dict[str, Any]: + """The whole job: import the declared classes, build the graph, and + report the files and out-of-repo modules that contributed to it.""" + repo_root = Path(request["repo_root"]).resolve() + sys.path.insert(0, str(repo_root)) + + hook_target = request.get("declared_paths_fn") + extra_objects: list[Any] = [] + if hook_target: + hook = _import_object(hook_target, "check.declared_paths_fn") + is_secret = _custom_marks(hook) + extra_objects.append(hook) + else: + is_secret = _standard_marks + + builder = _GraphBuilder(is_secret) + roots: dict[str, str] = {} + for namespace, target in sorted(request["stack_models"].items()): + model = _import_object(target, "check.stack_models") + if not _is_model(model): + raise Unwalkable( + f"check.stack_models {target!r}: not a pydantic BaseModel subclass" + ) + roots[namespace] = builder.add(model) + + classes = [builder.classes[key] for key in sorted(builder.classes)] + # The hook itself decides which fields are marked, so the module defining + # it is as much a source as any model's file. It is passed in as an object + # rather than resolved by name: the identity scan in `_collect_sources` + # finds it in its own module's globals, and so classifies it repo-local or + # out-of-repo by exactly the same rule as everything else. + files, external = _collect_sources(classes, extra_objects, repo_root) + return { + "roots": roots, + "models": builder.models, + "files": files, + "external": external, + } + + +def main(argv: list[str]) -> int: + """Read the request from `argv[1]`, write the graph to the file it names. + + The result goes to a file rather than to stdout on purpose: importing a + repository's modules runs that repository's import-time code, which is + free to print. A banner on stdout would corrupt a JSON document written + there, and would do it intermittently — only in the repositories that + happen to print something. stdout and stderr stay available for exactly + that output, and for this program's own error messages. + """ + if len(argv) != 2: + _die("usage: ") + try: + request = json.loads(argv[1]) + except ValueError as exc: + _die(f"request is not valid JSON: {exc}") + try: + result = generate(request) + except Unwalkable as exc: + _die(str(exc)) + Path(request["output"]).write_text(json.dumps(result), encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/src/stackward/cli.py b/src/stackward/cli.py index 01f3f14..78d6a9d 100644 --- a/src/stackward/cli.py +++ b/src/stackward/cli.py @@ -23,6 +23,7 @@ from .commands.pre_commit import cmd_pre_commit from .commands.session import cmd_exec, cmd_login, cmd_shell from .commands.set_secrets import cmd_set_secrets +from .commands.sync_declared import cmd_sync_declared from .config import find_repo_config # Commands that must keep working even when a repo demands a newer stackward @@ -167,6 +168,12 @@ def build_parser() -> argparse.ArgumentParser: ) set_secrets.set_defaults(func=cmd_set_secrets) + sync_declared = sub.add_parser( + "sync-declared-secrets", + help="regenerate the declared-secrets artifact from this repo's models", + ) + sync_declared.set_defaults(func=cmd_sync_declared) + check_passphrase = sub.add_parser( "check-passphrase", help="prove a passphrase decrypts one or more stacks (never as an argument)", diff --git a/src/stackward/commands/check_config.py b/src/stackward/commands/check_config.py index 2f0a78e..8984a71 100644 --- a/src/stackward/commands/check_config.py +++ b/src/stackward/commands/check_config.py @@ -1,11 +1,27 @@ -"""`check-config`: the heuristic net, run over one or more stack config files. +"""`check-config`: the credential nets, run over one or more stack config files. Registered in `cli.build_parser` as `stackward check-config FILE...`. Exit codes are deliberately distinct: 0 clean, 1 a credential was found, 2 the check could not run at all (an unreadable path, unparsable YAML, a -`.stackward.toml` this module could not load, or a document that is not a -mapping) — never conflated, so a later fail-closed refusal (also exit 2) -reads the same way this one does: "the gate did not get to answer". +`.stackward.toml` this module could not load, a document that is not a +mapping, or a model-net artifact that is missing, malformed or stale) — +never conflated, so a fail-closed refusal reads the same way whichever check +made it: "the gate did not get to answer". + +**Two nets, unioned.** `nets.heuristic` names a credential by the shape of +its key and needs no cooperation from the repository. `nets.model` names one +the repository *declared*, by walking a committed graph of its own pydantic +models against the document. A leaf either net names is a finding, and a leaf +both name is reported once — `scan_file` unions the two through a set, so a +path found twice does not print twice. + +**A stale model net is exit 2, not exit 1.** `load_model_net` raises rather +than returning an empty net when the artifact is missing, unreadable or no +longer matches the sources it was generated from, and this module maps that +to "could not run". Reporting it as a finding would file a fail-closed +refusal under "a credential was found"; reporting it as clean would be worse +still. `model_net = "none"` is the *declared* way to run without that net, +and is never reached as a fallback from a failure. """ from __future__ import annotations @@ -21,6 +37,7 @@ from ..config import CheckConfig, ConfigError, find_repo_config, load_config from ..nets.heuristic import DocumentError, find_plaintext_credentials +from ..nets.model import ModelNet, ModelNetError, find_declared_credentials, load_model_net # Matches a repr-quoted fragment the way PyYAML's `%r` interpolation # produces one — e.g. `'2'`, `` '`' ``, `'id001'`. Python's `repr()` flips @@ -89,17 +106,25 @@ def wrapped(args: argparse.Namespace) -> int: return wrapped -def scan_file(path: Path, check: CheckConfig) -> list[str]: - """Thin file-path wrapper over `find_plaintext_credentials`. +def scan_file( + path: Path, check: CheckConfig, net: ModelNet | None = None +) -> list[str]: + """Thin file-path wrapper over both nets, unioned. Reads `path` from the working tree and parses it as YAML before handing - the result to the content-level net. This is deliberately the *only* - place that happens: `find_plaintext_credentials` itself takes - already-parsed document data, so a caller that must check staged - content instead of the working tree — `pre-commit`, reading - `git show ":"` — calls that function directly rather than going - through this wrapper, and never round-trips a blob through a temp file - to get there. + the result to the content-level nets. This is deliberately the *only* + place that happens: both nets take already-parsed document data, so a + caller that must check staged content instead of the working tree — + `pre-commit`, reading `git show ":"` — calls them directly rather + than going through this wrapper, and never round-trips a blob through a + temp file to get there. + + `net` is `None` when the repository declares `model_net = "none"`, or + has no `[check]` policy at all; the heuristic net always runs. The two + result lists are unioned through a set, because both nets can name the + same leaf — a field marked `secret` whose key is also called `password` + is the ordinary case, not a corner one — and a finding printed twice + reads as two problems. Raises `CheckError` for anything that means the check could not run: the path cannot be read, the content is not valid YAML, or the parsed @@ -116,9 +141,12 @@ def scan_file(path: Path, check: CheckConfig) -> list[str]: raise CheckError(f"{path}: invalid YAML: {describe_yaml_error(exc)}") from exc try: - return find_plaintext_credentials(document, check) + found = set(find_plaintext_credentials(document, check)) + if net is not None: + found |= set(find_declared_credentials(document, net, check)) except DocumentError as exc: raise CheckError(f"{path}: {exc}") from exc + return sorted(found) def describe_yaml_error(exc: yaml.YAMLError) -> str: @@ -210,6 +238,12 @@ def cmd_check_config(args: argparse.Namespace) -> int: ConfigError` below only catches that one type — is caught instead by the `@fail_closed` decorator wrapping this function, for the same "never exit 1 for a reason other than a real finding" guarantee. + + The model net is loaded once, before the loop, and a failure to load it + exits 2 without scanning anything. That ordering is the point: a stale + artifact means this command does not know what the repository declared, + so reporting the *other* net's findings and exiting 1 would announce a + complete answer it does not have. """ try: check = _load_check_policy() @@ -217,11 +251,17 @@ def cmd_check_config(args: argparse.Namespace) -> int: print(f"error: {exc}", file=sys.stderr) return 2 + try: + net = load_model_net(check) + except ModelNetError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + findings: list[tuple[str, str]] = [] errored = False for file_arg in args.files: try: - paths = scan_file(Path(file_arg), check) + paths = scan_file(Path(file_arg), check, net) except CheckError as exc: print(f"error: {exc}", file=sys.stderr) errored = True diff --git a/src/stackward/commands/sync_declared.py b/src/stackward/commands/sync_declared.py new file mode 100644 index 0000000..6fdbeba --- /dev/null +++ b/src/stackward/commands/sync_declared.py @@ -0,0 +1,318 @@ +"""`sync-declared-secrets`: write `.stackward/declared-secrets.json`. + +Registered in `cli.build_parser` as `stackward sync-declared-secrets`. It +imports the repository's pydantic models, walks their `SECRET` marks into the +graph `nets.model` reads, records the git blob id of everything that +contributed a mark, and writes the artifact atomically. + +**It is not on the gate path.** `check-config` and `pre-commit` never call +this — they read the committed artifact. This command runs when a developer +changes a model, and it is the only part of the system that imports +repository code at all. It resolves no credentials, opens no store and +contacts nothing. + +**It runs the walk under the repository's interpreter.** A frozen `stackward` +bundles its own interpreter and cannot import the consuming repository's +classes, so the walk runs as a subprocess under `[python]` from +`.stackward.toml` and only JSON crosses back (`bootstrap.regen`). That +separation is what keeps pydantic a *test-only* dependency of `stackward` +itself, which Global Constraint 1 requires. + +**Everything that could make the result incomplete is a refusal.** An +annotation that might hold a model but cannot be walked, a contributing file +that git does not track, an out-of-repo contributor with no tracked lockfile +to pin it: each raises rather than producing an artifact that looks complete +and is not. A gate whose declarations quietly cover less than the repository +declared is the one failure mode the whole model-net design exists to +prevent. + +**The output is byte-deterministic.** Sorted keys, fixed indentation, one +trailing newline, and every list sorted. The sound closure for staleness is +regenerating in CI and failing on any diff; a generator whose output churned +for unchanged input would make that check noise, and a noisy check gets +switched off. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +from ..bootstrap import generator_source +from ..config import Config, ConfigError, find_repo_config, load_config +from ..nets.model import ( + ARTIFACT_PATH, + ARTIFACT_VERSION, + ModelNetError, + index_blob_ids, + repo_toplevel, +) +from ..store import atomic_write +from .check_config import fail_closed + +# Standard Python dependency lockfiles, checked at the repository root. These +# are ecosystem filenames, not knowledge about any particular organisation or +# vendor — `pre_commit` already recognises Pulumi's own `Pulumi..yaml` +# on the same basis. *Every* one that is tracked is recorded, not the first +# match: a repository can carry two, and pinning one while ignoring the other +# would leave a real dependency change invisible. +LOCKFILE_NAMES = ( + "uv.lock", + "poetry.lock", + "pdm.lock", + "Pipfile.lock", + "requirements.lock", + "requirements.txt", +) + +# The artifact is not read from the working tree, so mode is about who may +# look at a file naming a repository's config field names — not a credential, +# and committed anyway. +_ARTIFACT_MODE = 0o644 + +_GENERATION_TIMEOUT_SECONDS = 300 + + +class SyncError(Exception): + """Generation could not produce a complete artifact. Never partial: the + caller writes nothing when this is raised.""" + + +def _load_repo_config() -> tuple[Path, Config]: + """The repository's `.stackward.toml`, which this command requires. + + Unlike the gate, absence is *not* a normal state here: there is nothing to + generate without `check.stack_models`, and defaulting would write an empty + artifact that then passes every freshness check while declaring nothing. + """ + path = find_repo_config() + if path is None: + raise SyncError( + "no .stackward.toml found; this command needs check.stack_models " + "and the python interpreter to walk them with" + ) + try: + return path, load_config(path) + except ConfigError as exc: + raise SyncError(str(exc)) from exc + + +def _interpreter(config: Config, config_path: Path) -> str: + """The repository's own interpreter, resolved against the config's + directory so a relative `python = ".venv/bin/python"` means the same + thing from any working directory inside the repository.""" + if not config.python: + raise SyncError( + f"{config_path}: no 'python' key; this command needs the " + "repository's own interpreter to import its models" + ) + candidate = Path(config.python) + if not candidate.is_absolute(): + candidate = config_path.parent / candidate + if not candidate.is_file(): + raise SyncError(f"{config_path}: python interpreter not found: {candidate}") + return str(candidate) + + +def run_generator(interpreter: str, request: dict[str, object]) -> dict[str, object]: + """Run the generator under `interpreter` and return its result. + + The program's *text* goes in on stdin (`python -`), the request goes in + `argv`, and the result comes back through a temporary file named by the + request. Three channels rather than one because importing a repository's + modules runs that repository's import-time code: anything it prints would + corrupt a JSON document sharing stdout with it, and only in the + repositories that happen to print, which is the worst kind of + intermittent. + + The request carries module and class names only — never a credential — + so putting it in `argv` discloses nothing that `.stackward.toml` does not + already say in the clear. + """ + with tempfile.TemporaryDirectory(prefix="stackward-regen-") as workspace: + output = Path(workspace) / "graph.json" + payload = json.dumps({**request, "output": str(output)}) + try: + proc = subprocess.run( + [interpreter, "-", payload], + input=generator_source(), + capture_output=True, + text=True, + check=False, + timeout=_GENERATION_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired as exc: + raise SyncError( + f"the model walk did not finish within " + f"{_GENERATION_TIMEOUT_SECONDS}s" + ) from exc + except OSError as exc: + raise SyncError(f"cannot run {interpreter}: {exc}") from exc + + if proc.returncode != 0: + detail = (proc.stderr or "").strip() or f"exit status {proc.returncode}" + # The generator prefixes its own refusals with "error: ", and so + # does the caller printing this. Strip one so the message does not + # read "error: error: ...". + raise SyncError(detail.removeprefix("error: ")) + try: + text = output.read_text(encoding="utf-8") + except OSError as exc: + raise SyncError(f"the model walk produced no result: {exc}") from exc + + try: + result = json.loads(text) + except ValueError as exc: + raise SyncError(f"the model walk produced invalid JSON: {exc}") from exc + if not isinstance(result, dict): + raise SyncError("the model walk produced a result that is not an object") + return result + + +def _relative_sources(root: Path, absolute: list[str]) -> list[str]: + """Contributing files as repository-relative POSIX paths. + + A file outside the repository never reaches here — the generator + classifies those as out-of-repo modules, pinned by lockfile instead — so + a path that will not relativise is a bug, not a supported case, and is + refused rather than dropped. + """ + relative: list[str] = [] + for item in absolute: + try: + relative.append(Path(item).resolve().relative_to(root).as_posix()) + except ValueError as exc: + raise SyncError(f"{item}: not inside the repository") from exc + return sorted(set(relative)) + + +def _lockfile_sources(root: Path) -> dict[str, str]: + """Every *tracked* dependency lockfile at the repository root, by blob id. + + Recorded only when something outside the repository contributed a mark — + a base model or a marking alias imported from a dependency. `git show` + cannot reach site-packages, so the lockfile that decides which version is + installed is the closest thing to that module's identity that the index + holds. A lockfile present on disk but untracked is not returned: it has no + blob id to compare against later. + """ + candidates = [name for name in LOCKFILE_NAMES if (root / name).is_file()] + if not candidates: + return {} + return index_blob_ids(root, candidates) + + +def build_artifact(root: Path, result: dict[str, object]) -> dict[str, object]: + """Assemble the artifact, refusing anything that would leave it unverifiable. + + Two refusals matter more than they look: + + - a contributing file that git does not track. Omitting it would mean its + blob id can never be compared, so a mark could change in it forever + without the freshness check noticing — the exact fail-open this artifact + exists to close. + - an out-of-repo contributor with no tracked lockfile to stand in for it. + There would be nothing at all pinning that module's identity. + + The artifact never records **itself**: its own blob id changes on every + regeneration, so a self-reference would make every artifact stale the + moment it was written. + """ + files = _relative_sources(root, [str(item) for item in result.get("files", [])]) + external = sorted({str(item) for item in result.get("external", [])}) + + tracked = index_blob_ids(root, files) if files else {} + missing = sorted(set(files) - set(tracked)) + if missing: + raise SyncError( + f"{missing[0]} contributes a declared secret but is not tracked by " + "git, so it cannot be checked for staleness; add it to the index " + "and run this command again" + ) + + sources = dict(tracked) + if external: + lockfiles = _lockfile_sources(root) + if not lockfiles: + raise SyncError( + f"{external[0]} contributes a declared secret from outside the " + "repository, and no tracked dependency lockfile was found to " + f"pin it (looked for: {', '.join(LOCKFILE_NAMES)})" + ) + sources.update(lockfiles) + + sources.pop(ARTIFACT_PATH, None) + if not sources: + raise SyncError( + "no source file contributed a declared secret, so nothing could be " + "recorded to check for staleness" + ) + + return { + "version": ARTIFACT_VERSION, + "roots": result.get("roots", {}), + "models": result.get("models", {}), + "sources": sources, + } + + +def serialise(artifact: dict[str, object]) -> bytes: + """The artifact's committed bytes: sorted keys, two-space indent, one + trailing newline. + + Byte-determinism is the property CI leans on — regenerate, diff, fail on + any change. `sort_keys` is what supplies it, since dictionary insertion + order here follows the order classes happened to be walked in. + """ + return (json.dumps(artifact, sort_keys=True, indent=2) + "\n").encode("utf-8") + + +def sync(root: Path, config_path: Path, config: Config) -> Path: + """Generate and write the artifact. Returns the path written.""" + if not config.check.stack_models: + raise SyncError( + f"{config_path}: check.stack_models is empty; there is nothing to " + "walk. Declare a model, or set check.model_net = \"none\"" + ) + + result = run_generator( + _interpreter(config, config_path), + { + "repo_root": str(root), + "stack_models": dict(config.check.stack_models), + "declared_paths_fn": config.check.declared_paths_fn, + }, + ) + artifact = build_artifact(root, result) + + target = root / ARTIFACT_PATH + target.parent.mkdir(parents=True, exist_ok=True) + atomic_write(target, serialise(artifact), _ARTIFACT_MODE) + return target + + +@fail_closed +def cmd_sync_declared(_args: argparse.Namespace) -> int: + """Entry point for `stackward sync-declared-secrets`. + + Exit codes match the rest of the tool's fail-closed convention: 0 on + success, 2 on any refusal. There is no exit 1 here — this command does + not look for credentials, so it can never mean "a credential was found", + and `@fail_closed` keeps an unanticipated exception from exiting 1 by + Python's own default. + """ + try: + config_path, config = _load_repo_config() + root = repo_toplevel() + written = sync(root, config_path, config) + except (SyncError, ModelNetError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + print(f"wrote {written.relative_to(root).as_posix()}") + print("stage it with the change that caused it: git add " + ARTIFACT_PATH) + return 0 diff --git a/src/stackward/config.py b/src/stackward/config.py index 405ea15..38784be 100644 --- a/src/stackward/config.py +++ b/src/stackward/config.py @@ -65,6 +65,7 @@ { "model_net", "stack_models", + "declared_paths_fn", "sensitive_keys", "sensitive_parents", "allowed_references", @@ -94,6 +95,9 @@ class CheckConfig: model_net: str = "none" stack_models: dict[str, str] = field(default_factory=dict) + # `"module:function"`, or None for this tool's own marking convention. + # See `_require_module_class` for why the shape is validated here. + declared_paths_fn: str | None = None sensitive_keys: frozenset[str] = BUILTIN_SENSITIVE_KEYS sensitive_parents: frozenset[str] = BUILTIN_SENSITIVE_PARENTS allowed_references: list[str] = field(default_factory=list) @@ -236,6 +240,18 @@ def _build_check(raw: Any) -> CheckConfig: _require_module_class(target, f"check.stack_models.{namespace!r}") stack_models[namespace] = target + declared_paths_fn = _require_str( + raw, "declared_paths_fn", "check.declared_paths_fn" + ) + if declared_paths_fn is not None: + # The escape hatch for a repository whose marking convention is not + # this tool's `json_schema_extra={"secret": True}`. Same + # `"module:Attribute"` shape as `stack_models`, and validated here for + # the same reason: a malformed value would otherwise surface as a + # confusing ImportError inside the generator subprocess, far from the + # file that caused it. + _require_module_class(declared_paths_fn, "check.declared_paths_fn") + sensitive_keys = _require_str_list(raw, "sensitive_keys", "check.sensitive_keys") sensitive_parents = _require_str_list( raw, "sensitive_parents", "check.sensitive_parents" @@ -247,6 +263,7 @@ def _build_check(raw: Any) -> CheckConfig: return CheckConfig( model_net=model_net, stack_models=stack_models, + declared_paths_fn=declared_paths_fn, sensitive_keys=_extend(BUILTIN_SENSITIVE_KEYS, sensitive_keys), sensitive_parents=_replace(BUILTIN_SENSITIVE_PARENTS, sensitive_parents), allowed_references=list(allowed_references or []), diff --git a/src/stackward/nets/heuristic.py b/src/stackward/nets/heuristic.py index c844eb3..8969c61 100644 --- a/src/stackward/nets/heuristic.py +++ b/src/stackward/nets/heuristic.py @@ -16,8 +16,8 @@ (`commands.pre_commit`, checking `git show ":"` output) without either caller having to round-trip through a temp file first. -Two rules a plausible implementation gets wrong — see `_is_encrypted` and -`_is_empty` for why, in detail: +Two rules a plausible implementation gets wrong — see `is_encrypted` and +`is_empty` for why, in detail: - "Encrypted" is a property of the leaf's *parent mapping*, not of the leaf's own path text. `path.endswith(".secure")` cannot tell a genuine @@ -39,7 +39,7 @@ ships empty by design. That made the malformed-envelope case this module exists to catch (`apiToken: {"secure": X, "other": Y}`, `other` a plaintext leak) *unreachable* under the default policy: neither `secure` nor `other` -ever became a candidate, so `_is_encrypted`'s careful parent-shape check +ever became a candidate, so `is_encrypted`'s careful parent-shape check never ran. Propagating `sensitive_keys` down the subtree closes that gap without inventing an environment-specific default for `sensitive_parents` (which Global Constraint 5 forbids) — it needs no new configuration, only a @@ -220,9 +220,9 @@ def _check_leaf( ) if not (sensitive_by_key or ancestor_sensitive): return - if parent is not None and _is_encrypted(parent): + if parent is not None and is_encrypted(parent): return - if _is_empty(value): + if is_empty(value): return rendered = render(path) if rendered in ctx.allowed_references: @@ -235,10 +235,17 @@ def _matches_sensitive_key(key: str, sensitive_keys: frozenset[str]) -> bool: return any(pattern.lower() in lowered for pattern in sensitive_keys) -def _is_encrypted(parent: dict[str, Any]) -> bool: +def is_encrypted(parent: dict[str, Any]) -> bool: """The leaf's parent mapping is exactly `{"secure": ...}` — one key, named `secure`. + Public (not `_`-prefixed) so `nets.model` can reuse it, for the same + reason `check_config.describe_yaml_error` is public: the model net has + to make the identical "is this leaf actually a credential?" decision, + and two independently-written copies of this check would eventually + disagree about the same leaf — which, since the two nets are unioned, + would show up as one net reporting a value the other suppressed. + Tested against the mapping's shape, never against the leaf's own rendered path: `path.endswith(".secure")` cannot distinguish this from a malformed `{"secure": ..., "other": ...}`, whose `.secure` child is @@ -248,10 +255,13 @@ def _is_encrypted(parent: dict[str, Any]) -> bool: return set(parent.keys()) == _SECURE_WRAPPER_KEYS -def _is_empty(value: Any) -> bool: +def is_empty(value: Any) -> bool: """`None`, `""`, `True` and `False` are excluded; every integer, including `0` and `1`, is not. + Public for the same reason as `is_encrypted` above: `nets.model` needs + exactly this predicate, not a second one that agrees with it today. + `None`/`True`/`False` are tested by identity and `""` by an exact-type-safe equality (`isinstance` first) — never by a containment test like `value in (None, "", False, True)`. That test uses `==` under diff --git a/src/stackward/nets/model.py b/src/stackward/nets/model.py new file mode 100644 index 0000000..4eb242c --- /dev/null +++ b/src/stackward/nets/model.py @@ -0,0 +1,651 @@ +"""The model net: declared secrets, carried across a process boundary as a graph. + +The heuristic net (`nets.heuristic`) names a credential by the *shape of its +key*. This net names one because a repository *declared* it — a pydantic +field marked `json_schema_extra={"secret": True}`. The two are unioned, so a +credential either of them can name is caught. + +**Why an artifact at all.** A shipped `stackward` binary bundles its own +interpreter and cannot import the consuming repository's classes. The +declarations therefore have to cross that boundary as data, written by +`stackward sync-declared-secrets` (which runs under the *repository's* +interpreter — see `commands.sync_declared`) and read back here. Nothing in +this module imports pydantic, and the shipped binary must never bundle it. + +**Why a graph and not a list of path patterns.** This is the decision the +whole design turns on, and the counterexample is concrete:: + + class Node(BaseModel): + token: str = Field(json_schema_extra={"secret": True}) + children: list["Node"] = [] + +Any generator that emits a flat list of patterns needs a cycle guard, so it +emits `['token']` and stops. The *data* that model describes is unbounded: +`token`, `children[0].token`, `children[0].children[0].token`, … The flat +form therefore fails **open** at exactly the depth it must fail closed. So +the artifact is a graph — models, their fields, which fields are marked, and +which child model a field's values belong to — and `find_declared_credentials` +walks that graph and the document *together*. Recursion and mutual recursion +then work by construction rather than by enumeration, because the walk is +driven by the data, which is finite. + +Two semantics the equivalence is false without: + +- **Prefix, not exact.** A marked field covers everything beneath it, + whatever its annotation. A marked `peer: P` covers `peer.url` and `peer.k` + alike — see `_descend`, which stops consulting the graph entirely once + coverage is on. +- **Structural, not string-glob.** A mapping key may legally contain `.` and + `[`. This module walks the parsed document alongside the graph and calls + `paths.render` only to report a position it has already walked to; it never + builds or re-parses a path string to decide anything, exactly as + `nets.heuristic` does not. + +**Suppressions are shared with the heuristic net**, through +`heuristic.is_encrypted` and `heuristic.is_empty`: a declared field whose +value is the `{"secure": ...}` envelope is encrypted, and one that is absent +or blank holds nothing. Reimplementing either here would let the two nets +drift into disagreeing about whether the same leaf is a credential. + +**Freshness, and what it does and does not close.** A committed artifact goes +stale. `load_model_net` reads it from the **git index** +(`git show ":.stackward/declared-secrets.json"`), never the working tree, so +an unstaged regeneration cannot make a stale committed artifact pass; and it +compares the **git blob id** of every recorded source against the index, in +one `git ls-files -s`. The recorded sources come from an MRO walk, so a mark +inherited from a base class in another file is covered, and from the module +that defines any `Annotated` alias carrying a mark; for a contributor outside +the repository the dependency lockfile's blob id stands in, since `git show` +cannot reach site-packages. + +That is a *layer*, not a proof, and the boundary is worth stating plainly +rather than overclaiming: blob ids detect **modification of a recorded +source**. They cannot detect a **newly added** file that ought to contribute +but was never recorded — nothing in the index distinguishes that from a file +this repository does not use. Regenerating the artifact in CI and failing on +any diff is what closes that, and it is the mechanism to rely on; this one +catches the common case cheaply, at commit time. + +**Every failure to load is exit 2, never exit 1.** A missing, unreadable, +malformed or stale artifact under `model_net = "artifact"` means the check +could not run — reporting it as "a credential was found" would hide a +fail-closed refusal inside a normal finding, and reporting it as clean would +be worse. `model_net = "none"` is a *declared* mode that skips this net +entirely; it is never reached as a fallback from a failure above. +""" + +from __future__ import annotations + +import json +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Union + +from ..config import CheckConfig +from ..paths import render +from .heuristic import DocumentError, is_empty, is_encrypted + +# Repository-relative, POSIX-separated, and the same string on both sides: +# `commands.sync_declared` writes here and `load_model_net` reads `":"` +# out of the index. A `:` revision that does not start with `./` is +# resolved from the repository's top level, which is what makes one constant +# correct for both. +ARTIFACT_PATH = ".stackward/declared-secrets.json" + +# Named in every refusal this module raises. A fail-closed error that does not +# say how to clear itself is a fail-closed error someone disables. +REGEN_COMMAND = "stackward sync-declared-secrets" + +# Bumped when the artifact's shape changes. An unrecognised version is a hard +# refusal, not a best-effort parse: a newer generator's graph read by an older +# matcher would be silently under-walked, which is the one failure mode this +# net exists to prevent. +ARTIFACT_VERSION = 1 + +# The document key holding a Pulumi stack's configuration. Findings are +# rendered from the document root (`config..<...>`) so that a path +# this net reports and a path the heuristic net reports are the same string +# for the same leaf, and the union of the two can be deduplicated. +_CONFIG_KEY = "config" + + +class ModelNetError(Exception): + """The model net could not be loaded: the artifact is missing from the + index, unreadable, malformed, of an unrecognised version, or stale. + + Always maps to exit code 2 — "the check could not run" — never to 1, + which means specifically "a credential was found". + """ + + +@dataclass(frozen=True) +class ModelRef: + """A field's values are instances of the model with this id.""" + + model: str + + +@dataclass(frozen=True) +class ListOf: + """A field's values are a list; every element walks `item`.""" + + item: TypeNode + + +@dataclass(frozen=True) +class DictOf: + """A field's values are a mapping; every *value* walks `value`. + + The mapping's own keys are data, not field names, and are never matched + against anything — that is what keeps a key containing `.` or `[` from + being lost. + """ + + value: TypeNode + + +# `None` means "nothing beneath this position is a declared model", which is +# the overwhelmingly common case (every scalar field, and every container of +# scalars). Representing it as absence keeps the committed artifact small and +# its diffs meaningful. +TypeNode = Union[ModelRef, ListOf, DictOf] + + +@dataclass(frozen=True) +class DeclaredField: + """One field of one model, as the matcher needs it. + + `secret` is the mark. `child` is where the field's values go next in the + graph, and is `None` both for a scalar field and for a marked field — + once a field is marked, its annotation stops mattering, because coverage + is prefix-based and everything beneath it is a finding regardless. + """ + + secret: bool + child: TypeNode | None + + +@dataclass(frozen=True) +class ModelNet: + """The parsed graph: which model each config namespace starts at, and + every model reachable from those roots.""" + + roots: dict[str, str] + models: dict[str, dict[str, DeclaredField]] + + +def _require_mapping(value: Any, what: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise ModelNetError(f"artifact: {what} must be an object") + for key in value: + if not isinstance(key, str): + raise ModelNetError(f"artifact: {what} has a non-string key") + return value + + +def _parse_type_node(raw: Any, where: str) -> TypeNode | None: + """One `child` entry, validated rather than trusted. + + An unknown `kind`, a missing member or a dangling model reference is a + refusal: a graph this module cannot fully walk would silently under-report + exactly the nesting it exists to reach. + """ + if raw is None: + return None + node = _require_mapping(raw, f"{where}: child") + kind = node.get("kind") + if kind == "model": + model = node.get("model") + if not isinstance(model, str): + raise ModelNetError(f"artifact: {where}: 'model' must be a string") + return ModelRef(model) + if kind == "list": + return ListOf(_require_node(node.get("item"), f"{where}: list item")) + if kind == "dict": + return DictOf(_require_node(node.get("value"), f"{where}: dict value")) + raise ModelNetError(f"artifact: {where}: unknown child kind {kind!r}") + + +def _require_node(raw: Any, where: str) -> TypeNode: + """A container's interior, which must itself be a node. + + A container whose interior holds no model is written as a scalar field by + the generator — `list[str]` is not `{"kind": "list", "item": null}` — so + a null interior here is a malformed artifact, not an empty one. + """ + node = _parse_type_node(raw, where) + if node is None: + raise ModelNetError(f"artifact: {where} must not be null") + return node + + +def parse_net(payload: Any) -> tuple[ModelNet, dict[str, str]]: + """Validate a decoded artifact and return its graph and its `sources` map. + + Raises `ModelNetError` on anything it cannot fully account for: an + unrecognised version, a missing or misshapen section, a root or a child + naming a model the artifact does not define, or an empty `sources` map + (which would leave freshness unverifiable, and so unverified). + """ + document = _require_mapping(payload, "top level") + + version = document.get("version") + if version != ARTIFACT_VERSION: + raise ModelNetError( + f"artifact: unsupported version {version!r} " + f"(this stackward reads version {ARTIFACT_VERSION}); " + f"regenerate with: {REGEN_COMMAND}" + ) + + raw_models = _require_mapping(document.get("models"), "'models'") + models: dict[str, dict[str, DeclaredField]] = {} + for model_id, raw_fields in raw_models.items(): + fields: dict[str, DeclaredField] = {} + for name, raw_field in _require_mapping( + raw_fields, f"model {model_id!r}" + ).items(): + entry = _require_mapping(raw_field, f"model {model_id!r} field {name!r}") + secret = entry.get("secret", False) + if not isinstance(secret, bool): + raise ModelNetError( + f"artifact: model {model_id!r} field {name!r}: " + "'secret' must be a boolean" + ) + child = _parse_type_node( + entry.get("child"), f"model {model_id!r} field {name!r}" + ) + fields[name] = DeclaredField(secret=secret, child=child) + models[model_id] = fields + + raw_roots = _require_mapping(document.get("roots"), "'roots'") + roots: dict[str, str] = {} + for namespace, model_id in raw_roots.items(): + if not isinstance(model_id, str): + raise ModelNetError(f"artifact: root {namespace!r} must name a model") + roots[namespace] = model_id + + for model_id in _referenced_models(roots, models): + if model_id not in models: + raise ModelNetError(f"artifact: undefined model {model_id!r}") + + sources: dict[str, str] = {} + for path, blob in _require_mapping(document.get("sources"), "'sources'").items(): + if not isinstance(blob, str): + raise ModelNetError(f"artifact: source {path!r} must name a blob id") + sources[path] = blob + if not sources: + raise ModelNetError( + "artifact: 'sources' is empty, so freshness cannot be verified; " + f"regenerate with: {REGEN_COMMAND}" + ) + + return ModelNet(roots=roots, models=models), sources + + +def _referenced_models( + roots: dict[str, str], models: dict[str, dict[str, DeclaredField]] +) -> set[str]: + """Every model id named anywhere in the graph, roots included.""" + referenced = set(roots.values()) + for fields in models.values(): + for field in fields.values(): + node = field.child + while node is not None: + if isinstance(node, ModelRef): + referenced.add(node.model) + break + node = node.item if isinstance(node, ListOf) else node.value + return referenced + + +@dataclass(frozen=True) +class _MatchContext: + """What stays the same as `_walk` recurses, so its signature carries only + what varies: the node, its path, its graph position and its coverage.""" + + net: ModelNet + allowed_references: frozenset[str] + findings: list[str] + + +def find_declared_credentials( + document: Any, net: ModelNet, check: CheckConfig +) -> list[str]: + """Walk `document` against `net`, returning the rendered path of every + declared field found holding a plaintext value. + + Takes an already-parsed document and does no I/O, exactly like + `heuristic.find_plaintext_credentials` — so the same function serves a + file read from disk and a blob read from the index, with no caller + round-tripping content through a temporary file to reuse it. + + Matching starts at `document["config"]`, whose keys are Pulumi's + `:` namespaces; a namespace the artifact declares a root + for is walked against that root model, and one it does not is left to + the heuristic net. Findings are rendered from the *document* root, so a + path reported here and a path reported by the heuristic net are the same + string for the same leaf. + + Raises `DocumentError` if `document` is not a mapping — the same refusal + the heuristic net makes, for the same reason. + """ + if not isinstance(document, dict): + raise DocumentError( + "stack config must be a mapping at the top level, got " + f"{type(document).__name__}" + ) + + config = document.get(_CONFIG_KEY) + if not isinstance(config, dict): + return [] + + ctx = _MatchContext( + net=net, + allowed_references=frozenset(check.allowed_references), + findings=[], + ) + for namespace, model_id in sorted(net.roots.items()): + if namespace not in config: + continue + value = config[namespace] + path: list[str | int] = [_CONFIG_KEY, namespace] + if isinstance(value, (dict, list)): + _walk( + value, + path, + ModelRef(model_id), + False, + frozenset({id(config), id(value)}), + ctx, + ) + # A namespace whose value is a scalar cannot be a model instance; + # there is no field beneath it to have been marked. + return sorted(set(ctx.findings)) + + +def _walk( + node: Any, + path: list[str | int], + tnode: TypeNode | None, + covered: bool, + visiting: frozenset[int], + ctx: _MatchContext, +) -> None: + """Recurse through `node`, driven jointly by the data and by `tnode`. + + `covered` is true once a marked field has been entered. From that point + the graph is not consulted again: every scalar beneath is a finding, + whatever the marked field's annotation was, which is what makes coverage + prefix-based rather than exact. + + `visiting` holds `id()` of every container open on *this* descent path, + for the same reason `heuristic._walk` carries one: a YAML anchor/alias + pair can make a container contain itself, and revisiting it would recurse + forever producing a longer distinct path each time. It is scoped to the + current path rather than "every container ever seen", so two sibling + branches legitimately sharing one object are each still walked. + """ + if isinstance(node, dict): + for raw_key, value in node.items(): + key = raw_key if isinstance(raw_key, str) else str(raw_key) + child_covered, child_node = _descend(ctx, tnode, key, covered) + if not child_covered and child_node is None: + continue + child_path = [*path, key] + if isinstance(value, (dict, list)): + if id(value) in visiting: + continue + _walk( + value, + child_path, + child_node, + child_covered, + visiting | {id(value)}, + ctx, + ) + elif child_covered: + _report(node, value, child_path, ctx) + elif isinstance(node, list): + child_covered, child_node = _element(tnode, covered) + if not child_covered and child_node is None: + return + for index, value in enumerate(node): + child_path = [*path, index] + if isinstance(value, (dict, list)): + if id(value) in visiting: + continue + _walk( + value, + child_path, + child_node, + child_covered, + visiting | {id(value)}, + ctx, + ) + elif child_covered: + _report(None, value, child_path, ctx) + + +def _descend( + ctx: _MatchContext, tnode: TypeNode | None, key: str, covered: bool +) -> tuple[bool, TypeNode | None]: + """Where mapping key `key` lands: still covered, and what graph position. + + Once `covered` is on it stays on and the graph position is irrelevant. + Otherwise the key is a *field name* only when the current position is a + model; under a `DictOf` the key is data and never matched against + anything, which is exactly what keeps a key containing `.` or `[` from + being lost the way a dotted glob would lose it. + """ + if covered: + return True, None + if isinstance(tnode, ModelRef): + field = ctx.net.models[tnode.model].get(key) + if field is None: + return False, None + if field.secret: + return True, None + return False, field.child + if isinstance(tnode, DictOf): + return False, tnode.value + return False, None + + +def _element(tnode: TypeNode | None, covered: bool) -> tuple[bool, TypeNode | None]: + """Where a list element lands. A list carries no names, so nothing here + can newly become covered — only an already-covered ancestor, or a + `ListOf` position, gives an element anywhere to go.""" + if covered: + return True, None + if isinstance(tnode, ListOf): + return False, tnode.item + return False, None + + +def _report( + parent: dict[str, Any] | None, + value: Any, + path: list[str | int], + ctx: _MatchContext, +) -> None: + """Record a covered leaf, unless it is encrypted, empty, or an allowed + reference. + + `parent` is the mapping directly containing the leaf, or `None` for a + list element (which cannot be the `{"secure": ...}` envelope). Reports the + rendered path only — never the value, and never anything derived from it. + """ + if parent is not None and is_encrypted(parent): + return + if is_empty(value): + return + rendered = render(path) + if rendered in ctx.allowed_references: + return + ctx.findings.append(rendered) + + +# -------------------------------------------------------------------------- +# Loading the artifact. Everything above this line is pure: parsed data in, +# findings out, no I/O. Below it is the part that has to talk to git, because +# freshness is a property of the index and cannot be read from anywhere else. +# -------------------------------------------------------------------------- + + +def run_git(args: list[str], *, cwd: Path | None = None) -> bytes: + """Run `git ` and return stdout, raising `ModelNetError` on failure. + + Shared with `commands.sync_declared` so that both sides of the artifact — + the writer resolving blob ids and the reader verifying them — use one + invocation style and one failure mode. git's own stderr is included: the + only invocations made through this helper are `rev-parse`, `ls-files` and + `show` of the artifact path, and a *failing* run of any of them has not + produced blob content to leak. + """ + try: + proc = subprocess.run( + ["git", *args], + capture_output=True, + check=False, + cwd=None if cwd is None else str(cwd), + ) + except OSError as exc: + raise ModelNetError(f"cannot run git {' '.join(args)}: {exc}") from exc + if proc.returncode != 0: + stderr = proc.stderr.decode("utf-8", errors="replace").strip() + raise ModelNetError(f"git {' '.join(args)} failed: {stderr}") + return proc.stdout + + +def repo_toplevel() -> Path: + """The repository root, so every git invocation below can be anchored to + it with `-C`. `git ls-files`' pathspecs are resolved relative to the + current directory, so a check run from a subdirectory would otherwise + match nothing and read as "every source is missing".""" + raw = run_git(["rev-parse", "--show-toplevel"]) + try: + text = raw.decode("utf-8").strip() + except UnicodeDecodeError as exc: + raise ModelNetError(f"repository path is not valid UTF-8: {exc}") from exc + if not text: + raise ModelNetError("git did not report a repository root") + return Path(text) + + +def index_blob_ids(root: Path, paths: list[str]) -> dict[str, str]: + """Blob id of each of `paths` as the **index** currently holds it. + + One `git ls-files -s` for the whole set, which is both cheaper and more + exact than hashing file content: it reads what would actually be + committed, and it needs no assumption about line endings, filters, or the + working tree matching the index at all. + + Paths absent from the index are simply absent from the result — it is the + caller that decides whether that is a refusal, because the two callers + mean different things by it. An unmerged entry (stage other than 0) is a + refusal here, since there is no single blob to compare against. + """ + if not paths: + return {} + raw = run_git(["-C", str(root), "ls-files", "-s", "-z", "--", *paths]) + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise ModelNetError(f"index listing is not valid UTF-8: {exc}") from exc + + found: dict[str, str] = {} + for entry in text.split("\0"): + if not entry: + continue + meta, _, path = entry.partition("\t") + parts = meta.split() + if len(parts) != 3 or not path: + raise ModelNetError("could not parse git ls-files output") + _mode, blob, stage = parts + if stage != "0": + raise ModelNetError( + f"{path}: unresolved merge conflict in the index; " + "resolve conflicts and re-stage before checking" + ) + found[path] = blob + return found + + +def read_artifact_from_index(root: Path) -> Any: + """The artifact as the **index** holds it, decoded from JSON. + + Read with `git show ":"`, never `Path.read_text()`. An unstaged + regeneration sitting only in the working tree must not let the stale + artifact that is actually about to be committed pass — which is the whole + reason this check exists rather than trusting the file on disk. + """ + try: + raw = run_git(["-C", str(root), "show", f":{ARTIFACT_PATH}"]) + except ModelNetError as exc: + # git's own message here is about a revision it could not resolve and + # says nothing about what to do — and "not in the index" is by far the + # most likely reason to reach it: the artifact was never generated, or + # was generated and never staged. Say that, and say how to fix it. + raise ModelNetError( + f"{ARTIFACT_PATH} is not in the git index, and " + 'check.model_net = "artifact" requires it. Generate it with: ' + f"{REGEN_COMMAND}, then: git add {ARTIFACT_PATH} " + f"(git reported: {str(exc).splitlines()[0]})" + ) from exc + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise ModelNetError( + f"{ARTIFACT_PATH}: staged content is not valid UTF-8: {exc}" + ) from exc + try: + return json.loads(text) + except ValueError as exc: + raise ModelNetError(f"{ARTIFACT_PATH}: invalid JSON: {exc}") from exc + + +def verify_sources(root: Path, sources: dict[str, str]) -> None: + """Refuse unless every recorded source is in the index at its recorded + blob id. + + A mismatch means a file that contributed a mark has changed since the + artifact was written, and the artifact may no longer describe the models + — so it is a hard refusal naming the regeneration command, never a + downgrade to running without this net. Reports the first offending path + in sorted order, so the message is stable across runs. + """ + found = index_blob_ids(root, sorted(sources)) + for path in sorted(sources): + actual = found.get(path) + if actual is None: + raise ModelNetError( + f"{ARTIFACT_PATH} is stale: {path} is no longer in the index; " + f"regenerate with: {REGEN_COMMAND}" + ) + if actual != sources[path]: + raise ModelNetError( + f"{ARTIFACT_PATH} is stale: {path} has changed since it was " + f"generated; regenerate with: {REGEN_COMMAND}" + ) + + +def load_model_net(check: CheckConfig) -> ModelNet | None: + """The model net for this repository, or `None` when it is switched off. + + `model_net = "none"` returns `None` without touching git or the + filesystem: it is a *declared* mode, and this function must be + indistinguishable from a repository that never had an artifact. Every + other outcome under `model_net = "artifact"` — no artifact in the index, + unreadable content, a malformed or unsupported graph, a stale source — + raises `ModelNetError`, which callers map to exit code 2. + """ + if check.model_net == "none": + return None + if check.model_net != "artifact": + raise ModelNetError(f"unknown check.model_net mode {check.model_net!r}") + + root = repo_toplevel() + net, sources = parse_net(read_artifact_from_index(root)) + verify_sources(root, sources) + return net diff --git a/tests/test_config.py b/tests/test_config.py index aa25052..b6ef581 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -139,6 +139,41 @@ def test_check_stack_models_value_with_empty_class_raises(tmp_path): ) +def test_check_declared_paths_fn_defaults_to_none(tmp_path): + """Absent means this tool's own marking convention, not a disabled net.""" + config = load_config(write_config(tmp_path, "[check]\n")) + assert config.check.declared_paths_fn is None + + +def test_check_declared_paths_fn_is_parsed(tmp_path): + config = load_config( + write_config( + tmp_path, + '[check]\ndeclared_paths_fn = "conventions.marks:secret_fields"\n', + ) + ) + assert config.check.declared_paths_fn == "conventions.marks:secret_fields" + + +def test_check_declared_paths_fn_with_no_colon_raises(tmp_path): + """Same shape as `stack_models`, rejected here for the same reason: a + malformed value would otherwise surface as a confusing ImportError deep + inside the generator subprocess, far from the file that caused it.""" + with pytest.raises(ConfigError) as exc_info: + load_config( + write_config( + tmp_path, + '[check]\ndeclared_paths_fn = "conventions.marks.secret_fields"\n', + ) + ) + assert "declared_paths_fn" in str(exc_info.value) + + +def test_check_declared_paths_fn_must_be_a_string(tmp_path): + with pytest.raises(ConfigError): + load_config(write_config(tmp_path, "[check]\ndeclared_paths_fn = 3\n")) + + def test_check_sensitive_keys_is_parsed_and_extends_the_builtins(tmp_path): config = load_config( write_config(tmp_path, '[check]\nsensitive_keys = ["jwtsecret"]\n') diff --git a/tests/test_heuristic.py b/tests/test_heuristic.py index 4f15e0f..796a843 100644 --- a/tests/test_heuristic.py +++ b/tests/test_heuristic.py @@ -119,7 +119,7 @@ def test_malformed_secure_wrapper_flagged_under_default_policy(): policy (no `sensitive_parents` declared, which is every repository's starting policy), this returned `[]` before propagation — neither "secure" nor "other" matches a built-in key, and no ancestor was - declared a sensitive parent, so `_is_encrypted`'s parent-shape check + declared a sensitive parent, so `is_encrypted`'s parent-shape check never even ran. A sensitive key now makes its whole subtree sensitive, so both leaves are correctly flagged.""" document = {"apiToken": {"secure": "v1:AAAA", "other": "leaked-plaintext"}} diff --git a/tests/test_model_net.py b/tests/test_model_net.py new file mode 100644 index 0000000..1de8a0c --- /dev/null +++ b/tests/test_model_net.py @@ -0,0 +1,1132 @@ +"""Tests for the model net: the declared-secrets graph, its generator, and +the freshness check that decides whether the committed one may be trusted. + +Three layers, deliberately separate: + +- **the matcher**, driven by hand-written graphs. These need neither pydantic + nor git, and they are where the semantics that make the net worth having + are pinned: prefix coverage, structural (not glob) key handling, and + matching at unbounded depth through a recursive model. +- **the generator**, run for real under this interpreter against synthetic + pydantic models written into a temporary repository. Nothing is mocked: + the marks are read from real `model_fields`, the MRO walk crosses real + module files, and the blob ids come from a real index. A mocked generator + would prove the artifact's shape and nothing about whether a mark in a + repository actually reaches it. +- **the freshness check**, on real git repositories, because "read the index, + not the working tree" is a claim about git and cannot be tested without it. + +Every model here is synthetic and named for its role in the test. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +from stackward.bootstrap import generator_source +from stackward.cli import main +from stackward.commands.sync_declared import serialise +from stackward.config import CheckConfig +from stackward.nets.heuristic import DocumentError +from stackward.nets.model import ( + ARTIFACT_PATH, + ARTIFACT_VERSION, + DictOf, + ListOf, + ModelNetError, + ModelRef, + find_declared_credentials, + parse_net, +) + +# --------------------------------------------------------------------------- +# Matcher fixtures: graphs written by hand, so a matcher bug cannot be masked +# by a generator bug that happens to cancel it out. +# --------------------------------------------------------------------------- + + +def build_net(models: dict, roots: dict): + """A `ModelNet` through the real artifact parser, not around it. + + Constructing `ModelNet` directly would skip every validation the parser + performs, and the parser is what a malformed committed artifact meets + first. + """ + net, _sources = parse_net( + { + "version": ARTIFACT_VERSION, + "models": models, + "roots": roots, + "sources": {"declared.py": "0" * 40}, + } + ) + return net + + +def match(document, net, allowed: list[str] | None = None) -> list[str]: + return find_declared_credentials( + document, net, CheckConfig(allowed_references=allowed or []) + ) + + +SELF_REFERENTIAL = build_net( + models={ + "declared:Node": { + "token": {"secret": True}, + "plain": {}, + "children": { + "child": {"kind": "list", "item": {"kind": "model", "model": "declared:Node"}} + }, + } + }, + roots={"app:app": "declared:Node"}, +) + +MUTUALLY_RECURSIVE = build_net( + models={ + "declared:Even": { + "odd": {"child": {"kind": "model", "model": "declared:Odd"}}, + }, + "declared:Odd": { + "token": {"secret": True}, + "even": {"child": {"kind": "model", "model": "declared:Even"}}, + }, + }, + roots={"app:app": "declared:Even"}, +) + +MARKED_CONTAINER = build_net( + models={"declared:Root": {"peer": {"secret": True}, "open": {}}}, + roots={"app:app": "declared:Root"}, +) + +MAP_OF_MODELS = build_net( + models={ + "declared:Root": { + "entries": { + "child": { + "kind": "dict", + "value": {"kind": "model", "model": "declared:Entry"}, + } + } + }, + "declared:Entry": {"token": {"secret": True}}, + }, + roots={"app:app": "declared:Root"}, +) + + +# --------------------------------------------------------------------------- +# The matcher: the semantics a flat pattern list cannot express. +# --------------------------------------------------------------------------- + + +def test_a_self_referential_model_matches_a_mark_at_depth(): + """The counterexample the graph form exists for. + + A generator emitting flat patterns needs a cycle guard, so it emits + `token` and stops — while the data it describes is unbounded. Here the + third level down must be found, which no finite pattern list produced + from this model would contain. + """ + document = { + "config": { + "app:app": { + "token": "level0", + "children": [ + {"token": "level1", "children": [{"token": "level2"}]}, + ], + } + } + } + assert match(document, SELF_REFERENTIAL) == [ + "config.app:app.children[0].children[0].token", + "config.app:app.children[0].token", + "config.app:app.token", + ] + + +def test_mutual_recursion_matches_through_both_models(): + """Neither model refers to itself; each refers to the other. The walk has + to follow the cycle as far as the *data* goes and no further.""" + document = { + "config": { + "app:app": { + "odd": { + "token": "first", + "even": {"odd": {"token": "second"}}, + } + } + } + } + assert match(document, MUTUALLY_RECURSIVE) == [ + "config.app:app.odd.even.odd.token", + "config.app:app.odd.token", + ] + + +def test_a_marked_container_covers_every_descendant(): + """Prefix, not exact: a mark covers everything beneath it whatever the + field's annotation was, and however deeply the data nests.""" + document = { + "config": { + "app:app": { + "peer": {"url": "u", "nested": {"k": "v", "list": ["a", "b"]}}, + "open": "not-declared", + } + } + } + assert match(document, MARKED_CONTAINER) == [ + "config.app:app.peer.nested.k", + "config.app:app.peer.nested.list[0]", + "config.app:app.peer.nested.list[1]", + "config.app:app.peer.url", + ] + + +def test_a_dict_key_containing_a_dot_is_reported_with_bracket_quoting(): + """Structural, not string-glob. + + The key `a.b` is one segment. A matcher that joined segments into a + dotted string and matched patterns against it would read this as two + segments and lose the leaf; the rendered finding is asserted in full + because the rendering is exactly what such an implementation gets wrong. + """ + document = {"config": {"app:app": {"entries": {"a.b": {"token": "value"}}}}} + assert match(document, MAP_OF_MODELS) == ['config.app:app.entries["a.b"].token'] + + +def test_a_dict_key_containing_a_bracket_is_reported_with_bracket_quoting(): + """The same for `[`, which a glob translating `*` to `[^.\\[]+` also + loses.""" + document = {"config": {"app:app": {"entries": {"a[0]": {"token": "value"}}}}} + assert match(document, MAP_OF_MODELS) == ['config.app:app.entries["a[0]"].token'] + + +def test_a_mapping_key_is_never_matched_against_a_field_name(): + """Under a `dict[str, Model]` the keys are data, not field names. + + The key here is literally `token`, the name of the marked field one level + further down. A matcher that consulted the model at a mapping position + would treat it as that field and cover its whole subtree — so the value + beneath it carries an *undeclared* leaf, which is what makes the + assertion discriminate rather than merely hold. + """ + document = { + "config": {"app:app": {"entries": {"token": {"undeclared": "ordinary"}}}} + } + assert match(document, MAP_OF_MODELS) == [] + + +def test_an_undeclared_field_is_not_reported(): + document = {"config": {"app:app": {"plain": "ordinary-value"}}} + assert match(document, SELF_REFERENTIAL) == [] + + +def test_a_namespace_with_no_declared_root_is_left_to_the_heuristic_net(): + document = {"config": {"other:other": {"token": "value"}}} + assert match(document, SELF_REFERENTIAL) == [] + + +def test_a_declared_field_holding_an_encryption_envelope_is_not_reported(): + """`{"secure": ...}` is Pulumi's encrypted form. Reporting it would make + the net fire on every value it had already succeeded in protecting.""" + document = {"config": {"app:app": {"token": {"secure": "v1:ciphertext"}}}} + assert match(document, SELF_REFERENTIAL) == [] + + +def test_a_malformed_encryption_envelope_is_still_reported(): + """A sibling key beside `secure` means the mapping is not the envelope, + and nothing tells a reader the sibling was not the value that leaked.""" + document = { + "config": {"app:app": {"token": {"secure": "v1:ciphertext", "other": "leak"}}} + } + assert match(document, SELF_REFERENTIAL) == [ + "config.app:app.token.other", + "config.app:app.token.secure", + ] + + +@pytest.mark.parametrize("value", [None, "", True, False]) +def test_an_empty_declared_field_is_not_reported(value): + document = {"config": {"app:app": {"token": value}}} + assert match(document, SELF_REFERENTIAL) == [] + + +@pytest.mark.parametrize("value", [0, 1]) +def test_the_integers_zero_and_one_are_not_treated_as_empty(value): + """`value in (None, "", False, True)` would exclude these, because + `0 == False` and `1 == True`.""" + document = {"config": {"app:app": {"token": value}}} + assert match(document, SELF_REFERENTIAL) == ["config.app:app.token"] + + +def test_an_allowed_reference_is_not_reported(): + document = {"config": {"app:app": {"token": "names-another-secret"}}} + assert match(document, SELF_REFERENTIAL, ["config.app:app.token"]) == [] + + +def test_a_self_referencing_document_does_not_recurse_forever(): + """A YAML anchor/alias pair can make a mapping contain itself. The walk + must stop, and must still report the credential sitting beside the back + edge rather than abandoning the branch.""" + node: dict = {"token": "found-me"} + node["children"] = [node] + document = {"config": {"app:app": node}} + assert match(document, SELF_REFERENTIAL) == ["config.app:app.token"] + + +def test_a_document_that_is_not_a_mapping_is_refused(): + with pytest.raises(DocumentError): + match(["not", "a", "mapping"], SELF_REFERENTIAL) + + +def test_a_document_without_a_config_section_matches_nothing(): + assert match({"name": "project"}, SELF_REFERENTIAL) == [] + + +def test_a_scalar_where_a_model_was_declared_matches_nothing(): + assert match({"config": {"app:app": "scalar"}}, SELF_REFERENTIAL) == [] + + +# --------------------------------------------------------------------------- +# The artifact parser: a committed file this tool cannot fully account for is +# a refusal, never a partial walk. +# --------------------------------------------------------------------------- + + +def parse_should_fail(payload) -> str: + with pytest.raises(ModelNetError) as excinfo: + parse_net(payload) + return str(excinfo.value) + + +def test_an_unsupported_artifact_version_is_refused(): + message = parse_should_fail( + {"version": ARTIFACT_VERSION + 1, "models": {}, "roots": {}, "sources": {"a": "b"}} + ) + assert "unsupported version" in message + assert "sync-declared-secrets" in message + + +def test_an_artifact_naming_an_undefined_model_is_refused(): + assert "undefined model" in parse_should_fail( + { + "version": ARTIFACT_VERSION, + "models": {}, + "roots": {"app:app": "declared:Missing"}, + "sources": {"a": "b"}, + } + ) + + +def test_an_artifact_with_an_unknown_child_kind_is_refused(): + assert "unknown child kind" in parse_should_fail( + { + "version": ARTIFACT_VERSION, + "models": {"declared:Root": {"f": {"child": {"kind": "tuple"}}}}, + "roots": {}, + "sources": {"a": "b"}, + } + ) + + +def test_an_artifact_with_no_sources_is_refused(): + """An artifact recording no sources can never be found stale, so it would + be trusted forever.""" + assert "freshness cannot be verified" in parse_should_fail( + {"version": ARTIFACT_VERSION, "models": {}, "roots": {}, "sources": {}} + ) + + +def test_a_parsed_graph_keeps_the_child_node_shapes(): + net = build_net( + models={ + "declared:Root": { + "a": {"child": {"kind": "model", "model": "declared:Root"}}, + "b": {"child": {"kind": "list", "item": {"kind": "model", "model": "declared:Root"}}}, + "c": {"child": {"kind": "dict", "value": {"kind": "model", "model": "declared:Root"}}}, + } + }, + roots={}, + ) + fields = net.models["declared:Root"] + assert fields["a"].child == ModelRef("declared:Root") + assert fields["b"].child == ListOf(ModelRef("declared:Root")) + assert fields["c"].child == DictOf(ModelRef("declared:Root")) + + +# --------------------------------------------------------------------------- +# Real repositories, real models, real git. +# --------------------------------------------------------------------------- + + +def git(repo: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", *args], cwd=repo, capture_output=True, text=True, check=True + ) + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + path = tmp_path / "repo" + path.mkdir() + git(path, "init", "-q") + git(path, "config", "user.email", "test@example.com") + git(path, "config", "user.name", "Test") + git(path, "commit", "-q", "--allow-empty", "-m", "init") + return path + + +def write(repo: Path, name: str, content: str) -> Path: + target = repo / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(textwrap.dedent(content)) + return target + + +def declare(repo: Path, models: str = 'declared:Root', extra: str = "") -> None: + """Write a `.stackward.toml` naming this interpreter and one root model.""" + write( + repo, + ".stackward.toml", + f""" + python = "{sys.executable}" + + [check] + model_net = "artifact" + stack_models = {{ "app:app" = "{models}" }} + {extra} + """, + ) + + +def sync(repo: Path, monkeypatch) -> int: + monkeypatch.chdir(repo) + return main(["sync-declared-secrets"]) + + +def check(repo: Path, monkeypatch, name: str = "Pulumi.dev.yaml") -> int: + monkeypatch.chdir(repo) + return main(["check-config", name]) + + +def artifact(repo: Path) -> dict: + return json.loads((repo / ARTIFACT_PATH).read_text()) + + +def sync_and_commit(repo: Path, monkeypatch, capsys) -> dict: + assert sync(repo, monkeypatch) == 0 + capsys.readouterr() + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", "declare") + return artifact(repo) + + +RECURSIVE_MODEL = """ + from __future__ import annotations + + from pydantic import BaseModel, Field + + + class Root(BaseModel): + token: str = Field(default="", json_schema_extra={"secret": True}) + plain: str = "" + children: list[Root] = [] +""" + +# The same model with one more field. Used to change a *source* file after the +# artifact was written, which is what the freshness check has to notice. +RECURSIVE_MODEL_EDITED = RECURSIVE_MODEL + ' added_later: str = ""\n' + +# A marked field whose name matches none of the heuristic net's built-in +# patterns (password, passphrase, token, secret, apikey, api_key, jwt, +# credential, private_key, access_key_id, secret_access_key). The model net is +# the only thing that can name this one, which is what the pair of tests using +# it exists to demonstrate. +HEURISTIC_BLIND_MODEL = """ + from __future__ import annotations + + from pydantic import BaseModel, Field + + + class Root(BaseModel): + binding: str = Field(default="", json_schema_extra={"secret": True}) + plain: str = "" + children: list[Root] = [] +""" + +# A credential the heuristic net cannot reach: an unremarkable key name, at a +# depth no finite pattern list produced from the model would enumerate. +BLIND_DOCUMENT = ( + "config:\n" + " app:app:\n" + " children:\n" + " - children:\n" + " - binding: deeply-nested\n" +) + + +# --------------------------------------------------------------------------- +# Generation: what reaches the artifact, and what is refused rather than +# silently omitted from it. +# --------------------------------------------------------------------------- + + +def test_a_self_referential_model_generates_a_graph_not_a_pattern_list( + repo, monkeypatch, capsys +): + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + + fields = written["models"]["declared:Root"] + assert fields["token"] == {"secret": True} + # The reference points back at the same model, which is what makes the + # unbounded case expressible in a finite file. + assert fields["children"]["child"] == { + "kind": "list", + "item": {"kind": "model", "model": "declared:Root"}, + } + + +def test_a_mark_inherited_from_a_base_class_in_another_module_is_detected( + repo, monkeypatch, capsys +): + write( + repo, + "ancestry.py", + """ + from pydantic import BaseModel, Field + + + class Ancestor(BaseModel): + handed_down: str = Field(default="", json_schema_extra={"secret": True}) + """, + ) + write( + repo, + "declared.py", + """ + from ancestry import Ancestor + + + class Root(Ancestor): + own: str = "" + """, + ) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert written["models"]["declared:Root"]["handed_down"] == {"secret": True} + + +def test_a_base_class_in_another_module_is_recorded_as_a_source( + repo, monkeypatch, capsys +): + """`getsourcefile(Derived)` names only the subclass's own file, so the + freshness check must walk the MRO. Without that, removing the mark from + the base class would never register as a change.""" + write( + repo, + "ancestry.py", + """ + from pydantic import BaseModel, Field + + + class Ancestor(BaseModel): + handed_down: str = Field(default="", json_schema_extra={"secret": True}) + """, + ) + write(repo, "declared.py", "from ancestry import Ancestor\n\n\nclass Root(Ancestor):\n own: str = ''\n") + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert "ancestry.py" in written["sources"] + + +def test_a_mark_carried_by_an_annotated_alias_defined_elsewhere_is_detected( + repo, monkeypatch, capsys +): + write( + repo, + "conventions.py", + """ + from typing import Annotated + + from pydantic import Field + + Guarded = Annotated[str, Field(json_schema_extra={"secret": True})] + """, + ) + write( + repo, + "declared.py", + """ + from pydantic import BaseModel + + from conventions import Guarded + + + class Root(BaseModel): + borrowed: Guarded = "" + """, + ) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert written["models"]["declared:Root"]["borrowed"] == {"secret": True} + + +def test_the_module_defining_an_annotated_alias_is_recorded_as_a_source( + repo, monkeypatch, capsys +): + """pydantic resolves the alias's mark onto the field and discards the + wrapper, so nothing on the field says where the alias was written. The + alias module is found by identity instead — and if it were not recorded, + editing the mark out of it would leave the artifact silently wrong.""" + write( + repo, + "conventions.py", + """ + from typing import Annotated + + from pydantic import Field + + Guarded = Annotated[str, Field(json_schema_extra={"secret": True})] + """, + ) + write( + repo, + "declared.py", + """ + from pydantic import BaseModel + + from conventions import Guarded + + + class Root(BaseModel): + borrowed: Guarded = "" + """, + ) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert "conventions.py" in written["sources"] + + +def test_the_artifact_never_records_itself_as_a_source(repo, monkeypatch, capsys): + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert ARTIFACT_PATH not in written["sources"] + + +def test_regenerating_unchanged_models_produces_identical_bytes( + repo, monkeypatch, capsys +): + """The sound closure for staleness is regenerating in CI and failing on + any diff, which a generator with unstable output would turn into noise.""" + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 0 + first = (repo / ARTIFACT_PATH).read_bytes() + assert sync(repo, monkeypatch) == 0 + capsys.readouterr() + assert (repo / ARTIFACT_PATH).read_bytes() == first + + +def test_the_artifact_is_serialised_with_sorted_keys(): + """Byte-determinism across *environments*, which regenerating twice on one + machine cannot demonstrate: dictionary insertion order here follows the + order classes happened to be walked in, and `sort_keys` is the only thing + that makes the committed bytes independent of it. + """ + text = serialise( + { + "version": ARTIFACT_VERSION, + "sources": {"zebra.py": "b" * 40, "alpha.py": "a" * 40}, + "roots": {}, + "models": {}, + } + ).decode("utf-8") + assert text.index('"models"') < text.index('"roots"') < text.index('"sources"') + assert text.index("alpha.py") < text.index("zebra.py") + + +def test_the_artifact_ends_with_exactly_one_newline(repo, monkeypatch, capsys): + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 0 + capsys.readouterr() + assert (repo / ARTIFACT_PATH).read_bytes().endswith(b"}\n") + + +UNWALKABLE = { + "union_of_two_models": "peer: Other | Third = Other()", + "abstract_sequence_of_models": "peer: Sequence[Other] = []", + "abstract_mapping_of_models": "peer: Mapping[str, Other] = {}", + "bare_dict": "peer: dict = {}", + "any": "peer: Any = None", + "set_of_models": "peer: set[Other] = set()", +} + + +@pytest.mark.parametrize("name", sorted(UNWALKABLE)) +def test_an_unwalkable_annotation_is_refused_rather_than_silently_skipped( + name, repo, monkeypatch, capsys +): + """Emitting nothing for an annotation this tool cannot walk is the + fail-open the whole design exists to prevent: the artifact would look + complete while covering less than the repository declared.""" + write( + repo, + "declared.py", + f""" + from typing import Any, Mapping, Sequence + + from pydantic import BaseModel, Field + + + class Other(BaseModel): + token: str = Field(default="", json_schema_extra={{"secret": True}}) + + + class Third(BaseModel): + token: str = Field(default="", json_schema_extra={{"secret": True}}) + + + class Root(BaseModel): + {UNWALKABLE[name]} + """, + ) + declare(repo) + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 2 + assert not (repo / ARTIFACT_PATH).exists() + assert "declared:Root.peer" in capsys.readouterr().err + + +def test_a_marked_field_may_carry_an_annotation_that_cannot_be_walked( + repo, monkeypatch, capsys +): + """Coverage is prefix-based, so a marked field's annotation stops + mattering — refusing one would refuse a declaration that is already + complete.""" + write( + repo, + "declared.py", + """ + from typing import Any + + from pydantic import BaseModel, Field + + + class Root(BaseModel): + anything: Any = Field(default=None, json_schema_extra={"secret": True}) + """, + ) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert written["models"]["declared:Root"]["anything"] == {"secret": True} + + +def test_an_annotation_that_cannot_hold_a_model_is_a_leaf(repo, monkeypatch, capsys): + """`Sequence[str]` is refused only when it could carry a model. A + container of scalars has nothing beneath it to declare, so it is not a + refusal — otherwise the rule would be unusable.""" + write( + repo, + "declared.py", + """ + from typing import Literal, Sequence + + from pydantic import BaseModel + + + class Root(BaseModel): + names: Sequence[str] = [] + mode: Literal["a", "b"] = "a" + either: str | int = "" + """, + ) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert written["models"]["declared:Root"] == {} + + +def test_an_untracked_contributing_file_is_refused(repo, monkeypatch, capsys): + """A file git does not track has no blob id, so its marks could change + forever without the freshness check seeing anything.""" + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", ".stackward.toml") + assert sync(repo, monkeypatch) == 2 + assert "not tracked by git" in capsys.readouterr().err + + +def test_a_non_string_validation_alias_is_refused(repo, monkeypatch, capsys): + """Without a determinable data key there is no position at which to apply + coverage, so a mark on such a field would cover nothing at all.""" + write( + repo, + "declared.py", + """ + from pydantic import AliasChoices, BaseModel, Field + + + class Root(BaseModel): + token: str = Field( + default="", + validation_alias=AliasChoices("token", "tok"), + json_schema_extra={"secret": True}, + ) + """, + ) + declare(repo) + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 2 + assert "validation_alias" in capsys.readouterr().err + + +def test_a_field_is_matched_under_its_string_alias(repo, monkeypatch, capsys): + """Keying only by field name would miss an aliased field silently, which + is a fail-open; the field is emitted under every key it could appear + under.""" + write( + repo, + "declared.py", + """ + from pydantic import BaseModel, Field + + + class Root(BaseModel): + token: str = Field( + default="", alias="apiToken", json_schema_extra={"secret": True} + ) + """, + ) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert written["models"]["declared:Root"]["apiToken"] == {"secret": True} + assert written["models"]["declared:Root"]["token"] == {"secret": True} + + +def test_a_callable_json_schema_extra_is_refused(repo, monkeypatch, capsys): + """Its result depends on a schema-generation context that does not exist + here, so "cannot determine" must not become "not marked".""" + write( + repo, + "declared.py", + """ + from pydantic import BaseModel, Field + + + def annotate(schema: dict) -> None: + schema["secret"] = True + + + class Root(BaseModel): + token: str = Field(default="", json_schema_extra=annotate) + """, + ) + declare(repo) + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 2 + assert "callable" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# `declared_paths_fn`: the escape hatch for a non-standard marking convention. +# --------------------------------------------------------------------------- + + +def test_a_declared_paths_fn_replaces_the_marking_convention( + repo, monkeypatch, capsys +): + write( + repo, + "declared.py", + """ + from pydantic import BaseModel + + + class Root(BaseModel): + handled: str = "" + ordinary: str = "" + """, + ) + write( + repo, + "convention.py", + """ + def marks(model): + return [name for name in model.model_fields if name == "handled"] + """, + ) + declare(repo, extra='declared_paths_fn = "convention:marks"') + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert written["models"]["declared:Root"] == {"handled": {"secret": True}} + # The hook decides the marks, so its own module is a source too. + assert "convention.py" in written["sources"] + + +def test_a_declared_paths_fn_returning_a_path_is_refused(repo, monkeypatch, capsys): + """A hook returning flat paths would reintroduce the enumeration the + graph replaced, so it is refused rather than interpreted.""" + write(repo, "declared.py", RECURSIVE_MODEL) + write( + repo, + "convention.py", + """ + def marks(model): + return ["children[0].token"] + """, + ) + declare(repo, extra='declared_paths_fn = "convention:marks"') + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 2 + assert "field names, not paths" in capsys.readouterr().err + + +def test_a_declared_paths_fn_naming_an_unknown_field_is_refused( + repo, monkeypatch, capsys +): + """A typo that marked nothing would be indistinguishable from a field + deliberately left unmarked.""" + write(repo, "declared.py", RECURSIVE_MODEL) + write( + repo, + "convention.py", + """ + def marks(model): + return ["toekn"] + """, + ) + declare(repo, extra='declared_paths_fn = "convention:marks"') + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 2 + assert "not a field of this model" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# The gate: what `check-config` does with the artifact it finds. +# --------------------------------------------------------------------------- + + +def prepare_gate(repo: Path, monkeypatch, capsys, config: str) -> None: + """A repository whose models are declared, generated, and committed.""" + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + sync_and_commit(repo, monkeypatch, capsys) + write(repo, "Pulumi.dev.yaml", config) + + +def test_the_heuristic_net_alone_cannot_name_this_credential( + repo, monkeypatch, capsys +): + """Half of a pair, and the half that makes the other half mean something. + + With the model net switched off, this document is clean as far as the + gate is concerned: `binding` matches no built-in key pattern. If this + ever starts failing, the companion test below has stopped proving that + the model net is what found it. + """ + write(repo, ".stackward.toml", '[check]\nmodel_net = "none"\n') + write(repo, "Pulumi.dev.yaml", BLIND_DOCUMENT) + git(repo, "add", "-A") + assert check(repo, monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +def test_a_declared_secret_the_heuristic_cannot_name_is_found( + repo, monkeypatch, capsys +): + """The whole point of the second net: an unremarkable key name, at a depth + no finite pattern list produced from this model would enumerate.""" + write(repo, "declared.py", HEURISTIC_BLIND_MODEL) + declare(repo) + git(repo, "add", "-A") + sync_and_commit(repo, monkeypatch, capsys) + write(repo, "Pulumi.dev.yaml", BLIND_DOCUMENT) + + assert check(repo, monkeypatch) == 1 + out = capsys.readouterr().out + assert ( + "Pulumi.dev.yaml: plaintext credential at " + "'config.app:app.children[0].children[0].binding'" in out + ) + assert "deeply-nested" not in out + + +def test_a_leaf_both_nets_name_is_reported_once(repo, monkeypatch, capsys): + """`token` matches a built-in heuristic pattern *and* carries a mark. Two + nets finding one leaf is one problem, not two.""" + prepare_gate( + repo, monkeypatch, capsys, "config:\n app:app:\n token: value\n" + ) + assert check(repo, monkeypatch) == 1 + lines = [ + line + for line in capsys.readouterr().out.splitlines() + if "config.app:app.token" in line + ] + assert len(lines) == 1 + + +def test_a_clean_config_under_a_declared_model_exits_0(repo, monkeypatch, capsys): + prepare_gate( + repo, monkeypatch, capsys, "config:\n app:app:\n plain: ordinary\n" + ) + assert check(repo, monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +# --------------------------------------------------------------------------- +# Freshness. +# --------------------------------------------------------------------------- + + +def test_a_stale_artifact_exits_2_not_1(repo, monkeypatch, capsys): + """Exit 1 means "a credential was found". A refusal to run is a different + answer and must not be filed under that one.""" + prepare_gate( + repo, monkeypatch, capsys, "config:\n app:app:\n token: value\n" + ) + write(repo, "declared.py", RECURSIVE_MODEL_EDITED) + git(repo, "add", "declared.py") + + assert check(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert "declared.py has changed" in captured.err + assert "sync-declared-secrets" in captured.err + # And no finding was printed: the command does not know what was declared. + assert captured.out == "" + + +def test_an_unstaged_regeneration_does_not_rescue_a_stale_artifact( + repo, monkeypatch, capsys +): + """The artifact is read from the index, not the working tree — otherwise + a regeneration nobody staged would let the stale one that is actually + about to be committed pass.""" + prepare_gate( + repo, monkeypatch, capsys, "config:\n app:app:\n token: value\n" + ) + write(repo, "declared.py", RECURSIVE_MODEL_EDITED) + git(repo, "add", "declared.py") + assert sync(repo, monkeypatch) == 0 # working tree only; never staged + capsys.readouterr() + + assert check(repo, monkeypatch) == 2 + assert "sync-declared-secrets" in capsys.readouterr().err + + +def test_a_staged_regeneration_clears_the_staleness(repo, monkeypatch, capsys): + """The other direction, so the test above is not passing for the trivial + reason that nothing ever clears.""" + prepare_gate( + repo, monkeypatch, capsys, "config:\n app:app:\n token: value\n" + ) + write(repo, "declared.py", RECURSIVE_MODEL_EDITED) + git(repo, "add", "declared.py") + assert sync(repo, monkeypatch) == 0 + git(repo, "add", ARTIFACT_PATH) + capsys.readouterr() + + assert check(repo, monkeypatch) == 1 + + +def test_a_missing_artifact_under_artifact_mode_exits_2(repo, monkeypatch, capsys): + """`"artifact"` never degrades to running without the net.""" + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + write(repo, "Pulumi.dev.yaml", "config:\n app:app:\n plain: ordinary\n") + git(repo, "add", "-A") + + assert check(repo, monkeypatch) == 2 + error = capsys.readouterr().err + assert "is not in the git index" in error + assert "sync-declared-secrets" in error + + +def test_a_malformed_artifact_in_the_index_exits_2(repo, monkeypatch, capsys): + declare(repo) + write(repo, ARTIFACT_PATH, "{not json") + write(repo, "Pulumi.dev.yaml", "config:\n app:app:\n plain: ordinary\n") + git(repo, "add", "-A") + + assert check(repo, monkeypatch) == 2 + assert "invalid JSON" in capsys.readouterr().err + + +def test_none_mode_skips_cleanly_with_no_artifact_present(repo, monkeypatch, capsys): + """A declared mode, not a fallback: nothing is read, so the absence of the + artifact directory entirely cannot rescue a test that would otherwise + have got lucky.""" + write(repo, ".stackward.toml", '[check]\nmodel_net = "none"\n') + write(repo, "Pulumi.dev.yaml", "config:\n app:app:\n plain: ordinary\n") + git(repo, "add", "-A") + assert not (repo / ".stackward").exists() + + assert check(repo, monkeypatch) == 0 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + +def test_none_mode_still_runs_the_heuristic_net(repo, monkeypatch, capsys): + """Switching off the model net is not switching off the gate.""" + write(repo, ".stackward.toml", '[check]\nmodel_net = "none"\n') + write(repo, "Pulumi.dev.yaml", "config:\n app:app:\n dbPassword: value\n") + git(repo, "add", "-A") + assert check(repo, monkeypatch) == 1 + assert "config.app:app.dbPassword" in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# Global Constraint 1: pydantic is test-only. +# --------------------------------------------------------------------------- + + +def test_stackward_never_imports_pydantic(): + """The shipped binary must neither import nor bundle pydantic. Checked in + a subprocess because this test process has imported it already — asserting + against `sys.modules` here would assert nothing. + """ + proc = subprocess.run( + [ + sys.executable, + "-c", + "import sys; import stackward.cli; " + "import stackward.commands.sync_declared; " + "print('pydantic' in sys.modules)", + ], + capture_output=True, + text=True, + check=True, + ) + assert proc.stdout.strip() == "False" + + +def test_the_generator_is_read_through_the_accessor_the_shipped_code_uses(): + """Reading the file by path instead would assert nothing about a frozen + build, where that path does not exist.""" + source = generator_source() + assert "def generate(" in source + assert "from pydantic import BaseModel" in source From 351fae50c2a8776784710fdb3c5b9094108b33db Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 12:58:31 -0700 Subject: [PATCH 26/44] fix: self-review -- validate the graph before writing, and guard the gate path Four things the diff review found: - `sync-declared-secrets` wrote the artifact without ever reading it back. The generator and the matcher are two halves of one schema running in two processes under two interpreters, so a drift between them would have surfaced as a refusal in someone else's repository at their next commit. `build_artifact` now parses its own output with the matcher's parser and refuses on anything it cannot read. - `repo_toplevel` did not resolve its path, while every caller compares it against a resolved module path. Under a symlinked parent directory those are two strings for one directory and every source file would have read as "outside the repository". - No test asserted that the gate path reaches no credential code -- and this change added an import to it. Added one, in a subprocess. - `nets/__init__.py` still described the model net as a later addition. Also two tests that passed for the wrong reason, both found by sabotage rather than by the suite: the mapping-key test's value had no leaf beneath it, so matching keys against field names changed nothing observable; and regenerating twice on one machine cannot detect unsorted keys, since one interpreter's dict order is already stable. Both now discriminate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/commands/check_config.py | 7 ++- src/stackward/commands/sync_declared.py | 18 +++++- src/stackward/nets/__init__.py | 16 ++++-- src/stackward/nets/model.py | 7 ++- tests/test_model_net.py | 74 +++++++++++++++++++++++-- 5 files changed, 107 insertions(+), 15 deletions(-) diff --git a/src/stackward/commands/check_config.py b/src/stackward/commands/check_config.py index 8984a71..fcce09d 100644 --- a/src/stackward/commands/check_config.py +++ b/src/stackward/commands/check_config.py @@ -37,7 +37,12 @@ from ..config import CheckConfig, ConfigError, find_repo_config, load_config from ..nets.heuristic import DocumentError, find_plaintext_credentials -from ..nets.model import ModelNet, ModelNetError, find_declared_credentials, load_model_net +from ..nets.model import ( + ModelNet, + ModelNetError, + find_declared_credentials, + load_model_net, +) # Matches a repr-quoted fragment the way PyYAML's `%r` interpolation # produces one — e.g. `'2'`, `` '`' ``, `'id001'`. Python's `repr()` flips diff --git a/src/stackward/commands/sync_declared.py b/src/stackward/commands/sync_declared.py index 6fdbeba..dcb24e8 100644 --- a/src/stackward/commands/sync_declared.py +++ b/src/stackward/commands/sync_declared.py @@ -49,6 +49,7 @@ ARTIFACT_VERSION, ModelNetError, index_blob_ids, + parse_net, repo_toplevel, ) from ..store import atomic_write @@ -252,12 +253,23 @@ def build_artifact(root: Path, result: dict[str, object]) -> dict[str, object]: "recorded to check for staleness" ) - return { + artifact = { "version": ARTIFACT_VERSION, "roots": result.get("roots", {}), "models": result.get("models", {}), "sources": sources, } + # Read it back with the matcher's own parser before writing it. The + # generator and the matcher are the two halves of one schema, and they run + # in different processes under different interpreters — so nothing else + # would notice them drifting apart until the next commit, in someone + # else's repository, as a refusal they did not cause. Checking here turns + # that into a failure at the moment it is introduced. + try: + parse_net(artifact) + except ModelNetError as exc: + raise SyncError(f"the generated artifact is not readable: {exc}") from exc + return artifact def serialise(artifact: dict[str, object]) -> bytes: @@ -275,8 +287,8 @@ def sync(root: Path, config_path: Path, config: Config) -> Path: """Generate and write the artifact. Returns the path written.""" if not config.check.stack_models: raise SyncError( - f"{config_path}: check.stack_models is empty; there is nothing to " - "walk. Declare a model, or set check.model_net = \"none\"" + f"{config_path}: check.stack_models is empty; there is nothing " + 'to walk. Declare a model, or set check.model_net = "none"' ) result = run_generator( diff --git a/src/stackward/nets/__init__.py b/src/stackward/nets/__init__.py index e551e45..3730b4f 100644 --- a/src/stackward/nets/__init__.py +++ b/src/stackward/nets/__init__.py @@ -1,10 +1,18 @@ """The credential-detection nets: independent ways to answer "is this a plaintext credential?" over a parsed stack config document. -`heuristic` is the key-name/parent-name net and ships unconditionally. A -model net (walking a repository's own pydantic models, via `[check]`'s -`model_net`/`stack_models`) is a separate, later addition — this package is -named for the plural on purpose. +`heuristic` is the key-name/parent-name net and ships unconditionally: it +needs nothing from the repository beyond `[check]` policy. `model` is the +declaration-driven net, built from a repository's own pydantic marks and +carried across the frozen-binary boundary as a committed graph (`[check]`'s +`model_net`/`stack_models`); it runs only when that repository declares it. + +The two are **unioned**, never ranked — a leaf either can name is a finding — +and they share one leaf decision (`heuristic.is_encrypted`, +`heuristic.is_empty`) so they cannot drift into disagreeing about whether the +same value is a credential. Both take an already-parsed document and do no +I/O, which is what lets one caller scan a file on disk and another scan a +staged git blob without either round-tripping content through a temp file. """ from __future__ import annotations diff --git a/src/stackward/nets/model.py b/src/stackward/nets/model.py index 4eb242c..d419af0 100644 --- a/src/stackward/nets/model.py +++ b/src/stackward/nets/model.py @@ -530,7 +530,12 @@ def repo_toplevel() -> Path: raise ModelNetError(f"repository path is not valid UTF-8: {exc}") from exc if not text: raise ModelNetError("git did not report a repository root") - return Path(text) + # Resolved, because callers compare it against `Path(...).resolve()` of a + # source file to decide whether that file is inside the repository. On a + # platform where the repository sits under a symlinked directory, git's + # own answer and a resolved module path are two different strings for one + # directory, and the comparison would fail for every file. + return Path(text).resolve() def index_blob_ids(root: Path, paths: list[str]) -> dict[str, str]: diff --git a/tests/test_model_net.py b/tests/test_model_net.py index 1de8a0c..1312f51 100644 --- a/tests/test_model_net.py +++ b/tests/test_model_net.py @@ -31,7 +31,7 @@ from stackward.bootstrap import generator_source from stackward.cli import main -from stackward.commands.sync_declared import serialise +from stackward.commands.sync_declared import SyncError, build_artifact, serialise from stackward.config import CheckConfig from stackward.nets.heuristic import DocumentError from stackward.nets.model import ( @@ -81,7 +81,10 @@ def match(document, net, allowed: list[str] | None = None) -> list[str]: "token": {"secret": True}, "plain": {}, "children": { - "child": {"kind": "list", "item": {"kind": "model", "model": "declared:Node"}} + "child": { + "kind": "list", + "item": {"kind": "model", "model": "declared:Node"}, + } }, } }, @@ -346,12 +349,13 @@ def test_an_artifact_with_no_sources_is_refused(): def test_a_parsed_graph_keeps_the_child_node_shapes(): + SELF = {"kind": "model", "model": "declared:Root"} net = build_net( models={ "declared:Root": { - "a": {"child": {"kind": "model", "model": "declared:Root"}}, - "b": {"child": {"kind": "list", "item": {"kind": "model", "model": "declared:Root"}}}, - "c": {"child": {"kind": "dict", "value": {"kind": "model", "model": "declared:Root"}}}, + "a": {"child": SELF}, + "b": {"child": {"kind": "list", "item": SELF}}, + "c": {"child": {"kind": "dict", "value": SELF}}, } }, roots={}, @@ -545,7 +549,17 @@ class Ancestor(BaseModel): handed_down: str = Field(default="", json_schema_extra={"secret": True}) """, ) - write(repo, "declared.py", "from ancestry import Ancestor\n\n\nclass Root(Ancestor):\n own: str = ''\n") + write( + repo, + "declared.py", + """ + from ancestry import Ancestor + + + class Root(Ancestor): + own: str = "" + """, + ) declare(repo) git(repo, "add", "-A") written = sync_and_commit(repo, monkeypatch, capsys) @@ -767,6 +781,31 @@ class Root(BaseModel): assert written["models"]["declared:Root"] == {} +def test_a_graph_the_matcher_could_not_read_is_refused_before_it_is_written( + repo, monkeypatch, capsys +): + """The generator and the matcher are two halves of one schema running in + two processes. Reading the artifact back through the matcher's own parser + before writing it turns a drift between them into a failure where it was + introduced, rather than a refusal in someone else's repository later.""" + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + monkeypatch.chdir(repo) + with pytest.raises(SyncError) as excinfo: + build_artifact( + repo, + { + "roots": {"app:app": "declared:NeverDefined"}, + "models": {}, + "files": [str(repo / "declared.py")], + "external": [], + }, + ) + assert "not readable" in str(excinfo.value) + assert "undefined model" in str(excinfo.value) + + def test_an_untracked_contributing_file_is_refused(repo, monkeypatch, capsys): """A file git does not track has no blob id, so its marks could change forever without the freshness check seeing anything.""" @@ -1124,6 +1163,29 @@ def test_stackward_never_imports_pydantic(): assert proc.stdout.strip() == "False" +def test_the_gate_path_reaches_no_credential_code(): + """`check-config` and `pre-commit` must never reach the credential store + or `cryptography`, so a commit cannot be blocked by an expired session or + a missing native extension. This net added an import to the gate path, so + the property is worth asserting rather than assuming — checked in a + subprocess, since this test process has imported half the tool already. + """ + proc = subprocess.run( + [ + sys.executable, + "-c", + "import sys; import stackward.commands.check_config; " + "import stackward.commands.pre_commit; " + "print(sorted(m for m in ('cryptography', 'stackward.store') " + "if m in sys.modules))", + ], + capture_output=True, + text=True, + check=True, + ) + assert proc.stdout.strip() == "[]" + + def test_the_generator_is_read_through_the_accessor_the_shipped_code_uses(): """Reading the file by path instead would assert nothing about a frozen build, where that path does not exist.""" From 06eacb6a69bf00aee46eb3134f0de94335604ce7 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 13:02:57 -0700 Subject: [PATCH 27/44] test: cover the out-of-repo contributor and lockfile pinning path The brief mandates recording the dependency lockfile's hash for modules outside the repository. That was implemented and then had no test, and none of the sabotage mutations touched it either -- so the whole branch was asserted by nothing. Five tests through `build_artifact`, which is already directly callable: - a tracked lockfile is recorded by blob id, compared against `git rev-parse` - every tracked lockfile is recorded, not the first match - a lockfile on disk but not in the index pins nothing and still refuses - no tracked lockfile with an out-of-repo contributor refuses, naming both the module and the filenames looked for - no lockfile is recorded when every mark came from inside the repository Four mutations confirm they discriminate: no lockfile ever recorded, only the first recorded, the out-of-repo refusal removed, and an untracked lockfile treated as though it pinned something. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- tests/test_model_net.py | 79 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/test_model_net.py b/tests/test_model_net.py index 1312f51..928e42e 100644 --- a/tests/test_model_net.py +++ b/tests/test_model_net.py @@ -806,6 +806,85 @@ def test_a_graph_the_matcher_could_not_read_is_refused_before_it_is_written( assert "undefined model" in str(excinfo.value) +def external_result(repo: Path) -> dict: + """A generator result whose marks came partly from outside the repository + — a base model or a marking alias imported from a dependency.""" + return { + "roots": {}, + "models": {}, + "files": [str(repo / "declared.py")], + "external": ["outside.package.conventions"], + } + + +def test_an_out_of_repo_contributor_is_pinned_by_the_dependency_lockfile(repo): + """`git show` cannot reach site-packages, so the lockfile that decides + which version is installed is the closest thing to that module's identity + the index holds.""" + write(repo, "declared.py", RECURSIVE_MODEL) + write(repo, "uv.lock", "# a lockfile\n") + git(repo, "add", "-A") + + sources = build_artifact(repo, external_result(repo))["sources"] + assert "uv.lock" in sources + # Recorded by blob id, exactly like every other source. + expected = git(repo, "rev-parse", ":uv.lock").stdout.strip() + assert sources["uv.lock"] == expected + + +def test_every_tracked_lockfile_is_recorded_not_only_the_first(repo): + """A repository can carry two. Pinning one while ignoring the other would + leave a real dependency change invisible.""" + write(repo, "declared.py", RECURSIVE_MODEL) + write(repo, "uv.lock", "# a lockfile\n") + write(repo, "requirements.txt", "somepackage==1.0\n") + git(repo, "add", "-A") + + sources = build_artifact(repo, external_result(repo))["sources"] + assert "uv.lock" in sources + assert "requirements.txt" in sources + + +def test_an_untracked_lockfile_does_not_pin_an_out_of_repo_contributor(repo): + """A lockfile on disk but not in the index has no blob id to compare + against later, so it pins nothing and must not be treated as though it + did.""" + write(repo, "declared.py", RECURSIVE_MODEL) + write(repo, "uv.lock", "# a lockfile\n") + git(repo, "add", "declared.py") # the lockfile is deliberately left out + + with pytest.raises(SyncError) as excinfo: + build_artifact(repo, external_result(repo)) + assert "outside.package.conventions" in str(excinfo.value) + + +def test_an_out_of_repo_contributor_with_no_lockfile_is_refused(repo): + """There would be nothing at all pinning that module's identity, so the + artifact could never be found stale on account of it.""" + write(repo, "declared.py", RECURSIVE_MODEL) + git(repo, "add", "-A") + + with pytest.raises(SyncError) as excinfo: + build_artifact(repo, external_result(repo)) + message = str(excinfo.value) + assert "outside.package.conventions" in message + assert "uv.lock" in message # the message names what it looked for + + +def test_no_lockfile_is_recorded_when_nothing_came_from_outside_the_repo(repo): + """The lockfile is evidence about a dependency, and a repository whose + marks are all its own has no dependency to pin.""" + write(repo, "declared.py", RECURSIVE_MODEL) + write(repo, "uv.lock", "# a lockfile\n") + git(repo, "add", "-A") + + sources = build_artifact( + repo, + {"roots": {}, "models": {}, "files": [str(repo / "declared.py")], "external": []}, + )["sources"] + assert sources == {"declared.py": sources["declared.py"]} + + def test_an_untracked_contributing_file_is_refused(repo, monkeypatch, capsys): """A file git does not track has no blob id, so its marks could change forever without the freshness check seeing anything.""" From 84bfe83f011de1b096f634aff99bde278eec6148 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 13:14:13 -0700 Subject: [PATCH 28/44] feat: run the model net in the hook, refuse RootModel, verify the bundle Four items from coordinator review; two are correctness. 1. `pre-commit` now runs the model net. It is the layer that actually prevents publication -- `check-config` invoked by hand reports a leak that may already be committed -- so a model net running only on manual invocation was absent at the one moment it counts. Same index-read and exit-2-on-stale semantics as `check-config`, loaded before the scan loop so a stale artifact refuses having reported nothing. Four of the five new tests drive a real `git commit`, with a companion proving the same commit succeeds under `model_net = "none"` so the blocking test cannot pass because the hook blocks everything. 2. `RootModel` is refused at generation. It appears in a document as its inner value with no `root` key, so walking it as an ordinary model looks up a field name that can never occur, produces no coverage, and says nothing about it -- unknowable coverage, which by R1's own principle refuses. Not walked: a mark on `root` covers the whole value at the *parent* position, and the schema cannot say that when the model is reached through a container node, which carries no `secret` of its own. 3. The refusal message names the field, its class and the annotation as written, then three ways forward. A refusal a reader cannot act on is one that gets silenced with `model_net = "none"`, losing the whole net. 4. `doctor` now reads `regen.py` back through the shipped accessor and exits non-zero if it is missing or is not the generator; the release workflow greps for the line. Verified against real frozen bundles both ways: with `--add-data`, doctor exits 0 and no pydantic is in the bundle; without it, doctor exits 1 and the grep fails. Also moves the real-console-script fixture into `tests/conftest.py`: two test files now need its "skip locally, fail under CI" rule, and a second copy that forgot the CI branch would let CI go green having lost this coverage. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- .github/workflows/release.yml | 12 +- src/stackward/bootstrap/regen.py | 84 +++++++-- src/stackward/cli.py | 39 ++++- src/stackward/commands/pre_commit.py | 68 ++++++-- tests/conftest.py | 60 +++++++ tests/test_install_hooks.py | 43 ----- tests/test_model_net.py | 247 +++++++++++++++++++++++++++ tests/test_pre_commit.py | 2 +- 8 files changed, 484 insertions(+), 71 deletions(-) create mode 100644 tests/conftest.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84f6369..36ac555 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,10 +73,20 @@ jobs: # A bundle that builds is not a bundle that works. doctor performs a # real Argon2id + AES-GCM round trip, so this catches a native # extension that failed to freeze — the classic failure of this route. + # + # doctor also reads `regen.py` back out of the bundle through the same + # accessor `sync-declared-secrets` uses. That file is carried by the + # `--add-data` entry above rather than by import analysis, so dropping + # it would produce a binary that builds, starts, and then fails at the + # one command that needs it — in the shipped artifact and nowhere + # else. doctor exits non-zero on that, and the grep below states the + # expectation so a future edit to doctor's output cannot silently + # retire this check. run: | set -euo pipefail ./dist/stackward/stackward --version - ./dist/stackward/stackward doctor + ./dist/stackward/stackward doctor | tee doctor.txt + grep -q "^model walker regen.py readable" doctor.txt - name: Sign for macOS if: runner.os == 'macOS' diff --git a/src/stackward/bootstrap/regen.py b/src/stackward/bootstrap/regen.py index 5608498..9e6ca27 100644 --- a/src/stackward/bootstrap/regen.py +++ b/src/stackward/bootstrap/regen.py @@ -80,6 +80,34 @@ def _is_model(annotation: Any) -> bool: return isinstance(annotation, type) and issubclass(annotation, BaseModel) +def _is_root_model(model: type) -> bool: + """A model whose whole value *is* its single `root` field. + + Refused, because its coverage is unknowable rather than empty. In the + document such a model appears as the inner value directly, with no `root` + key — so walking it as an ordinary model would look up field names that + can never occur, produce no coverage at all, and say nothing about having + done so. That is exactly the silent under-coverage this artifact exists to + eliminate, so it gets the same treatment as an unwalkable annotation. + + Walking one correctly is not the small change it looks. A mark on the + `root` field means the whole value at the *parent* position is covered, + and there is no way to say that in this schema when the model is reached + through a `list[...]` or `dict[str, ...]` — the mark would have to + propagate through a container node, which carries no `secret` of its own. + Refusing is the honest answer until the schema can express it. + + Imported defensively: `RootModel` exists in every pydantic 2.x, but a + generator that crashed on an unexpected import would fail *less* clearly + than one that reports what it could not decide. + """ + try: + from pydantic import RootModel + except ImportError: # pragma: no cover - pydantic 2.x always has it + return False + return isinstance(model, type) and issubclass(model, RootModel) + + def _model_id(model: type) -> str: """`module:QualName` — stable, readable, and meaningful in a diff of the committed artifact, which synthetic indices would not be.""" @@ -145,6 +173,18 @@ def add(self, model: type) -> str: model_id = _model_id(model) if model_id in self.models: return model_id + if _is_root_model(model): + # Checked here rather than at each call site, so a root model + # reached as a declared root and one reached through a field are + # refused by the same line. See `_is_root_model`. + raise Unwalkable( + f"{model_id}: a RootModel appears in a document as its inner " + "value, with no field name to match, so any mark it carries " + "would cover nothing. Declare an ordinary BaseModel with named " + "fields, or mark the field that holds it " + "(json_schema_extra={'secret': True}), which covers everything " + "beneath it whatever its type" + ) # Registered before its fields are walked, so a self-reference # resolves to this same id instead of recursing forever. self.models[model_id] = {} @@ -165,7 +205,7 @@ def _fields(self, model: type, model_id: str) -> dict[str, dict[str, Any]]: # refuse a declaration that is already complete. entry["secret"] = True else: - child = self._resolve(info.annotation, where) + child = self._resolve(info.annotation, where, model_id) if child is not None: entry["child"] = child for key in _data_keys(name, info, where): @@ -184,7 +224,9 @@ def _fields(self, model: type, model_id: str) -> dict[str, dict[str, Any]]: fields[key] = entry return fields - def _resolve(self, annotation: Any, where: str) -> dict[str, Any] | None: + def _resolve( + self, annotation: Any, where: str, model_id: str + ) -> dict[str, Any] | None: """One annotation, as a graph node — or `None` when nothing beneath it is a declared model, which is the common case and is written as the absence of a `child` entry.""" @@ -203,7 +245,7 @@ def _resolve(self, annotation: Any, where: str) -> dict[str, Any] | None: # `Optional[X]` / `X | None`: one real member, so there is # exactly one thing to walk. `None` itself is not a mapping # and has nothing beneath it. - return self._resolve(present[0], where) + return self._resolve(present[0], where, model_id) if any(_may_hold_model(member) for member in present): raise Unwalkable( f"{where}: a union of more than one type cannot be walked, " @@ -212,24 +254,46 @@ def _resolve(self, annotation: Any, where: str) -> dict[str, Any] | None: ) return None if origin is list and len(args) == 1: - item = self._resolve(args[0], where) + item = self._resolve(args[0], where, model_id) return None if item is None else {"kind": "list", "item": item} if origin is dict and len(args) == 2 and _strip_annotated(args[0]) is str: - value = self._resolve(args[1], where) + value = self._resolve(args[1], where, model_id) return None if value is None else {"kind": "dict", "value": value} if _may_hold_model(annotation): + # Names the field, its class and the annotation itself, then every + # way forward. A refusal a reader cannot act on is a refusal that + # gets silenced with model_net = "none", which loses the whole net + # -- and first adoption of this tool is exactly when several of + # these arrive at once. raise Unwalkable( - f"{where}: this annotation may hold a declared model but is not " - "one this tool can walk. Walkable forms are a model, list[X], " - "dict[str, X] and Optional[X]. Either narrow the annotation to " - "one of those, or mark the field " + f"{where}: annotation {_describe(annotation)} may hold a " + f"declared model, and {model_id} cannot be walked through it. " + "Walkable forms are a model, list[X], dict[str, X] and " + "Optional[X]. Three ways forward: narrow the annotation to one " + "of those; or mark the field " "(json_schema_extra={'secret': True}), which covers everything " - "beneath it whatever its type" + "beneath it whatever its type; or, if this repository's marking " + "convention is not this tool's, declare " + "check.declared_paths_fn" ) return None +def _describe(annotation: Any) -> str: + """An annotation as a reader would recognise it in their own source. + + `repr` on a bare class is ``, which reads as noise in a + message whose whole job is to point at a line of code; typing constructs + already repr as they were written. Carries type names from the consuming + repository, which are not credentials -- the same category as the module + and class names its `.stackward.toml` already states in the clear. + """ + if isinstance(annotation, type): + return getattr(annotation, "__qualname__", None) or repr(annotation) + return repr(annotation) + + def _data_keys(name: str, info: Any, where: str) -> list[str]: """Every key this field may appear under in a stack config document. diff --git a/src/stackward/cli.py b/src/stackward/cli.py index 78d6a9d..1362c5a 100644 --- a/src/stackward/cli.py +++ b/src/stackward/cli.py @@ -17,6 +17,7 @@ from pathlib import Path from . import __version__ +from .bootstrap import GENERATOR_NAME, generator_source from .commands.check_config import cmd_check_config from .commands.check_passphrase import cmd_check_passphrase from .commands.install_hooks import cmd_install_hooks @@ -85,6 +86,34 @@ def crypto_selftest() -> str: return f"UNAVAILABLE: {type(exc).__name__}: {exc}" +def generator_selftest() -> str: + """Prove the model walker's source is reachable, rather than assuming it. + + `sync-declared-secrets` ships `bootstrap/regen.py` as a **data** file and + pipes its text to the consuming repository's interpreter; nothing imports + it, which is what keeps pydantic out of the bundle. The cost of that is + that PyInstaller's import analysis cannot see it either, so it is carried + by an explicit `--add-data` entry in the release workflow -- and if that + entry is ever dropped, the binary builds cleanly, starts cleanly, and + then `sync-declared-secrets` fails in the one artifact everyone installs + and nowhere else. + + That is the same failure shape as `crypto_selftest`'s: something that + works everywhere except the shipped bundle. So it gets the same treatment + -- `doctor` is what a release smoke test runs, and it checks the real + accessor the shipped code uses rather than the file's path on disk, which + does not exist in a bundle. It can only fail if the packaging is wrong: + an installed wheel and a source checkout both carry the file already. + """ + try: + source = generator_source() + except Exception as exc: # noqa: BLE001 - report any failure, never crash doctor + return f"UNAVAILABLE: {type(exc).__name__}: {exc}" + if "def generate(" not in source: + return f"FAILED: {GENERATOR_NAME} is present but is not the generator" + return f"{GENERATOR_NAME} readable ({len(source)} bytes)" + + def cmd_doctor(_args: argparse.Namespace) -> int: """Report what stackward can see. Never prints a credential value.""" config = find_repo_config() @@ -103,9 +132,13 @@ def cmd_doctor(_args: argparse.Namespace) -> int: print() crypto = crypto_selftest() print(f"crypto {crypto}") - # Exit non-zero when the credential layer could not work here. doctor is - # what a release smoke test runs, so it has to fail rather than narrate. - return 1 if crypto.startswith(("UNAVAILABLE", "FAILED")) else 0 + generator = generator_selftest() + print(f"model walker {generator}") + # Exit non-zero when the credential layer or the model walker could not + # work here. doctor is what a release smoke test runs, so it has to fail + # rather than narrate -- and both of these can only break in a bundle. + broken = ("UNAVAILABLE", "FAILED") + return 1 if crypto.startswith(broken) or generator.startswith(broken) else 0 def build_parser() -> argparse.ArgumentParser: diff --git a/src/stackward/commands/pre_commit.py b/src/stackward/commands/pre_commit.py index 9abd727..fc502b1 100644 --- a/src/stackward/commands/pre_commit.py +++ b/src/stackward/commands/pre_commit.py @@ -22,8 +22,9 @@ mistaken for what will actually be committed. **Content-level, not file-level.** `_scan_staged_config` calls -`nets.heuristic.find_plaintext_credentials` directly with an -already-parsed document, the same content-level entry point +`nets.heuristic.find_plaintext_credentials` and +`nets.model.find_declared_credentials` directly with an already-parsed +document, the same content-level entry points `commands.check_config.scan_file` calls for a file on disk. Neither this module nor that one ever round-trips a blob through a temporary file to reuse the other's file-path wrapper -- doing that here specifically would @@ -31,6 +32,20 @@ index, and would reintroduce the working-tree-vs-index confusion this module exists to avoid. +**Both nets run here, not only the heuristic one.** This is the layer that +prevents publication; `check-config`, invoked by hand, reports a leak that +may already be committed. A model net running only on manual invocation +would be absent at the one moment it counts, so the stronger of the two nets +would not be protecting anything. The two are unioned through a set, so a +leaf both name is reported once. + +**The declared-secrets artifact is read from the index too**, by +`nets.model.load_model_net` -- the same rule as every other path this module +reads, and for the same reason: an unstaged regeneration must not vouch for +the stale artifact that is actually about to be committed. A missing, +malformed or stale artifact under `model_net = "artifact"` exits 2, never 1; +`model_net = "none"` skips that net without reading anything at all. + **No credential resolution.** This module imports `yaml`, `..config` and `..nets.heuristic` -- never `cryptography` and never any credential provider. The gate decides whether a value looks like a plaintext @@ -63,6 +78,12 @@ from ..config import CheckConfig, ConfigError, find_repo_config, load_config from ..nets.heuristic import DocumentError, find_plaintext_credentials +from ..nets.model import ( + ModelNet, + ModelNetError, + find_declared_credentials, + load_model_net, +) from .check_config import CheckError, describe_yaml_error, fail_closed _STATE_EXPORT_NAME = "state.json" @@ -188,17 +209,23 @@ def _read_staged_blob(path: str) -> str: raise CheckError(f"{path}: staged content is not valid UTF-8: {exc}") from exc -def _scan_staged_config(path: str, check: CheckConfig) -> list[str]: - """Content-level scan of `path`'s *staged* blob. +def _scan_staged_config( + path: str, check: CheckConfig, net: ModelNet | None +) -> list[str]: + """Content-level scan of `path`'s *staged* blob, by both nets. Mirrors `check_config.scan_file` exactly, except the source of text is `_read_staged_blob` (the index) rather than `Path.read_text()` (the - working tree): both parse with `yaml.safe_load` and hand the resulting - document straight to `find_plaintext_credentials`, and both wrap + working tree): both parse with `yaml.safe_load`, hand the resulting + document straight to the two content-level nets, union the results + through a set (a leaf both name is one problem, not two), and wrap every way that can fail in `CheckError` -- unreadable/undecodable content, invalid YAML (via `describe_yaml_error`, which redacts what PyYAML would otherwise interpolate into its own message), or a document that is not a mapping. + + `net` is `None` when the repository declares `model_net = "none"`, or + has no `[check]` policy at all; the heuristic net always runs. """ text = _read_staged_blob(path) try: @@ -206,9 +233,12 @@ def _scan_staged_config(path: str, check: CheckConfig) -> list[str]: except yaml.YAMLError as exc: raise CheckError(f"{path}: invalid YAML: {describe_yaml_error(exc)}") from exc try: - return find_plaintext_credentials(document, check) + found = set(find_plaintext_credentials(document, check)) + if net is not None: + found |= set(find_declared_credentials(document, net, check)) except DocumentError as exc: raise CheckError(f"{path}: {exc}") from exc + return sorted(found) def _load_check_policy() -> CheckConfig: @@ -233,13 +263,14 @@ def cmd_pre_commit(_args: argparse.Namespace) -> int: Exit codes, never conflated (see the module docstring in `check_config.py` for why this distinction matters to a later, - fail-closed refusal): **0** clean; **1** a credential was found by the - heuristic net, or a Pulumi state export was staged (refused outright, - since a state export is credentials by construction, not by finding); + fail-closed refusal): **0** clean; **1** a credential was found by either + net, or a Pulumi state export was staged (refused outright, since a + state export is credentials by construction, not by finding); **2** the check could not run at all -- an unmerged index entry, a git command failing, staged content that is not decodable or not valid YAML, a document that is not a mapping, an invalid `.stackward.toml`, - or an unanticipated exception. Python's own default for an uncaught + a missing or stale declared-secrets artifact under + `model_net = "artifact"`, or an unanticipated exception. Python's own default for an uncaught exception is exit 1, which here would misreport "a credential was found" for a file this command never actually finished evaluating -- every per-file scan below is wrapped accordingly, and the `@fail_closed` @@ -269,7 +300,12 @@ def cmd_pre_commit(_args: argparse.Namespace) -> int: refusal. 2. Staged Pulumi state exports (`state.json`, `*.stack-export.json`) -- refused outright, before the credential scan below even starts. - 3. The heuristic net, over every staged `Pulumi..yaml`. + 3. The declared-secrets artifact, loaded from the index. Loaded once, + before the scan loop, and a failure to load it exits 2 having + reported nothing: a stale artifact means this command does not know + what the repository declared, so printing the *other* net's findings + and exiting 1 would claim a complete answer it does not have. + 4. Both nets, unioned, over every staged `Pulumi..yaml`. Never prints a credential value: a finding is reported only by its rendered config path, exactly like `check-config`. @@ -309,13 +345,19 @@ def cmd_pre_commit(_args: argparse.Namespace) -> int: print(f"error: {exc}", file=sys.stderr) return 2 + try: + net = load_model_net(check) + except ModelNetError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + stack_configs = sorted(path for path in staged if _is_pulumi_stack_config(path)) findings: list[tuple[str, str]] = [] errored = False for path in stack_configs: try: - found = _scan_staged_config(path, check) + found = _scan_staged_config(path, check, net) except CheckError as exc: print(f"error: {exc}", file=sys.stderr) errored = True diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c366828 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,60 @@ +"""Fixtures shared by the end-to-end tests that drive a real `git commit`. + +Two test files now need the *installed* `stackward` console script — the one +that proves a git hook really runs, rather than that a file landed at the +expected path. The rule about when its absence is a skip and when it is a +failure is a judgement that must not exist in two places and drift: a second +copy that forgot the `CI` branch would let a CI run go green having lost +exactly the coverage these tests are for. +""" + +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +import pytest + +from stackward.commands import install_hooks as install_hooks_module + + +def real_stackward_executable() -> str: + """The actual installed `stackward` console script for this test + interpreter -- not a stub. + + Used only by the end-to-end tests, which prove the generated hook body + really gets invoked by a real `git commit`, not merely that a file landed + at the expected path: a file at the right path that git never actually + runs is precisely the "silent no-op" the hooks-directory resolution exists + to prevent, and asserting on the file alone cannot catch that failure + mode. + + Skipping when nothing is found keeps a local run convenient (an + interpreter not launched through this project's own `uv`-managed venv + genuinely may not have one), but `uv sync` always produces + `.venv/bin/stackward`, so CI must never quietly go green having lost + exactly the coverage the real-git-repository approach leans on hardest. + Skip locally; fail loudly under `CI`. + """ + candidate = Path(sys.executable).parent / "stackward" + if candidate.is_file() and os.access(candidate, os.X_OK): + return str(candidate) + found = shutil.which("stackward") + if found: + return found + message = "no installed `stackward` console script found for an end-to-end test" + if os.environ.get("CI"): + pytest.fail(f"{message} -- `uv sync` should have installed one") + pytest.skip(message) + + +@pytest.fixture +def real_executable(monkeypatch) -> str: + """Make `_install_hook` embed the real `stackward` console script instead + of whatever `sys.argv[0]` happens to be under pytest (its own + interpreter) -- see `real_stackward_executable`.""" + executable = real_stackward_executable() + monkeypatch.setattr(install_hooks_module, "_running_executable", lambda: executable) + return executable diff --git a/tests/test_install_hooks.py b/tests/test_install_hooks.py index d636a53..12f018e 100644 --- a/tests/test_install_hooks.py +++ b/tests/test_install_hooks.py @@ -10,8 +10,6 @@ from __future__ import annotations -import os -import shutil import stat import subprocess import sys @@ -54,47 +52,6 @@ def backups(hooks_dir: Path) -> list[Path]: return sorted(hooks_dir.glob("pre-commit.backup.*")) -def _real_stackward_executable() -> str: - """The actual installed `stackward` console script for this test - interpreter -- not a stub. Used only by the end-to-end tests below, - which prove the generated hook body really gets invoked by a real - `git commit`, not merely that a file landed at the expected path: a - file at the right path that git never actually runs is precisely the - "silent no-op" this command's own hooks-directory resolution exists to - prevent, and asserting on the file alone cannot catch that failure - mode. - - Skipping when nothing is found keeps a local run convenient (an - interpreter not launched through this project's own `uv`-managed venv - genuinely may not have one), but `uv sync` always produces - `.venv/bin/stackward`, so CI must never quietly go green having lost - exactly the coverage the rest of this test file's real-git-repository - approach leans on hardest. Skip locally; fail loudly under `CI`. - """ - candidate = Path(sys.executable).parent / "stackward" - if candidate.is_file() and os.access(candidate, os.X_OK): - return str(candidate) - found = shutil.which("stackward") - if found: - return found - message = ( - "no installed `stackward` console script found for an end-to-end test" - ) - if os.environ.get("CI"): - pytest.fail(f"{message} -- `uv sync` should have installed one") - pytest.skip(message) - - -@pytest.fixture -def real_executable(monkeypatch) -> str: - """Make `_install_hook` embed the real `stackward` console script - instead of whatever `sys.argv[0]` happens to be under pytest (its own - interpreter) -- see `_real_stackward_executable`.""" - executable = _real_stackward_executable() - monkeypatch.setattr(install_hooks_module, "_running_executable", lambda: executable) - return executable - - # --------------------------------------------------------------------------- # Default location: `.git/hooks`, resolved rather than hardcoded. # --------------------------------------------------------------------------- diff --git a/tests/test_model_net.py b/tests/test_model_net.py index 928e42e..057b456 100644 --- a/tests/test_model_net.py +++ b/tests/test_model_net.py @@ -21,6 +21,7 @@ from __future__ import annotations +import argparse import json import subprocess import sys @@ -29,7 +30,9 @@ import pytest +from stackward import cli as cli_module from stackward.bootstrap import generator_source +from stackward.cli import cmd_doctor from stackward.cli import main from stackward.commands.sync_declared import SyncError, build_artifact, serialise from stackward.config import CheckConfig @@ -731,6 +734,91 @@ class Root(BaseModel): assert "declared:Root.peer" in capsys.readouterr().err +def test_a_root_model_is_refused_rather_than_covering_nothing( + repo, monkeypatch, capsys +): + """A RootModel appears in a document as its inner value, with no `root` + key, so walking it as an ordinary model would look up a field name that + can never occur and produce no coverage while saying nothing about it. + By R1's own principle, unknowable coverage refuses.""" + write( + repo, + "declared.py", + """ + from pydantic import BaseModel, Field, RootModel + + + class Wrapped(RootModel[str]): + root: str = Field(default="", json_schema_extra={"secret": True}) + + + class Root(BaseModel): + wrapped: Wrapped = Wrapped(root="") + """, + ) + declare(repo) + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 2 + assert not (repo / ARTIFACT_PATH).exists() + error = capsys.readouterr().err + assert "declared:Wrapped" in error + assert "RootModel" in error + + +def test_a_root_model_declared_as_a_stack_root_is_refused_too( + repo, monkeypatch, capsys +): + """The refusal lives where every model passes through, so a root model + reached as a declared root and one reached through a field are refused by + the same line rather than by two that could drift apart.""" + write( + repo, + "declared.py", + """ + from pydantic import RootModel + + + class Root(RootModel[dict[str, str]]): + pass + """, + ) + declare(repo) + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 2 + assert "RootModel" in capsys.readouterr().err + + +def test_a_refusal_names_the_field_the_class_the_annotation_and_the_way_out( + repo, monkeypatch, capsys +): + """A refusal a reader cannot act on is a refusal that gets silenced with + `model_net = "none"`, which loses the whole net -- and first adoption is + exactly when several of these arrive at once.""" + write( + repo, + "declared.py", + """ + from typing import Any + + from pydantic import BaseModel + + + class Root(BaseModel): + settings: dict[str, Any] = {} + """, + ) + declare(repo) + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 2 + error = capsys.readouterr().err + assert "settings" in error # the field + assert "declared:Root" in error # the class + assert "Any" in error # the annotation + assert "narrow the annotation" in error # remedy 1 + assert "json_schema_extra" in error # remedy 2 + assert "declared_paths_fn" in error # remedy 3 + + def test_a_marked_field_may_carry_an_annotation_that_cannot_be_walked( repo, monkeypatch, capsys ): @@ -1217,6 +1305,125 @@ def test_none_mode_still_runs_the_heuristic_net(repo, monkeypatch, capsys): assert "config.app:app.dbPassword" in capsys.readouterr().out +# --------------------------------------------------------------------------- +# The hook: the layer that actually prevents publication. +# +# `check-config` invoked by hand reports a leak that may already be committed; +# only `pre-commit` stops one entering history. A model net that ran only on +# manual invocation would be absent at the one moment it counts, so these +# drive a real `git commit` rather than asserting on command output. +# --------------------------------------------------------------------------- + + +def install_hook(repo: Path, monkeypatch) -> None: + monkeypatch.chdir(repo) + assert main(["hooks", "install"]) == 0 + + +def attempt_commit(repo: Path, message: str) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", "commit", "-m", message], cwd=repo, capture_output=True, text=True + ) + + +def declared_repo(repo: Path, monkeypatch, capsys, model: str) -> None: + """A repository with `model` declared, generated and committed, and the + gate installed as a real git hook.""" + write(repo, "declared.py", model) + declare(repo) + git(repo, "add", "-A") + sync_and_commit(repo, monkeypatch, capsys) + install_hook(repo, monkeypatch) + + +def test_a_model_net_only_finding_blocks_a_real_commit( + repo, monkeypatch, capsys, real_executable +): + """The headline claim of wiring the model net into the hook. + + `binding` matches none of the heuristic net's built-in key patterns, and + it sits two levels down a recursive model. Its companion below proves the + same commit succeeds with the model net switched off, so this test cannot + be passing because something else blocked the commit. + """ + declared_repo(repo, monkeypatch, capsys, HEURISTIC_BLIND_MODEL) + write(repo, "Pulumi.dev.yaml", BLIND_DOCUMENT) + git(repo, "add", "Pulumi.dev.yaml") + + result = attempt_commit(repo, "add a declared secret") + assert result.returncode != 0 + output = result.stdout + result.stderr + assert "config.app:app.children[0].children[0].binding" in output + assert "deeply-nested" not in output + + +def test_the_same_commit_succeeds_with_the_model_net_switched_off( + repo, monkeypatch, capsys, real_executable +): + """The other half of the pair. Without it, the test above would pass just + as well if the hook blocked every commit.""" + declared_repo(repo, monkeypatch, capsys, HEURISTIC_BLIND_MODEL) + write(repo, ".stackward.toml", '[check]\nmodel_net = "none"\n') + write(repo, "Pulumi.dev.yaml", BLIND_DOCUMENT) + git(repo, "add", ".stackward.toml", "Pulumi.dev.yaml") + + assert attempt_commit(repo, "add the same document").returncode == 0 + + +def test_a_stale_artifact_blocks_a_real_commit( + repo, monkeypatch, capsys, real_executable +): + """The hook reads the artifact from the index and refuses when it no + longer matches its sources -- it does not fall back to the heuristic net + alone, which would be a silent downgrade at commit time.""" + declared_repo(repo, monkeypatch, capsys, RECURSIVE_MODEL) + write(repo, "declared.py", RECURSIVE_MODEL_EDITED) + write(repo, "Pulumi.dev.yaml", "config:\n app:app:\n plain: ordinary\n") + git(repo, "add", "declared.py", "Pulumi.dev.yaml") + + result = attempt_commit(repo, "change a model without regenerating") + assert result.returncode != 0 + assert "sync-declared-secrets" in result.stdout + result.stderr + + +def test_the_hook_exits_2_on_a_stale_artifact_not_1(repo, monkeypatch, capsys): + """Driven in-process, because a `git commit` reports only "the hook said + no" and cannot distinguish the two codes -- and the distinction is the + whole reason exit 2 exists.""" + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + sync_and_commit(repo, monkeypatch, capsys) + write(repo, "declared.py", RECURSIVE_MODEL_EDITED) + write(repo, "Pulumi.dev.yaml", "config:\n app:app:\n token: value\n") + git(repo, "add", "declared.py", "Pulumi.dev.yaml") + + monkeypatch.chdir(repo) + assert main(["pre-commit"]) == 2 + captured = capsys.readouterr() + assert "sync-declared-secrets" in captured.err + # Nothing reported: the command does not know what the repository declared. + assert captured.out == "" + + +def test_the_hook_reports_a_leaf_both_nets_name_once(repo, monkeypatch, capsys): + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + sync_and_commit(repo, monkeypatch, capsys) + write(repo, "Pulumi.dev.yaml", "config:\n app:app:\n token: value\n") + git(repo, "add", "Pulumi.dev.yaml") + + monkeypatch.chdir(repo) + assert main(["pre-commit"]) == 1 + lines = [ + line + for line in capsys.readouterr().out.splitlines() + if "plaintext credential at" in line + ] + assert len(lines) == 1 + + # --------------------------------------------------------------------------- # Global Constraint 1: pydantic is test-only. # --------------------------------------------------------------------------- @@ -1265,6 +1472,46 @@ def test_the_gate_path_reaches_no_credential_code(): assert proc.stdout.strip() == "[]" +def test_doctor_reports_the_model_walker_as_readable(capsys): + """`regen.py` is carried into the frozen bundle by an explicit + `--add-data` entry rather than by import analysis, so dropping that entry + produces a binary that builds, starts, and then fails only at + `sync-declared-secrets`. `doctor` is the release smoke test, so it checks + the real accessor -- and the release workflow greps for this exact line, + which is why the prefix is asserted verbatim rather than loosely. + """ + assert cmd_doctor(argparse.Namespace()) == 0 + reported = [ + line + for line in capsys.readouterr().out.splitlines() + if line.startswith("model walker regen.py readable") + ] + assert len(reported) == 1 + + +def test_doctor_fails_when_the_model_walker_cannot_be_read(monkeypatch, capsys): + """The check has to discriminate, or the release workflow's grep is + decoration. Verified against a real frozen bundle built without the + `--add-data` entry as well; this is the version that runs every time.""" + monkeypatch.setattr( + cli_module, + "generator_source", + lambda: (_ for _ in ()).throw(FileNotFoundError("regen.py")), + ) + assert cmd_doctor(argparse.Namespace()) == 1 + assert "model walker UNAVAILABLE" in capsys.readouterr().out + + +def test_doctor_fails_when_the_bundled_file_is_not_the_generator( + monkeypatch, capsys +): + """Present but wrong is a distinct failure from absent, and it would + otherwise read as success.""" + monkeypatch.setattr(cli_module, "generator_source", lambda: "# not it\n") + assert cmd_doctor(argparse.Namespace()) == 1 + assert "model walker FAILED" in capsys.readouterr().out + + def test_the_generator_is_read_through_the_accessor_the_shipped_code_uses(): """Reading the file by path instead would assert nothing about a frozen build, where that path does not exist.""" diff --git a/tests/test_pre_commit.py b/tests/test_pre_commit.py index 2931fc1..f43bf01 100644 --- a/tests/test_pre_commit.py +++ b/tests/test_pre_commit.py @@ -477,7 +477,7 @@ def test_unanticipated_scan_exception_exits_2_not_1(repo, monkeypatch, capsys): write(repo, "Pulumi.dev.yaml", "name: myproject\n") stage(repo, "Pulumi.dev.yaml") - def boom(_path, _check): + def boom(_path, _check, _net): raise RuntimeError("unanticipated failure, not a CheckError") monkeypatch.setattr(pre_commit_module, "_scan_staged_config", boom) From a3f551f53e4469175e191d121fa714a2aaeaae0d Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 13:37:29 -0700 Subject: [PATCH 29/44] fix: close three fail-opens that let check-config exit 0 on a credential Each reproduced end-to-end in a clean, fully-staged repository before fixing, and confirmed closed after against the same repository. 1. `_may_hold_model`'s last branch answered "no" for any unrecognised class, so a pydantic dataclass, `TypedDict` or `NamedTuple` holding a marked field left the graph with no refusal -- R1 inverted for a whole category. A bare class is now a leaf only when no class in its MRO declares an annotation of its own whose name does not start with an underscore: pydantic's own field-collection rule, which separates a structure from a scalar without enumerating either, and which keeps `AnyUrl` and `SecretStr` (private annotations only) as leaves. Fixing the generator does nothing for an artifact already committed by the broken one -- it is fresh against every source it records and internally consistent while covering less than the repository declares. So ARTIFACT_VERSION is bumped 1 -> 2, and every existing artifact now refuses with the regeneration command named. 2. The MRO source walk filtered on `issubclass(base, BaseModel)`, so pydantic collected a marked field from a plain mixin while the mixin's file never reached `sources` -- a tracked, already-contributing file, not the "newly added" boundary. Now: skip `object`/`BaseModel`, record repo-local bases unconditionally (a blob id already in the index costs nothing), and out-of-repo bases only when they declare fields. Not "every base except BaseModel": that would record `abc` for `class Config(BaseModel, ABC)` and reimpose the universal lockfile precondition. A test with no lockfile in the repository pins it. 3. `.stackward.toml` was not a recorded source, though it supplies `roots` and `declared_paths_fn` -- so a namespace could be added to the policy, staged and committed with the artifact untouched, and the namespace was simply skipped. Its blob id is now recorded, and `_verify_roots` adds a second guard needing no blob id at all: the artifact's roots must be exactly the namespaces the policy declares, compared as key sets since `module:QualName` diverges from `module:Class` for a nested class. Also: `scan_file`'s `net` loses its default (asserted as a signature, over both scanners); the duplicate-data-key refusal tracks claimed keys separately from emitted entries so it no longer depends on declaration order; `run_git`'s dead `cwd` keyword dropped; refusals name the annotation as written plus the offending part; `getsourcefile` guarded against OSError as well as TypeError; and CI now asserts pydantic is absent from `dist/`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- .github/workflows/release.yml | 11 + src/stackward/bootstrap/regen.py | 142 +++++++- src/stackward/commands/check_config.py | 11 +- src/stackward/commands/sync_declared.py | 19 +- src/stackward/nets/model.py | 65 +++- tests/test_heuristic.py | 18 +- tests/test_model_net.py | 451 +++++++++++++++++++++++- 7 files changed, 661 insertions(+), 56 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 36ac555..5611374 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,6 +88,17 @@ jobs: ./dist/stackward/stackward doctor | tee doctor.txt grep -q "^model walker regen.py readable" doctor.txt + # The other half of that arrangement: `regen.py` ships as data + # precisely so nothing imports it and pydantic stays test-only. If it + # ever became an import, the bundle would still build, doctor would + # still pass, and the only visible symptom would be a fatter + # download — so the absence has to be asserted, not assumed. + if find dist -iname '*pydantic*' -print -quit | grep -q .; then + echo "::error::pydantic was bundled; it is a test-only dependency" >&2 + find dist -iname '*pydantic*' | head -20 >&2 + exit 1 + fi + - name: Sign for macOS if: runner.os == 'macOS' # Ad-hoc signature. Not notarisation, but enough that arm64 will load a diff --git a/src/stackward/bootstrap/regen.py b/src/stackward/bootstrap/regen.py index 9e6ca27..76cbebf 100644 --- a/src/stackward/bootstrap/regen.py +++ b/src/stackward/bootstrap/regen.py @@ -108,6 +108,47 @@ def _is_root_model(model: type) -> bool: return isinstance(model, type) and issubclass(model, RootModel) +def _declares_fields(klass: type) -> bool: + """Does `klass` declare fields of its own? + + True when it carries at least one annotation of its own whose name does + not start with an underscore. That is pydantic's own rule, not an + approximation of it: an underscore-prefixed annotated name is a *private + attribute*, which pydantic never collects as a field, so it can never + carry a mark under this tool's convention either. + + The distinction is what separates a structure from a scalar without + enumerating either. A pydantic dataclass, a `TypedDict`, a `NamedTuple` + and a plain annotated mixin all declare public annotations; `str`, an + `Enum`, `Decimal`, `UUID`, `datetime` and `abc.ABC` declare none, and + pydantic's own scalar wrappers (`AnyUrl`, `SecretStr`) declare only + private ones. Refusing that second group would make the rule unusable — + "narrow the annotation" is not even available for `AnyUrl`. + + Read from `__dict__` rather than the attribute: since Python 3.10, + `klass.__annotations__` lazily *creates* an empty dict on a class that + has none, and on some builtins raises instead. + + Known conservative case, stated rather than hidden: a class whose only + annotations are `ClassVar` declares no fields to pydantic but does to + this rule, so it refuses. That fails closed with an actionable message, + and detecting `ClassVar` through a possibly-stringised annotation would + be a guess where this is a fact. + """ + annotations = klass.__dict__.get("__annotations__") + if not annotations: + return False + return any(not name.startswith("_") for name in annotations) + + +def _declares_fields_anywhere(klass: type) -> bool: + """`_declares_fields` over the whole MRO — a class inherits its shape.""" + for base in getattr(klass, "__mro__", (klass,)): + if base is not object and _declares_fields(base): + return True + return False + + def _model_id(model: type) -> str: """`module:QualName` — stable, readable, and meaningful in a diff of the committed artifact, which synthetic indices would not be.""" @@ -151,7 +192,15 @@ def _may_hold_model(annotation: Any) -> bool: # `Callable[[A], B]` carries its parameter types in a list. return any(_may_hold_model(item) for item in annotation) if isinstance(annotation, type): - return False + # The last branch, and the one a blanket `False` gets catastrophically + # wrong. A pydantic dataclass, a `TypedDict` and a `NamedTuple` are + # none of the things tested above — not a `BaseModel`, no + # `get_origin`, not a bare container — so they all arrive here, and + # answering "no" would drop every marked field inside them from the + # graph with no refusal: the whole category silently uncovered. Only a + # class that declares no fields of its own anywhere in its MRO is a + # leaf; see `_declares_fields`. + return _declares_fields_anywhere(annotation) return True @@ -194,6 +243,14 @@ def add(self, model: type) -> str: def _fields(self, model: type, model_id: str) -> dict[str, dict[str, Any]]: fields: dict[str, dict[str, Any]] = {} + # Every key any field could appear under, whether or not that field + # ends up emitted. Tracked separately from `fields` because an entry + # is only stored when it says something (see below): keying the + # collision check on `fields` alone made it order-dependent — an + # unmarked scalar never claimed its key, so a later marked field + # aliased to that name took it silently, while the same two fields + # declared in the other order refused. + claimed: set[str] = set() marked = self.is_secret(model) for name, info in model.model_fields.items(): where = f"{model_id}.{name}" @@ -205,15 +262,18 @@ def _fields(self, model: type, model_id: str) -> dict[str, dict[str, Any]]: # refuse a declaration that is already complete. entry["secret"] = True else: - child = self._resolve(info.annotation, where, model_id) + child = self._resolve( + info.annotation, where, model_id, info.annotation + ) if child is not None: entry["child"] = child for key in _data_keys(name, info, where): - if key in fields: + if key in claimed: raise Unwalkable( f"{where}: data key {key!r} is claimed by more than one " "field; give the fields distinct names or aliases" ) + claimed.add(key) if entry: # A field with neither a mark nor a model beneath it is # omitted rather than written as `{}`: the matcher treats @@ -225,7 +285,7 @@ def _fields(self, model: type, model_id: str) -> dict[str, dict[str, Any]]: return fields def _resolve( - self, annotation: Any, where: str, model_id: str + self, annotation: Any, where: str, model_id: str, written: Any ) -> dict[str, Any] | None: """One annotation, as a graph node — or `None` when nothing beneath it is a declared model, which is the common case and is written as the @@ -245,7 +305,7 @@ def _resolve( # `Optional[X]` / `X | None`: one real member, so there is # exactly one thing to walk. `None` itself is not a mapping # and has nothing beneath it. - return self._resolve(present[0], where, model_id) + return self._resolve(present[0], where, model_id, written) if any(_may_hold_model(member) for member in present): raise Unwalkable( f"{where}: a union of more than one type cannot be walked, " @@ -254,10 +314,10 @@ def _resolve( ) return None if origin is list and len(args) == 1: - item = self._resolve(args[0], where, model_id) + item = self._resolve(args[0], where, model_id, written) return None if item is None else {"kind": "list", "item": item} if origin is dict and len(args) == 2 and _strip_annotated(args[0]) is str: - value = self._resolve(args[1], where, model_id) + value = self._resolve(args[1], where, model_id, written) return None if value is None else {"kind": "dict", "value": value} if _may_hold_model(annotation): @@ -267,7 +327,8 @@ def _resolve( # -- and first adoption of this tool is exactly when several of # these arrive at once. raise Unwalkable( - f"{where}: annotation {_describe(annotation)} may hold a " + f"{where}: annotation {_describe(written)}" + f"{_at(written, annotation)} may hold a " f"declared model, and {model_id} cannot be walked through it. " "Walkable forms are a model, list[X], dict[str, X] and " "Optional[X]. Three ways forward: narrow the annotation to one " @@ -294,6 +355,19 @@ def _describe(annotation: Any) -> str: return repr(annotation) +def _at(written: Any, offending: Any) -> str: + """` (at X)`, when the part that refused is not the whole annotation. + + The message has to name the annotation as the reader *wrote* it — + `dict[str, Any]` — or they are sent looking for a line that says `Any` + and does not exist. Naming the offending part as well keeps the precision + that made the inner-only message tempting. + """ + if written is offending: + return "" + return f" (at {_describe(offending)})" + + def _data_keys(name: str, info: Any, where: str) -> list[str]: """Every key this field may appear under in a stack config document. @@ -444,6 +518,14 @@ def visit(annotation: Any) -> None: return found +def _inside(source: Any, repo_root: Path) -> bool: + try: + Path(source).resolve().relative_to(repo_root) + except ValueError: + return False + return True + + def _collect_sources( classes: list[type], extra_objects: list[Any], repo_root: Path ) -> tuple[list[str], list[str]]: @@ -454,7 +536,12 @@ def _collect_sources( - the **MRO** of every reachable model, so a mark inherited from a base class in another file is recorded. `getsourcefile(cls)` alone gives - only the subclass's own file, which is the case this closes. + only the subclass's own file, which is the case this closes. The walk + is *not* restricted to `BaseModel` subclasses: pydantic collects a + marked field from a plain `class Mixin:` into + `Root(Mixin, BaseModel).model_fields`, so a filter keyed on + `issubclass(base, BaseModel)` takes the mark and discards the file it + came from. - **identity** of every `Annotated` alias used by those models, matched against the globals of every imported module. An alias is a module-level object, and the annotation resolves to that very object, so the module @@ -464,7 +551,12 @@ class in another file is recorded. `getsourcefile(cls)` alone gives `pydantic.BaseModel` itself is excluded from the MRO walk. Including it would make pydantic an out-of-repo contributor for *every* repository, and so make a tracked lockfile a universal precondition, for no signal: - the marks this tool reads are its own convention, not pydantic's. + the marks this tool reads are its own convention, not pydantic's. Every + *other* base is recorded when it is repo-local (free — the blob id is in + the index already) and, when it is not, only if it declares fields of its + own. Without that second condition `class Config(BaseModel, ABC)` would + record `abc` and reimpose the very lockfile precondition the `BaseModel` + exclusion exists to avoid. A module with no `__file__` (a builtin, a namespace package, this program itself) is skipped: there is nothing to record a blob id for. That is a @@ -478,23 +570,35 @@ class in another file is recorded. `getsourcefile(cls)` alone gives def record(source: Any, module_name: str) -> None: if not source: return - path = Path(source).resolve() - try: - path.relative_to(repo_root) - except ValueError: + if _inside(source, repo_root): + files.add(str(Path(source).resolve())) + else: external.add(module_name) - return - files.add(str(path)) for model in classes: for base in model.__mro__: - if base is BaseModel or not issubclass(base, BaseModel): + if base is object or base is BaseModel: continue try: source = inspect.getsourcefile(base) - except TypeError: + except (TypeError, OSError): + # TypeError for a builtin; OSError ("source code not + # available") for a class whose module has no file at all. source = None - record(source, base.__module__) + if source is None: + continue + if _inside(source, repo_root): + # Repo-local: recorded unconditionally. A blob id for a file + # already in the index costs nothing, and a base that declares + # no field today is one edit away from declaring a marked one. + record(source, base.__module__) + elif _declares_fields(base): + # Out of the repository: recorded only when it actually + # declares fields, because recording one makes a tracked + # lockfile a precondition for generating at all. That is why + # this cannot simply be "every base except object" — + # `class Config(BaseModel, ABC)` would pin `abc`. + external.add(base.__module__) wanted = {id(obj) for model in classes for obj in _annotated_aliases(model)} wanted.update(id(obj) for obj in extra_objects) diff --git a/src/stackward/commands/check_config.py b/src/stackward/commands/check_config.py index fcce09d..07ac9ae 100644 --- a/src/stackward/commands/check_config.py +++ b/src/stackward/commands/check_config.py @@ -111,9 +111,7 @@ def wrapped(args: argparse.Namespace) -> int: return wrapped -def scan_file( - path: Path, check: CheckConfig, net: ModelNet | None = None -) -> list[str]: +def scan_file(path: Path, check: CheckConfig, net: ModelNet | None) -> list[str]: """Thin file-path wrapper over both nets, unioned. Reads `path` from the working tree and parses it as YAML before handing @@ -125,7 +123,12 @@ def scan_file( temp file to get there. `net` is `None` when the repository declares `model_net = "none"`, or - has no `[check]` policy at all; the heuristic net always runs. The two + has no `[check]` policy at all; the heuristic net always runs. It has no + default, deliberately: a caller that simply forgot it would silently get + the heuristic net alone, which is the whole failure this parameter + exists to prevent. `pre_commit._scan_staged_config` takes it the same + way, and that already paid for itself — a test stub with the old + signature failed loudly rather than quietly scanning with one net. The two result lists are unioned through a set, because both nets can name the same leaf — a field marked `secret` whose key is also called `password` is the ordinary case, not a corner one — and a finding printed twice diff --git a/src/stackward/commands/sync_declared.py b/src/stackward/commands/sync_declared.py index dcb24e8..18cb495 100644 --- a/src/stackward/commands/sync_declared.py +++ b/src/stackward/commands/sync_declared.py @@ -207,9 +207,19 @@ def _lockfile_sources(root: Path) -> dict[str, str]: return index_blob_ids(root, candidates) -def build_artifact(root: Path, result: dict[str, object]) -> dict[str, object]: +def build_artifact( + root: Path, result: dict[str, object], config_path: Path +) -> dict[str, object]: """Assemble the artifact, refusing anything that would leave it unverifiable. + `config_path` is `.stackward.toml`, and it is a source like any other: + `check.stack_models` supplies the artifact's `roots` and + `check.declared_paths_fn` decides which fields are marked at all, so it + decides part of the content as surely as a model file does. Leaving it + out meant a namespace could be added to the policy, staged, and committed + with the artifact untouched — the artifact then has no root for it, the + namespace is skipped, and the gate reports clean. + Two refusals matter more than they look: - a contributing file that git does not track. Omitting it would mean its @@ -223,7 +233,10 @@ def build_artifact(root: Path, result: dict[str, object]) -> dict[str, object]: regeneration, so a self-reference would make every artifact stale the moment it was written. """ - files = _relative_sources(root, [str(item) for item in result.get("files", [])]) + files = _relative_sources( + root, + [str(item) for item in result.get("files", [])] + [str(config_path)], + ) external = sorted({str(item) for item in result.get("external", [])}) tracked = index_blob_ids(root, files) if files else {} @@ -299,7 +312,7 @@ def sync(root: Path, config_path: Path, config: Config) -> Path: "declared_paths_fn": config.check.declared_paths_fn, }, ) - artifact = build_artifact(root, result) + artifact = build_artifact(root, result, config_path) target = root / ARTIFACT_PATH target.parent.mkdir(parents=True, exist_ok=True) diff --git a/src/stackward/nets/model.py b/src/stackward/nets/model.py index d419af0..402c8c9 100644 --- a/src/stackward/nets/model.py +++ b/src/stackward/nets/model.py @@ -97,11 +97,22 @@ class Node(BaseModel): # say how to clear itself is a fail-closed error someone disables. REGEN_COMMAND = "stackward sync-declared-secrets" -# Bumped when the artifact's shape changes. An unrecognised version is a hard -# refusal, not a best-effort parse: a newer generator's graph read by an older -# matcher would be silently under-walked, which is the one failure mode this -# net exists to prevent. -ARTIFACT_VERSION = 1 +# Bumped when the artifact's shape changes **or when the generator's coverage +# does**. An unrecognised version is a hard refusal, not a best-effort parse, +# and the refusal has to run in both directions: a newer generator's graph read +# by an older matcher would be under-walked, and an older generator's graph read +# by a newer matcher is under-*covered* — internally consistent, fresh against +# every source it records, and quietly describing less than the repository +# declares. +# +# That second direction is why this is 2 rather than 1. Version 1's generator +# answered "this cannot hold a model" for any class it did not recognise, so a +# pydantic dataclass, a `TypedDict` or a `NamedTuple` holding a marked field +# left the graph with no refusal. Fixing the generator does nothing for an +# artifact already committed by the old one — nothing in it is stale — so the +# version is what forces every repository to regenerate once, and the refusal +# names the command that does it. +ARTIFACT_VERSION = 2 # The document key holding a Pulumi stack's configuration. Findings are # rendered from the document root (`config..<...>`) so that a path @@ -493,9 +504,13 @@ def _report( # -------------------------------------------------------------------------- -def run_git(args: list[str], *, cwd: Path | None = None) -> bytes: +def run_git(args: list[str]) -> bytes: """Run `git ` and return stdout, raising `ModelNetError` on failure. + Runs in the current working directory; every invocation that must be + anchored to the repository passes `-C ` explicitly rather than + through a parameter here, so there is one way it is done rather than two. + Shared with `commands.sync_declared` so that both sides of the artifact — the writer resolving blob ids and the reader verifying them — use one invocation style and one failure mode. git's own stderr is included: the @@ -504,12 +519,7 @@ def run_git(args: list[str], *, cwd: Path | None = None) -> bytes: produced blob content to leak. """ try: - proc = subprocess.run( - ["git", *args], - capture_output=True, - check=False, - cwd=None if cwd is None else str(cwd), - ) + proc = subprocess.run(["git", *args], capture_output=True, check=False) except OSError as exc: raise ModelNetError(f"cannot run git {' '.join(args)}: {exc}") from exc if proc.returncode != 0: @@ -635,6 +645,36 @@ def verify_sources(root: Path, sources: dict[str, str]) -> None: ) +def _verify_roots(net: ModelNet, check: CheckConfig) -> None: + """The artifact must start from exactly the namespaces the policy declares. + + A second, independent guard on the same drift the recorded blob id of + `.stackward.toml` catches — and it needs no blob id at all, so it still + holds if that file is somehow not among the sources. A namespace declared + in `check.stack_models` with no root in the artifact is not a smaller + answer; it is *no* answer for that namespace, silently. + + **Key sets only.** A model id is `module:QualName` while the config names + `module:Class`, and those diverge for a nested class — comparing values + would refuse a correct artifact. + """ + declared = set(check.stack_models) + present = set(net.roots) + if declared == present: + return + missing = sorted(declared - present) + extra = sorted(present - declared) + detail = [] + if missing: + detail.append(f"declares {missing} that it does not cover") + if extra: + detail.append(f"covers {extra} that it no longer declares") + raise ModelNetError( + f"{ARTIFACT_PATH} is stale: this repository {' and '.join(detail)}; " + f"regenerate with: {REGEN_COMMAND}" + ) + + def load_model_net(check: CheckConfig) -> ModelNet | None: """The model net for this repository, or `None` when it is switched off. @@ -653,4 +693,5 @@ def load_model_net(check: CheckConfig) -> ModelNet | None: root = repo_toplevel() net, sources = parse_net(read_artifact_from_index(root)) verify_sources(root, sources) + _verify_roots(net, check) return net diff --git a/tests/test_heuristic.py b/tests/test_heuristic.py index 796a843..a223e56 100644 --- a/tests/test_heuristic.py +++ b/tests/test_heuristic.py @@ -309,20 +309,20 @@ def write_yaml(tmp_path, name: str, text: str): def test_scan_file_returns_no_findings_for_a_clean_file(tmp_path): path = write_yaml(tmp_path, "Pulumi.dev.yaml", "name: myproject\nruntime: nodejs\n") - assert scan_file(path, CheckConfig()) == [] + assert scan_file(path, CheckConfig(), None) == [] def test_scan_file_finds_a_plaintext_credential(tmp_path): path = write_yaml( tmp_path, "Pulumi.dev.yaml", "config:\n myproject:dbPassword: hunter2\n" ) - assert scan_file(path, CheckConfig()) == ["config.myproject:dbPassword"] + assert scan_file(path, CheckConfig(), None) == ["config.myproject:dbPassword"] def test_scan_file_raises_check_error_on_invalid_yaml(tmp_path): path = write_yaml(tmp_path, "Pulumi.dev.yaml", "key: [unclosed\n") with pytest.raises(CheckError): - scan_file(path, CheckConfig()) + scan_file(path, CheckConfig(), None) def test_invalid_yaml_error_never_prints_a_credential_shaped_value(tmp_path): @@ -338,7 +338,7 @@ def test_invalid_yaml_error_never_prints_a_credential_shaped_value(tmp_path): "config:\n myproject:dbPassword: hunter2: not-valid-yaml\n", ) with pytest.raises(CheckError) as exc_info: - scan_file(path, CheckConfig()) + scan_file(path, CheckConfig(), None) assert "hunter2" not in str(exc_info.value) @@ -352,7 +352,7 @@ def test_invalid_yaml_bad_escape_error_redacts_the_leaked_character(tmp_path): does not.""" path = write_yaml(tmp_path, "Pulumi.leak.yaml", 'password: "hunter\\2ok"\n') with pytest.raises(CheckError) as exc_info: - scan_file(path, CheckConfig()) + scan_file(path, CheckConfig(), None) message = str(exc_info.value) assert "'2'" not in message assert "hunter" not in message @@ -371,7 +371,7 @@ def test_invalid_yaml_reserved_leading_character_error_redacts_the_leaked_charac "found character %r that cannot start any token".""" path = write_yaml(tmp_path, "Pulumi.leak.yaml", "password: `s3cr3tPass99\n") with pytest.raises(CheckError) as exc_info: - scan_file(path, CheckConfig()) + scan_file(path, CheckConfig(), None) message = str(exc_info.value) assert "'`'" not in message assert "s3cr3tPass99" not in message @@ -391,7 +391,7 @@ def test_scan_file_redacts_an_apostrophe_that_flips_reprs_delimiter(tmp_path): not just a synthetic exception.""" path = write_yaml(tmp_path, "Pulumi.leak.yaml", "password: \"s3cr3t\\'value\"\n") with pytest.raises(CheckError) as exc_info: - scan_file(path, CheckConfig()) + scan_file(path, CheckConfig(), None) message = str(exc_info.value) assert "'" not in message assert "s3cr3t" not in message @@ -438,12 +438,12 @@ def test_yaml_error_redaction_removes_any_percent_r_reserved_character(trigger_c def test_scan_file_raises_check_error_on_non_mapping_document(tmp_path): path = write_yaml(tmp_path, "Pulumi.dev.yaml", "- a\n- b\n") with pytest.raises(CheckError): - scan_file(path, CheckConfig()) + scan_file(path, CheckConfig(), None) def test_scan_file_raises_check_error_on_missing_file(tmp_path): with pytest.raises(CheckError): - scan_file(tmp_path / "does-not-exist.yaml", CheckConfig()) + scan_file(tmp_path / "does-not-exist.yaml", CheckConfig(), None) def test_check_config_exits_0_when_clean(tmp_path, capsys): diff --git a/tests/test_model_net.py b/tests/test_model_net.py index 057b456..b171878 100644 --- a/tests/test_model_net.py +++ b/tests/test_model_net.py @@ -22,6 +22,7 @@ from __future__ import annotations import argparse +import inspect import json import subprocess import sys @@ -31,8 +32,11 @@ import pytest from stackward import cli as cli_module +from stackward.nets import model as model_module from stackward.bootstrap import generator_source from stackward.cli import cmd_doctor +from stackward.commands import check_config as check_config_module +from stackward.commands import pre_commit as pre_commit_module from stackward.cli import main from stackward.commands.sync_declared import SyncError, build_artifact, serialise from stackward.config import CheckConfig @@ -313,14 +317,37 @@ def parse_should_fail(payload) -> str: return str(excinfo.value) -def test_an_unsupported_artifact_version_is_refused(): +@pytest.mark.parametrize("version", [ARTIFACT_VERSION + 1, ARTIFACT_VERSION - 1]) +def test_an_artifact_version_this_matcher_does_not_read_is_refused(version): + """Both directions refuse, and the older one is the one that matters in + practice: an artifact written by a previous generator is fresh against + every source it records and internally consistent, while quietly covering + less than the repository declares. Only the version can say so.""" message = parse_should_fail( - {"version": ARTIFACT_VERSION + 1, "models": {}, "roots": {}, "sources": {"a": "b"}} + {"version": version, "models": {}, "roots": {}, "sources": {"a": "b"}} ) assert "unsupported version" in message assert "sync-declared-secrets" in message +def test_an_artifact_written_by_the_version_1_generator_is_refused(): + """The literal version, pinned rather than expressed relative to the + constant -- a test written as `ARTIFACT_VERSION - 1` moves with the + constant and would pass just as well if the bump were reverted. + + Version 1's generator answered "this cannot hold a model" for any class it + did not recognise, so a pydantic dataclass or `TypedDict` holding a marked + field left the graph with no refusal. Such an artifact is fresh against + every source it records and internally consistent; nothing but the version + can say it covers less than the repository declares. + """ + message = parse_should_fail( + {"version": 1, "models": {}, "roots": {}, "sources": {"a": "b"}} + ) + assert "unsupported version 1" in message + assert "sync-declared-secrets" in message + + def test_an_artifact_naming_an_undefined_model_is_refused(): assert "undefined model" in parse_should_fail( { @@ -469,6 +496,20 @@ class Root(BaseModel): children: list[Root] = [] """ +# Two independently declarable roots, for the tests about what happens when +# `.stackward.toml` names a namespace the artifact does not cover. +TWO_MODELS = """ + from pydantic import BaseModel, Field + + + class Root(BaseModel): + binding: str = Field(default="", json_schema_extra={"secret": True}) + + + class Other(Root): + pass +""" + # A credential the heuristic net cannot reach: an unremarkable key name, at a # depth no finite pattern list produced from the model would enumerate. BLIND_DOCUMENT = ( @@ -734,6 +775,364 @@ class Root(BaseModel): assert "declared:Root.peer" in capsys.readouterr().err +# A class whose fields the walk cannot reach, in each of the three shapes a +# repository actually writes one. None is a `BaseModel` subclass, none has a +# `get_origin`, and none is a bare container -- so each reaches the very last +# branch of `_may_hold_model`, which is where a blanket "no" would let a +# marked field leave the graph with no refusal at all. +STRUCTURED_LEAF = { + "pydantic_dataclass": """ + from pydantic import Field + from pydantic.dataclasses import dataclass + + + @dataclass + class Peer: + handshake: str = Field(default="", json_schema_extra={"secret": True}) + """, + "typed_dict": """ + from typing import Annotated, TypedDict + + from pydantic import Field + + + class Peer(TypedDict): + handshake: Annotated[str, Field(json_schema_extra={"secret": True})] + """, + "named_tuple": """ + from typing import NamedTuple + + + class Peer(NamedTuple): + handshake: str = "" + """, +} +# A plain annotated class is deliberately absent: pydantic refuses to build a +# model with such a field at all (`PydanticSchemaGenerationError`), so the walk +# never sees it. It still fails closed -- with pydantic's message rather than +# this tool's -- which is why the rule in `_declares_fields_anywhere` is stated +# as a property rather than as this list. + + +@pytest.mark.parametrize("shape", sorted(STRUCTURED_LEAF)) +def test_a_class_whose_fields_cannot_be_walked_is_refused( + shape, repo, monkeypatch, capsys +): + """The last branch of the walkability question must not answer "no". + + None of these is a `BaseModel`, a parameterised generic or a bare + container, so each falls through every earlier branch to the final one. + Answering "no" there emits an *empty* model and the field vanishes from + the graph silently -- the whole category, not one annotation -- which is + R1 inverted: an unanswerable question answered "no". + """ + write( + repo, + "peers.py", + textwrap.dedent(STRUCTURED_LEAF[shape]), + ) + write( + repo, + "declared.py", + """ + from pydantic import BaseModel + + from peers import Peer + + + class Root(BaseModel): + peer: Peer + """, + ) + declare(repo) + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 2 + assert not (repo / ARTIFACT_PATH).exists() + assert "declared:Root.peer" in capsys.readouterr().err + + +def test_a_scalar_class_is_still_a_leaf(repo, monkeypatch, capsys): + """The other side of the same rule, and the reason it is about *public* + annotations rather than any annotations at all. + + `Enum`, `Decimal`, `UUID` and `datetime` declare nothing; pydantic's own + scalar wrappers declare only underscore-prefixed private attributes, which + pydantic itself does not collect as fields. Refusing these would make the + rule unusable, and the remedy for them ("narrow the annotation") does not + even exist. + """ + write( + repo, + "declared.py", + """ + import datetime + import decimal + import enum + import uuid + + from pydantic import AnyUrl, BaseModel, SecretStr + + + class Mode(enum.Enum): + FAST = "fast" + + + class Root(BaseModel): + mode: Mode = Mode.FAST + amount: decimal.Decimal = decimal.Decimal(0) + ident: uuid.UUID = uuid.UUID(int=0) + when: datetime.datetime = datetime.datetime(2000, 1, 1) + endpoint: AnyUrl = AnyUrl("https://example.invalid") + hidden: SecretStr = SecretStr("") + """, + ) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert written["models"]["declared:Root"] == {} + + +def test_an_out_of_repo_base_declaring_no_fields_needs_no_lockfile( + repo, monkeypatch, capsys +): + """The shortcut the MRO fix must not take. + + Recording *every* base except `object` and `BaseModel` would make + `class Config(BaseModel, ABC)` record `abc` as an out-of-repo contributor + and turn a tracked lockfile into a universal precondition -- the exact + cost the `BaseModel` exclusion exists to avoid. `ABC` declares no fields, + so it contributes nothing and is not recorded. No lockfile exists in this + repository, so generation would refuse if it were. + """ + write( + repo, + "declared.py", + """ + from abc import ABC + + from pydantic import BaseModel, Field + + + class Root(BaseModel, ABC): + binding: str = Field(default="", json_schema_extra={"secret": True}) + """, + ) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert set(written["sources"]) == {"declared.py", ".stackward.toml"} + + +# The same two colliding fields in both declaration orders. `plain` is an +# unmarked scalar, which emits no entry of its own; `token` is marked and +# aliased onto that same data key. +KEY_COLLISION = { + "unmarked_field_first": """ + from pydantic import BaseModel, Field + + + class Root(BaseModel): + plain: str = "" + token: str = Field( + default="", alias="plain", json_schema_extra={"secret": True} + ) + """, + "aliased_field_first": """ + from pydantic import BaseModel, Field + + + class Root(BaseModel): + token: str = Field( + default="", alias="plain", json_schema_extra={"secret": True} + ) + plain: str = "" + """, +} + + +@pytest.mark.parametrize("order", sorted(KEY_COLLISION)) +def test_a_data_key_claimed_by_two_fields_is_refused_in_either_order( + order, repo, monkeypatch, capsys +): + """The refusal must not depend on declaration order. + + An unmarked scalar emits no entry, so a check keyed on *emitted* entries + never saw it claim its key: with the unmarked field first, the marked one + took that key silently, while the same two fields written the other way + round did refuse. Both orders are asserted, because either alone passes + against the order-dependent version. + """ + write(repo, "declared.py", textwrap.dedent(KEY_COLLISION[order])) + declare(repo) + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 2 + assert "claimed by more than one field" in capsys.readouterr().err + + +def test_a_mark_inherited_from_a_plain_mixin_records_the_mixins_file( + repo, monkeypatch, capsys +): + """pydantic collects a marked field from a base that is not itself a + `BaseModel`, so a source filter keyed on `issubclass(base, BaseModel)` + takes the mark and discards the file it came from. The mixin is tracked + and already contributing when the artifact is written, so this is not the + "newly added file" boundary -- the MRO walk sees it and must record it. + """ + write( + repo, + "mixin_mod.py", + """ + from pydantic import Field + + + class Marks: + binding: str = Field(default="", json_schema_extra={"secret": True}) + """, + ) + write( + repo, + "declared.py", + """ + from pydantic import BaseModel + + from mixin_mod import Marks + + + class Root(Marks, BaseModel): + plain: str = "" + """, + ) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert written["models"]["declared:Root"]["binding"] == {"secret": True} + assert "mixin_mod.py" in written["sources"] + + +def test_editing_a_plain_mixin_makes_the_artifact_stale(repo, monkeypatch, capsys): + """The consequence of the test above, and the thing that actually fails + open without it: a mark changing in the mixin with no refusal.""" + write( + repo, + "mixin_mod.py", + """ + from pydantic import Field + + + class Marks: + binding: str = Field(default="", json_schema_extra={"secret": True}) + """, + ) + write( + repo, + "declared.py", + """ + from pydantic import BaseModel + + from mixin_mod import Marks + + + class Root(Marks, BaseModel): + plain: str = "" + """, + ) + declare(repo) + git(repo, "add", "-A") + sync_and_commit(repo, monkeypatch, capsys) + write( + repo, + "mixin_mod.py", + """ + from pydantic import Field + + + class Marks: + binding: str = Field(default="", json_schema_extra={"secret": True}) + added_later: str = Field(default="", json_schema_extra={"secret": True}) + """, + ) + write(repo, "Pulumi.dev.yaml", "config:\n app:app:\n added_later: leaked\n") + git(repo, "add", "-A") + + assert check(repo, monkeypatch) == 2 + assert "mixin_mod.py has changed" in capsys.readouterr().err + + +def test_the_repository_config_is_a_recorded_source(repo, monkeypatch, capsys): + """`.stackward.toml` supplies `roots` and `declared_paths_fn`, so it + decides part of the artifact's content as surely as any model file does. + Leaving it out means a declaration can be added to it and never checked. + """ + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert ".stackward.toml" in written["sources"] + + +def test_adding_a_namespace_to_the_config_makes_the_artifact_stale( + repo, monkeypatch, capsys +): + """The fail-open the recorded config file closes: a second namespace + declared in `.stackward.toml` and staged, with the artifact untouched, so + the namespace is simply skipped and its credential is never looked at.""" + write(repo, "declared.py", TWO_MODELS) + declare(repo) + git(repo, "add", "-A") + sync_and_commit(repo, monkeypatch, capsys) + + write( + repo, + ".stackward.toml", + f""" + python = "{sys.executable}" + + [check] + model_net = "artifact" + stack_models = {{ "app:app" = "declared:Root", "app:other" = "declared:Other" }} + """, + ) + write(repo, "Pulumi.dev.yaml", "config:\n app:other:\n binding: leaked\n") + git(repo, "add", "-A") + + assert check(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert "sync-declared-secrets" in captured.err + # Nothing reported: before the fix this exited 0, having silently skipped + # the namespace the policy declared and the artifact did not cover. + assert captured.out == "" + + +def test_a_root_the_config_declares_and_the_artifact_lacks_is_refused( + repo, monkeypatch, capsys +): + """The second, independent guard on the same class of drift, needing no + blob id: the namespaces the artifact starts from must be exactly the ones + the policy declares. Key sets only -- a model id is `module:QualName` and + diverges from the config's `module:Class` target for a nested class.""" + net, _sources = parse_net( + { + "version": ARTIFACT_VERSION, + "models": {"declared:Root": {"token": {"secret": True}}}, + "roots": {"app:app": "declared:Root"}, + "sources": {"declared.py": "0" * 40}, + } + ) + monkeypatch.setattr(model_module, "repo_toplevel", lambda: repo) + monkeypatch.setattr(model_module, "read_artifact_from_index", lambda _root: {}) + monkeypatch.setattr(model_module, "parse_net", lambda _payload: (net, {})) + monkeypatch.setattr(model_module, "verify_sources", lambda _root, _sources: None) + + with pytest.raises(ModelNetError) as excinfo: + model_module.load_model_net( + CheckConfig( + model_net="artifact", + stack_models={"app:app": "declared:Root", "app:other": "declared:Other"}, + ) + ) + assert "app:other" in str(excinfo.value) + + def test_a_root_model_is_refused_rather_than_covering_nothing( repo, monkeypatch, capsys ): @@ -813,7 +1212,10 @@ class Root(BaseModel): error = capsys.readouterr().err assert "settings" in error # the field assert "declared:Root" in error # the class - assert "Any" in error # the annotation + # The annotation *as written*. Asserting only that "Any" appears would + # pass against a message that reported the inner type alone and sent the + # reader looking for a line saying `Any`, which their source does not have. + assert "dict[str, " in error and "Any]" in error assert "narrow the annotation" in error # remedy 1 assert "json_schema_extra" in error # remedy 2 assert "declared_paths_fn" in error # remedy 3 @@ -889,11 +1291,18 @@ def test_a_graph_the_matcher_could_not_read_is_refused_before_it_is_written( "files": [str(repo / "declared.py")], "external": [], }, + repo / ".stackward.toml", ) assert "not readable" in str(excinfo.value) assert "undefined model" in str(excinfo.value) +def CONFIG(repo: Path) -> Path: + """`.stackward.toml`, which `build_artifact` records as a source like any + other -- see `test_the_repository_config_is_a_recorded_source`.""" + return repo / ".stackward.toml" + + def external_result(repo: Path) -> dict: """A generator result whose marks came partly from outside the repository — a base model or a marking alias imported from a dependency.""" @@ -911,9 +1320,10 @@ def test_an_out_of_repo_contributor_is_pinned_by_the_dependency_lockfile(repo): the index holds.""" write(repo, "declared.py", RECURSIVE_MODEL) write(repo, "uv.lock", "# a lockfile\n") + declare(repo) git(repo, "add", "-A") - sources = build_artifact(repo, external_result(repo))["sources"] + sources = build_artifact(repo, external_result(repo), CONFIG(repo))["sources"] assert "uv.lock" in sources # Recorded by blob id, exactly like every other source. expected = git(repo, "rev-parse", ":uv.lock").stdout.strip() @@ -926,9 +1336,10 @@ def test_every_tracked_lockfile_is_recorded_not_only_the_first(repo): write(repo, "declared.py", RECURSIVE_MODEL) write(repo, "uv.lock", "# a lockfile\n") write(repo, "requirements.txt", "somepackage==1.0\n") + declare(repo) git(repo, "add", "-A") - sources = build_artifact(repo, external_result(repo))["sources"] + sources = build_artifact(repo, external_result(repo), CONFIG(repo))["sources"] assert "uv.lock" in sources assert "requirements.txt" in sources @@ -939,10 +1350,11 @@ def test_an_untracked_lockfile_does_not_pin_an_out_of_repo_contributor(repo): did.""" write(repo, "declared.py", RECURSIVE_MODEL) write(repo, "uv.lock", "# a lockfile\n") - git(repo, "add", "declared.py") # the lockfile is deliberately left out + declare(repo) + git(repo, "add", "declared.py", ".stackward.toml") # lockfile left out with pytest.raises(SyncError) as excinfo: - build_artifact(repo, external_result(repo)) + build_artifact(repo, external_result(repo), CONFIG(repo)) assert "outside.package.conventions" in str(excinfo.value) @@ -950,10 +1362,11 @@ def test_an_out_of_repo_contributor_with_no_lockfile_is_refused(repo): """There would be nothing at all pinning that module's identity, so the artifact could never be found stale on account of it.""" write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) git(repo, "add", "-A") with pytest.raises(SyncError) as excinfo: - build_artifact(repo, external_result(repo)) + build_artifact(repo, external_result(repo), CONFIG(repo)) message = str(excinfo.value) assert "outside.package.conventions" in message assert "uv.lock" in message # the message names what it looked for @@ -964,13 +1377,15 @@ def test_no_lockfile_is_recorded_when_nothing_came_from_outside_the_repo(repo): marks are all its own has no dependency to pin.""" write(repo, "declared.py", RECURSIVE_MODEL) write(repo, "uv.lock", "# a lockfile\n") + declare(repo) git(repo, "add", "-A") sources = build_artifact( repo, {"roots": {}, "models": {}, "files": [str(repo / "declared.py")], "external": []}, + CONFIG(repo), )["sources"] - assert sources == {"declared.py": sources["declared.py"]} + assert set(sources) == {"declared.py", ".stackward.toml"} def test_an_untracked_contributing_file_is_refused(repo, monkeypatch, capsys): @@ -1449,6 +1864,24 @@ def test_stackward_never_imports_pydantic(): assert proc.stdout.strip() == "False" +@pytest.mark.parametrize( + "entry_point", + [check_config_module.scan_file, pre_commit_module._scan_staged_config], +) +def test_neither_scan_entry_point_makes_the_model_net_optional(entry_point): + """`net` must have no default on either scanner. + + A default would make the model net silently opt-in: a future caller that + simply forgot the argument would scan with the heuristic net alone and + report clean, which is the failure this whole net exists to prevent. The + property is a signature, so it is asserted as one -- there is no + behaviour to observe, because the whole point is what happens to a call + site that does not yet exist. + """ + parameter = inspect.signature(entry_point).parameters["net"] + assert parameter.default is inspect.Parameter.empty + + def test_the_gate_path_reaches_no_credential_code(): """`check-config` and `pre-commit` must never reach the credential store or `cryptography`, so a commit cannot be blocked by an expired session or From b2fde4b83a710c35bff5e426d3202c164f8aef28 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 14:29:53 -0700 Subject: [PATCH 30/44] feat: provider interface for credentials and secrets (Task 11) Two roles, one method each, both `typing.Protocol`s so a caller depends on the method shape rather than a module: `CredentialStore.resolve(profile)` returns a profile's bootstrap set (`file`, `env`); `SecretSource.resolve (name)` returns one manifest logical name's value or `None` (`dotenv`, `env`). `file` and `dotenv` adapt Task 6's `store.resolve_credentials` and Task 8's `LocalSecretSource` respectively, unchanged, per the brief. A generic `command` provider (for both roles) shells out to a configured CLI -- the vehicle for "adding a remote provider must not add a Python dependency" and for a fake remote provider the contract test can exercise with a real subprocess rather than a Python object standing in for "remote". Selection is `provider = "..."` in `[profile..credentials]` (store.py's Profile, defaulting to "file") and in `[secrets.source]` (set_secrets.py, defaulting to "dotenv") -- both wired into the real `exec`/`shell`/`login` and `set-secrets` entry points, not only into a factory nothing calls. Gate-path invariant: no `stackward.providers*` module is ever imported by check-config/pre-commit. session.py and set_secrets.py import from `providers` only inside the function that is about to resolve a credential (never at module scope), since cli.py imports both unconditionally. Verified both ways: `test_the_check_config_entry_point_never_loads_a_ provider_module` (literal, per the brief) and a stronger sibling that runs the real `cli.main(["check-config", ...])` dispatch, since cli.py's own module-scope imports are what a real commit's hook process actually exercises. Both were confirmed to fail under a deliberate sabotage import before being reverted. A provider failure is `ProviderError`, reported, never a silent empty value: `_publish_entries` records it as `failed` (never `skipped`), `_check_ drift` warns rather than folding it into its existing silent states, and `session._prepare_env` turns it into `SessionError` rather than running a child with partial credentials. `file`'s `StoreError` propagates unchanged instead of being rewrapped, since existing session.py tests already assert on it and rewrapping would be a rewrite, not an adaptation. Carry-forward fix from Task 6's review: `crypto.seal_json` was reported to raise a bare `UnicodeEncodeError` on a payload value containing a lone surrogate. Empirically false as stated -- `json.dumps(..., ensure_ascii=True)` escapes a lone surrogate to plain ASCII before the `.encode("utf-8")` that follows, confirmed by test. The real, reproduced site is `_normalise`'s raw `str.encode("utf-8")` on an NFC-normalised password (reachable via `STACKWARD_PASSWORD` from `os.environ`, which Python decodes with `surrogateescape`), plus the identical `aad.encode ("utf-8")` in both `seal` and `unseal` -- so this also fail-closes the unseal path, not only seal. All three, plus `seal_json`'s own call as a second line of defence, now raise the new `EncodingError(CryptoError)` instead of a bare exception. Contract tests (tests/test_providers.py) prove the "swapping the provider changes nothing for the caller" guarantee rather than merely asserting it: `set-secrets --dry-run` produces byte-identical printed outcomes under `dotenv` and the fake `command` remote seeded with the same values; `set-secrets` without `--dry-run` produces byte-identical `pulumi` argv *and* stdin against a real stub (`--dry-run` alone can't prove this -- `_publish_entries` never reaches `_pulumi_config_set` under it, so that comparison would be `[] == []`); and `exec` injects byte-identical child environments across `file`, `command` and `env` `CredentialStore`s seeded with the same credentials. Scope note: `providers/command.py` is not one of the four implementations the brief names (`file`, `env`, `dotenv`, `env`) -- it exists because "a remote provider shells out to its CLI" is otherwise untestable without a Python object standing in for "remote", and the contract test needs a real fork/exec. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/commands/session.py | 77 ++- src/stackward/commands/set_secrets.py | 121 +++- src/stackward/crypto.py | 69 +- src/stackward/providers/__init__.py | 120 ++++ src/stackward/providers/command.py | 105 +++ src/stackward/providers/dotenv.py | 44 ++ src/stackward/providers/env.py | 55 ++ src/stackward/providers/file.py | 36 + src/stackward/store.py | 36 +- tests/test_crypto.py | 89 +++ tests/test_providers.py | 907 ++++++++++++++++++++++++++ 11 files changed, 1629 insertions(+), 30 deletions(-) create mode 100644 src/stackward/providers/__init__.py create mode 100644 src/stackward/providers/command.py create mode 100644 src/stackward/providers/dotenv.py create mode 100644 src/stackward/providers/env.py create mode 100644 src/stackward/providers/file.py create mode 100644 tests/test_providers.py diff --git a/src/stackward/commands/session.py b/src/stackward/commands/session.py index 74c6e25..94bc2c2 100644 --- a/src/stackward/commands/session.py +++ b/src/stackward/commands/session.py @@ -122,6 +122,7 @@ import sys from collections.abc import Mapping from pathlib import Path +from typing import TYPE_CHECKING from urllib.parse import quote, urlencode from ..config import ConfigError, find_repo_config, load_config @@ -129,11 +130,17 @@ Profile, StoreError, load_store_config, - resolve_credentials, select_profile, ) from .check_config import fail_closed +if TYPE_CHECKING: + # Never imported at runtime -- see `_build_credential_store`'s own + # docstring on why provider modules stay off this module's own top + # level. `TYPE_CHECKING` is always `False` when this file actually runs, + # so this line never touches `sys.modules`. + from ..providers import CredentialStore + # Read once, never printed: the non-interactive escape hatch for `exec`/ # `shell` in an environment with no controlling terminal (CI, most notably). # Never accepted as a CLI argument -- only ever an environment variable, per @@ -430,12 +437,59 @@ def _child_env(credentials: Mapping[str, str], backend_url: str) -> dict[str, st return env +def _build_credential_store( + profile: Profile, *, directory: Path | None +) -> "CredentialStore": + """The `CredentialStore` `profile.credentials`'s `provider` selects + (`"file"` when the table or the key is absent, preserving today's + behaviour unchanged). + + Provider modules are imported here, function-locally -- never at this + module's own top level -- for the reason `providers/__init__.py`'s + module docstring states: `cli.py` imports this module unconditionally, + so a module-scope import here would put a provider on every + `stackward` invocation's import graph, `check-config` and `pre-commit` + included, which is exactly what the gate-path invariant forbids. A + caller that never calls this function -- which is every gate-path + command -- never triggers any of these imports. + + `"file"` reads the store password now, at the same point `_prepare_env` + always has -- after the backend guard, never before it (see + `test_prepare_env_checks_the_backend_guard_before_reading_a_password`). + Only `"file"` needs one; `"env"` and `"command"` never prompt. + """ + provider = profile.credentials.get("provider", "file") + if provider == "file": + from ..providers.file import FileCredentialStore + + return FileCredentialStore(_read_store_password(), directory=directory) + if provider == "env": + from ..providers.env import EnvCredentialStore + + return EnvCredentialStore(CREDENTIAL_NAMES) + if provider == "command": + from ..providers.command import CommandCredentialStore + + command = profile.credentials.get("command") + if not isinstance(command, list) or not command or not all( + isinstance(item, str) for item in command + ): + raise SessionError( + f"profile {profile.name!r}: credentials provider 'command' needs " + "a non-empty 'command' list of strings" + ) + return CommandCredentialStore(command) + raise SessionError( + f"profile {profile.name!r}: unknown credentials provider {provider!r}" + ) + + def _prepare_env( explicit_profile: str | None, *, directory: Path | None = None ) -> dict[str, str]: - """Resolve the profile, enforce the backend guard, decrypt its - credentials and return the exact environment `exec`/`shell` must run - their child in. + """Resolve the profile, enforce the backend guard, resolve its + credentials through whichever `CredentialStore` its `provider` selects, + and return the exact environment `exec`/`shell` must run their child in. Raises `StoreError`, `ConfigError` or `SessionError` and returns nothing partial: every step that can fail runs before `_child_env` builds @@ -445,8 +499,19 @@ def _prepare_env( profile = _resolve_profile(explicit_profile, directory=directory) url = _compose_backend_url(profile) _check_backend_guard(url) - password = _read_store_password() - credentials = resolve_credentials(profile.name, password, directory=directory) + credential_store = _build_credential_store(profile, directory=directory) + + from ..providers import ProviderError # local: see `_build_credential_store` + + try: + credentials = credential_store.resolve(profile.name) + except ProviderError as exc: + # `file` never raises this -- `store.StoreError` propagates + # unchanged, per `providers.file`'s own docstring -- so this branch + # is reachable only for a provider (`command`, or a future one) that + # actually failed, never for `file`'s existing, already-tested + # error paths. + raise SessionError(str(exc)) from exc _require_credential_names(credentials, profile.name) return _child_env(credentials, url) diff --git a/src/stackward/commands/set_secrets.py b/src/stackward/commands/set_secrets.py index cc85089..9da4f0b 100644 --- a/src/stackward/commands/set_secrets.py +++ b/src/stackward/commands/set_secrets.py @@ -87,13 +87,19 @@ from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from .. import paths from ..config import Config, ConfigError, find_repo_config, load_config from ..store import warn_if_permissive from .check_config import fail_closed +if TYPE_CHECKING: + # Never imported at runtime -- see `_build_secret_source`'s own + # docstring, and `providers/__init__.py`'s, on why provider modules stay + # off this module's own top level. + from ..providers import SecretSource + # How long any single `pulumi` invocation (a `config set` or a `config get`) # is allowed to run before this module gives up on it and reports that one # entry as failed. A module-level variable, not a function default, so a test @@ -415,6 +421,59 @@ def resolve_source_files(config: Config, repo_root: Path, stack: str | None) -> return resolved +_SECRET_SOURCE_PROVIDERS = frozenset({"dotenv", "env", "command"}) + + +def _build_secret_source( + config: Config, repo_root: Path, stack: str | None +) -> "SecretSource": + """The `SecretSource` `[secrets.source].provider` selects (`"dotenv"` + when the table or the key is absent, preserving today's environment- + then-files behaviour unchanged). + + Provider modules are imported here, function-locally -- never at this + module's own top level -- for the same reason `session. + _build_credential_store` does it the same way: `cli.py` imports this + module unconditionally, so a module-scope import here would put a + provider on every `stackward` invocation's import graph, `check-config` + and `pre-commit` included -- see `providers/__init__.py`'s module + docstring. A caller that never calls this function -- every gate-path + command -- never triggers any of these imports. + """ + source = config.secrets.get("source", {}) + if not isinstance(source, dict): + raise SetSecretsError("secrets.source must be a table") + + provider = source.get("provider", "dotenv") + if not isinstance(provider, str): + raise SetSecretsError("secrets.source.provider must be a string") + + if provider == "dotenv": + from ..providers.dotenv import DotenvSecretSource + + files = resolve_source_files(config, repo_root, stack) + return DotenvSecretSource(os.environ, files) + if provider == "env": + from ..providers.env import EnvSecretSource + + return EnvSecretSource(os.environ) + if provider == "command": + from ..providers.command import CommandSecretSource + + command = source.get("command") + if not isinstance(command, list) or not command or not all( + isinstance(item, str) for item in command + ): + raise SetSecretsError( + "secrets.source.command must be a non-empty list of strings" + ) + return CommandSecretSource(command) + raise SetSecretsError( + f"secrets.source.provider must be one of {sorted(_SECRET_SOURCE_PROVIDERS)!r}, " + f"got {provider!r}" + ) + + def parse_env_file(path: Path) -> dict[str, str]: """Parse one `KEY=VALUE` file: blank lines and `#` comments are ignored, a leading `export ` is stripped from the key side, and exactly one matched @@ -547,7 +606,7 @@ def _pulumi_config_get( def _publish_entries( manifest: ProjectManifest, - source: LocalSecretSource, + source: "SecretSource", *, pulumi: str | None, stack: str | None, @@ -559,23 +618,36 @@ def _publish_entries( publishing each one with its own table's mode — one `EntryOutcome` per entry, regardless of whether it succeeded. - Every failure mode here — an unresolved required path, a `pulumi` timeout, - a missing `pulumi` binary, a non-zero `pulumi` exit — is recorded on its - own `EntryOutcome` and the loop continues to the next entry rather than - raising: a half-finished rotation that stops on the first failure and - exits non-zero has already left every entry after it unset, and reports - that failure no differently than one that finishes and simply fails to + Every failure mode here — an unresolved required path, a provider that + could not resolve a name at all, a `pulumi` timeout, a missing `pulumi` + binary, a non-zero `pulumi` exit — is recorded on its own `EntryOutcome` + and the loop continues to the next entry rather than raising: a + half-finished rotation that stops on the first failure and exits + non-zero has already left every entry after it unset, and reports that + failure no differently than one that finishes and simply fails to report which entries did not land. The order entries are declared within each table (a `dict`, so insertion order — the order the TOML was written in) is preserved throughout, since it is what makes "the failing entry was in the middle" a meaningful, reproducible scenario to test. + + A `source.resolve(name)` that raises `ProviderError` is a `failed` + outcome, never folded into `skipped` — an unresolved name and a provider + that could not answer are different facts (see `providers/__init__.py`), + and treating the second as the first would silently skip a secret the + operator believes their remote store actually holds. """ + from ..providers import ProviderError # local: see `_build_secret_source` + entries = [(path, name, "secret") for path, name in manifest.secret.items()] entries += [(path, name, "plaintext") for path, name in manifest.plaintext.items()] outcomes: list[EntryOutcome] = [] for path, name, mode in entries: - value = source.resolve(name) + try: + value = source.resolve(name) + except ProviderError as exc: + outcomes.append(EntryOutcome(path, name, "failed", f"provider error: {exc}")) + continue if not value: if path in manifest.required: outcomes.append(EntryOutcome(path, name, "failed", "required but unresolved")) @@ -620,7 +692,7 @@ def _config_path_for_name(entries: dict[str, str], name: str) -> str | None: def _check_drift( manifest: ProjectManifest, - source: LocalSecretSource, + source: "SecretSource", *, pulumi: str | None, stack: str | None, @@ -630,9 +702,22 @@ def _check_drift( value and published `managed` value disagree — see the module docstring for exactly which states are silent on purpose. Never called under `--dry-run`: drift detection reads `pulumi config get`, which is a real - invocation, and dry-run's contract is to run nothing at all.""" + invocation, and dry-run's contract is to run nothing at all. + + A provider that raises resolving `bootstrap` is reported as a warning, + the same channel every other drift issue uses, rather than folded into + the silent "nothing to compare" state a `None`/`""` bootstrap value gets + — the three silent states this function's docstring names are all + legitimate absences, and a provider failure is not one of them (see + `providers/__init__.py`). It does not change this command's exit code: + drift detection has never affected the exit code (only `_publish_entries`' + outcomes do), and a provider failure resolving `bootstrap` here is no + exception to that. + """ if pulumi is None: return + from ..providers import ProviderError # local: see `_build_secret_source` + # `secret` and `plaintext` are guaranteed disjoint by # `parse_project_manifest`, so this merge cannot silently drop or shadow # an entry from either side -- `managed` may name a path in either table, @@ -644,7 +729,15 @@ def _check_drift( # dict, raising `AttributeError` rather than evaluating the pair at all. published_entries = {**manifest.secret, **manifest.plaintext} for pair in manifest.drift_pairs: - bootstrap_value = source.resolve(pair.bootstrap) + try: + bootstrap_value = source.resolve(pair.bootstrap) + except ProviderError as exc: + print( + f"warning: drift pair {pair.bootstrap!r}/{pair.managed!r}: " + f"could not resolve {pair.bootstrap!r}: {exc}", + file=sys.stderr, + ) + continue if not bootstrap_value: continue @@ -699,9 +792,7 @@ def cmd_set_secrets(args: argparse.Namespace) -> int: repo_root = config_path.parent project = select_project(config, repo_root, Path.cwd()) manifest = parse_project_manifest(config.secrets[project], project) - files = resolve_source_files(config, repo_root, args.stack) - file_values = [parse_env_file(f) for f in files if f.exists()] - source = LocalSecretSource(os.environ, file_values) + source = _build_secret_source(config, repo_root, args.stack) except (ConfigError, SetSecretsError) as exc: print(f"error: {exc}", file=sys.stderr) return 2 diff --git a/src/stackward/crypto.py b/src/stackward/crypto.py index f4645e3..fb11d4f 100644 --- a/src/stackward/crypto.py +++ b/src/stackward/crypto.py @@ -139,6 +139,26 @@ class EmptyPasswordError(CryptoError): reasoning `store.select_profile` applies to an empty `STACKWARD_PROFILE`.""" +class EncodingError(CryptoError): + """A password or associated-data string cannot be encoded as UTF-8: it + contains a lone surrogate. + + `os.environ` decodes a byte sequence that was never valid UTF-8 using + Python's `surrogateescape` error handler rather than raising, so a + provider that reads a store password (`STACKWARD_PASSWORD`) or any other + value out of the process environment can hand this module a string that + round-trips through `str` but cannot be re-encoded. Without this, + `_normalise`'s `str.encode("utf-8")` — and the identical `aad.encode + ("utf-8")` in `seal`/`unseal` — raise a bare `UnicodeEncodeError`, + letting it escape past every `except CryptoError` this codebase's + docstrings promise stays fail-closed. Found by review while auditing + Task 6's store ahead of Task 11 feeding it environment-sourced values; + confirmed empirically that the failure is here, in the raw encode of an + unescaped string, and not in `seal_json`'s `json.dumps(..., + ensure_ascii=True)` output, which escapes a lone surrogate to plain + ASCII (`\\udc80`) and therefore encodes without error.""" + + def _normalise(password: str) -> bytes: """NFC-normalise and encode, refusing a password that is not one. @@ -149,7 +169,15 @@ def _normalise(password: str) -> bytes: """ if not password.strip(): raise EmptyPasswordError("a password is required") - return unicodedata.normalize("NFC", password).encode("utf-8") + normalised = unicodedata.normalize("NFC", password) + try: + return normalised.encode("utf-8") + except UnicodeEncodeError as exc: + # See `EncodingError`: reachable for a password read out of the + # process environment that was never valid UTF-8 to begin with. + raise EncodingError( + "password contains a character that cannot be encoded as UTF-8" + ) from exc def describe_value(value: Any) -> str: @@ -239,6 +267,20 @@ class of machine where nothing about the store works. ) from exc +def _encode_aad(aad: str) -> bytes: + """`aad.encode("utf-8")`, with a lone surrogate mapped to `EncodingError` + rather than a bare `UnicodeEncodeError` — see `EncodingError`. Every + caller in this codebase passes a profile name or a fixed literal, both + already restricted to safe charsets, but this module has no way to see + a future caller that does not, and the encode is one line either way.""" + try: + return aad.encode("utf-8") + except UnicodeEncodeError as exc: + raise EncodingError( + "associated data contains a character that cannot be encoded as UTF-8" + ) from exc + + def seal(plaintext: bytes, password: str, aad: str) -> dict[str, Any]: """Encrypt `plaintext` under `password`, binding `aad` into the tag. @@ -257,7 +299,7 @@ def seal(plaintext: bytes, password: str, aad: str) -> dict[str, Any]: "lanes": ARGON2ID_LANES, } key = _derive(password, salt, params) - ciphertext = AESGCM(key).encrypt(nonce, plaintext, aad.encode("utf-8")) + ciphertext = AESGCM(key).encrypt(nonce, plaintext, _encode_aad(aad)) return { "v": ENVELOPE_VERSION, "kdf": {"name": KDF_NAME, **params}, @@ -314,7 +356,7 @@ def unseal(envelope: Any, password: str, aad: str) -> bytes: key = _derive(password, salt, params) try: - return AESGCM(key).decrypt(nonce, ciphertext, aad.encode("utf-8")) + return AESGCM(key).decrypt(nonce, ciphertext, _encode_aad(aad)) except InvalidTag as exc: raise DecryptionError( "could not decrypt: wrong password, or the sealed data does not " @@ -329,12 +371,23 @@ def seal_json(payload: Any, password: str, aad: str) -> dict[str, Any]: on the order a dict happened to be built in — the length of the ciphertext already leaks the size of the payload, and there is no reason to let its construction order leak too. + + `json.dumps`'s default `ensure_ascii=True` already escapes a lone + surrogate in `payload` to plain ASCII (`\\udc80`, six literal + characters), so the `.encode("utf-8")` below cannot itself raise on one — + confirmed empirically, not assumed. The `try` stays anyway, as a second + line of defence at the one call in this module a future change to + `ensure_ascii` or `separators` could put back in `_normalise`'s + situation, and because the cost of guarding an unreachable line here is + one `except` clause. """ - return seal( - json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8"), - password, - aad, - ) + try: + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + except UnicodeEncodeError as exc: + raise EncodingError( + "payload contains a character that cannot be encoded as UTF-8" + ) from exc + return seal(encoded, password, aad) def unseal_json(envelope: Any, password: str, aad: str) -> Any: diff --git a/src/stackward/providers/__init__.py b/src/stackward/providers/__init__.py new file mode 100644 index 0000000..dc522c3 --- /dev/null +++ b/src/stackward/providers/__init__.py @@ -0,0 +1,120 @@ +"""Provider interface: swapping the credential source is a configuration +change, not a migration. + +**Two roles, one method each.** `CredentialStore.resolve(profile)` returns a +profile's bootstrap set (the names `commands.session` needs to run `pulumi` +at all: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, +`PULUMI_CONFIG_PASSPHRASE`), the same shape `store.resolve_credentials` +already has. `SecretSource.resolve(name)` returns one manifest logical name's +value, or `None`, the same shape `commands.set_secrets.LocalSecretSource` +already has. Both are `typing.Protocol`s — structural, not a base class a +provider must inherit from — because the whole point is that a caller written +against the method, not against a particular module, cannot tell which +provider answered it. `file` and `dotenv` in this package are thin adapters +over exactly those two pre-existing, reviewed functions (see `file.py` and +`dotenv.py`); this task adapts them rather than rewriting them. + +**Construction is per-provider; only the method is uniform.** A `file` +`CredentialStore` needs a password and a store directory; an `env` one needs +a list of names; a `command` one needs an argv prefix. None of that belongs +in one generic factory signature that would have to grow a parameter for +every provider's own configuration — so `providers/__init__.py` defines no +`build_*` function at all. `commands.session` and `commands.set_secrets` each +own a small `if provider == "...":` dispatch instead, exactly the same shape +`commands.set_secrets._pulumi_config_set` already uses for `_MODE_FLAGS`, and +that is the whole point of the pattern being small enough to read in place +rather than a registry needing its own tests. + +**A provider failure is `ProviderError`, reported, never a silent empty +value.** `SecretSource.resolve` returning `None` and `SecretSource.resolve` +raising are different facts: `None` is "not set", which +`commands.set_secrets._publish_entries` already treats as a normal skip; a +raise is "this provider could not answer", which must surface as a `failed` +outcome instead — an empty value and a broken provider are indistinguishable +to everything downstream unless this distinction is kept. `file`'s +`CredentialStore` is the one exception to `ProviderError` specifically: it +lets `store.StoreError` (a wrong password, a missing envelope) propagate +unchanged, because `commands.session`'s existing tests already assert on +`StoreError` for exactly those failures, and rewrapping a pre-existing, +correct error type would be a rewrite this task's brief does not ask for, not +an adaptation. + +**No provider module is ever imported from the gate path.** `check-config` +and `pre-commit` resolve no credentials at all — that is what stops a commit +being blocked by an expired session or a flaky network — so neither may ever +cause a `stackward.providers*` module to be imported, even transitively. +`commands.session` and `commands.set_secrets` are both imported at module +scope by `cli.py` (and so, transitively, by every `stackward` invocation, +`check-config` and `pre-commit` included), so the only way to keep that +property is for *both* modules to import from this package **only inside the +function that is actually about to resolve a credential** (`session. +_build_credential_store`, `set_secrets._build_secret_source`, and the +`ProviderError` imports inside `set_secrets._publish_entries`/`_check_drift`) +— never at their own module scope. This mirrors `store.store_dir`'s own +`from .cli import config_home` and `config.enforce_min_version`'s `from .cli +import VERSION_CHECK_EXEMPT`, both local for the same reason: an import cycle +or an eager load neither module can afford at its own top level. +`tests/test_providers.py`'s +`test_the_check_config_entry_point_never_loads_a_provider_module` (and its +stronger, `cli.main`-driven sibling) is what proves this holds, in a +subprocess, rather than merely by this docstring's say-so. + +This package's own module scope (this file) imports nothing from `file.py`, +`env.py`, `dotenv.py` or `command.py` — each of those is imported directly by +whichever command needs it, so importing `stackward.providers` bare costs +nothing beyond defining the two protocols and `ProviderError` below. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + + +class ProviderError(Exception): + """A provider could not answer: a non-zero exit, a timeout, a missing + executable, or output it could not parse. Never carries a credential + value — every message here names a command, an exit code or a profile, + never anything the command printed. + + The distinction this type exists to make: an empty resolved value and a + provider that failed to produce one are different facts (see the module + docstring). Raise this rather than returning `""` or an incomplete + mapping on failure; it is the caller's job, not the provider's, to decide + what "not set" itself means for the operation at hand. + """ + + +@runtime_checkable +class CredentialStore(Protocol): + """Resolves a profile's bootstrap credential set.""" + + def resolve(self, profile: str) -> dict[str, str]: + """Every credential name known for `profile`, decrypted/fetched. + + May return a mapping missing a name the caller needs — `file`'s + envelope legitimately might not hold every name, and `env`'s process + environment legitimately might not export every name — callers + (`commands.session._require_credential_names`) already tell a + missing name apart from a resolution failure. A resolution failure + itself (the store could not be opened, the remote CLI failed) is + raised, never folded into an empty or partial mapping — see + `ProviderError`. + """ + ... + + +@runtime_checkable +class SecretSource(Protocol): + """Resolves one manifest logical name to a value.""" + + def resolve(self, name: str) -> str | None: + """`name`'s value, or `None` when it is simply not set — a + legitimate, silent outcome the caller treats as a skip (see + `commands.set_secrets.LocalSecretSource`, whose contract this + matches exactly: presence, not truthiness, is what `None` means). + + Raises `ProviderError` when the provider itself could not answer at + all, which must never be conflated with `None` — see the module + docstring. + """ + ... diff --git a/src/stackward/providers/command.py b/src/stackward/providers/command.py new file mode 100644 index 0000000..5f3d567 --- /dev/null +++ b/src/stackward/providers/command.py @@ -0,0 +1,105 @@ +"""`command`: a generic provider that shells out to an externally-configured +CLI — what "adding a remote provider must not add a Python dependency" (the +brief's own wording) means in practice. This module imports nothing beyond +the standard library and `ProviderError` from this same package; the argv it +runs, and everything that binary needs to authenticate or reach a remote +store, comes entirely from `command` in configuration. No vendor or product +name, API shape, or authentication convention appears anywhere in this +module, by design — a real remote store's own CLI is exactly as far away +from this code as `config` says it is. + +**Wire format.** `CommandSecretSource` runs `[*command, name]` and reads +stdout as the value (one trailing newline stripped, matching +`commands.set_secrets._pulumi_config_get`'s own convention for the same +reason); empty output means "not set". `CommandCredentialStore` runs +`[*command, profile]` and parses stdout as a JSON object of `name: value` +strings — the one already-generic wire format this codebase depends on +everywhere else (`json` is stdlib; no new format to invent or document +per-remote). Both raise `ProviderError` — never return an empty or partial +result to mean failure — on a non-zero exit, a timeout, or a missing +executable; `CommandCredentialStore` raises it too on stdout that is not a +JSON object of strings, since that is this provider failing to answer just +as much as a non-zero exit is. + +**Never written to disk, and stdout is decoded, never re-encoded, for +`CommandSecretSource`.** A fetched value lives in this process's memory for +the one call that resolved it — matching `providers/__init__.py`'s +"fetched values are held in memory for the invocation only" — and `_run` +decodes the child's stdout with `errors="surrogateescape"` rather than +`"strict"`, so a value that is not valid UTF-8 becomes a `str` this module +can still return (Python's own encoding for `os.environ`, applied here for +the same reason) instead of this provider itself raising on a value it +never needed to interpret as text beyond passing it through. +""" + +from __future__ import annotations + +import json +import subprocess +from collections.abc import Sequence + +from . import ProviderError + +DEFAULT_TIMEOUT_SECONDS = 30.0 + + +def _require_command(command: Sequence[str]) -> list[str]: + if not command: + raise ProviderError("command provider: 'command' must be a non-empty list") + return list(command) + + +def _run(command: Sequence[str], arg: str, timeout: float) -> str: + """Run `[*command, arg]` and return its stdout, decoded as UTF-8 with + `surrogateescape` (see the module docstring). + + Raises `ProviderError` on anything short of a clean exit: a timeout, a + missing executable, or a non-zero exit code. Never includes the child's + stdout or stderr in the exception message — neither is this module's to + vouch for as free of the very value it just ran to fetch. + """ + try: + completed = subprocess.run([*command, arg], capture_output=True, timeout=timeout) + except subprocess.TimeoutExpired as exc: + raise ProviderError(f"{command[0]!r} timed out") from exc + except OSError as exc: + raise ProviderError(f"cannot run {command[0]!r}: {exc}") from exc + if completed.returncode != 0: + raise ProviderError(f"{command[0]!r} exited {completed.returncode}") + return completed.stdout.decode("utf-8", errors="surrogateescape") + + +class CommandSecretSource: + """Resolves one logical name by running `command ` and reading its + stdout as the value. Empty stdout means "not set" — see the module + docstring's wire format.""" + + def __init__(self, command: Sequence[str], *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> None: + self._command = _require_command(command) + self._timeout = timeout + + def resolve(self, name: str) -> str | None: + text = _run(self._command, name, self._timeout) + value = text[:-1] if text.endswith("\n") else text + return value or None + + +class CommandCredentialStore: + """Resolves a profile's bootstrap set by running `command ` and + parsing its stdout as a JSON object of `name: value` strings.""" + + def __init__(self, command: Sequence[str], *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> None: + self._command = _require_command(command) + self._timeout = timeout + + def resolve(self, profile: str) -> dict[str, str]: + text = _run(self._command, profile, self._timeout) + try: + payload = json.loads(text) + except json.JSONDecodeError as exc: + raise ProviderError(f"{self._command[0]!r} did not print a JSON object") from exc + if not isinstance(payload, dict) or not all( + isinstance(key, str) and isinstance(value, str) for key, value in payload.items() + ): + raise ProviderError(f"{self._command[0]!r} must print a JSON object of strings") + return payload diff --git a/src/stackward/providers/dotenv.py b/src/stackward/providers/dotenv.py new file mode 100644 index 0000000..6e31be6 --- /dev/null +++ b/src/stackward/providers/dotenv.py @@ -0,0 +1,44 @@ +"""`dotenv`: the process environment layered over parsed `KEY=VALUE` files, +adapted to the `SecretSource` role — this is `set-secrets`'s existing, +default value resolution, unchanged, and the default when +`[secrets.source].provider` is absent. + +`commands.set_secrets.LocalSecretSource` already has this role's exact +shape (`resolve(name) -> str | None`, environment first, then each parsed +file in declared order, later files overriding earlier ones) — its own +docstring says it was written to this shape specifically so this interface +could adapt it rather than needing `commands.set_secrets._publish_entries`/ +`_check_drift` rewritten. This module is that adapter: it does the file +parsing `commands.set_secrets.cmd_set_secrets` used to do inline, and then +delegates to `LocalSecretSource` for resolution itself. + +Importing `commands.set_secrets` from here (rather than the other way +round) is deliberate, not a layering inversion: `set_secrets.py` must not +import this package at its own module scope — see `providers/__init__.py`'s +module docstring on why — so the dependency has to run in this direction, +and it costs nothing new, since `commands.set_secrets` is already imported +at `cli.py`'s module scope by every `stackward` invocation today. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path + +from ..commands.set_secrets import LocalSecretSource, parse_env_file + + +class DotenvSecretSource: + """`LocalSecretSource`, built from `environ` and the already-resolved + `files` list (`commands.set_secrets.resolve_source_files`'s output) — + each existing file parsed with `parse_env_file`, a missing one silently + contributing nothing, exactly as `cmd_set_secrets` did before this + provider existed. + """ + + def __init__(self, environ: Mapping[str, str], files: list[Path]) -> None: + file_values = [parse_env_file(f) for f in files if f.exists()] + self._inner = LocalSecretSource(environ, file_values) + + def resolve(self, name: str) -> str | None: + return self._inner.resolve(name) diff --git a/src/stackward/providers/env.py b/src/stackward/providers/env.py new file mode 100644 index 0000000..9277100 --- /dev/null +++ b/src/stackward/providers/env.py @@ -0,0 +1,55 @@ +"""`env`: for CI, where credentials and secret values already live directly +in the process environment — no store to decrypt, no file to parse. + +Reading a mapping never fails, so neither class here has a `ProviderError` +path: a name this provider does not have is "not set", the same legitimate, +silent outcome any other provider reports the same way (see +`providers/__init__.py`'s module docstring), not a malfunction. +""" + +from __future__ import annotations + +import os +from collections.abc import Mapping, Sequence + + +class EnvCredentialStore: + """Resolves a profile's bootstrap set by reading exactly `names` out of + `environ` — never the whole environment (see `providers/__init__.py`'s + caution about this: a caller that iterates an unfiltered `dict(os. + environ)` back as though it were a credential mapping would leak + everything else the process happens to have set). + + `profile` is accepted, by `CredentialStore`'s shape, and ignored: a CI + runner's environment is not partitioned by profile, so there is nothing + here for a profile name to select between — whichever profile asks gets + the same environment. + """ + + def __init__(self, names: Sequence[str], environ: Mapping[str, str] | None = None) -> None: + self._names = tuple(names) + self._environ = os.environ if environ is None else environ + + def resolve(self, profile: str) -> dict[str, str]: + return {name: self._environ[name] for name in self._names if name in self._environ} + + +class EnvSecretSource: + """Resolves a logical name directly from the process environment. Unlike + `dotenv` (`providers.dotenv.DotenvSecretSource`), no file is ever + consulted — a manifest declaring `[secrets.source]` `provider = "env"` + with a `files` list still gets *only* the environment; `files` is + `dotenv`'s own key, and this provider does not look at it. + + Presence, not truthiness: an environment variable exported as `""` is + still returned as `""`, not `None` — matching `SecretSource.resolve`'s + contract that the two are different facts (see + `commands.set_secrets.LocalSecretSource`, whose environment tier this + mirrors exactly). + """ + + def __init__(self, environ: Mapping[str, str] | None = None) -> None: + self._environ = os.environ if environ is None else environ + + def resolve(self, name: str) -> str | None: + return self._environ.get(name) diff --git a/src/stackward/providers/file.py b/src/stackward/providers/file.py new file mode 100644 index 0000000..c3f66ea --- /dev/null +++ b/src/stackward/providers/file.py @@ -0,0 +1,36 @@ +"""`file`: the encrypted store, adapted to the `CredentialStore` role. + +`store.resolve_credentials` already has this role's exact shape — a profile +name and a password in, a mapping of every sealed name back out — so this +class adds nothing beyond binding the password and the store directory at +construction time, which `CredentialStore.resolve`'s single-argument +signature has no room for otherwise. See `store.resolve_credentials`'s own +docstring for the resolution semantics themselves; this module does not +duplicate them. + +`StoreError` (and its subclasses `ProfileError`, `PasswordError`) propagate +unchanged rather than being rewrapped as `ProviderError`. That is a +deliberate exception to this package's own "`ProviderError`, uniformly" +stance (see `providers/__init__.py`): `commands.session`'s existing, +reviewed tests already assert `pytest.raises(StoreError)` for a wrong +password and for a profile with no envelope, and this task's brief asks for +adaptation, not a rewrite of an error type nothing about this task requires +changing. +""" + +from __future__ import annotations + +from pathlib import Path + +from .. import store + + +class FileCredentialStore: + """`store.resolve_credentials`, bound to one password and directory.""" + + def __init__(self, password: str, *, directory: Path | None = None) -> None: + self._password = password + self._directory = directory + + def resolve(self, profile: str) -> dict[str, str]: + return store.resolve_credentials(profile, self._password, directory=self._directory) diff --git a/src/stackward/store.py b/src/stackward/store.py index acaa7d6..6ac3aaa 100644 --- a/src/stackward/store.py +++ b/src/stackward/store.py @@ -103,7 +103,9 @@ _PROFILE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$") _TOP_LEVEL_KEYS = frozenset({"default_profile", "profile"}) -_PROFILE_KEYS = frozenset({"backend_url", "bucket", "prefix", "endpoint", "region"}) +_PROFILE_KEYS = frozenset( + {"backend_url", "bucket", "prefix", "endpoint", "region", "credentials"} +) _COMPONENT_KEYS = ("bucket", "prefix", "endpoint", "region") @@ -147,6 +149,16 @@ class Profile: expects is knowledge about Pulumi, not about profiles, and it belongs with the command that runs `pulumi login`. This module carries the components as structured data and stops there. + + `credentials` is the raw, otherwise-unvalidated `[profile.. + credentials]` table -- `provider` (a string, defaulting to `"file"` when + the table or the key is absent) and whatever else a chosen provider + needs (a remote provider's `command`, say). This module validates only + that the table exists and that `provider`, if present, is a string; it + does not know which providers exist any more than `config.py`'s + `_build_secrets` knows what `[secrets.*]` means -- that is + `providers/__init__.py`'s vocabulary, not this one's, and this module + does not import it (see that package's own module docstring for why). """ name: str @@ -155,6 +167,7 @@ class Profile: prefix: str | None = None endpoint: str | None = None region: str | None = None + credentials: Mapping[str, Any] = field(default_factory=dict) @property def has_components(self) -> bool: @@ -325,6 +338,26 @@ def _require_str(table: Mapping[str, Any], key: str, where: str) -> str | None: return value +def _build_credentials_table(raw: Any, where: str) -> Mapping[str, Any]: + """Shape only: a table, with `provider` a string if present at all. + + Everything else in the table (a remote provider's `command`, say) is + passed through unexamined, for the same reason `config._build_secrets` + defers `[secrets.*]`'s interior to the module that knows its semantics: + this one does not know which providers exist, and inventing a whitelist + here would either reject a provider this module has never heard of or + have to be kept in step with `providers/__init__.py` by hand. + """ + if raw is None: + return {} + if not isinstance(raw, dict): + raise StoreError(f"{where}.credentials must be a table") + provider = raw.get("provider") + if provider is not None and not isinstance(provider, str): + raise StoreError(f"{where}.credentials.provider must be a string") + return raw + + def _build_profile(name: str, raw: Any) -> Profile: if not isinstance(raw, dict): raise StoreError(f"profile.{name} must be a table") @@ -341,6 +374,7 @@ def _build_profile(name: str, raw: Any) -> Profile: prefix=_require_str(raw, "prefix", where), endpoint=_require_str(raw, "endpoint", where), region=_require_str(raw, "region", where), + credentials=_build_credentials_table(raw.get("credentials"), where), ) # Exactly one form. Both would leave two answers to "which backend?" with diff --git a/tests/test_crypto.py b/tests/test_crypto.py index 7d791a2..755e3c6 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -350,3 +350,92 @@ def unsupported(*_args, **_kwargs): monkeypatch.setattr(crypto, "Argon2id", unsupported) with pytest.raises(CryptoError): seal(MARKER, PASSWORD, AAD) + + +# --------------------------------------------------------------------------- +# A lone surrogate — reachable once a provider feeds this module a password +# read out of `os.environ`, which Python decodes with `surrogateescape` +# rather than raising. See `EncodingError`. +# --------------------------------------------------------------------------- + +# One low surrogate `os.environ` would actually produce for a byte that was +# never valid UTF-8 (`os.fsdecode(b"\x80")`, on a POSIX filesystem encoding) — +# not an arbitrary codepoint picked for the shape alone. +SURROGATE_PASSWORD = "before\udc80after" + + +def test_a_lone_surrogate_password_fails_as_a_crypto_error_not_a_bare_one(): + """`_normalise`'s `str.encode("utf-8")` is not JSON-escaped the way a + `seal_json` payload is, so a password containing a lone surrogate must + raise `EncodingError` (a `CryptoError`), not let Python's own + `UnicodeEncodeError` escape past every `except CryptoError` boundary in + `store.py`.""" + with pytest.raises(crypto.EncodingError): + seal(MARKER, SURROGATE_PASSWORD, AAD) + + +def test_encoding_error_is_a_crypto_error(): + """`pytest.raises(crypto.EncodingError)` above proves the *specific* + type; this proves the property those tests actually rely on for + `store.py`'s `except CryptoError` boundaries to see it at all -- the + same distinction `test_an_absent_password_is_refused_as_a_crypto_error` + draws for `EmptyPasswordError`.""" + assert issubclass(crypto.EncodingError, CryptoError) + + +def test_a_lone_surrogate_password_fails_closed_on_unseal_too(): + """`_derive` — and therefore `_normalise` — sits under `unseal` as well + as `seal`; the guard must hold on the read path, not only the write + path a review first flagged this on.""" + envelope = seal(MARKER, PASSWORD, AAD) + with pytest.raises(crypto.EncodingError): + unseal(envelope, SURROGATE_PASSWORD, AAD) + + +def test_a_lone_surrogate_password_without_the_guard_would_raise_a_bare_unicode_error(): + """Proves the failure this guard exists for is real, not hypothetical: + the exact same encode, done the way `_normalise` used to do it, raises + Python's own `UnicodeEncodeError` — outside `CryptoError` entirely.""" + import unicodedata + + with pytest.raises(UnicodeEncodeError): + unicodedata.normalize("NFC", SURROGATE_PASSWORD).encode("utf-8") + + +def test_seal_json_payload_with_a_lone_surrogate_round_trips_without_raising(): + """The opposite finding, stated as a test: `json.dumps`'s default + `ensure_ascii=True` escapes a lone surrogate in the *payload* to plain + ASCII before it is ever encoded, so `seal_json` does not raise on one — + unlike the password case above. Documented here so a future change to + `seal_json`'s `ensure_ascii`/`separators` that reintroduced the failure + would be caught by `test_a_lone_surrogate_payload_value_fails_as_a_crypto_error` + below, and so this module's own claim about where the bug does and does + not live is verified, not merely asserted in a docstring.""" + envelope = seal_json({"NAME": SURROGATE_PASSWORD}, PASSWORD, AAD) + assert unseal_json(envelope, PASSWORD, AAD) == {"NAME": SURROGATE_PASSWORD} + + +def test_a_lone_surrogate_payload_value_fails_as_a_crypto_error(monkeypatch): + """`seal_json`'s own defensive guard, exercised directly: forces the + `ensure_ascii` escaping that normally protects this call to fail, so the + `except UnicodeEncodeError` inside `seal_json` itself is proven live + rather than merely present in the source.""" + + class _Unencodable(str): + def encode(self, *_args, **_kwargs): + raise UnicodeEncodeError("utf-8", self, 0, 1, "simulated") + + def fake_dumps(*_args, **_kwargs): + return _Unencodable("{}") + + monkeypatch.setattr(crypto.json, "dumps", fake_dumps) + with pytest.raises(crypto.EncodingError): + seal_json({"NAME": "value"}, PASSWORD, AAD) + + +def test_a_lone_surrogate_aad_fails_as_a_crypto_error(): + """The same guard on `aad.encode("utf-8")` in `seal`/`unseal` — no + current caller passes an AAD sourced from the environment, but the encode + is identical in shape to the password's, and identically reachable.""" + with pytest.raises(crypto.EncodingError): + seal(MARKER, PASSWORD, SURROGATE_PASSWORD) diff --git a/tests/test_providers.py b/tests/test_providers.py new file mode 100644 index 0000000..e7431f4 --- /dev/null +++ b/tests/test_providers.py @@ -0,0 +1,907 @@ +"""Tests for the provider interface (Task 11). + +**The centerpiece is the contract test.** The whole point of this task is +that swapping `provider = "..."` changes nothing a caller can observe — +`test_dotenv_and_a_fake_remote_produce_identical_dry_run_outcomes`, +`test_dotenv_and_a_fake_remote_produce_identical_pulumi_invocations` and +`test_file_and_a_fake_remote_produce_identical_child_environments` seed a +`dotenv`/`file` provider and a fake **remote** provider (`providers.command`, +driven by a real subprocess script — never a Python object standing in for +"remote", which would prove nothing about the shell-out requirement) with +the same values and assert the real `set-secrets`/`exec` entry points behave +identically: same printed outcomes, the same `pulumi` argv *and* stdin +byte-for-byte, the same injected environment. + +**The gate-path invariant is enforced, not merely stated.** +`test_the_check_config_entry_point_never_loads_a_provider_module` (and its +stronger sibling that runs the command through `cli.main`) checks +`sys.modules` in a subprocess — this test process has already imported half +the tool by the time either test runs, so asserting against its own +`sys.modules` would prove nothing. + +**No test ever prints a credential value.** Every marker below is a +placeholder distinctive enough that an accidental substring match in +captured output would be implausible (GC4); assertions are on absence, on +exception types, or on values compared to a known constant, never on a +value's appearance in output being treated as proof of anything. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +from stackward import store +from stackward.cli import main +from stackward.commands import session, set_secrets +from stackward.commands.session import ( + AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY, + CREDENTIAL_NAMES, + ENV_STORE_PASSWORD, + PULUMI_BACKEND_URL, + PULUMI_CONFIG_PASSPHRASE, + SessionError, +) +from stackward.commands.set_secrets import SetSecretsError +from stackward.config import find_repo_config, load_config +from stackward.providers import CredentialStore, ProviderError, SecretSource +from stackward.providers.command import CommandCredentialStore, CommandSecretSource +from stackward.providers.dotenv import DotenvSecretSource +from stackward.providers.env import EnvCredentialStore, EnvSecretSource +from stackward.providers.file import FileCredentialStore +from stackward.store import Profile, StoreError + +PASSWORD = "the store password" +WRONG_PASSWORD = "not the store password" + +# Placeholder values only -- see GC4. Distinctive enough that an accidental +# substring match elsewhere in captured output would be implausible. +MARKER_KEY = "marker-access-key-1a2b3c4d" +MARKER_SECRET = "marker-secret-key-5e6f7a8b" +MARKER_PASSPHRASE = "marker-passphrase-9c0d1e2f" +MARKER_DB_PASSWORD = "marker-db-password-1a2b3c4d" +MARKER_API_TOKEN = "marker-api-token-5e6f7a8b" + +FULL_CREDENTIALS = { + AWS_ACCESS_KEY_ID: MARKER_KEY, + AWS_SECRET_ACCESS_KEY: MARKER_SECRET, + PULUMI_CONFIG_PASSPHRASE: MARKER_PASSPHRASE, +} + + +@pytest.fixture(autouse=True) +def cheap_kdf(monkeypatch): + """Lower the Argon2id cost for this module only -- see `test_store.py`'s + identical fixture. `FileCredentialStore` performs real seal/open round + trips.""" + from stackward import crypto + + monkeypatch.setattr(crypto, "ARGON2ID_MEMORY_KIB", 8192) + monkeypatch.setattr(crypto, "ARGON2ID_ITERATIONS", 1) + monkeypatch.setattr(crypto, "ARGON2ID_LANES", 1) + + +@pytest.fixture(autouse=True) +def no_real_pulumi_state(monkeypatch, tmp_path): + """Point `PULUMI_HOME` at an empty, per-test directory, and clear + `PULUMI_BACKEND_URL` -- see `test_session.py`'s identical fixture. No + test in this file may read or depend on this machine's real Pulumi + state.""" + monkeypatch.setenv("PULUMI_HOME", str(tmp_path / "pulumi-home")) + monkeypatch.delenv(PULUMI_BACKEND_URL, raising=False) + + +# --------------------------------------------------------------------------- +# A real "remote" CLI, driven entirely by environment variables -- never a +# Python object standing in for "remote". `STUB_MODE` picks the wire shape: +# "secret" (one name in, one value or nothing on stdout -- `CommandSecretSource`) +# or "credentials" (one profile in, a JSON object out -- `CommandCredentialStore`). +# --------------------------------------------------------------------------- + +_REMOTE_STUB = textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json, os, sys, time + + sleep_seconds = os.environ.get("STUB_SLEEP_SECONDS") + if sleep_seconds: + time.sleep(float(sleep_seconds)) + + exit_code = int(os.environ.get("STUB_EXIT_CODE", "0")) + if exit_code != 0: + sys.exit(exit_code) + + mode = os.environ.get("STUB_MODE", "secret") + arg = sys.argv[1] if len(sys.argv) > 1 else "" + + if mode == "credentials": + values = json.loads(os.environ.get("STUB_CREDENTIALS", "{}")) + sys.stdout.write(json.dumps(values)) + elif mode == "raw": + sys.stdout.write(os.environ.get("STUB_RAW_OUTPUT", "")) + else: + values = json.loads(os.environ.get("STUB_VALUES", "{}")) + sys.stdout.write(values.get(arg, "")) + """ +) + + +@pytest.fixture +def remote_stub(tmp_path) -> Path: + path = tmp_path / "remote-stub.py" + path.write_text(_REMOTE_STUB) + path.chmod(0o755) + return path + + +def remote_command(stub: Path) -> list[str]: + """The `command` list a `[secrets.source]`/`[profile..credentials]` + table would declare -- `sys.executable` first, since the stub is a + Python script and not directly executable on every platform this suite + might run on.""" + return [sys.executable, str(stub)] + + +# A minimal `pulumi` stand-in: records every invocation's argv and stdin, +# then reports success for a `set`. No `get` support -- no test in this file +# declares a `drift_pairs` entry, so `_check_drift` never calls one. +_PULUMI_STUB = textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json, os, sys + + argv = sys.argv[1:] + dump_argv_to = os.environ.get("STUB_DUMP_ARGV_TO") + if dump_argv_to: + with open(dump_argv_to, "a") as f: + f.write(json.dumps(argv) + "\\n") + + if "set" in argv: + data = sys.stdin.buffer.read() + dump_stdin_to = os.environ.get("STUB_DUMP_STDIN_TO") + if dump_stdin_to: + with open(dump_stdin_to, "ab") as f: + f.write(data + b"\\n") + sys.exit(0) + + sys.exit(1) + """ +) + + +@pytest.fixture +def pulumi_stub(tmp_path) -> Path: + path = tmp_path / "pulumi-stub.py" + path.write_text(_PULUMI_STUB) + path.chmod(0o755) + return path + + +def fake_pulumi(monkeypatch, executable: Path) -> None: + monkeypatch.setattr( + set_secrets.shutil, "which", lambda name: str(executable) if name == "pulumi" else None + ) + + +# A real child, dumping its own environment -- see `test_session.py`'s `_STUB`. +_ENV_STUB = textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json, os, sys + + dump_env_to = os.environ.get("STUB_DUMP_ENV_TO") + if dump_env_to: + with open(dump_env_to, "w") as f: + json.dump(dict(os.environ), f) + sys.exit(0) + """ +) + + +@pytest.fixture +def env_stub(tmp_path) -> Path: + path = tmp_path / "env-stub.py" + path.write_text(_ENV_STUB) + path.chmod(0o755) + return path + + +# --------------------------------------------------------------------------- +# Repos and stores -- mirrors `test_set_secrets.py`/`test_session.py`. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def repo(tmp_path) -> Path: + root = tmp_path / "repo" + (root / ".git").mkdir(parents=True) + return root + + +def write_repo_config(repo: Path, text: str) -> Path: + path = repo / ".stackward.toml" + path.write_text(text) + return path + + +@pytest.fixture +def store_directory(tmp_path) -> Path: + return tmp_path / "stackward" + + +def write_store_config(directory: Path, text: str) -> Path: + directory.mkdir(parents=True, exist_ok=True) + path = directory / store.CONFIG_FILENAME + path.write_text(text) + path.chmod(store.CONFIG_MODE) + return path + + +# --------------------------------------------------------------------------- +# `FileCredentialStore` -- the thin adapter over `store.resolve_credentials` +# --------------------------------------------------------------------------- + + +def test_file_credential_store_resolves_exactly_what_the_store_holds(store_directory): + store.init_store(PASSWORD, directory=store_directory) + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=store_directory) + + provider = FileCredentialStore(PASSWORD, directory=store_directory) + assert provider.resolve("staging") == FULL_CREDENTIALS + + +def test_file_credential_store_propagates_store_error_for_a_wrong_password(store_directory): + """Deliberately *not* wrapped in `ProviderError` -- see `providers.file`'s + own docstring on why `StoreError` propagates unchanged.""" + store.init_store(PASSWORD, directory=store_directory) + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=store_directory) + + provider = FileCredentialStore(WRONG_PASSWORD, directory=store_directory) + with pytest.raises(StoreError): + provider.resolve("staging") + + +def test_file_credential_store_satisfies_the_credential_store_protocol(store_directory): + store.init_store(PASSWORD, directory=store_directory) + provider = FileCredentialStore(PASSWORD, directory=store_directory) + assert isinstance(provider, CredentialStore) + + +# --------------------------------------------------------------------------- +# `EnvCredentialStore` +# --------------------------------------------------------------------------- + + +def test_env_credential_store_reads_only_the_requested_names(): + environ = { + AWS_ACCESS_KEY_ID: MARKER_KEY, + AWS_SECRET_ACCESS_KEY: MARKER_SECRET, + "UNRELATED_VARIABLE": "should never appear", + } + provider = EnvCredentialStore(CREDENTIAL_NAMES, environ) + resolved = provider.resolve("any-profile-name") + assert resolved == {AWS_ACCESS_KEY_ID: MARKER_KEY, AWS_SECRET_ACCESS_KEY: MARKER_SECRET} + assert "UNRELATED_VARIABLE" not in resolved + + +def test_env_credential_store_ignores_the_profile_argument(): + """A CI runner's environment is not partitioned by profile -- whichever + profile name is asked for gets the same answer.""" + environ = {AWS_ACCESS_KEY_ID: MARKER_KEY} + provider = EnvCredentialStore((AWS_ACCESS_KEY_ID,), environ) + assert provider.resolve("staging") == provider.resolve("anything-else") + + +def test_env_credential_store_omits_a_name_it_does_not_have_rather_than_erroring(): + provider = EnvCredentialStore((AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY), {}) + assert provider.resolve("staging") == {} + + +def test_env_credential_store_satisfies_the_credential_store_protocol(): + assert isinstance(EnvCredentialStore((), {}), CredentialStore) + + +# --------------------------------------------------------------------------- +# `EnvSecretSource` +# --------------------------------------------------------------------------- + + +def test_env_secret_source_distinguishes_unset_from_empty(): + """Presence, not truthiness -- see `SecretSource.resolve`'s own + contract. `LocalSecretSource`'s environment tier makes the identical + distinction; this provider must not weaken it.""" + provider = EnvSecretSource({"DB_PASSWORD": ""}) + assert provider.resolve("DB_PASSWORD") == "" + assert provider.resolve("DB_PASSWORD") is not None + assert provider.resolve("NEVER_SET") is None + + +def test_env_secret_source_never_consults_a_file(tmp_path): + """`env` and `dotenv` must not be indistinguishable (the exact trap Task + 2's review caught for `sensitive_parents`): given the same environment + and a file that *would* answer the name, `EnvSecretSource` still says + "not set" while `DotenvSecretSource` (below) resolves it from the file. + `EnvSecretSource` has no `files` constructor parameter at all -- there + is no way to hand it the file even by mistake.""" + env_file = tmp_path / "secrets.env" + env_file.write_text(f"DB_PASSWORD={MARKER_DB_PASSWORD}\n") + environ: dict[str, str] = {} + assert EnvSecretSource(environ).resolve("DB_PASSWORD") is None + assert DotenvSecretSource(environ, [env_file]).resolve("DB_PASSWORD") == MARKER_DB_PASSWORD + + +def test_env_secret_source_satisfies_the_secret_source_protocol(): + assert isinstance(EnvSecretSource({}), SecretSource) + + +# --------------------------------------------------------------------------- +# `DotenvSecretSource` -- the adapter over `LocalSecretSource` +# --------------------------------------------------------------------------- + + +def test_dotenv_secret_source_prefers_the_environment_over_a_file(tmp_path): + env_file = tmp_path / "secrets.env" + env_file.write_text("DB_PASSWORD=from-the-file\n") + env_file.chmod(0o600) + provider = DotenvSecretSource({"DB_PASSWORD": MARKER_DB_PASSWORD}, [env_file]) + assert provider.resolve("DB_PASSWORD") == MARKER_DB_PASSWORD + + +def test_dotenv_secret_source_falls_back_to_a_file_when_unset_in_the_environment(tmp_path): + env_file = tmp_path / "secrets.env" + env_file.write_text(f"API_TOKEN={MARKER_API_TOKEN}\n") + env_file.chmod(0o600) + provider = DotenvSecretSource({}, [env_file]) + assert provider.resolve("API_TOKEN") == MARKER_API_TOKEN + + +def test_dotenv_secret_source_ignores_a_file_that_does_not_exist(tmp_path): + missing = tmp_path / "does-not-exist.env" + provider = DotenvSecretSource({}, [missing]) + assert provider.resolve("ANYTHING") is None + + +def test_dotenv_secret_source_returns_none_for_an_unresolved_name(): + provider = DotenvSecretSource({}, []) + assert provider.resolve("NEVER_SET") is None + + +def test_dotenv_secret_source_satisfies_the_secret_source_protocol(): + assert isinstance(DotenvSecretSource({}, []), SecretSource) + + +# --------------------------------------------------------------------------- +# `CommandSecretSource` / `CommandCredentialStore` -- the generic remote shape +# --------------------------------------------------------------------------- + + +def test_command_secret_source_reads_the_value_from_a_real_subprocess(monkeypatch, remote_stub): + monkeypatch.setenv("STUB_VALUES", json.dumps({"DB_PASSWORD": MARKER_DB_PASSWORD})) + provider = CommandSecretSource(remote_command(remote_stub)) + assert provider.resolve("DB_PASSWORD") == MARKER_DB_PASSWORD + + +def test_command_secret_source_empty_output_means_not_set(monkeypatch, remote_stub): + monkeypatch.setenv("STUB_VALUES", json.dumps({})) + provider = CommandSecretSource(remote_command(remote_stub)) + assert provider.resolve("NEVER_PUBLISHED") is None + + +def test_command_secret_source_a_nonzero_exit_is_a_provider_error_not_none( + monkeypatch, remote_stub +): + """The failure this whole task's brief calls out: a broken remote must + never be indistinguishable from "not set".""" + monkeypatch.setenv("STUB_EXIT_CODE", "1") + provider = CommandSecretSource(remote_command(remote_stub)) + with pytest.raises(ProviderError): + provider.resolve("DB_PASSWORD") + + +def test_command_secret_source_a_timeout_is_a_provider_error(monkeypatch, remote_stub): + monkeypatch.setenv("STUB_SLEEP_SECONDS", "5") + provider = CommandSecretSource(remote_command(remote_stub), timeout=0.2) + with pytest.raises(ProviderError): + provider.resolve("DB_PASSWORD") + + +def test_command_secret_source_a_missing_executable_is_a_provider_error(tmp_path): + provider = CommandSecretSource([str(tmp_path / "does-not-exist-anywhere")]) + with pytest.raises(ProviderError): + provider.resolve("DB_PASSWORD") + + +def test_command_secret_source_refuses_an_empty_command(): + with pytest.raises(ProviderError): + CommandSecretSource([]) + + +def test_command_secret_source_never_prints_the_resolved_value(monkeypatch, remote_stub, capfd): + """A fetched value is held in memory for the call that resolved it and + handed back to the caller -- never printed by this provider itself.""" + monkeypatch.setenv("STUB_VALUES", json.dumps({"DB_PASSWORD": MARKER_DB_PASSWORD})) + provider = CommandSecretSource(remote_command(remote_stub)) + provider.resolve("DB_PASSWORD") + assert MARKER_DB_PASSWORD not in capfd.readouterr().out + + +def test_command_credential_store_parses_a_json_object_from_stdout(monkeypatch, remote_stub): + monkeypatch.setenv("STUB_MODE", "credentials") + monkeypatch.setenv("STUB_CREDENTIALS", json.dumps(FULL_CREDENTIALS)) + provider = CommandCredentialStore(remote_command(remote_stub)) + assert provider.resolve("staging") == FULL_CREDENTIALS + + +def test_command_credential_store_a_nonzero_exit_is_a_provider_error(monkeypatch, remote_stub): + monkeypatch.setenv("STUB_MODE", "credentials") + monkeypatch.setenv("STUB_EXIT_CODE", "1") + provider = CommandCredentialStore(remote_command(remote_stub)) + with pytest.raises(ProviderError): + provider.resolve("staging") + + +def test_command_credential_store_rejects_output_that_is_not_a_json_object( + monkeypatch, remote_stub +): + monkeypatch.setenv("STUB_MODE", "raw") + monkeypatch.setenv("STUB_RAW_OUTPUT", "not json at all") + provider = CommandCredentialStore(remote_command(remote_stub)) + with pytest.raises(ProviderError): + provider.resolve("staging") + + +def test_command_credential_store_rejects_a_json_array(monkeypatch, remote_stub): + monkeypatch.setenv("STUB_MODE", "raw") + monkeypatch.setenv("STUB_RAW_OUTPUT", json.dumps(["not", "an", "object"])) + provider = CommandCredentialStore(remote_command(remote_stub)) + with pytest.raises(ProviderError): + provider.resolve("staging") + + +def test_command_providers_satisfy_their_protocols(remote_stub): + assert isinstance(CommandSecretSource(remote_command(remote_stub)), SecretSource) + assert isinstance(CommandCredentialStore(remote_command(remote_stub)), CredentialStore) + + +# --------------------------------------------------------------------------- +# Config wiring: `[profile..credentials]` +# --------------------------------------------------------------------------- + + +def test_a_profile_with_no_credentials_table_defaults_to_the_file_provider(store_directory): + write_store_config(store_directory, '[profile.staging]\nbackend_url = "file:///x"\n') + profile = store.load_store_config(store_directory).profiles["staging"] + assert profile.credentials.get("provider", "file") == "file" + + +def test_a_profile_can_declare_a_non_default_credentials_provider(store_directory): + write_store_config( + store_directory, + '[profile.staging]\nbackend_url = "file:///x"\n\n' + '[profile.staging.credentials]\nprovider = "env"\n', + ) + profile = store.load_store_config(store_directory).profiles["staging"] + assert profile.credentials["provider"] == "env" + + +def test_a_non_table_credentials_value_is_refused(store_directory): + write_store_config( + store_directory, + '[profile.staging]\nbackend_url = "file:///x"\ncredentials = "not a table"\n', + ) + with pytest.raises(StoreError): + store.load_store_config(store_directory) + + +def test_a_non_string_credentials_provider_is_refused(store_directory): + write_store_config( + store_directory, + '[profile.staging]\nbackend_url = "file:///x"\n\n' + "[profile.staging.credentials]\nprovider = 1\n", + ) + with pytest.raises(StoreError): + store.load_store_config(store_directory) + + +# --------------------------------------------------------------------------- +# `session._build_credential_store` -- provider dispatch +# --------------------------------------------------------------------------- + + +def test_build_credential_store_defaults_to_file(store_directory, monkeypatch): + store.init_store(PASSWORD, directory=store_directory) + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=store_directory) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + profile = Profile(name="staging") + provider = session._build_credential_store(profile, directory=store_directory) + assert provider.resolve("staging") == FULL_CREDENTIALS + + +def test_build_credential_store_selects_env(monkeypatch): + monkeypatch.setenv(AWS_ACCESS_KEY_ID, MARKER_KEY) + profile = Profile(name="staging", credentials={"provider": "env"}) + provider = session._build_credential_store(profile, directory=None) + assert provider.resolve("staging")[AWS_ACCESS_KEY_ID] == MARKER_KEY + + +def test_build_credential_store_selects_command(monkeypatch, remote_stub): + monkeypatch.setenv("STUB_MODE", "credentials") + monkeypatch.setenv("STUB_CREDENTIALS", json.dumps(FULL_CREDENTIALS)) + profile = Profile( + name="staging", credentials={"provider": "command", "command": remote_command(remote_stub)} + ) + provider = session._build_credential_store(profile, directory=None) + assert provider.resolve("staging") == FULL_CREDENTIALS + + +def test_build_credential_store_command_without_a_command_list_is_refused(): + profile = Profile(name="staging", credentials={"provider": "command"}) + with pytest.raises(SessionError, match="command"): + session._build_credential_store(profile, directory=None) + + +def test_build_credential_store_unknown_provider_is_refused(): + profile = Profile(name="staging", credentials={"provider": "not-a-real-provider"}) + with pytest.raises(SessionError, match="not-a-real-provider"): + session._build_credential_store(profile, directory=None) + + +# --------------------------------------------------------------------------- +# `set_secrets._build_secret_source` -- provider dispatch +# --------------------------------------------------------------------------- + + +def test_build_secret_source_defaults_to_dotenv(repo, monkeypatch): + write_repo_config(repo, '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n') + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + config = load_config(find_repo_config(repo)) + source = set_secrets._build_secret_source(config, repo, None) + assert source.resolve("DB_PASSWORD") == MARKER_DB_PASSWORD + + +def test_build_secret_source_env_ignores_a_configured_files_list(repo, monkeypatch): + """The Task 2 trap this codebase already learned from: an `env` + provider that happened to also honour `files` would be indistinguishable + from `dotenv` in a test that never populates a file. This one does.""" + env_file = repo / "secrets.env" + env_file.write_text(f"DB_PASSWORD={MARKER_DB_PASSWORD}\n") + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n\n' + '[secrets.source]\nprovider = "env"\nfiles = ["secrets.env"]\n', + ) + monkeypatch.delenv("DB_PASSWORD", raising=False) + config = load_config(find_repo_config(repo)) + source = set_secrets._build_secret_source(config, repo, None) + assert source.resolve("DB_PASSWORD") is None + + +def test_build_secret_source_selects_command(repo, monkeypatch, remote_stub): + monkeypatch.setenv("STUB_VALUES", json.dumps({"DB_PASSWORD": MARKER_DB_PASSWORD})) + command_toml = json.dumps(remote_command(remote_stub)) + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n\n' + f'[secrets.source]\nprovider = "command"\ncommand = {command_toml}\n', + ) + config = load_config(find_repo_config(repo)) + source = set_secrets._build_secret_source(config, repo, None) + assert source.resolve("DB_PASSWORD") == MARKER_DB_PASSWORD + + +def test_build_secret_source_command_without_a_command_list_is_refused(repo): + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n\n' + '[secrets.source]\nprovider = "command"\n', + ) + config = load_config(find_repo_config(repo)) + with pytest.raises(SetSecretsError, match="command"): + set_secrets._build_secret_source(config, repo, None) + + +def test_build_secret_source_unknown_provider_is_refused(repo): + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n\n' + '[secrets.source]\nprovider = "not-a-real-provider"\n', + ) + config = load_config(find_repo_config(repo)) + with pytest.raises(SetSecretsError, match="not-a-real-provider"): + set_secrets._build_secret_source(config, repo, None) + + +# --------------------------------------------------------------------------- +# A provider failure is reported, never a silent empty value +# --------------------------------------------------------------------------- + + +def test_publish_entries_reports_a_failing_provider_as_failed_not_skipped(monkeypatch, remote_stub): + monkeypatch.setenv("STUB_EXIT_CODE", "1") + manifest = set_secrets.ProjectManifest(secret={"db.password": "DB_PASSWORD"}) + source = CommandSecretSource(remote_command(remote_stub)) + outcomes = set_secrets._publish_entries( + manifest, source, pulumi=None, stack=None, dry_run=True, timeout=5.0 + ) + assert len(outcomes) == 1 + assert outcomes[0].status == "failed" + assert outcomes[0].status != "skipped" + + +def test_check_drift_reports_a_failing_provider_as_a_warning_not_silence( + monkeypatch, remote_stub, capsys +): + """A provider failure resolving `bootstrap` must not fall into the same + silent `continue` a legitimately-unresolved bootstrap value gets -- the + three silent states `_check_drift`'s own docstring names are all + absences, and this is not one. `pulumi` is a non-`None` placeholder: the + provider raises before `_pulumi_config_get` would ever be reached, so no + real `pulumi` binary is needed for this path to be exercised.""" + monkeypatch.setenv("STUB_EXIT_CODE", "1") + manifest = set_secrets.ProjectManifest( + secret={"db.password": "DB_PASSWORD"}, + drift_pairs=( + set_secrets.DriftPair(bootstrap="DB_PASSWORD", managed="DB_PASSWORD_MANAGED"), + ), + ) + source = CommandSecretSource(remote_command(remote_stub)) + + set_secrets._check_drift(manifest, source, pulumi="placeholder-pulumi", stack=None, timeout=5.0) + + err = capsys.readouterr().err + assert "warning" in err + assert "DB_PASSWORD" in err + + +def test_exec_reports_a_failing_command_credential_store_rather_than_running_with_nothing( + tmp_path, monkeypatch, remote_stub, env_stub, capsys +): + """A provider failure during credential resolution must stop `exec` + outright -- never run the child with an empty or partial environment + that a caller could mistake for "this profile simply has no + credentials".""" + home = tmp_path / "xdg" + monkeypatch.setenv("XDG_CONFIG_HOME", str(home)) + write_store_config( + home / "stackward", + '[profile.staging]\nbackend_url = "file:///staging-backend"\n\n' + '[profile.staging.credentials]\nprovider = "command"\n' + f"command = {json.dumps(remote_command(remote_stub))}\n", + ) + monkeypatch.setenv("STUB_MODE", "credentials") + monkeypatch.setenv("STUB_EXIT_CODE", "1") + + code = main(["exec", "--profile", "staging", "--", str(env_stub)]) + + assert code == 2 + assert "error" in capsys.readouterr().err.lower() + + +# --------------------------------------------------------------------------- +# The gate-path invariant +# --------------------------------------------------------------------------- + + +def test_the_check_config_entry_point_never_loads_a_provider_module(): + """The brief's own literal requirement: importing `check-config`'s + module must never bring in a `stackward.providers*` module. Checked in a + subprocess -- this test process has already imported half the tool via + other test modules in the same session, so asserting against its own + `sys.modules` would prove nothing. Enumerated by prefix, not a fixed + tuple, so a provider module added later is covered without editing this + test. + """ + proc = subprocess.run( + [ + sys.executable, + "-c", + "import sys; " + "import stackward.commands.check_config; " + "import stackward.commands.pre_commit; " + "print(sorted(m for m in sys.modules if m == 'stackward.providers' " + "or m.startswith('stackward.providers.')))", + ], + capture_output=True, + text=True, + check=True, + ) + assert proc.stdout.strip() == "[]" + + +def test_running_check_config_through_the_real_cli_never_loads_a_provider_module(tmp_path): + """The stronger form: `cli.py` imports `commands.session` and + `commands.set_secrets` at its own module scope, unconditionally, so a + module-scope provider import in *either* of those (rather than the + function-local imports this task actually uses) would make a plain + `stackward check-config` reach provider code even though the weaker, + literal test above stays green. This runs the real dispatch path + (`cli.main`) a `git commit` actually takes, not just a bare module + import, so it would catch that regression where the weaker form cannot. + """ + target = tmp_path / "config.yaml" + target.write_text("key: value\n") + proc = subprocess.run( + [ + sys.executable, + "-c", + "import sys; from stackward.cli import main; " + f"main(['check-config', {str(target)!r}]); " + "print(sorted(m for m in sys.modules if m == 'stackward.providers' " + "or m.startswith('stackward.providers.')))", + ], + capture_output=True, + text=True, + check=True, + ) + assert proc.stdout.strip().splitlines()[-1] == "[]" + + +# --------------------------------------------------------------------------- +# The contract test: swapping the provider changes nothing for the caller +# --------------------------------------------------------------------------- + +# The manifest body is identical across both providers under test -- only +# `[secrets.source]` differs -- so any difference in outcome or invocation +# can only come from the provider swap, never from a manifest difference. +_MANIFEST_BODY = ( + '[secrets."."]\n' + 'secret = { "db.password" = "DB_PASSWORD", "api.token" = "API_TOKEN" }\n' + 'plaintext = { "registry.user" = "REGISTRY_USER" }\n\n' + '[secrets.".".required]\n' + 'paths = ["db.password"]\n\n' +) + + +@pytest.fixture +def dotenv_repo(tmp_path, monkeypatch) -> Path: + """`db.password` resolved via the environment tier, `api.token` via the + file tier, `registry.user` left unresolved -- exercising all three + outcome states through `dotenv`'s layered resolution, which the `command` + run below must match without any layering of its own.""" + root = tmp_path / "dotenv-repo" + (root / ".git").mkdir(parents=True) + env_file = root / "secrets.env" + env_file.write_text(f"API_TOKEN={MARKER_API_TOKEN}\n") + env_file.chmod(0o600) + write_repo_config(root, _MANIFEST_BODY + '[secrets.source]\nfiles = ["secrets.env"]\n') + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + monkeypatch.delenv("REGISTRY_USER", raising=False) + monkeypatch.delenv("API_TOKEN", raising=False) + return root + + +@pytest.fixture +def command_repo(tmp_path, monkeypatch, remote_stub) -> Path: + """The same three logical names, seeded with the same values, answered + entirely by the fake remote CLI -- no file, no environment variable of + the manifest's own names involved at all.""" + root = tmp_path / "command-repo" + (root / ".git").mkdir(parents=True) + command_toml = json.dumps(remote_command(remote_stub)) + write_repo_config( + root, + _MANIFEST_BODY + + f'[secrets.source]\nprovider = "command"\ncommand = {command_toml}\n', + ) + monkeypatch.setenv( + "STUB_VALUES", + json.dumps({"DB_PASSWORD": MARKER_DB_PASSWORD, "API_TOKEN": MARKER_API_TOKEN}), + ) + return root + + +def test_dotenv_and_a_fake_remote_produce_identical_dry_run_outcomes( + dotenv_repo, command_repo, monkeypatch, capfd +): + """The first half of the contract: `set-secrets --dry-run`'s printed + outcomes -- would-set, skip, and (via `required`) failed -- are + byte-identical whether the values came from `dotenv`'s environment+file + layering or from the fake remote, seeded with the same values.""" + monkeypatch.chdir(dotenv_repo) + assert main(["set-secrets", "--dry-run"]) == 0 + dotenv_output = capfd.readouterr().out + + monkeypatch.chdir(command_repo) + assert main(["set-secrets", "--dry-run"]) == 0 + command_output = capfd.readouterr().out + + assert dotenv_output == command_output + assert "would set" in dotenv_output + assert "skip" in dotenv_output + for marker in (MARKER_DB_PASSWORD, MARKER_API_TOKEN): + assert marker not in dotenv_output + assert marker not in command_output + + +def test_dotenv_and_a_fake_remote_produce_identical_pulumi_invocations( + dotenv_repo, command_repo, monkeypatch, tmp_path, pulumi_stub +): + """The second half: without `--dry-run`, the real `pulumi config set` + argv *and* stdin this module sends are byte-identical across both + providers -- proving the same value, not merely the same flags, reached + the child either way. `--dry-run` alone cannot prove this: it invokes no + subprocess at all (`_publish_entries` returns before + `_pulumi_config_set`), so this is the operation the brief's "identical + subprocess invocations" actually requires. + """ + fake_pulumi(monkeypatch, pulumi_stub) + + argv_a, stdin_a = tmp_path / "argv-a.log", tmp_path / "stdin-a.log" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_a)) + monkeypatch.setenv("STUB_DUMP_STDIN_TO", str(stdin_a)) + monkeypatch.chdir(dotenv_repo) + assert main(["set-secrets"]) == 0 + + argv_b, stdin_b = tmp_path / "argv-b.log", tmp_path / "stdin-b.log" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_b)) + monkeypatch.setenv("STUB_DUMP_STDIN_TO", str(stdin_b)) + monkeypatch.chdir(command_repo) + assert main(["set-secrets"]) == 0 + + assert argv_a.read_bytes() == argv_b.read_bytes() + assert stdin_a.read_bytes() == stdin_b.read_bytes() + # Two `secret` entries resolved (`db.password`, `api.token`); + # `registry.user` never resolved, so it never reaches `pulumi` at all. + assert len(argv_a.read_text().splitlines()) == 2 + + +def test_file_and_a_fake_remote_produce_identical_child_environments( + tmp_path, monkeypatch, remote_stub, env_stub +): + """The `CredentialStore` half of the contract: `exec` injects the exact + same four names, with the exact same values, whether the profile it + resolves uses `file` (the default), `command` (the fake remote) or + `env` (CI's own no-store-at-all path) -- all three seeded with the same + credentials. `env` gets no encrypted store at all: this is the path a CI + runner actually takes, and it is exercised here through the real `exec` + entry point, not only through `session._build_credential_store` in + isolation.""" + home = tmp_path / "xdg" + monkeypatch.setenv("XDG_CONFIG_HOME", str(home)) + store_dir = home / "stackward" + command_toml = json.dumps(remote_command(remote_stub)) + write_store_config( + store_dir, + '[profile.staging-file]\nbackend_url = "file:///staging-backend"\n\n' + '[profile.staging-remote]\nbackend_url = "file:///staging-backend"\n\n' + '[profile.staging-remote.credentials]\n' + f'provider = "command"\ncommand = {command_toml}\n\n' + '[profile.staging-env]\nbackend_url = "file:///staging-backend"\n\n' + '[profile.staging-env.credentials]\nprovider = "env"\n', + ) + store.init_store(PASSWORD, directory=store_dir) + store.set_credentials("staging-file", FULL_CREDENTIALS, PASSWORD, directory=store_dir) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + monkeypatch.setenv("STUB_MODE", "credentials") + monkeypatch.setenv("STUB_CREDENTIALS", json.dumps(FULL_CREDENTIALS)) + # `staging-env` has no envelope at all -- these are the only place its + # credentials come from, exactly as a CI runner's own secrets would be. + for name, value in FULL_CREDENTIALS.items(): + monkeypatch.setenv(name, value) + + dump_a = tmp_path / "env-a.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump_a)) + assert main(["exec", "--profile", "staging-file", "--", str(env_stub)]) == 0 + env_a = json.loads(dump_a.read_text()) + + dump_b = tmp_path / "env-b.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump_b)) + assert main(["exec", "--profile", "staging-remote", "--", str(env_stub)]) == 0 + env_b = json.loads(dump_b.read_text()) + + dump_c = tmp_path / "env-c.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump_c)) + assert main(["exec", "--profile", "staging-env", "--", str(env_stub)]) == 0 + env_c = json.loads(dump_c.read_text()) + + relevant = (*CREDENTIAL_NAMES, PULUMI_BACKEND_URL) + expected = {key: env_a[key] for key in relevant} + assert {key: env_b[key] for key in relevant} == expected + assert {key: env_c[key] for key in relevant} == expected + assert env_a[AWS_ACCESS_KEY_ID] == MARKER_KEY From 8b2696f2587140c9f3dd0dc257b324f0b00caf68 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 15:54:10 -0700 Subject: [PATCH 31/44] =?UTF-8?q?fix:=20close=20the=20gate's=20fail-opens?= =?UTF-8?q?=20=E2=80=94=20index=20policy,=20missing=20policy,=20isolation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five defects two branch reviews found in the gate itself, each of which let a command exit for a reason it does not mean. **`pre-commit` read content from the index and policy from the working tree.** Every path it scanned came from `git show ":"`, but `_load_check_policy` called `find_repo_config()` + `load_config(path)` — plain filesystem reads. That is a fail-open with a working exploit: stage a credential, then edit the working-tree `.stackward.toml` to add an `allowed_references` entry for it, staging nothing, and the commit goes through. The credential enters history under a relaxation that is not itself committed, so the next clone has the leak and not the excuse. Three knobs weaken from that direction — `allowed_references` adds a suppression, `model_net = "none"` drops a whole net, and `sensitive_parents` *replaces* rather than extends, so the worktree can shrink what the index declared. `nets.model.verify_sources` cannot close any of them: it compares an index blob against a recorded blob, a worktree-only edit touches neither, and under `model_net = "none"` it does not run at all. `config.py` now separates parsing from file reading (`load_config_text(text, origin)`, with `load_config(path)` as a thin reader in front of it), which is what `pre-commit` needs to load the policy the index holds. `_index_config_path` reproduces `find_repo_config`'s upward walk in repository-relative terms via `git rev-parse --show-prefix`, and the two `_load_check_policy` helpers are now genuinely different rather than redundant — `check-config` answers "is this file clean right now?" and so reads its policy from disk; `pre-commit` answers "is what I am about to commit clean?" and so reads both from the index. Both docstrings say which they are and why. **A missing `.stackward.toml` was a silent skip.** Both commands returned `CheckConfig()` defaults, which carry `model_net = "none"` — turning the declared heuristic-only mode into exactly the silent fallback the plan says it must never be, and Global Constraint 3 says a missing policy file is a refusal. Both now exit 2 naming the minimal file to create, `model_net` included, following `sync_declared._load_repo_config`'s precedent. `cmd_doctor` still never loads a config, so it keeps working with none. Many existing gate tests ran in repositories with no policy file and asserted exit 0 — they were pinning the forbidden behaviour. They now declare a minimal policy and still check what they were checking. Three branches that survived all 746 tests are pinned for the first time: `check-config`'s and `pre-commit`'s policy guards (`return 2` mutated to `return 0` passed), and `config.py`'s `_require_str_list`, `_build_check` and `load_config` error paths (each mutated into a silent default passed). **`cryptography` and the credential store were on the real gate path.** `cli.py` imported `commands.check_passphrase` (-> `cryptography`) and `commands.session` (-> `store` -> `crypto`) at module scope, so every invocation loaded them. With `cryptography` unimportable, `stackward pre-commit` died on a traceback and exited 1 — the code reserved exclusively for "a credential was found". Those four command modules are now imported by a dispatch thunk at call time; `--help` still works for every subcommand, and the thunk resolves through `getattr` so a `monkeypatch.setattr` on the command module still takes effect. The test that should have caught this was split so that neither half could: `test_model_net.py` checked `cryptography`/`store` but imported the command modules directly, bypassing `cli.py`, while `test_providers.py` went through `cli.main` but grepped only for `stackward.providers*`. Both are replaced by one test in `tests/test_gate_isolation.py` asserting the union through `cli.main`. That file also adds the first tests for guarantee 1's "never prompt" and "never touch the network" clauses, over both gate commands — inserting a real `getpass` call into `cmd_check_config` previously survived the whole suite. **`min_version` was built and never wired.** `enforce_min_version` had zero production call sites; `cli.main` dispatched straight to `args.func`. It is now called between parsing and dispatch, from one broadly-guarded helper so a malformed config cannot take `doctor` down with it (Task 2's carry-forward ruling) — deliberately one guard and not two, because with two neither is falsifiable. The floor runs before the missing-policy refusal, which costs nothing: with no config there is no floor to enforce. Its message pointed at `stackward self-update`, a subcommand `build_parser` never registered; it now points at `install.sh`. `self-update` stays in `VERSION_CHECK_EXEMPT` with a comment saying it is reserved, not registered. **Public-repo hygiene.** The comment in `config.py` justifying an empty `sensitive_parents` default described a gate script in the private environment this repository was extracted from, and named its sensitive-parent category; the rule is kept and the provenance dropped. `tests/test_heuristic.py`'s fixture corroborated that same string and is renamed. A fixture literal naming a vendor, a region and a namespace in one value is replaced with the `placeholder-*` convention the rest of the suite already uses. **Deferred, now fixed:** `pre_commit._run_git` sets `LC_ALL=C`, with the environment merged rather than replaced — git sets `GIT_INDEX_FILE` and `GIT_DIR` for a hook process, and dropping them would make the gate read a different index than the commit it is gating. Every test added or changed here was verified by breaking the source and confirming it goes red; where a mutation left a test green, the test's claim was corrected rather than the result explained away. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/cli.py | 119 +++++++- src/stackward/commands/check_config.py | 133 ++++++++- src/stackward/commands/pre_commit.py | 227 +++++++++++--- src/stackward/config.py | 99 ++++-- tests/test_cli.py | 14 +- tests/test_config.py | 177 ++++++++++- tests/test_gate_isolation.py | 203 +++++++++++++ tests/test_heuristic.py | 147 +++++++-- tests/test_install_hooks.py | 18 +- tests/test_model_net.py | 30 +- tests/test_pre_commit.py | 398 +++++++++++++++++++++++-- tests/test_providers.py | 62 +--- 12 files changed, 1418 insertions(+), 209 deletions(-) create mode 100644 tests/test_gate_isolation.py diff --git a/src/stackward/cli.py b/src/stackward/cli.py index 1362c5a..696c724 100644 --- a/src/stackward/cli.py +++ b/src/stackward/cli.py @@ -9,6 +9,7 @@ from __future__ import annotations import argparse +import importlib import os import platform import shutil @@ -19,20 +20,61 @@ from . import __version__ from .bootstrap import GENERATOR_NAME, generator_source from .commands.check_config import cmd_check_config -from .commands.check_passphrase import cmd_check_passphrase from .commands.install_hooks import cmd_install_hooks from .commands.pre_commit import cmd_pre_commit -from .commands.session import cmd_exec, cmd_login, cmd_shell -from .commands.set_secrets import cmd_set_secrets -from .commands.sync_declared import cmd_sync_declared -from .config import find_repo_config +from .config import ( + MinVersionError, + enforce_min_version, + find_repo_config, + load_config, +) # Commands that must keep working even when a repo demands a newer stackward # than the one installed. Without this exemption the upgrade instruction would # itself be blocked by the check that prints it. +# +# `self-update` is *reserved*, not registered: `build_parser` below defines no +# such subcommand, and `enforce_min_version`'s message points at `install.sh` +# instead. The name stays here so that a future `self-update` is exempt on the +# day it is added rather than the day someone notices it is not -- an exempt +# name that no parser accepts costs nothing, while a missing one would block +# the very command that fixes the block. VERSION_CHECK_EXEMPT = frozenset({"doctor", "self-update"}) +def _dispatch(module_name: str, function_name: str): + """A subcommand entry point that imports its module only when the + subcommand is actually run. + + Global Constraint 2 says no code reachable from `check-config` or + `pre-commit` may prompt, open a network connection, or import a + credential provider -- so that a commit cannot be blocked by an expired + session or a flaky connection. Importing the credential commands at this + module's own scope broke that: `import stackward.cli` pulled in + `commands.check_passphrase` -> `cryptography`, and `commands.session` -> + `store` -> `crypto`, on *every* invocation. With `cryptography` made + unimportable, `stackward pre-commit` died on a raw traceback and exited + 1 -- the code this tool reserves exclusively for "a credential was + found", reporting a leak in a repository that had none. + + Written as a thunk rather than a module-level `__getattr__`, because + `build_parser` names every entry point on every run: `__getattr__` would + import all of them again the moment the parser was built, which is the + whole cost being avoided. The lookup is `getattr` at *call* time, so + `monkeypatch.setattr(stackward.commands.session, "cmd_login", ...)` + still takes effect -- a thunk that captured the function at import time + would silently bypass such a patch. + """ + + def dispatch(args: argparse.Namespace) -> int: + module = importlib.import_module(f".commands.{module_name}", __package__) + return getattr(module, function_name)(args) + + dispatch.__name__ = function_name + dispatch.__qualname__ = f"{module_name}.{function_name}" + return dispatch + + def config_home() -> Path: """Where profiles and credentials live. XDG, with the usual fallback.""" xdg = os.environ.get("XDG_CONFIG_HOME") @@ -173,7 +215,7 @@ def build_parser() -> argparse.ArgumentParser: login = sub.add_parser("login", help="point pulumi at a profile's backend") login.add_argument("--profile", help="profile to use (overrides selection precedence)") - login.set_defaults(func=cmd_login) + login.set_defaults(func=_dispatch("session", "cmd_login")) exec_ = sub.add_parser( "exec", help="run a command with a profile's credentials injected" @@ -182,13 +224,13 @@ def build_parser() -> argparse.ArgumentParser: exec_.add_argument( "argv", nargs="*", metavar="COMMAND", help="command to run, after --" ) - exec_.set_defaults(func=cmd_exec) + exec_.set_defaults(func=_dispatch("session", "cmd_exec")) shell = sub.add_parser( "shell", help="open $SHELL with a profile's credentials injected" ) shell.add_argument("--profile", help="profile to use (overrides selection precedence)") - shell.set_defaults(func=cmd_shell) + shell.set_defaults(func=_dispatch("session", "cmd_shell")) set_secrets = sub.add_parser( "set-secrets", help="publish declared stack secrets into pulumi config" @@ -199,30 +241,85 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="report what would be set without running pulumi", ) - set_secrets.set_defaults(func=cmd_set_secrets) + set_secrets.set_defaults(func=_dispatch("set_secrets", "cmd_set_secrets")) sync_declared = sub.add_parser( "sync-declared-secrets", help="regenerate the declared-secrets artifact from this repo's models", ) - sync_declared.set_defaults(func=cmd_sync_declared) + sync_declared.set_defaults(func=_dispatch("sync_declared", "cmd_sync_declared")) check_passphrase = sub.add_parser( "check-passphrase", help="prove a passphrase decrypts one or more stacks (never as an argument)", ) check_passphrase.add_argument("stacks", nargs="+", metavar="STACK") - check_passphrase.set_defaults(func=cmd_check_passphrase) + check_passphrase.set_defaults(func=_dispatch("check_passphrase", "cmd_check_passphrase")) return parser +def _min_version_refusal(command: str) -> int | None: + """`2` if this repository demands a newer `stackward` than the installed + one, otherwise `None`. + + One guard, not two. Everything from finding the config to comparing the + versions is inside a single `try`, and every failure other than + `MinVersionError` means "there is no floor I can establish here" and + lets the command run. `doctor` is the command you run *because* + `.stackward.toml` is broken, and Task 2's carry-forward ruling requires + it to keep working when it is; a floor check that refused to run before + dispatch would take that away. Nothing is lost by staying quiet: + anything genuinely wrong with the file is reported, in better words, by + whichever command actually needs it — both gate commands refuse outright + on an unparseable or missing policy. + + A second, outer guard around this would be worse than none: with two, + neither is falsifiable, and a test that deletes either one still passes. + + It reads the *working tree*, deliberately, even though `pre-commit` + reads its `[check]` policy from the index. `min_version` is a statement + about the binary you are running right now, not about the commit you are + making, and this runs before dispatch — before there is any reason to + believe the current directory is even a git repository. + """ + try: + path = find_repo_config() + config = load_config(path) if path is not None else None + enforce_min_version(config, command) + except MinVersionError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + except Exception: # noqa: BLE001 - a broken config must not break dispatch + return None + return None + + def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) if not getattr(args, "func", None): parser.print_help() return 2 + + # The `min_version` floor, enforced here rather than in each command, so + # it applies to every subcommand instead of whichever ones remembered to + # ask. It runs *before* the gate commands' own missing-policy refusal, + # which costs nothing: with no config there is no floor to enforce + # (`enforce_min_version(None, ...)` returns immediately) and the command + # then refuses with its own, more useful message. The order only matters + # when a config exists, and there the floor should win -- a repository + # whose policy needs a newer stackward is telling you that this binary + # may not understand the policy it is about to read. + # + # `main` is outside `@fail_closed`, so an escaping exception here would + # exit 1: the code reserved for "a credential was found". That is why + # `_min_version_refusal` returns an exit code rather than raising, and + # why its own guard is broad. + refusal = _min_version_refusal(getattr(args, "command", "") or "") + if refusal is not None: + return refusal + return args.func(args) diff --git a/src/stackward/commands/check_config.py b/src/stackward/commands/check_config.py index 07ac9ae..c1ba7d6 100644 --- a/src/stackward/commands/check_config.py +++ b/src/stackward/commands/check_config.py @@ -3,8 +3,9 @@ Registered in `cli.build_parser` as `stackward check-config FILE...`. Exit codes are deliberately distinct: 0 clean, 1 a credential was found, 2 the check could not run at all (an unreadable path, unparsable YAML, a -`.stackward.toml` this module could not load, a document that is not a -mapping, or a model-net artifact that is missing, malformed or stale) — +`.stackward.toml` this module could not load *or could not find*, a document +that is not a mapping, or a model-net artifact that is missing, malformed or +stale) — never conflated, so a fail-closed refusal reads the same way whichever check made it: "the gate did not get to answer". @@ -15,6 +16,25 @@ both name is reported once — `scan_file` unions the two through a set, so a path found twice does not print twice. +**A missing `.stackward.toml` is exit 2, not a scan under defaults.** Global +Constraint 3 states it directly: "A missing policy file is a refusal, not a +skip." `CheckConfig()`'s own defaults carry `model_net = "none"`, so +defaulting on absence would turn the *declared* heuristic-only mode into a +silent fallback — the exact shape of "passed because a check could not run" +that fail-closed exists to prevent. Both gate commands refuse instead, and +name the minimal file to create. + +**This command reads the working tree; `pre-commit` reads the index.** That +asymmetry is deliberate and is not the bug it looks like. `check-config` +takes file paths on the command line and scans them from disk, so its policy +has to come from disk too: a policy read out of the index would describe a +commit the caller may not be making, and would make `check-config` unable to +answer "is the file I am looking at right now clean?" — which is the whole +question it exists to answer, most often on a file that is not staged at all. +`pre-commit` answers a different question ("is what I am about to commit +clean?") and so reads both content *and* policy from the index; see +`pre_commit._load_check_policy` for why anything else there fails open. + **A stale model net is exit 2, not exit 1.** `load_model_net` raises rather than returning an empty net when the artifact is missing, unreadable or no longer matches the sources it was generated from, and this module maps that @@ -35,7 +55,13 @@ import yaml -from ..config import CheckConfig, ConfigError, find_repo_config, load_config +from ..config import ( + CONFIG_FILENAME, + CheckConfig, + ConfigError, + find_repo_config, + load_config, +) from ..nets.heuristic import DocumentError, find_plaintext_credentials from ..nets.model import ( ModelNet, @@ -54,6 +80,17 @@ _QUOTED_FRAGMENT = re.compile(r"'[^']*'|\"[^\"]*\"") +# The minimal policy file both gate commands name when there is none. An +# explicit `model_net` is part of the minimum on purpose: it is the one key +# whose default ("none") silently drops a whole net, so a repository has to +# have chosen it rather than inherited it. +MINIMAL_POLICY = '[check]\nmodel_net = "none"' + +_MINIMAL_POLICY_BLOCK = "\n".join( + f" {line}" for line in MINIMAL_POLICY.splitlines() +) + + class CheckError(Exception): """A file could not be checked at all: an unreadable path, unparsable YAML, or a document that is not a mapping. Maps to exit code 2, never @@ -66,6 +103,39 @@ class CheckError(Exception): would have to be kept consistent with this one by hand.""" +class MissingPolicyError(CheckError): + """The repository declares no `.stackward.toml` at all. + + A subclass of `CheckError` because it means exactly what `CheckError` + means — the check could not run, exit 2 — while still being + distinguishable from "this file would not parse" for a caller that wants + to say something more useful. Deliberately *not* a `config.ConfigError`: + that type's own contract is "present but unusable", and `config` reports + absence by returning `None` rather than raising, because `doctor` has to + keep working in a repository that has no policy yet. + """ + + +def missing_policy_message(*, where: str, remedy: str) -> str: + """The refusal text for a missing policy file, in one place. + + Both gate commands print it, and they must not drift: they differ only + in where they looked (`where`) and what closes the gap (`remedy`). The + shape follows `sync_declared._load_repo_config`, the precedent already + in the tree for "absence is a refusal here" — say what is missing, say + why this command cannot proceed without it, and name the file to create. + """ + return ( + f"no {CONFIG_FILENAME} {where}; a repository must declare its " + "credential policy before this gate will scan under one — defaulting " + "would make the weaker net a silent fallback rather than a declared " + f"mode.\ncreate {CONFIG_FILENAME} at the repository root with at " + f"least:\n\n{_MINIMAL_POLICY_BLOCK}\n\n" + '(use model_net = "artifact" instead to also run the declared-secrets ' + f"net)\n{remedy}" + ) + + def fail_closed( command: Callable[[argparse.Namespace], int], ) -> Callable[[argparse.Namespace], int]: @@ -122,8 +192,9 @@ def scan_file(path: Path, check: CheckConfig, net: ModelNet | None) -> list[str] than going through this wrapper, and never round-trips a blob through a temp file to get there. - `net` is `None` when the repository declares `model_net = "none"`, or - has no `[check]` policy at all; the heuristic net always runs. It has no + `net` is `None` when the repository declares `model_net = "none"`; the + heuristic net always runs, and a repository that declared no policy at + all never reaches here (it is refused). It has no default, deliberately: a caller that simply forgot it would silently get the heuristic net alone, which is the whole failure this parameter exists to prevent. `pre_commit._scan_staged_config` takes it the same @@ -209,14 +280,45 @@ def describe_yaml_error(exc: yaml.YAMLError) -> str: def _load_check_policy() -> CheckConfig: """The `[check]` policy for the repository containing the current - working directory, or the built-in defaults when there is none — - absence is a normal state (see `config.py`), not an error.""" + working directory, read from the **working tree**. + + Working-tree by design, not by oversight — see the module docstring. + This is the one deliberate difference from `pre_commit`'s helper of the + same name, which reads the identical file out of the git index; a reader + comparing the two should see two answers to two different questions, not + a copy that was missed. + + Absence is a refusal (`MissingPolicyError`), never the built-in + defaults: `CheckConfig()` carries `model_net = "none"`, so returning it + here would silently drop the stronger net in exactly the repositories + that had not yet said anything about it. + """ config_path = find_repo_config() if config_path is None: - return CheckConfig() + raise MissingPolicyError( + missing_policy_message( + where="found at or above the current directory", + remedy=( + "then re-run this command from inside the repository " + "it belongs to." + ), + ) + ) return load_config(config_path).check +# TODO(final-review): `hooks install` should refuse, or at least warn, when +# the repository has no `.stackward.toml` — a repo that installs the hook +# before writing a policy now discovers that at someone's first blocked +# commit rather than at install time. The change belongs in +# `commands.install_hooks.cmd_install_hooks` (not owned by this wave): after +# the hook is written, call `config.find_repo_config()` and, on `None`, +# print a warning naming the minimal file (`check_config.MINIMAL_POLICY`). +# A warning rather than a refusal, because installing the hook first and +# writing the policy second is a legitimate order to do things in, and +# `install_hooks` exits 1/2 only for reasons that make the *install* wrong. + + @fail_closed def cmd_check_config(args: argparse.Namespace) -> int: """Entry point for `stackward check-config FILE...`. @@ -241,11 +343,14 @@ def cmd_check_config(args: argparse.Namespace) -> int: unanticipated exception's text is not something this module can vouch for as free of file content. - That per-file guard only covers the scan loop. Anything policy-loading - raises other than `ConfigError` — this function's own `except - ConfigError` below only catches that one type — is caught instead by - the `@fail_closed` decorator wrapping this function, for the same - "never exit 1 for a reason other than a real finding" guarantee. + That per-file guard only covers the scan loop. Policy loading is + guarded separately: `ConfigError` (a `.stackward.toml` that will not + parse) and `CheckError` (via `MissingPolicyError`, no `.stackward.toml` + at all) both print and exit 2 before any file is opened, and anything + *else* it raises is caught by the `@fail_closed` decorator wrapping this + function, for the same "never exit 1 for a reason other than a real + finding" guarantee. Nothing scans under default policy: a repository + with no declared policy is refused, not scanned with the weaker net. The model net is loaded once, before the loop, and a failure to load it exits 2 without scanning anything. That ordering is the point: a stale @@ -255,7 +360,7 @@ def cmd_check_config(args: argparse.Namespace) -> int: """ try: check = _load_check_policy() - except ConfigError as exc: + except (ConfigError, CheckError) as exc: print(f"error: {exc}", file=sys.stderr) return 2 diff --git a/src/stackward/commands/pre_commit.py b/src/stackward/commands/pre_commit.py index fc502b1..cffa4ea 100644 --- a/src/stackward/commands/pre_commit.py +++ b/src/stackward/commands/pre_commit.py @@ -14,12 +14,29 @@ rather than guess, because the cost of a false positive here is a rerun, and the cost of a false negative is permanent. -**Staged, never working-tree.** Every path this module reads goes through -`git show ":"` (see `_read_staged_blob`), which is stage 0 of the -index as it stands right now -- not `Path.read_text()`. An unstaged fix -sitting only in the working tree must not let an already-staged credential -through, and a working-tree file that merely *looks* clean must not be -mistaken for what will actually be committed. +**Staged, never working-tree -- policy included.** Every path this module +reads goes through `git show ":"` (see `_read_staged_blob`), which is +stage 0 of the index as it stands right now -- not `Path.read_text()`. An +unstaged fix sitting only in the working tree must not let an already-staged +credential through, and a working-tree file that merely *looks* clean must +not be mistaken for what will actually be committed. + +`.stackward.toml` is read the same way (`_index_config_path`, +`_load_check_policy`), and it took a review to notice it had not been. +Reading content from the index while reading the *rules* from the working +tree is a fail-open with a working exploit: stage a credential, then edit +the working-tree `.stackward.toml` to add an `allowed_references` entry for +it -- staging nothing -- and this command exits 0. The credential is +committed under a relaxation that is not itself being committed, and the +next clone of the repository contains the leak but not the excuse. Three +knobs weaken from that direction (`allowed_references` adds a suppression, +`model_net = "none"` drops a whole net, and `sensitive_parents` *replaces* +rather than extends, so the worktree can shrink what the index declared); +`nets.model.verify_sources` cannot close any of them, because it compares an +index blob against a recorded blob and a worktree-only edit touches neither, +and under `model_net = "none"` it does not run at all. Reading policy from +the index closes all three at once: a relaxation only takes effect in the +same commit that carries it. **Content-level, not file-level.** `_scan_staged_config` calls `nets.heuristic.find_plaintext_credentials` and @@ -56,27 +73,27 @@ repository's top level regardless of the caller's current working directory (verified empirically: a path printed by the first command, handed unchanged to the second, resolves correctly whether invoked from -the repo root or a subdirectory several levels down). `_load_check_policy` -relies on the same property transitively through `find_repo_config`, which -walks upward from `Path.cwd()` to the repository root and checks -`/.stackward.toml` before checking whether `/.git` -exists -- so the repo root's own `.stackward.toml` is always found on the -same pass that discovers `.git`, never one step too late. Between the two, -this command needs no special handling for "the hook ran from a -subdirectory": there is no such thing, only "the hook ran somewhere inside -the repository." +the repo root or a subdirectory several levels down). `_index_config_path` +reproduces `config.find_repo_config`'s upward walk in those same +repository-relative terms, using `git rev-parse --show-prefix` for where +the caller is standing -- so a `.stackward.toml` in a subdirectory is found +from below it and ignored from beside it, exactly as the working-tree walk +would. Between the two, this command needs no special handling for "the hook +ran from a subdirectory": there is no such thing, only "the hook ran +somewhere inside the repository." """ from __future__ import annotations import argparse +import os import subprocess import sys from pathlib import PurePosixPath import yaml -from ..config import CheckConfig, ConfigError, find_repo_config, load_config +from ..config import CONFIG_FILENAME, CheckConfig, ConfigError, load_config_text from ..nets.heuristic import DocumentError, find_plaintext_credentials from ..nets.model import ( ModelNet, @@ -84,7 +101,13 @@ find_declared_credentials, load_model_net, ) -from .check_config import CheckError, describe_yaml_error, fail_closed +from .check_config import ( + CheckError, + MissingPolicyError, + describe_yaml_error, + fail_closed, + missing_policy_message, +) _STATE_EXPORT_NAME = "state.json" _STATE_EXPORT_SUFFIX = ".stack-export.json" @@ -102,14 +125,27 @@ def _run_git(args: list[str]) -> bytes: tree, or the invoked subcommand failing for any git-reported reason (including `git show` on a path with no stage-0 entry -- an unmerged path). The message includes git's own stderr text verbatim: the only - two invocations this module ever makes are `diff --cached --name-only` - and `show ":"`, and a *failing* run of either never gets far - enough to have printed blob content -- a non-zero exit here means git - could not produce output at all, so its diagnostic is always about a - path or a ref, never about what a blob contains. + invocations this module makes are `diff --cached --name-only`, + `rev-parse --show-prefix`, `ls-files` and `show ":"`, and a + *failing* run of any of them never gets far enough to have printed blob + content -- a non-zero exit here means git could not produce output at + all, so its diagnostic is always about a path or a ref, never about what + a blob contains. + + Runs under `LC_ALL=C`. Everything below branches on `returncode` alone + and never on git's message text, so this is hardening rather than a live + fix here -- but git's diagnostics are gettext-marked, and a message + quoted into a `CheckError` should read the same in a bug report as it did + on the machine that hit it. The environment is *merged*, never replaced: + git sets `GIT_INDEX_FILE`, `GIT_DIR` and friends for a hook process, and + dropping them would make this command read a different index than the + commit it is gating. """ + env = {**os.environ, "LC_ALL": "C"} try: - proc = subprocess.run(["git", *args], capture_output=True, check=False) + proc = subprocess.run( + ["git", *args], capture_output=True, check=False, env=env + ) except OSError as exc: raise CheckError(f"cannot run git {' '.join(args)}: {exc}") from exc if proc.returncode != 0: @@ -224,8 +260,9 @@ def _scan_staged_config( PyYAML would otherwise interpolate into its own message), or a document that is not a mapping. - `net` is `None` when the repository declares `model_net = "none"`, or - has no `[check]` policy at all; the heuristic net always runs. + `net` is `None` when the repository declares `model_net = "none"`; the + heuristic net always runs. There is no third case where it is `None` + because no policy was found -- that is a refusal now, not a scan. """ text = _read_staged_blob(path) try: @@ -241,20 +278,108 @@ def _scan_staged_config( return sorted(found) +def _index_config_path() -> str | None: + """Repository-relative path of the nearest `.stackward.toml` **in the + index** at or above the current directory, or `None` if the index holds + none. + + The index-side twin of `config.find_repo_config`, and it walks in the + same direction for the same reason: a policy file beside the caller + beats one at the repository root. `git rev-parse --show-prefix` gives + the current directory as a repository-relative prefix -- git's own + answer, in git's own byte encoding, so no assumption is needed about + where the process's `cwd` sits relative to a symlinked work tree, and + (verified) it is not subject to `core.quotePath` mangling the way + `ls-files` output would be without `-z`. + + Candidates are matched with `:(literal,top)` pathspec magic. `top` is + load-bearing: `ls-files`' pathspecs resolve against the *caller's* + directory, not the repository root, so a hook invoked from a + subdirectory would otherwise look for `//.stackward.toml` + and find nothing. `literal` is belt-and-braces -- verified empirically + that git compares a pathspec's literal text against each path before + falling back to wildmatch, so an exact-path pathspec containing `[`, `*` + or `?` matches its own directory either way -- and it is kept because + the intent here is an exact path, and nothing downstream should have to + depend on that fallback ordering staying as it is. What actually makes + a metacharacter harmless is the line below: a candidate is accepted only + by exact string equality against this list, never by taking whatever git + happened to print. `--full-name` makes the answer repository-relative, + which is what `git show ":"` then wants, and `-z` keeps + `core.quotePath` from escaping a non-ASCII path into something no + candidate would equal. + """ + raw_prefix = _run_git(["rev-parse", "--show-prefix"]) + try: + prefix = raw_prefix.decode("utf-8") + except UnicodeDecodeError as exc: + raise CheckError(f"repository path is not valid UTF-8: {exc}") from exc + # Split rather than strip: a directory name may legally begin or end + # with a space, and `.strip()` would silently rename it. + parts = [part for part in prefix.rstrip("\n").split("/") if part] + + # Nearest first, ending at the repository root -- `find_repo_config`'s + # order, so the two helpers cannot disagree about which file wins. + candidates = [ + "/".join([*parts[:depth], CONFIG_FILENAME]) + for depth in range(len(parts), -1, -1) + ] + listed = _run_git( + [ + "ls-files", + "-z", + "--full-name", + "--", + *(f":(literal,top){candidate}" for candidate in candidates), + ] + ) + try: + text = listed.decode("utf-8") + except UnicodeDecodeError as exc: + raise CheckError(f"index path list is not valid UTF-8: {exc}") from exc + present = {path for path in text.split("\0") if path} + for candidate in candidates: + if candidate in present: + return candidate + return None + + def _load_check_policy() -> CheckConfig: - """The `[check]` policy for the repository containing the current - working directory, or the built-in defaults when there is none -- - absence is a normal state (see `config.py`), not an error. - - The same three lines as `check_config`'s private helper of the same - name, built entirely from this module's own public imports - (`find_repo_config`, `load_config`); not worth an inter-command import - for something this small. + """The `[check]` policy this commit will actually carry, read from the + **index** -- never from the working tree. + + Not the same three lines as `check_config`'s helper of the same name, + and the difference is the point rather than an oversight: `check-config` + answers "is this file on disk clean?" and so reads its policy from disk, + while this command answers "is what I am about to commit clean?" and so + reads both the content and the rules from the index. Reading the rules + from the working tree was a fail-open with a working exploit -- see the + module docstring for it and for the three knobs that weaken from that + direction. + + A policy file present in the working tree but never staged is therefore + *not* a policy as far as this command is concerned, which is the same + rule it already applies to every stack config it scans. Absence is a + refusal (`MissingPolicyError`), never the built-in defaults: those carry + `model_net = "none"`, so returning them here would drop the stronger net + in exactly the repositories that had never said anything about it. """ - config_path = find_repo_config() + config_path = _index_config_path() if config_path is None: - return CheckConfig() - return load_config(config_path).check + raise MissingPolicyError( + missing_policy_message( + where="in the index", + remedy=( + f"then stage it: git add {CONFIG_FILENAME} " + "(this command reads its policy from staged content, " + "exactly like the files it scans, so a relaxation cannot " + "take effect without being committed alongside what it " + "relaxes)" + ), + ) + ) + # Labelled the way a person would reproduce it: `git show ":"`. + return load_config_text(_read_staged_blob(config_path), f":{config_path}").check @fail_closed @@ -268,20 +393,21 @@ def cmd_pre_commit(_args: argparse.Namespace) -> int: state export is credentials by construction, not by finding); **2** the check could not run at all -- an unmerged index entry, a git command failing, staged content that is not decodable or not valid - YAML, a document that is not a mapping, an invalid `.stackward.toml`, - a missing or stale declared-secrets artifact under - `model_net = "artifact"`, or an unanticipated exception. Python's own default for an uncaught + YAML, a document that is not a mapping, a `.stackward.toml` that is + invalid *or absent from the index*, a missing or stale + declared-secrets artifact under `model_net = "artifact"`, or an + unanticipated exception. Python's own default for an uncaught exception is exit 1, which here would misreport "a credential was found" for a file this command never actually finished evaluating -- every per-file scan below is wrapped accordingly, and the `@fail_closed` decorator above is the outer boundary that catches anything else this function does not: policy loading (`_load_check_policy`) and the staged-file listing (`_staged_paths`) both run *outside* the per-file - loop, guarded only by `except ConfigError` / `except CheckError` - respectively, so a different, unanticipated exception from either -- - a bare `OSError` from `find_repo_config` walking through an - unreadable parent directory, say -- needs `@fail_closed` to avoid - escaping uncaught and exiting 1 by Python's own default. + loop, guarded only by `except (ConfigError, CheckError)` / `except + CheckError` respectively, so a different, unanticipated exception from + either -- an `OSError` from the `git` invocation layer that neither + converts, say -- needs `@fail_closed` to avoid escaping uncaught and + exiting 1 by Python's own default. Checks run in this fixed order, each a hard gate before the next: @@ -299,7 +425,16 @@ def cmd_pre_commit(_args: argparse.Namespace) -> int: unmerged, and this command can be invoked directly, bypassing that refusal. 2. Staged Pulumi state exports (`state.json`, `*.stack-export.json`) -- - refused outright, before the credential scan below even starts. + refused outright, before the credential scan below even starts, and + deliberately *before* the policy is loaded: this refusal needs no + policy to make (a state export is credentials by construction, not + by finding), and exit 1 is the more specific answer than the exit 2 + a missing or unparseable policy would produce. A repository that has + not written a `.stackward.toml` yet still gets told it staged a + state export, rather than being told about its config first. + 2a. The `[check]` policy, loaded from the index (`_load_check_policy`). + A repository with none is refused here, exit 2, naming the minimal + file to create -- never scanned under `CheckConfig()`'s defaults. 3. The declared-secrets artifact, loaded from the index. Loaded once, before the scan loop, and a failure to load it exits 2 having reported nothing: a stale artifact means this command does not know @@ -341,7 +476,7 @@ def cmd_pre_commit(_args: argparse.Namespace) -> int: try: check = _load_check_policy() - except ConfigError as exc: + except (ConfigError, CheckError) as exc: print(f"error: {exc}", file=sys.stderr) return 2 diff --git a/src/stackward/config.py b/src/stackward/config.py index 38784be..dcb4925 100644 --- a/src/stackward/config.py +++ b/src/stackward/config.py @@ -16,6 +16,20 @@ invalid file quietly falls back to defaults — a typo that silently disabled part of a credential gate would be worse than one that refused to run at all. + +What the gate commands decide for the absent case is: refuse. `check-config` +and `pre-commit` both exit 2 naming the minimal file to create, because +`CheckConfig()`'s own defaults include `model_net = "none"`, and defaulting +to that would make the weaker of the two nets a *silent fallback* rather +than the declared mode it is meant to be. This module still reports absence +rather than raising, because `cli.cmd_doctor` genuinely needs to run in a +repository that has no policy yet — reporting that fact is most of what it +is for. + +**Reading and parsing are separate.** `load_config_text` validates text; +`load_config` is the thin file reader in front of it. `commands.pre_commit` +needs the second half without the first, because its policy comes out of the +git index rather than the working tree. """ from __future__ import annotations @@ -51,12 +65,12 @@ # No parent key is sensitive by default. Unlike `sensitive_keys` above, this # tool ships no built-in guess at what a "sensitive parent" category is -# called in any particular repository's config shape — inventing one (e.g. -# copying a name like "environment_variables" out of somebody's existing -# gate script) would be exactly the kind of environment-specific knowledge -# this module exists to keep out of a public, generic tool. A repo that -# wants whole categories of keys flagged declares `sensitive_parents` -# itself, which then REPLACES this (empty) default outright. +# called in any particular repository's config shape: any non-empty default +# would be a guess about someone's config shape, which is exactly the kind of +# environment-specific knowledge this module exists to keep out of a public, +# generic tool. A repo that wants whole categories of keys flagged declares +# `sensitive_parents` itself, which then REPLACES this (empty) default +# outright. BUILTIN_SENSITIVE_PARENTS: frozenset[str] = frozenset() _MODEL_NET_VALUES = frozenset({"artifact", "none"}) @@ -318,8 +332,24 @@ def _build_config(data: dict[str, Any]) -> Config: ) -def load_config(path: Path) -> Config: - """Parse and validate `path` as `.stackward.toml`. +def load_config_text(text: str, origin: str) -> Config: + """Parse and validate `text` as `.stackward.toml` content. + + Parsing is separated from reading a file on purpose, and this is the + seam that makes it so. `commands.pre_commit` must load the policy the + *index* holds -- `git show ":.stackward.toml"` -- because a policy read + from the working tree could relax the gate for a commit that does not + itself carry the relaxation. Having only a path-taking loader forced + that command to read the working tree, which was a fail-open: the + content it scanned came from the index while the rules it scanned under + came from wherever the developer had most recently typed. There is no + round-trip through a temporary file here either, for the same reason + `pre_commit` never writes a staged blob to disk. + + `origin` is what the raised `ConfigError` names, so a caller can label + where the text came from in the terms a person would use to reproduce + it: a filesystem path for `load_config`, and `":.stackward.toml"` -- the + literal argument to `git show` -- for the index. Raises `ConfigError` naming the offending key on anything invalid: an unknown key at any recognised level, a value of the wrong type, or an @@ -327,20 +357,35 @@ def load_config(path: Path) -> Config: fails to validate must not be treated as though it did not exist. """ try: - with path.open("rb") as handle: - data = tomllib.load(handle) - except OSError as exc: - raise ConfigError(f"{path}: cannot read: {exc}") from exc + data = tomllib.loads(text) except tomllib.TOMLDecodeError as exc: # `.stackward.toml` holds path and key *names* only, never a # credential value, so it is safe for this message to include # whatever tomllib quotes from the offending line. - raise ConfigError(f"{path}: invalid TOML: {exc}") from exc + raise ConfigError(f"{origin}: invalid TOML: {exc}") from exc try: return _build_config(data) except ConfigError as exc: - raise ConfigError(f"{path}: {exc}") from exc + raise ConfigError(f"{origin}: {exc}") from exc + + +def load_config(path: Path) -> Config: + """Parse and validate the file at `path` as `.stackward.toml`. + + A thin reader in front of `load_config_text`, which does all the + validation; see there for what is raised and why the two are separate. + An unreadable file and one that is not UTF-8 are both `ConfigError`, + never a silent fall back to defaults: TOML is defined as UTF-8, so bytes + that are not are a broken config file, not an absent one. + """ + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise ConfigError(f"{path}: cannot read: {exc}") from exc + except UnicodeDecodeError as exc: + raise ConfigError(f"{path}: cannot read: not valid UTF-8: {exc}") from exc + return load_config_text(text, str(path)) def enforce_min_version( @@ -351,12 +396,21 @@ def enforce_min_version( ) -> None: """Raise `MinVersionError` when `installed` is older than `config.min_version`. - Commands in `cli.VERSION_CHECK_EXEMPT` (`doctor`, `self-update`) always - run: blocking either one would make the upgrade instruction this raises - unreachable. `cli` is imported locally rather than at module level - because `cli` imports `find_repo_config` from this module — importing - `cli` here at module scope would make the two modules need each other to - finish executing. + Called from `cli.main` between argument parsing and dispatch, so the + floor applies to every command rather than to whichever ones remembered + to ask. Commands in `cli.VERSION_CHECK_EXEMPT` always run: blocking + `doctor` would make the upgrade instruction this raises unreachable from + the one command that diagnoses the problem. + + The instruction names `install.sh`, the installer this project actually + ships, rather than a `stackward self-update` subcommand -- there is no + such subcommand, and an error message whose only advice is a command + that does not exist is worse than no advice at all. + + `cli` is imported locally rather than at module level because `cli` + imports `find_repo_config` from this module — importing `cli` here at + module scope would make the two modules need each other to finish + executing. """ from .cli import VERSION_CHECK_EXEMPT # local: breaks an import cycle @@ -367,5 +421,8 @@ def enforce_min_version( if _parse_version(installed) < _parse_version(config.min_version): raise MinVersionError( f"this repository requires stackward >= {config.min_version} " - f"(installed: {installed}); upgrade with: stackward self-update" + f"(installed: {installed}); upgrade by re-running the installer: " + "curl -fsSL " + "https://raw.githubusercontent.com/longthread/stackward/main/install.sh" + " | sh" ) diff --git a/tests/test_cli.py b/tests/test_cli.py index e71c80f..8115283 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -77,9 +77,19 @@ def test_config_home_falls_back_to_dot_config(monkeypatch, tmp_path): assert config_home() == tmp_path / ".config" / "stackward" -def test_check_config_is_registered_and_dispatches(tmp_path): +def test_check_config_is_registered_and_dispatches(tmp_path, monkeypatch): """Wiring only — the rules `check-config` enforces are covered in - tests/test_heuristic.py.""" + tests/test_heuristic.py. + + The `.stackward.toml` and the `chdir` are what make this a dispatch + test rather than a policy test: a repository that declares no policy is + now refused with exit 2 before any file is opened, so without them this + would assert the wrong exit code for a reason that has nothing to do + with whether the subcommand is registered. + """ + (tmp_path / ".git").mkdir() + (tmp_path / ".stackward.toml").write_text('[check]\nmodel_net = "none"\n') + monkeypatch.chdir(tmp_path) clean = tmp_path / "Pulumi.dev.yaml" clean.write_text("name: myproject\n") assert main(["check-config", str(clean)]) == 0 diff --git a/tests/test_config.py b/tests/test_config.py index b6ef581..4e49068 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -11,6 +11,7 @@ import pytest +from stackward import __version__ from stackward.config import ( BUILTIN_SENSITIVE_KEYS, Config, @@ -21,6 +22,7 @@ enforce_min_version, find_repo_config, load_config, + load_config_text, ) @@ -329,7 +331,13 @@ def test_min_version_rejects_a_lower_installed_version(): enforce_min_version(config, "check-config", installed="0.3.0") message = str(exc_info.value) assert "0.4.0" in message - assert "self-update" in message + # The instruction has to name something that exists. `stackward + # self-update` does not: `cli.build_parser` registers no such + # subcommand, so the one message this feature prints used to end by + # telling the reader to run a command that would exit 2 as a usage + # error. `install.sh` is the upgrade path this project actually ships. + assert "install.sh" in message + assert "self-update" not in message def test_min_version_compares_numerically_not_lexicographically(): @@ -401,3 +409,170 @@ def test_secrets_value_that_is_not_a_table_raises(tmp_path): def test_secrets_member_that_is_not_a_table_raises(tmp_path): with pytest.raises(ConfigError): load_config(write_config(tmp_path, '[secrets]\ninfra = "oops"\n')) + + +# --------------------------------------------------------------------------- +# Branches that had no test at all: each `raise`/`read` below survived the +# whole suite when mutated into a silent default, which is the exact failure +# shape this module's docstring says must not exist ("no third state where an +# invalid file quietly falls back to defaults"). +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "key", ["sensitive_keys", "sensitive_parents", "allowed_references"] +) +def test_a_list_valued_check_key_of_the_wrong_type_raises(tmp_path, key): + """`_require_str_list`'s rejection. Mutating its `raise` to `return + None` made every one of these load as "key absent" — which for + `sensitive_keys` means the built-ins alone, and for `sensitive_parents` + means the empty default: a policy that declared a whole sensitive + category, mistyped, would silently protect nothing.""" + with pytest.raises(ConfigError) as exc_info: + load_config(write_config(tmp_path, f"[check]\n{key} = 5\n")) + assert f"check.{key}" in str(exc_info.value) + + +def test_a_list_valued_check_key_containing_a_non_string_raises(tmp_path): + """The `all(isinstance(...))` half of the same guard: a list is not + enough, its members have to be names.""" + with pytest.raises(ConfigError): + load_config(write_config(tmp_path, '[check]\nsensitive_keys = ["ok", 5]\n')) + + +def test_a_check_table_that_is_not_a_table_raises(tmp_path): + """`_build_check`'s type guard. Mutating it to `raw = {}` made + `check = "oops"` load as an empty `[check]` table — every declared key + silently discarded, including `model_net`.""" + with pytest.raises(ConfigError) as exc_info: + load_config(write_config(tmp_path, 'check = "oops"\n')) + assert "check" in str(exc_info.value) + + +def test_an_unreadable_config_raises_rather_than_defaulting(tmp_path): + """`load_config`'s `OSError` branch. Mutating it to `return Config()` + turned a config the tool could not read into a config that said + nothing — the single worst outcome available, since `Config()` carries + `model_net = "none"`.""" + unreadable = tmp_path / ".stackward.toml" + unreadable.mkdir() # a directory where a file is expected + with pytest.raises(ConfigError) as exc_info: + load_config(unreadable) + assert "cannot read" in str(exc_info.value) + + +def test_a_config_that_is_not_utf8_raises_rather_than_defaulting(tmp_path): + """TOML is defined as UTF-8, so bytes that are not are a broken config + file, not an absent one. `Path.read_text` raises `UnicodeDecodeError`, + which is a `ValueError` and not an `OSError` — caught separately, or it + would escape `load_config` as something no caller expects.""" + path = tmp_path / ".stackward.toml" + path.write_bytes(b'profile = "\xff\xfe"\n') + with pytest.raises(ConfigError): + load_config(path) + + +# --------------------------------------------------------------------------- +# Parsing separated from reading: what `commands.pre_commit` needs to load +# the policy the git index holds rather than the one on disk. +# --------------------------------------------------------------------------- + + +def test_load_config_text_parses_without_touching_the_filesystem(): + config = load_config_text('[check]\nmodel_net = "artifact"\n', ":.stackward.toml") + assert config.check.model_net == "artifact" + + +def test_load_config_text_names_its_origin_in_an_error(): + """The origin label is how a person reproduces the failure: for the + index reader it is literally the argument to `git show`.""" + with pytest.raises(ConfigError) as exc_info: + load_config_text("this is not valid toml [[[", ":.stackward.toml") + assert ":.stackward.toml" in str(exc_info.value) + + +def test_load_config_applies_the_same_validation_as_load_config_text(tmp_path): + """The file reader must not be a second, drifting implementation: it is + a reader in front of the same validator, so an invalid key is rejected + identically whichever entry point saw it.""" + text = '[check]\nmodel_net = "bogus"\n' + with pytest.raises(ConfigError): + load_config_text(text, "") + with pytest.raises(ConfigError): + load_config(write_config(tmp_path, text)) + + +# --------------------------------------------------------------------------- +# `min_version` reaches a real invocation. +# +# The floor was built here and never wired: `enforce_min_version` had zero +# production call sites, and `cli.main` dispatched straight to `args.func`. +# The plan states Task 2's goal as "Load and validate `.stackward.toml`, and +# enforce `min_version`" — the second half of which no command performed. +# These tests go through `cli.main`, because that is the only place the +# wiring exists and a unit test of `enforce_min_version` cannot see it. +# --------------------------------------------------------------------------- + + +def _repo_declaring(tmp_path, monkeypatch, text: str): + (tmp_path / ".git").mkdir() + (tmp_path / ".stackward.toml").write_text(text) + (tmp_path / "Pulumi.dev.yaml").write_text("name: myproject\n") + monkeypatch.chdir(tmp_path) + return tmp_path / "Pulumi.dev.yaml" + + +def test_main_refuses_a_command_below_the_repositorys_min_version( + tmp_path, monkeypatch, capsys +): + """A repository whose policy needs a newer `stackward` than the one + installed must not have that policy interpreted by this one. Exit 2 — + could-not-run, never 1 — and the message names the required version and + how to upgrade.""" + from stackward.cli import main as cli_main + + target = _repo_declaring( + tmp_path, monkeypatch, 'min_version = "99.0.0"\n[check]\nmodel_net = "none"\n' + ) + assert cli_main(["check-config", str(target)]) == 2 + err = capsys.readouterr().err + assert "99.0.0" in err + assert "install.sh" in err + + +def test_main_dispatches_normally_when_the_floor_is_met(tmp_path, monkeypatch, capsys): + """The other half: a satisfied floor must be invisible. Without this, the + test above would pass just as well if every command exited 2.""" + from stackward.cli import main as cli_main + + target = _repo_declaring( + tmp_path, monkeypatch, 'min_version = "0.0.1"\n[check]\nmodel_net = "none"\n' + ) + assert cli_main(["check-config", str(target)]) == 0 + assert capsys.readouterr().out == "" + + +def test_doctor_still_runs_under_an_unmet_min_version(tmp_path, monkeypatch, capsys): + """`VERSION_CHECK_EXEMPT` reaching a real invocation. `doctor` is how you + diagnose the problem; a floor that blocked it would print an instruction + from a command it had just made unreachable.""" + from stackward.cli import main as cli_main + + _repo_declaring(tmp_path, monkeypatch, 'min_version = "99.0.0"\n') + assert cli_main(["doctor"]) == 0 + assert __version__ in capsys.readouterr().out + + +def test_a_malformed_config_does_not_break_dispatch(tmp_path, monkeypatch, capsys): + """Task 2's carry-forward ruling, re-pinned against the new call site: + the floor check runs before every command, so a `.stackward.toml` that + will not parse must make it stand aside rather than take `doctor` down + with it. The gate commands still refuse — with their own, better + message — because they load the same file themselves.""" + from stackward.cli import main as cli_main + + (tmp_path / ".git").mkdir() + (tmp_path / ".stackward.toml").write_text("this is not valid toml [[[") + monkeypatch.chdir(tmp_path) + assert cli_main(["doctor"]) == 0 + assert __version__ in capsys.readouterr().out diff --git a/tests/test_gate_isolation.py b/tests/test_gate_isolation.py new file mode 100644 index 0000000..4265692 --- /dev/null +++ b/tests/test_gate_isolation.py @@ -0,0 +1,203 @@ +"""Global Constraint 2, tested as one property rather than three halves. + + "The gate resolves no credentials. `check-config` and `pre-commit` read + files only. No code reachable from them may prompt, open a network + connection, or import a credential provider." + +`README.md` states the same thing under the heading "These are constraints, +not aspirations — each has a test", and adds the consequence users actually +care about: a commit "cannot be blocked by an expired session or a flaky +connection". + +Two reviews found that claim under-tested in three separate ways, and this +module exists because each gap was invisible from inside the test that was +supposed to cover it: + +* `tests/test_model_net.py` checked `cryptography` and `stackward.store`, but + imported the two command modules **directly**, bypassing `cli.py` — which + imported `commands.check_passphrase` (-> `cryptography`) and + `commands.session` (-> `store` -> `crypto`) at its own module scope. Every + real invocation goes through `cli.py`. +* `tests/test_providers.py` did go through `cli.main`, but grepped only for + `stackward.providers*`, so it saw none of that. +* Nothing at all tested "never prompt" or "never touch the network". + Inserting a real `store.resolve_credentials(...)` call into + `cmd_check_config` survived the entire suite, and blocked on a `getpass` + prompt at a terminal. + +So the import check is one assertion over the union of every forbidden +module, taken through the dispatch path a `git commit` really takes, and the +prompting and network checks are their own tests over both gate commands. +""" + +from __future__ import annotations + +import getpass +import socket +import subprocess +import sys +from pathlib import Path + +import pytest + +from stackward.cli import main + +MINIMAL_POLICY = '[check]\nmodel_net = "none"\n' + +# Every module the gate must not reach, as prefixes. `stackward.providers` +# and `cryptography` are matched with their submodules too, so a provider or +# a backend added later is covered without editing this list. +FORBIDDEN_PREFIXES = ( + "stackward.providers", + "stackward.store", + "stackward.crypto", + "cryptography", +) + +_PROBE = """ +import sys +from stackward.cli import main +code = main(["check-config", {target!r}]) +forbidden = sorted( + module + for module in sys.modules + for prefix in {prefixes!r} + if module == prefix or module.startswith(prefix + ".") +) +print("exit", code) +print("forbidden", forbidden) +""" + + +def test_the_gate_path_reaches_no_credential_or_provider_code(tmp_path): + """One assertion over the union, through `cli.main`. + + A subprocess, because this test process has already imported half the + tool through other test modules in the same session — asserting against + its own `sys.modules` would prove nothing. + + The exit code is asserted alongside the module list on purpose: a + `check-config` that refused before doing any work would import nothing + either, and would pass this test while proving the opposite of what it + claims. + """ + (tmp_path / ".stackward.toml").write_text(MINIMAL_POLICY) + target = tmp_path / "Pulumi.dev.yaml" + target.write_text("name: myproject\n") + + proc = subprocess.run( + [sys.executable, "-c", _PROBE.format(target=str(target), prefixes=FORBIDDEN_PREFIXES)], + cwd=tmp_path, + capture_output=True, + text=True, + check=True, + ) + assert "exit 0" in proc.stdout + assert "forbidden []" in proc.stdout + + +class GateViolation(BaseException): + """Raised by the `sealed` fixture when the gate does something Global + Constraint 2 forbids. See that fixture for why it is not an + `Exception`.""" + + +@pytest.fixture +def sealed(monkeypatch): + """Make prompting and networking impossible, loudly. + + `GateViolation` derives from `BaseException`, not `Exception`, and that + is the whole trick: `check_config.fail_closed` converts any `Exception` + into `return 2`, so a control raised as an `AssertionError` inside a + gate command is silently swallowed and the test then asserts on an exit + code that looks like an ordinary refusal — it would pass whether the + forbidden call happened or not. (That trap has already caught one + reviewer on this project.) + + `KeyboardInterrupt` would also survive the boundary, but pytest treats + it as "the human wants out" and aborts the whole session instead of + failing the one test, which turns a precise signal into a confusing + one. A private `BaseException` subclass survives `fail_closed` *and* is + reported as an ordinary failure naming this test. + """ + + def refuse(reason): + def refusing(*_args, **_kwargs): + raise GateViolation(reason) + + return refusing + + monkeypatch.setattr(getpass, "getpass", refuse("the gate prompted for a password")) + monkeypatch.setattr("builtins.input", refuse("the gate prompted on stdin")) + monkeypatch.setattr(socket, "socket", refuse("the gate opened a socket")) + monkeypatch.setattr( + socket, "create_connection", refuse("the gate opened a connection") + ) + + +def test_check_config_neither_prompts_nor_connects(sealed, tmp_path, monkeypatch): + (tmp_path / ".stackward.toml").write_text(MINIMAL_POLICY) + target = tmp_path / "Pulumi.dev.yaml" + target.write_text("name: myproject\n") + monkeypatch.chdir(tmp_path) + + assert main(["check-config", str(target)]) == 0 + + +def test_check_config_neither_prompts_nor_connects_on_a_finding( + sealed, tmp_path, monkeypatch +): + """The finding path, not only the clean one: reporting a credential is + where a credential-resolving call would most plausibly be added.""" + (tmp_path / ".stackward.toml").write_text(MINIMAL_POLICY) + target = tmp_path / "Pulumi.dev.yaml" + target.write_text("config:\n myproject:dbPassword: hunter2\n") + monkeypatch.chdir(tmp_path) + + assert main(["check-config", str(target)]) == 1 + + +def _repo_with_policy(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + for args in ( + ("init", "-q"), + ("config", "user.email", "test@example.com"), + ("config", "user.name", "Test"), + ): + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + (repo / ".stackward.toml").write_text(MINIMAL_POLICY) + subprocess.run( + ["git", "add", ".stackward.toml"], cwd=repo, check=True, capture_output=True + ) + subprocess.run( + ["git", "commit", "-q", "-m", "init"], cwd=repo, check=True, capture_output=True + ) + return repo + + +def test_pre_commit_neither_prompts_nor_connects(sealed, tmp_path, monkeypatch): + """The half that matters most: `pre-commit` is the command a `git + commit` runs, so a prompt here blocks the commit on a terminal and hangs + it everywhere else.""" + repo = _repo_with_policy(tmp_path) + (repo / "Pulumi.dev.yaml").write_text("name: myproject\n") + subprocess.run( + ["git", "add", "Pulumi.dev.yaml"], cwd=repo, check=True, capture_output=True + ) + monkeypatch.chdir(repo) + + assert main(["pre-commit"]) == 0 + + +def test_pre_commit_neither_prompts_nor_connects_on_a_finding( + sealed, tmp_path, monkeypatch +): + repo = _repo_with_policy(tmp_path) + (repo / "Pulumi.dev.yaml").write_text("config:\n myproject:dbPassword: hunter2\n") + subprocess.run( + ["git", "add", "Pulumi.dev.yaml"], cwd=repo, check=True, capture_output=True + ) + monkeypatch.chdir(repo) + + assert main(["pre-commit"]) == 1 diff --git a/tests/test_heuristic.py b/tests/test_heuristic.py index a223e56..5d39afe 100644 --- a/tests/test_heuristic.py +++ b/tests/test_heuristic.py @@ -49,7 +49,7 @@ def test_clean_config_has_no_findings(): document = { "name": "myproject", "runtime": "nodejs", - "config": {"aws:region": "us-east-1"}, + "config": {"placeholder:region": "placeholder-region"}, } assert find_plaintext_credentials(document, CheckConfig()) == [] @@ -66,16 +66,16 @@ def test_plaintext_value_flagged_by_key_match(): def test_plaintext_value_flagged_by_sensitive_parent(): """The leaf's own key ("DB_USER") matches no built-in sensitive key — only a declared `sensitive_parents` ancestor makes it a finding.""" - check = CheckConfig(sensitive_parents=frozenset({"environment_variables"})) - document = {"environment_variables": {"DB_USER": "admin"}} - assert find_plaintext_credentials(document, check) == ["environment_variables.DB_USER"] + check = CheckConfig(sensitive_parents=frozenset({"placeholder_parent"})) + document = {"placeholder_parent": {"DB_USER": "admin"}} + assert find_plaintext_credentials(document, check) == ["placeholder_parent.DB_USER"] def test_encrypted_leaf_passes(): """A leaf made sensitive only by its `sensitive_parents` ancestor, whose parent mapping is exactly `{"secure": ...}`, must not be flagged.""" - check = CheckConfig(sensitive_parents=frozenset({"environment_variables"})) - document = {"environment_variables": {"DB_PASS": {"secure": "v1:AAAA"}}} + check = CheckConfig(sensitive_parents=frozenset({"placeholder_parent"})) + document = {"placeholder_parent": {"DB_PASS": {"secure": "v1:AAAA"}}} assert find_plaintext_credentials(document, check) == [] @@ -84,16 +84,16 @@ def test_malformed_secure_wrapper_with_extra_sibling_is_flagged(): both children must be flagged. A `path.endswith(".secure")` check would wrongly exclude the first of the two; asserting it is present is the point of this test.""" - check = CheckConfig(sensitive_parents=frozenset({"environment_variables"})) + check = CheckConfig(sensitive_parents=frozenset({"placeholder_parent"})) document = { - "environment_variables": { + "placeholder_parent": { "DB_PASS": {"secure": "v1:AAAA", "other": "leaked-plaintext"} } } findings = find_plaintext_credentials(document, check) assert findings == [ - "environment_variables.DB_PASS.other", - "environment_variables.DB_PASS.secure", + "placeholder_parent.DB_PASS.other", + "placeholder_parent.DB_PASS.secure", ] @@ -307,6 +307,25 @@ def write_yaml(tmp_path, name: str, text: str): return path +@pytest.fixture +def policy_repo(tmp_path, monkeypatch): + """`tmp_path` set up as a repository root that declares a policy, with + the process's working directory inside it. + + Every `check-config` invocation below needs one. A repository with no + `.stackward.toml` is now refused outright (Global Constraint 3: "A + missing policy file is a refusal, not a skip"), so without this fixture + the exit-0 and exit-1 tests here would fail and — worse — every exit-2 + test would pass for the wrong reason, never reaching the condition it + names. Returns `tmp_path` unchanged, so each test's own `write_yaml` + call still builds its fixture file where it always did. + """ + (tmp_path / ".git").mkdir() + (tmp_path / ".stackward.toml").write_text('[check]\nmodel_net = "none"\n') + monkeypatch.chdir(tmp_path) + return tmp_path + + def test_scan_file_returns_no_findings_for_a_clean_file(tmp_path): path = write_yaml(tmp_path, "Pulumi.dev.yaml", "name: myproject\nruntime: nodejs\n") assert scan_file(path, CheckConfig(), None) == [] @@ -446,13 +465,13 @@ def test_scan_file_raises_check_error_on_missing_file(tmp_path): scan_file(tmp_path / "does-not-exist.yaml", CheckConfig(), None) -def test_check_config_exits_0_when_clean(tmp_path, capsys): +def test_check_config_exits_0_when_clean(policy_repo, tmp_path, capsys): path = write_yaml(tmp_path, "Pulumi.dev.yaml", "name: myproject\nruntime: nodejs\n") assert main(["check-config", str(path)]) == 0 assert capsys.readouterr().out == "" -def test_check_config_exits_1_and_prints_the_finding_format(tmp_path, capsys): +def test_check_config_exits_1_and_prints_the_finding_format(policy_repo, tmp_path, capsys): path = write_yaml( tmp_path, "Pulumi.dev.yaml", "config:\n myproject:dbPassword: hunter2\n" ) @@ -462,7 +481,7 @@ def test_check_config_exits_1_and_prints_the_finding_format(tmp_path, capsys): assert "hunter2" not in out -def test_check_config_findings_print_sorted(tmp_path, capsys): +def test_check_config_findings_print_sorted(policy_repo, tmp_path, capsys): """Insertion order in the file is zebra-then-apple; output must be sorted regardless.""" path = write_yaml( @@ -478,13 +497,13 @@ def test_check_config_findings_print_sorted(tmp_path, capsys): ] -def test_check_config_exits_2_on_invalid_yaml(tmp_path, capsys): +def test_check_config_exits_2_on_invalid_yaml(policy_repo, tmp_path, capsys): path = write_yaml(tmp_path, "Pulumi.dev.yaml", "key: [unclosed\n") assert main(["check-config", str(path)]) == 2 assert capsys.readouterr().out == "" -def test_check_config_exits_2_on_non_mapping_document(tmp_path): +def test_check_config_exits_2_on_non_mapping_document(policy_repo, tmp_path): path = write_yaml(tmp_path, "Pulumi.dev.yaml", "- a\n- b\n") assert main(["check-config", str(path)]) == 2 @@ -497,7 +516,7 @@ def test_check_config_exits_2_with_no_files_given(): assert exit_info.value.code == 2 -def test_check_config_exits_2_on_non_utf8_file(tmp_path, capsys): +def test_check_config_exits_2_on_non_utf8_file(policy_repo, tmp_path, capsys): """`Path.read_text()` raises `UnicodeDecodeError` for a file that is not valid UTF-8 — not an `OSError`, and previously uncaught, which would exit 1 (Python's default for an uncaught exception): the code @@ -509,7 +528,7 @@ def test_check_config_exits_2_on_non_utf8_file(tmp_path, capsys): assert capsys.readouterr().out == "" -def test_check_config_exits_2_on_unanticipated_scan_error(tmp_path, monkeypatch, capsys): +def test_check_config_exits_2_on_unanticipated_scan_error(policy_repo, tmp_path, monkeypatch, capsys): """Any exception a per-file scan raises other than `CheckError` must still map to exit 2, not propagate and exit 1 (Python's default for an uncaught exception) — regardless of what raised it. Simulated here @@ -518,7 +537,7 @@ def test_check_config_exits_2_on_unanticipated_scan_error(tmp_path, monkeypatch, non-UTF8 regressions covered elsewhere.""" path = write_yaml(tmp_path, "Pulumi.dev.yaml", "name: myproject\n") - def boom(_path, _check): + def boom(_path, _check, _net): raise RuntimeError("unanticipated failure, not a CheckError") monkeypatch.setattr("stackward.commands.check_config.scan_file", boom) @@ -527,7 +546,7 @@ def boom(_path, _check): def test_check_config_exits_2_when_policy_loading_raises_an_unexpected_error( - tmp_path, monkeypatch, capsys + policy_repo, tmp_path, monkeypatch, capsys ): """A bare exception from policy loading -- not `ConfigError`, which `cmd_check_config`'s own `except ConfigError` already handles -- must @@ -552,7 +571,7 @@ def boom(): def test_check_config_prints_a_real_finding_even_when_another_file_errors( - tmp_path, capsys + policy_repo, tmp_path, capsys ): """A parse error on one file must never hide a genuine finding on another: the exit code (2, since a check-could-not-run error always @@ -562,3 +581,91 @@ def test_check_config_prints_a_real_finding_even_when_another_file_errors( assert main(["check-config", str(good), str(broken)]) == 2 out = capsys.readouterr().out assert out == f"{good}: plaintext credential at 'password'\n" + + +# --------------------------------------------------------------------------- +# A missing or unloadable policy file is a refusal, not a skip. +# +# Global Constraint 3: "A missing policy file is a refusal, not a skip." The +# plan's Task 3 adds the reason it has to be: heuristic-only is a *declared* +# mode (`model_net = "none"`), never a silent fallback — and an absent config +# defaulting to `CheckConfig()` is precisely that fallback, since `"none"` is +# what `CheckConfig()` carries. +# --------------------------------------------------------------------------- + + +def test_check_config_refuses_when_no_policy_file_is_found(tmp_path, monkeypatch, capsys): + """No `.stackward.toml` anywhere at or above the working directory. Exit + 2, naming the file and the key that has to be in it — a refusal that + does not say what to create is not actionable.""" + (tmp_path / ".git").mkdir() + monkeypatch.chdir(tmp_path) + path = write_yaml(tmp_path, "Pulumi.dev.yaml", "name: myproject\n") + + assert main(["check-config", str(path)]) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert ".stackward.toml" in captured.err + assert "model_net" in captured.err + + +def test_check_config_refuses_rather_than_reporting_when_there_is_no_policy( + tmp_path, monkeypatch, capsys +): + """Exit 2, not 1, even for a file the heuristic net alone would have + flagged. Without a policy this command does not know what the repository + considers sensitive, and exit 1 would claim a complete answer it does + not have — the same reasoning that makes a stale model net exit 2.""" + (tmp_path / ".git").mkdir() + monkeypatch.chdir(tmp_path) + path = write_yaml( + tmp_path, "Pulumi.dev.yaml", "config:\n myproject:dbPassword: hunter2\n" + ) + + assert main(["check-config", str(path)]) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "hunter2" not in captured.err + + +def test_check_config_exits_2_on_a_policy_file_that_will_not_parse( + tmp_path, monkeypatch, capsys +): + """The `except ConfigError` branch in `cmd_check_config`. Nothing pinned + it: mutating its `return 2` to `return 0` survived the whole suite, so a + repository with a typo in its policy could be reported clean.""" + (tmp_path / ".git").mkdir() + (tmp_path / ".stackward.toml").write_text("this is not valid toml [[[") + monkeypatch.chdir(tmp_path) + path = write_yaml( + tmp_path, "Pulumi.dev.yaml", "config:\n myproject:dbPassword: hunter2\n" + ) + + assert main(["check-config", str(path)]) == 2 + captured = capsys.readouterr() + # Not scanned at all: the finding must not print, or the exit code and + # the output would be telling two different stories. + assert captured.out == "" + assert "invalid TOML" in captured.err + + +def test_check_config_reads_the_policy_from_the_working_tree_by_design( + tmp_path, monkeypatch, capsys +): + """The deliberate asymmetry with `pre-commit`, pinned so a later change + cannot "fix" it into matching. `check-config` scans files from disk and + reads its policy from disk, and needs no git repository at all: it + answers "is this file clean right now?", which is a question about the + working tree. `pre-commit` answers "is what I am about to commit + clean?", and reads both from the index — see tests/test_pre_commit.py. + """ + (tmp_path / ".stackward.toml").write_text( + '[check]\nmodel_net = "none"\nallowed_references = ["password"]\n' + ) + monkeypatch.chdir(tmp_path) + path = write_yaml(tmp_path, "Pulumi.dev.yaml", 'password: "hunter2"\n') + + # No `git init`, nothing staged: the policy still applies. + assert not (tmp_path / ".git").exists() + assert main(["check-config", str(path)]) == 0 + assert capsys.readouterr().out == "" diff --git a/tests/test_install_hooks.py b/tests/test_install_hooks.py index 12f018e..34e2184 100644 --- a/tests/test_install_hooks.py +++ b/tests/test_install_hooks.py @@ -33,13 +33,27 @@ def _git(repo: Path, *args: str) -> subprocess.CompletedProcess: @pytest.fixture def repo(tmp_path: Path) -> Path: - """A real, minimal git repository with one initial commit.""" + """A real, minimal git repository with one initial commit that declares + a `.stackward.toml`. + + The policy file is committed rather than merely written because + `pre-commit` -- which the end-to-end tests here drive through a real + `git commit` -- now reads its policy from the index and refuses a + repository that declares none (Global Constraint 3: "A missing policy + file is a refusal, not a skip"). Without it, the commit-blocking tests + below would still see a non-zero exit, but for the wrong reason, and the + clean-commit test would fail outright. `hooks install` itself reads no + policy, so the tests that only assert on installation are unaffected + either way. + """ path = tmp_path / "repo" path.mkdir() _git(path, "init", "-q") _git(path, "config", "user.email", "test@example.com") _git(path, "config", "user.name", "Test") - _git(path, "commit", "-q", "--allow-empty", "-m", "init") + (path / ".stackward.toml").write_text('[check]\nmodel_net = "none"\n') + _git(path, "add", ".stackward.toml") + _git(path, "commit", "-q", "-m", "init") return path diff --git a/tests/test_model_net.py b/tests/test_model_net.py index b171878..89a1664 100644 --- a/tests/test_model_net.py +++ b/tests/test_model_net.py @@ -1882,27 +1882,15 @@ def test_neither_scan_entry_point_makes_the_model_net_optional(entry_point): assert parameter.default is inspect.Parameter.empty -def test_the_gate_path_reaches_no_credential_code(): - """`check-config` and `pre-commit` must never reach the credential store - or `cryptography`, so a commit cannot be blocked by an expired session or - a missing native extension. This net added an import to the gate path, so - the property is worth asserting rather than assuming — checked in a - subprocess, since this test process has imported half the tool already. - """ - proc = subprocess.run( - [ - sys.executable, - "-c", - "import sys; import stackward.commands.check_config; " - "import stackward.commands.pre_commit; " - "print(sorted(m for m in ('cryptography', 'stackward.store') " - "if m in sys.modules))", - ], - capture_output=True, - text=True, - check=True, - ) - assert proc.stdout.strip() == "[]" +# The gate-path invariant this net's own imports could have broken — that +# `check-config` and `pre-commit` reach no credential-store or `cryptography` +# code — lives in `tests/test_gate_isolation.py`. It used to live here, and +# it was checked by importing the two command modules *directly*: that +# bypassed `cli.py`, which imported `commands.check_passphrase` and +# `commands.session` at module scope and so pulled `cryptography` and +# `stackward.store` onto the real gate path with this test still green. The +# replacement runs `cli.main(["check-config", ...])`, the path a `git commit` +# actually takes, and asserts the union of every forbidden module. def test_doctor_reports_the_model_walker_as_readable(capsys): diff --git a/tests/test_pre_commit.py b/tests/test_pre_commit.py index f43bf01..17822a1 100644 --- a/tests/test_pre_commit.py +++ b/tests/test_pre_commit.py @@ -30,10 +30,20 @@ def _git(repo: Path, *args: str) -> subprocess.CompletedProcess: ) +# The smallest policy that satisfies the gate: an explicit `model_net`, which +# is the whole minimum, because it is the one key whose default silently +# drops a net. +MINIMAL_POLICY = '[check]\nmodel_net = "none"\n' + + @pytest.fixture -def repo(tmp_path: Path) -> Path: - """A real, minimal git repository with one initial commit -- so every - `git diff --cached` below has a HEAD to diff against.""" +def bare_repo(tmp_path: Path) -> Path: + """A real git repository with one empty commit and **no** policy file. + + What every repository looked like before `.stackward.toml` was a + refusal; kept as its own fixture so the tests that assert the refusal + cannot be quietly rescued by a fixture that starts declaring one. + """ path = tmp_path / "repo" path.mkdir() _git(path, "init", "-q") @@ -43,6 +53,23 @@ def repo(tmp_path: Path) -> Path: return path +@pytest.fixture +def repo(bare_repo: Path) -> Path: + """`bare_repo` with a minimal `.stackward.toml` **committed**. + + Committed, not merely written: this command reads its policy out of the + index exactly as it reads the content it scans, so a policy sitting only + in the working tree is not a policy as far as the gate is concerned and + the whole command refuses (exit 2) before reaching whatever the test was + actually about. Every test below that is not itself about policy + discovery therefore needs one in the index. + """ + write(bare_repo, ".stackward.toml", MINIMAL_POLICY) + stage(bare_repo, ".stackward.toml") + _git(bare_repo, "commit", "-q", "-m", "declare policy") + return bare_repo + + def write(repo: Path, name: str, content: str) -> Path: target = repo / name target.parent.mkdir(parents=True, exist_ok=True) @@ -244,16 +271,46 @@ def test_state_export_refusal_needs_no_valid_repo_config(repo, monkeypatch, caps """The outright refusal happens before `.stackward.toml` is even loaded: a state export staged in a repository whose policy file is broken must still exit 1 (refused), never 2 (as the broken config - alone would if a real credential scan needed to run). The broken file - only needs to be on disk -- `find_repo_config` reads the working tree, - not the index -- so it is deliberately not staged here.""" + alone would if a real credential scan needed to run). + + The broken config is *staged* here. It has to be: this command reads + its policy from the index, so an unstaged edit would be invisible to it + and the test would pass without the ordering it claims to check ever + being exercised. Staging it makes the config genuinely unloadable for + this invocation, which is exactly the condition the state-export rule + must not depend on. + """ write(repo, ".stackward.toml", "this is not valid toml [[[") write(repo, "state.json", "{}") - stage(repo, "state.json") + stage(repo, ".stackward.toml", "state.json") + # Premise: this config really would sink the run if policy were loaded + # first -- proved by the companion test below, which stages the same + # broken file without a state export and gets exit 2. assert run_pre_commit(repo, monkeypatch) == 1 assert "state.json" in capsys.readouterr().out +def test_a_broken_staged_config_alone_exits_2(repo, monkeypatch, capsys): + """The companion that makes the ordering test above mean something: the + same broken `.stackward.toml`, staged, with no state export to short- + circuit the run, is a could-not-run refusal. + + Mutating `cmd_pre_commit`'s policy guard to swallow the error and carry + on with `CheckConfig()` defaults survived the whole suite before this + test existed -- a repository could ship a typo in its policy and be + scanned by the weaker net without anyone being told. + """ + write(repo, ".stackward.toml", "this is not valid toml [[[") + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(repo, ".stackward.toml", "Pulumi.dev.yaml") + assert run_pre_commit(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "invalid TOML" in captured.err + # Named the way a person reproduces it: `git show ":.stackward.toml"`. + assert ":.stackward.toml" in captured.err + + # --------------------------------------------------------------------------- # Matching `Pulumi..yaml` by prefix/suffix, not a character class. # --------------------------------------------------------------------------- @@ -489,23 +546,20 @@ def boom(_path, _check, _net): def test_policy_load_failure_exits_2_not_1(repo, monkeypatch, capsys): - """A bare exception from policy loading -- not `ConfigError`, which - `_load_check_policy`'s own caller already handles -- must not escape - `cmd_pre_commit` uncaught. Concretely: `find_repo_config` walks - upward through parent directories with plain `.is_file()`/`.exists()` - calls and no try/except of its own, so a `PermissionError` on a - non-traversable parent would surface as a bare `OSError`. Without the - `@fail_closed` decorator on `cmd_pre_commit`, that would propagate - out of `main()` entirely and exit 1 by Python's own default -- - misreporting "credential found" for an invocation that never got as - far as loading policy, let alone scanning a file.""" + """A bare exception from policy loading -- neither `ConfigError` nor + `CheckError`, which `cmd_pre_commit`'s own guard already handles -- + must not escape uncaught. Without the `@fail_closed` decorator on + `cmd_pre_commit`, it would propagate out of `main()` entirely and exit + 1 by Python's own default -- misreporting "credential found" for an + invocation that never got as far as loading policy, let alone scanning + a file.""" write(repo, "Pulumi.dev.yaml", "name: myproject\n") stage(repo, "Pulumi.dev.yaml") def boom(): - raise OSError("simulated permission error walking parent directories") + raise OSError("simulated failure inside policy discovery") - monkeypatch.setattr(pre_commit_module, "find_repo_config", boom) + monkeypatch.setattr(pre_commit_module, "_index_config_path", boom) assert run_pre_commit(repo, monkeypatch) == 2 captured = capsys.readouterr() assert captured.out == "" @@ -568,3 +622,309 @@ def test_unexpected_argument_is_a_usage_error_exiting_2(): with pytest.raises(SystemExit) as exit_info: main(["pre-commit", "unexpected-argument"]) assert exit_info.value.code == 2 + + +# --------------------------------------------------------------------------- +# The policy comes from the index too, not the working tree. +# +# The gate read *content* from the index and *rules* from the working tree, +# which is a fail-open with a working exploit: relax the rules without +# staging the relaxation and the credential commits under an excuse that is +# not itself committed. Each test below pins one half of the fix, and each +# has a companion proving the knob it uses is a knob at all -- otherwise +# "the relaxation did nothing" would be indistinguishable from "the gate +# ignores this key entirely". +# --------------------------------------------------------------------------- + +CREDENTIAL_DOCUMENT = "config:\n myproject:dbPassword: hunter2\n" +CREDENTIAL_PATH = "config.myproject:dbPassword" + + +def test_a_worktree_only_relaxation_does_not_let_a_staged_credential_through( + repo, monkeypatch, capsys +): + """The exploit, reproduced. A staged credential, then a working-tree + edit to `.stackward.toml` adding an `allowed_references` entry for it, + staging nothing. The commit being made carries the credential and not + the relaxation, so the relaxation must not apply to it.""" + write(repo, "Pulumi.dev.yaml", CREDENTIAL_DOCUMENT) + stage(repo, "Pulumi.dev.yaml") + write( + repo, + ".stackward.toml", + MINIMAL_POLICY + f'allowed_references = ["{CREDENTIAL_PATH}"]\n', + ) + + # Premise: the relaxation really is only in the working tree. + assert " M .stackward.toml" in _git(repo, "status", "--porcelain").stdout + + assert run_pre_commit(repo, monkeypatch) == 1 + out = capsys.readouterr().out + assert f"plaintext credential at '{CREDENTIAL_PATH}'" in out + assert "hunter2" not in out + + +def test_the_same_relaxation_staged_does_take_effect(repo, monkeypatch, capsys): + """The companion. Without it, the test above would pass just as well if + `allowed_references` were ignored altogether -- and then it would be + proving nothing about *where* the policy was read from.""" + write(repo, "Pulumi.dev.yaml", CREDENTIAL_DOCUMENT) + write( + repo, + ".stackward.toml", + MINIMAL_POLICY + f'allowed_references = ["{CREDENTIAL_PATH}"]\n', + ) + stage(repo, "Pulumi.dev.yaml", ".stackward.toml") + + assert run_pre_commit(repo, monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +def test_a_worktree_only_sensitive_parents_shrink_does_not_weaken_the_gate( + repo, monkeypatch, capsys +): + """The second of the three knobs that weaken from the working tree. + `sensitive_parents` REPLACES rather than extends (`config._replace`), so + a worktree edit can shrink what the index declared -- here from a + category that flags the leaf to one that does not.""" + write(repo, ".stackward.toml", MINIMAL_POLICY + 'sensitive_parents = ["vars"]\n') + write(repo, "Pulumi.dev.yaml", "vars:\n DB_USER: admin\n") + stage(repo, ".stackward.toml", "Pulumi.dev.yaml") + _git(repo, "commit", "-q", "-m", "declare a sensitive parent") + + write(repo, "Pulumi.dev.yaml", "vars:\n DB_USER: admin\n# changed\n") + stage(repo, "Pulumi.dev.yaml") + # Shrink the declared category in the working tree only. + write(repo, ".stackward.toml", MINIMAL_POLICY + 'sensitive_parents = ["other"]\n') + + assert run_pre_commit(repo, monkeypatch) == 1 + assert "plaintext credential at 'vars.DB_USER'" in capsys.readouterr().out + + +def test_the_same_shrink_staged_does_take_effect(repo, monkeypatch, capsys): + """Companion to the test above, for the same reason as the pair before + it: proves `sensitive_parents` is a knob, so that "still flagged" above + can only mean "read from the index".""" + write(repo, ".stackward.toml", MINIMAL_POLICY + 'sensitive_parents = ["other"]\n') + write(repo, "Pulumi.dev.yaml", "vars:\n DB_USER: admin\n") + stage(repo, ".stackward.toml", "Pulumi.dev.yaml") + + assert run_pre_commit(repo, monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +def test_a_policy_deleted_from_the_working_tree_still_applies(repo, monkeypatch, capsys): + """The same rule from the other direction: deleting `.stackward.toml` + from the working tree without staging the deletion cannot switch the + gate off. A worktree-reading implementation would find no config at all + here and (before this change) scan under defaults, or (after it) refuse + -- either way, the wrong answer for a commit whose index still declares + a policy.""" + write(repo, ".stackward.toml", MINIMAL_POLICY + 'allowed_references = ["a.b"]\n') + stage(repo, ".stackward.toml") + _git(repo, "commit", "-q", "-m", "declare policy with a reference") + + write(repo, "Pulumi.dev.yaml", "config:\n a:\n b: not-a-credential\n") + stage(repo, "Pulumi.dev.yaml") + (repo / ".stackward.toml").unlink() + + assert run_pre_commit(repo, monkeypatch) == 0 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + +# --------------------------------------------------------------------------- +# A missing policy file is a refusal, not a skip (Global Constraint 3). +# --------------------------------------------------------------------------- + + +def test_no_policy_in_the_index_is_a_refusal_naming_the_file( + bare_repo, monkeypatch, capsys +): + """Defaulting to `CheckConfig()` would make `model_net = "none"` -- a + mode the plan requires to be *declared* -- a silent fallback, and would + scan a repository under rules nobody chose. Exit 2, and the message has + to name the minimal file, or the refusal is not actionable.""" + write(bare_repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(bare_repo, "Pulumi.dev.yaml") + + assert run_pre_commit(bare_repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert ".stackward.toml" in captured.err + assert "model_net" in captured.err + + +def test_a_staged_credential_is_refused_not_reported_when_there_is_no_policy( + bare_repo, monkeypatch, capsys +): + """Exit 2, not 1, even though the heuristic net alone would have found + this leaf. The command does not know what the repository considers + sensitive, so it cannot claim a complete answer -- reporting one net's + findings and exiting 1 would announce exactly that.""" + write(bare_repo, "Pulumi.dev.yaml", CREDENTIAL_DOCUMENT) + stage(bare_repo, "Pulumi.dev.yaml") + + assert run_pre_commit(bare_repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "hunter2" not in captured.err + + +def test_a_policy_only_in_the_working_tree_is_not_a_policy( + bare_repo, monkeypatch, capsys +): + """Written but never staged. The same rule this command applies to + every stack config it scans, applied to the file that says how to scan + them: if it is not in the index, it is not part of this commit.""" + write(bare_repo, ".stackward.toml", MINIMAL_POLICY) + write(bare_repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(bare_repo, "Pulumi.dev.yaml") + + assert run_pre_commit(bare_repo, monkeypatch) == 2 + err = capsys.readouterr().err + assert ".stackward.toml" in err + # The fix is nameable: stage it. + assert "git add" in err + + +def test_staging_the_policy_is_enough_it_need_not_be_committed( + bare_repo, monkeypatch, capsys +): + """The index, not HEAD. A repository declaring its policy in the very + commit it is making must pass -- otherwise the first commit that adds a + policy could never be made.""" + write(bare_repo, ".stackward.toml", MINIMAL_POLICY) + write(bare_repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(bare_repo, ".stackward.toml", "Pulumi.dev.yaml") + + assert run_pre_commit(bare_repo, monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +# --------------------------------------------------------------------------- +# Policy discovery walks upward, in the index, the way `find_repo_config` +# walks upward in the working tree. +# --------------------------------------------------------------------------- + + +def test_the_root_policy_applies_when_invoked_from_a_subdirectory( + repo, monkeypatch, capsys +): + """`git ls-files`' pathspecs resolve against the caller's directory, not + the repository root, so a discovery that forgot `:(top)` would look for + `deploy/.stackward.toml` from inside `deploy/` and find nothing.""" + write(repo, "deploy/Pulumi.dev.yaml", CREDENTIAL_DOCUMENT) + stage(repo, "deploy/Pulumi.dev.yaml") + + assert run_pre_commit(repo / "deploy", monkeypatch) == 1 + assert f"plaintext credential at '{CREDENTIAL_PATH}'" in capsys.readouterr().out + + +def test_the_nearest_policy_wins_over_the_repository_root_one( + repo, monkeypatch, capsys +): + """Same precedence as `config.find_repo_config`'s working-tree walk: the + file beside the caller beats the one at the root. Here the nested policy + allows the reference the root policy does not.""" + write( + repo, + "deploy/.stackward.toml", + MINIMAL_POLICY + f'allowed_references = ["{CREDENTIAL_PATH}"]\n', + ) + write(repo, "deploy/Pulumi.dev.yaml", CREDENTIAL_DOCUMENT) + stage(repo, "deploy/.stackward.toml", "deploy/Pulumi.dev.yaml") + + # From the root, the root policy applies and the leaf is a finding. + assert run_pre_commit(repo, monkeypatch) == 1 + capsys.readouterr() + # From the subdirectory, the nearer policy applies. + assert run_pre_commit(repo / "deploy", monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +def test_discovery_survives_a_directory_named_with_glob_characters( + repo, monkeypatch, capsys +): + """A directory name is not something this tool gets to constrain, and + `[`, `*` and `?` are all pathspec metacharacters. + + Note what this does *not* prove: `:(literal)` is not what saves it. + Verified directly against git -- a pathspec is compared literally before + wildmatch is tried, so `cache[1]*/.stackward.toml` matches its own + directory under `:(literal)`, `:(glob)` and no magic alike. What makes + the metacharacters harmless is that `_index_config_path` accepts a + candidate only by exact string equality, never by taking whatever git + printed -- and that is what this goes red for (proved by replacing the + match loop with `sorted(present)[0]`).""" + subdir = "cache[1]*" + write( + repo, + f"{subdir}/.stackward.toml", + MINIMAL_POLICY + f'allowed_references = ["{CREDENTIAL_PATH}"]\n', + ) + write(repo, f"{subdir}/Pulumi.dev.yaml", CREDENTIAL_DOCUMENT) + stage(repo, f"{subdir}/.stackward.toml", f"{subdir}/Pulumi.dev.yaml") + + assert run_pre_commit(repo / subdir, monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +def test_discovery_survives_a_non_ascii_directory_name(repo, monkeypatch, capsys): + """`git rev-parse --show-prefix` is what says where the caller is + standing, and its bytes are the path's own -- not `core.quotePath` + escapes. A discovery that mis-decoded it would silently fall back to the + repository root's policy, which here would report a finding.""" + subdir = "déploy" + write( + repo, + f"{subdir}/.stackward.toml", + MINIMAL_POLICY + f'allowed_references = ["{CREDENTIAL_PATH}"]\n', + ) + write(repo, f"{subdir}/Pulumi.dev.yaml", CREDENTIAL_DOCUMENT) + stage(repo, f"{subdir}/.stackward.toml", f"{subdir}/Pulumi.dev.yaml") + + assert run_pre_commit(repo / subdir, monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +def test_git_runs_under_a_fixed_locale(repo, monkeypatch): + """Every git invocation gets `LC_ALL=C`, with the environment merged + rather than replaced. + + Nothing in this module branches on git's message text, so this is + hardening rather than a live fix here -- but git's diagnostics are + gettext-marked, and a message quoted verbatim into a `CheckError` should + read the same in a bug report as it did on the machine that hit it. + (`commands.install_hooks._is_tracked` *does* branch on translated text, + and is a live bug under a non-English locale; it is not this wave's + file.) + + The merge half matters more than the override: git sets `GIT_INDEX_FILE` + and `GIT_DIR` for a hook process, and passing a bare `env={"LC_ALL": + "C"}` would make this command read a different index than the commit it + is gating -- a fail-open that no output assertion elsewhere would catch. + """ + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(repo, "Pulumi.dev.yaml") + + # Recording starts only now: this file's own `_git` helper shells out to + # git too, and it is not what is under test. + monkeypatch.setenv("STACKWARD_TEST_MARKER", "inherited") + seen: list[dict[str, str] | None] = [] + real_run = subprocess.run + + def recording_run(args, **kwargs): + if args and args[0] == "git": + seen.append(kwargs.get("env")) + return real_run(args, **kwargs) + + monkeypatch.setattr(subprocess, "run", recording_run) + assert run_pre_commit(repo, monkeypatch) == 0 + + assert seen, "no git invocation was recorded" + for env in seen: + assert env is not None, "a git invocation inherited the ambient locale" + assert env.get("LC_ALL") == "C" + assert env.get("STACKWARD_TEST_MARKER") == "inherited" diff --git a/tests/test_providers.py b/tests/test_providers.py index e7431f4..ce51812 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -688,58 +688,16 @@ def test_exec_reports_a_failing_command_credential_store_rather_than_running_wit # --------------------------------------------------------------------------- -def test_the_check_config_entry_point_never_loads_a_provider_module(): - """The brief's own literal requirement: importing `check-config`'s - module must never bring in a `stackward.providers*` module. Checked in a - subprocess -- this test process has already imported half the tool via - other test modules in the same session, so asserting against its own - `sys.modules` would prove nothing. Enumerated by prefix, not a fixed - tuple, so a provider module added later is covered without editing this - test. - """ - proc = subprocess.run( - [ - sys.executable, - "-c", - "import sys; " - "import stackward.commands.check_config; " - "import stackward.commands.pre_commit; " - "print(sorted(m for m in sys.modules if m == 'stackward.providers' " - "or m.startswith('stackward.providers.')))", - ], - capture_output=True, - text=True, - check=True, - ) - assert proc.stdout.strip() == "[]" - - -def test_running_check_config_through_the_real_cli_never_loads_a_provider_module(tmp_path): - """The stronger form: `cli.py` imports `commands.session` and - `commands.set_secrets` at its own module scope, unconditionally, so a - module-scope provider import in *either* of those (rather than the - function-local imports this task actually uses) would make a plain - `stackward check-config` reach provider code even though the weaker, - literal test above stays green. This runs the real dispatch path - (`cli.main`) a `git commit` actually takes, not just a bare module - import, so it would catch that regression where the weaker form cannot. - """ - target = tmp_path / "config.yaml" - target.write_text("key: value\n") - proc = subprocess.run( - [ - sys.executable, - "-c", - "import sys; from stackward.cli import main; " - f"main(['check-config', {str(target)!r}]); " - "print(sorted(m for m in sys.modules if m == 'stackward.providers' " - "or m.startswith('stackward.providers.')))", - ], - capture_output=True, - text=True, - check=True, - ) - assert proc.stdout.strip().splitlines()[-1] == "[]" +# The two tests that used to live here -- "importing `check-config`'s module +# loads no `stackward.providers*`" and its stronger form through `cli.main` +# -- are now one test in `tests/test_gate_isolation.py`. Splitting the +# property in two is what hid the real defect: this file went through +# `cli.main` but grepped only for `stackward.providers*`, while +# `tests/test_model_net.py` looked for `cryptography` and `stackward.store` +# but imported the command modules directly, bypassing `cli.py` -- which was +# where `cryptography` was reaching the gate path all along. One assertion +# over the union of every forbidden module, taken through the real dispatch +# path, is the only shape that could have caught it. # --------------------------------------------------------------------------- From f249c405de4c045ef978575220a303d8402e77ba Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 15:55:32 -0700 Subject: [PATCH 32/44] docs: scope the LC_ALL assertion to the call site this wave owns `test_git_runs_under_a_fixed_locale` loops over every git invocation the command makes, which holds only because the `repo` fixture declares `model_net = "none"`. Under `model_net = "artifact"`, `nets.model.run_git` runs too and sets no locale -- a second call site with the same gap, in a file this wave does not own. Left as a whole-command assertion on purpose (it is the property that matters), but a fixture change would otherwise produce a failure in this test pointing at a file nobody was told about. `install_hooks._is_tracked` is a third call site, and there the missing locale is a live bug rather than hardening: it branches on git's translated strings, so under a non-English locale `hooks install` exits 2 everywhere. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- tests/test_pre_commit.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/test_pre_commit.py b/tests/test_pre_commit.py index 17822a1..33a605b 100644 --- a/tests/test_pre_commit.py +++ b/tests/test_pre_commit.py @@ -897,9 +897,16 @@ def test_git_runs_under_a_fixed_locale(repo, monkeypatch): hardening rather than a live fix here -- but git's diagnostics are gettext-marked, and a message quoted verbatim into a `CheckError` should read the same in a bug report as it did on the machine that hit it. - (`commands.install_hooks._is_tracked` *does* branch on translated text, - and is a live bug under a non-English locale; it is not this wave's - file.) + + Scope, so this does not become a trap for whoever changes the fixture + next: the loop covers every git invocation the command makes, and it + holds only because `repo` declares `model_net = "none"`. Under + `model_net = "artifact"`, `nets.model.run_git` also runs, and it sets no + locale -- a second call site with the same gap, in a file this wave does + not own. `commands.install_hooks._is_tracked` is a third, and there it + is a live bug rather than hardening: it branches on git's *translated* + strings, so under a non-English locale it raises and `hooks install` + exits 2 in every repository. The merge half matters more than the override: git sets `GIT_INDEX_FILE` and `GIT_DIR` for a hook process, and passing a bare `env={"LC_ALL": From 2b86044bd707b199b1bdd10f5d515d074021a010 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 15:57:55 -0700 Subject: [PATCH 33/44] test: one leak assertion that sees partial and re-encoded disclosure Every leak test on this branch used `assert value not in text`. The final review showed the boundary precisely: {value!r} in a message is caught, because repr embeds the whole string, but value[:8] in an error message survives all 746 tests -- as does the value base64-encoded. A whole-string containment check cannot see a partial disclosure, and a partial disclosure of a credential is still a disclosure. assert_no_leak checks the value, every 8-character run of it, and the encodings this codebase actually emits. It reports the encoding and offset and never the value, so the check cannot itself become the leak. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- tests/leakcheck.py | 79 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/leakcheck.py diff --git a/tests/leakcheck.py b/tests/leakcheck.py new file mode 100644 index 0000000..632d37d --- /dev/null +++ b/tests/leakcheck.py @@ -0,0 +1,79 @@ +"""One assertion for "this text does not disclose that credential". + +Every leak test on this branch was written as `assert value not in text`. +The final review showed what that idiom cannot see: `{value!r}` is caught +(repr embeds the whole string), but `value[:8]` in an error message, or the +value base64-encoded into a payload, passes a whole-string `in` check while +disclosing the credential just as completely. A partial disclosure is still +a disclosure -- an attacker who learns the first eight characters of a +passphrase has had the search space cut by orders of magnitude. + +So the check is: the value, any run of it long enough to be identifying, +and the encodings it plausibly passes through on its way to an output +stream. +""" + +from __future__ import annotations + +import base64 +import urllib.parse + +# Shorter than this and a "leak" is more likely a coincidental collision +# with ordinary output than a disclosure -- a 4-character run of a +# hex-ish credential appears in unrelated text often enough to make the +# assertion flaky, which is how a leak check gets deleted. +MIN_IDENTIFYING_RUN = 8 + + +def _encodings(value: str) -> dict[str, str]: + """The forms a credential can reach an output stream in. + + Not exhaustive -- it cannot be. These are the ones this codebase + actually produces: raw, what `json`/`repr` emit, what a URL carrying + userinfo emits, and what a serialised envelope emits. + """ + raw = value.encode("utf-8") + return { + "raw": value, + "base64": base64.b64encode(raw).decode("ascii"), + "base64-unpadded": base64.b64encode(raw).decode("ascii").rstrip("="), + "hex": raw.hex(), + "urlquote": urllib.parse.quote(value, safe=""), + "urlquote-plus": urllib.parse.quote_plus(value), + } + + +def assert_no_leak(text: str, value: str, *, what: str = "value") -> None: + """Fail if `text` discloses `value` whole, in part, or re-encoded. + + `what` names the thing in the failure message. The message never + contains `value` itself -- a leak check that prints the credential to + prove the credential leaked would be its own bug (Global Constraint 4). + It reports the encoding and the offset, which is enough to find it. + """ + if not value: + raise ValueError( + "assert_no_leak called with an empty value: it would pass " + "against any text and prove nothing" + ) + + for name, encoded in _encodings(value).items(): + if encoded and encoded in text: + raise AssertionError( + f"{what} leaked into output, {name}-encoded, in full " + f"(at offset {text.index(encoded)})" + ) + + # Partial disclosure: any identifying-length run of the raw value. + # Only the raw form -- a run of a base64 encoding is not recoverable + # without its alignment, and checking it produces false positives. + for start in range(0, max(1, len(value) - MIN_IDENTIFYING_RUN + 1)): + run = value[start : start + MIN_IDENTIFYING_RUN] + if len(run) < MIN_IDENTIFYING_RUN: + break + if run in text: + raise AssertionError( + f"{what} partially leaked into output: a " + f"{MIN_IDENTIFYING_RUN}-character run starting at index " + f"{start} of the value appears at offset {text.index(run)}" + ) From 9e9c6cd01601dac7e39cc6d2aa3203da37ba0318 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 16:12:48 -0700 Subject: [PATCH 34/44] test: execute the path grammar's refusals, and fix the model net's locale Six ValueError branches in `paths.py` had never run in any test, and `set_secrets.parse_project_manifest` wraps `paths.parse` in its own `try`, so none of them could surface as a traceback either. A refusal that has never executed is where a fail-open hides. Each of the six now has a case asserting the position and the wording, and that the offending text stays out of the message. `nets.model.run_git` was the last git call site with no `LC_ALL=C`. The environment is merged rather than replaced: git exports `GIT_DIR` and `GIT_INDEX_FILE` to a hook process, and this helper is what reads the index the commit is being gated on. `tests/test_providers.py` derived its three-provider contract `expected` from one of the three captured environments, so a uniform mutation in the shared `_child_env` satisfied it as well as correct behaviour did; it is now anchored to the seeded constants. Its `import subprocess` went unused when the gate-path test moved to `tests/test_gate_isolation.py`. The plan's adversarial "a marked map whose only key is literally `secure`" case is now named as an accepted false negative rather than left looking untested. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/nets/model.py | 21 +++++++++- tests/test_model_net.py | 81 +++++++++++++++++++++++++++++++++++++ tests/test_paths.py | 51 +++++++++++++++++++++++ tests/test_providers.py | 22 +++++++--- 4 files changed, 168 insertions(+), 7 deletions(-) diff --git a/src/stackward/nets/model.py b/src/stackward/nets/model.py index 402c8c9..25a67a3 100644 --- a/src/stackward/nets/model.py +++ b/src/stackward/nets/model.py @@ -77,6 +77,7 @@ class Node(BaseModel): from __future__ import annotations import json +import os import subprocess from dataclasses import dataclass from pathlib import Path @@ -517,9 +518,27 @@ def run_git(args: list[str]) -> bytes: only invocations made through this helper are `rev-parse`, `ls-files` and `show` of the artifact path, and a *failing* run of any of them has not produced blob content to leak. + + Runs under `LC_ALL=C`, like `commands.pre_commit`'s own git calls. Nothing + here branches on git's message text — every caller branches on + `returncode` — so this is hardening rather than a live fix, but a stderr + line quoted verbatim into a `ModelNetError` should read the same in a bug + report as it did on the machine that hit it. + + The environment is **merged**, never replaced. git exports `GIT_DIR` and + `GIT_INDEX_FILE` to a hook process, and this helper is what reads the + index the commit is actually being gated on; a bare `env={"LC_ALL": "C"}` + would silently point every `ls-files` and `show` here at a different index + than `pre-commit` is checking — a fail-open no output assertion would + catch. """ try: - proc = subprocess.run(["git", *args], capture_output=True, check=False) + proc = subprocess.run( + ["git", *args], + capture_output=True, + check=False, + env={**os.environ, "LC_ALL": "C"}, + ) except OSError as exc: raise ModelNetError(f"cannot run git {' '.join(args)}: {exc}") from exc if proc.returncode != 0: diff --git a/tests/test_model_net.py b/tests/test_model_net.py index 89a1664..5e2c48d 100644 --- a/tests/test_model_net.py +++ b/tests/test_model_net.py @@ -251,6 +251,30 @@ def test_a_declared_field_holding_an_encryption_envelope_is_not_reported(): assert match(document, SELF_REFERENTIAL) == [] +def test_a_marked_map_whose_only_key_is_literally_secure_is_an_accepted_false_negative(): + """The adversarial case the plan names, pinned as **accepted**, not fixed. + + A declared field may legitimately hold a mapping whose single key happens + to be the word `secure` and whose value is an ordinary plaintext string. + That is byte-for-byte the shape of Pulumi's encryption envelope, so + `heuristic.is_encrypted` suppresses it and the credential goes unreported. + + This is a deliberate trade, not an oversight. The alternative is to decide + what a ciphertext looks like -- a version prefix, a length, an alphabet -- + and every such rule reports *genuinely encrypted* values as findings the + day Pulumi changes its envelope format. A gate that fires on values it has + already succeeded in protecting is a gate that gets switched off, which + costs more than this one blind spot does. + + So the test exists to say that out loud, and to fail if someone + "fixes" it: tightening `is_encrypted` to inspect the wrapped value is + exactly the change this docstring rules out, and the sibling test above + (whose value reads like a real ciphertext) would not notice it. + """ + document = {"config": {"app:app": {"token": {"secure": "not-really-encrypted"}}}} + assert match(document, SELF_REFERENTIAL) == [] + + def test_a_malformed_encryption_envelope_is_still_reported(): """A sibling key beside `secure` means the mapping is not the envelope, and nothing tells a reader the sibling was not the value that leaked.""" @@ -1839,6 +1863,63 @@ def test_the_hook_reports_a_leaf_both_nets_name_once(repo, monkeypatch, capsys): assert len(lines) == 1 +# --------------------------------------------------------------------------- +# Talking to git. +# --------------------------------------------------------------------------- + + +def test_every_git_invocation_runs_under_a_fixed_locale(repo, monkeypatch, capsys): + """`nets.model.run_git` is the third git call site in this tool, and the + last one without a fixed locale -- `commands.pre_commit` and + `commands.install_hooks` have theirs. + + Hardening rather than a live fix here: every caller in this module + branches on `returncode`, never on git's text. But git's diagnostics are + gettext-marked, and this module quotes git's stderr verbatim into a + `ModelNetError` a reader is expected to act on. + + **The merge is the half that matters.** git exports `GIT_DIR` and + `GIT_INDEX_FILE` to a hook process, and this module's `ls-files`/`show` + calls are what read the index the commit is being gated on. A bare + `env={"LC_ALL": "C"}` would silently point them at a different index than + `pre-commit` is checking -- a fail-open that no assertion about output + could catch, which is why the ambient marker is asserted alongside the + locale. + + Driven through `check-config` under `model_net = "artifact"` rather than + by calling `run_git` directly, so it covers every invocation this module + makes -- `rev-parse`, `ls-files` and `show` -- rather than the one a + direct call happens to pick. `check-config` shells out to nothing but + git, so every recorded call came from this module. + """ + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + sync_and_commit(repo, monkeypatch, capsys) + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + + # Recording starts only now: this file's own `git` helper shells out too, + # and it is not what is under test. + monkeypatch.setenv("STACKWARD_TEST_MARKER", "inherited") + seen: list[dict[str, str] | None] = [] + real_run = subprocess.run + + def recording_run(args, **kwargs): + if args and args[0] == "git": + seen.append(kwargs.get("env")) + return real_run(args, **kwargs) + + monkeypatch.setattr(subprocess, "run", recording_run) + assert check(repo, monkeypatch) == 0 + capsys.readouterr() + + assert seen, "no git invocation was recorded" + for env in seen: + assert env is not None, "a git invocation inherited the ambient locale" + assert env.get("LC_ALL") == "C" + assert env.get("STACKWARD_TEST_MARKER") == "inherited" + + # --------------------------------------------------------------------------- # Global Constraint 1: pydantic is test-only. # --------------------------------------------------------------------------- diff --git a/tests/test_paths.py b/tests/test_paths.py index e985cb0..5048d52 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -227,3 +227,54 @@ def test_invalid_escape_sequence_raises_value_error_naming_the_position(): message = str(exc_info.value) assert "position 4" in message # the stray backslash itself assert "b\\nc" not in message + + +# --------------------------------------------------------------------------- +# The grammar's refusals. Every branch below is a `ValueError` that no other +# test in this file reaches, and `set_secrets.parse_project_manifest` wraps +# `parse` in its own `try`, so none of them can ever surface as a traceback a +# reader would notice. A refusal that has never executed is where a fail-open +# hides: the branch could have been deleted, or could raise something the +# caller does not catch, and the suite would stay green either way. +# +# Each case asserts the position as well as the wording, because the position +# is what makes a refusal actionable, and asserts the offending text is absent +# from the message, which is the promise `parse`'s own docstring makes. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("text", "position", "fragment"), + [ + # A path may not begin with the separator: `.a` names no first segment. + (".a", 0, "cannot start with"), + # A bare key may not contain a character that only means something + # inside the bracket form -- here the closing bracket itself. + ("a]b", 1, "unexpected character in key"), + # A quoted key whose closing `"` never arrives. + ('a["b', 3, "unterminated quoted key"), + # A quoted key that closes, followed by anything other than `]`. + ('a["b"c', 5, "expected ']' after quoted key"), + # A bare character resuming straight after a bracket, with no + # separator -- the `b` in `a[0]b`. + ("a[0]b", 4, "expected '.' or '['"), + ], +) +def test_a_malformed_path_is_refused_naming_the_position(text, position, fragment): + with pytest.raises(ValueError) as exc_info: + parse(text) + message = str(exc_info.value) + assert f"position {position}" in message + assert fragment in message + assert text not in message + + +def test_the_empty_path_is_refused(): + """Its own test rather than a parametrised case: there is no offending + text to assert the absence of, and `""` is a substring of every string, + so the shared `text not in message` assertion would be vacuous for it.""" + with pytest.raises(ValueError) as exc_info: + parse("") + message = str(exc_info.value) + assert "position 0" in message + assert "empty path" in message diff --git a/tests/test_providers.py b/tests/test_providers.py index ce51812..fdb08d5 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -29,7 +29,6 @@ from __future__ import annotations import json -import subprocess import sys import textwrap from pathlib import Path @@ -68,6 +67,11 @@ MARKER_DB_PASSWORD = "marker-db-password-1a2b3c4d" MARKER_API_TOKEN = "marker-api-token-5e6f7a8b" +# The one backend URL every profile in the three-provider contract test +# carries. Named so the contract assertion can be anchored to it rather than +# to whichever environment happened to be captured first. +STAGING_BACKEND_URL = "file:///staging-backend" + FULL_CREDENTIALS = { AWS_ACCESS_KEY_ID: MARKER_KEY, AWS_SECRET_ACCESS_KEY: MARKER_SECRET, @@ -826,11 +830,11 @@ def test_file_and_a_fake_remote_produce_identical_child_environments( command_toml = json.dumps(remote_command(remote_stub)) write_store_config( store_dir, - '[profile.staging-file]\nbackend_url = "file:///staging-backend"\n\n' - '[profile.staging-remote]\nbackend_url = "file:///staging-backend"\n\n' + f'[profile.staging-file]\nbackend_url = "{STAGING_BACKEND_URL}"\n\n' + f'[profile.staging-remote]\nbackend_url = "{STAGING_BACKEND_URL}"\n\n' '[profile.staging-remote.credentials]\n' f'provider = "command"\ncommand = {command_toml}\n\n' - '[profile.staging-env]\nbackend_url = "file:///staging-backend"\n\n' + f'[profile.staging-env]\nbackend_url = "{STAGING_BACKEND_URL}"\n\n' '[profile.staging-env.credentials]\nprovider = "env"\n', ) store.init_store(PASSWORD, directory=store_dir) @@ -858,8 +862,14 @@ def test_file_and_a_fake_remote_produce_identical_child_environments( assert main(["exec", "--profile", "staging-env", "--", str(env_stub)]) == 0 env_c = json.loads(dump_c.read_text()) + # Anchored to the seeded constants, not to `env_a`. Deriving `expected` + # from one of the three environments makes the assertion say only "all + # three agree", which a uniform mutation in the shared `_child_env` -- + # dropping a name, or injecting the wrong one under every provider -- + # satisfies just as well as the correct behaviour does. Naming the values + # is what makes this test say what it means. relevant = (*CREDENTIAL_NAMES, PULUMI_BACKEND_URL) - expected = {key: env_a[key] for key in relevant} + expected = {**FULL_CREDENTIALS, PULUMI_BACKEND_URL: STAGING_BACKEND_URL} + assert {key: env_a[key] for key in relevant} == expected assert {key: env_b[key] for key in relevant} == expected assert {key: env_c[key] for key in relevant} == expected - assert env_a[AWS_ACCESS_KEY_ID] == MARKER_KEY From 429e4c2908fa75797918ec358111a95650bf413a Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 16:20:34 -0700 Subject: [PATCH 35/44] test: prove the stdin-not-argv rule for every shape, and see partial leaks "Values reach the CLI on stdin, never argv" was proved for exactly one invocation shape: one `secret` entry, no `--stack`. Appending the value to argv under `--stack` -- the production shape for any rotation -- or under `plaintext`, which shares `_pulumi_config_set`, passed all 781 tests. The assertion is now a helper every argv-dumping test reads its calls through, and the test is parametrised over both tables and both stack shapes. Every "no value printed" assertion was a whole-string `not in`, which sees `{value!r}` but not `value[:8]` and not a base64 encoding. They now go through `leakcheck.assert_no_leak`. The markers had to be respelled: the old `marker-db-password-...` shares the eight-character run `password` with the config path `db.password` those same tests print, so the stricter check would have fired on output that disclosed nothing. `parse_env_file` tolerated an inline-`#` truncation, a split at the last `=`, and the loss of either half of the quote anchoring, because no test anywhere wrote a value containing `#` or `=` -- both ordinary in a credential. A scalar where the `secret` table belongs produced `0 set, 0 skipped, 0 failed` and exit 0 when its raise was removed. The summary line's counts were asserted nowhere. `resolve_source_files` re-checked that `[secrets.source]` is a table after its only caller had already refused a non-table; the copy could never execute. Removed, with the reason recorded so it is not helpfully re-added. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/commands/set_secrets.py | 14 +- tests/test_set_secrets.py | 305 ++++++++++++++++++++++---- 2 files changed, 267 insertions(+), 52 deletions(-) diff --git a/src/stackward/commands/set_secrets.py b/src/stackward/commands/set_secrets.py index 9da4f0b..131026f 100644 --- a/src/stackward/commands/set_secrets.py +++ b/src/stackward/commands/set_secrets.py @@ -404,11 +404,17 @@ def resolve_source_files(config: Config, repo_root: Path, stack: str | None) -> against `repo_root` when not already absolute. A missing `[secrets.source]` table, or one with no `files`, resolves to an empty list — value resolution then falls back to the process environment alone, which is a - normal, working configuration, not an error.""" + normal, working configuration, not an error. + + Deliberately does **not** re-check that `[secrets.source]` is a table. + `_build_secret_source` is this function's only caller and refuses a + non-table `source` before it ever gets here, so a second copy of that + check could never execute — and a refusal that cannot execute is worse + than none: it reads as a live guard, so the next reader trusts it and + stops looking for the real one. Said here rather than left as an absence, + because an absent guard is exactly what gets helpfully re-added. + """ source = config.secrets.get("source", {}) - if not isinstance(source, dict): - raise SetSecretsError("secrets.source must be a table") - raw_files = source.get("files", []) if not isinstance(raw_files, list) or not all(isinstance(f, str) for f in raw_files): raise SetSecretsError("secrets.source.files must be a list of strings") diff --git a/tests/test_set_secrets.py b/tests/test_set_secrets.py index e5e7cc2..027c987 100644 --- a/tests/test_set_secrets.py +++ b/tests/test_set_secrets.py @@ -50,12 +50,42 @@ ) from stackward.config import Config, find_repo_config, load_config -# Placeholder values only -- see GC4 in the task brief. Distinctive enough -# that an accidental substring match elsewhere in captured output would be -# implausible. -MARKER_DB_PASSWORD = "marker-db-password-1a2b3c4d" -MARKER_PUBLISHED = "marker-published-value-9c0d1e2f" -MARKER_BOOTSTRAP = "marker-bootstrap-value-3f4e5d6c" +from leakcheck import assert_no_leak + +# Placeholder values only -- see GC4 in the task brief. +# +# They read as noise on purpose. The leak assertions below go through +# `leakcheck.assert_no_leak`, which fails on *any* eight-character run of the +# value as well as on the whole string, so a marker sharing an eight-character +# run with something the command legitimately prints -- a config path, a +# logical name, a `tmp_path` component -- would fail on output that disclosed +# nothing. The previous spelling, `marker-db-password-...`, shared the run +# `password` with the config path `db.password` that every one of these tests +# prints, which is exactly that false positive; and a leak check that cries +# wolf is a leak check that gets deleted. +# +# No two of these share an eight-character run with each other either, so a +# test asserting one marker's absence cannot fire on another marker's +# legitimate presence. +MARKER_DB_PASSWORD = "qz7m4x-1f8b3d9k-6t2v5r0w" +MARKER_PUBLISHED = "hj9c5n-2p6y8s4g-7l1e3a0u" +MARKER_BOOTSTRAP = "wd3r7v-5k9z1q6h-8n4j2c0m" +# The value a `plaintext`-table entry carries. Spelled opaquely for the same +# reason as the markers above: the old literal shared the run `plaintext` with +# the `--plaintext` flag the same argv legitimately carries. +MARKER_PLAINTEXT = "np4b8k-2z6r1x9d-5c7h3j0t" + +# One per parameter case of the stdin/argv test below, so a leak under one +# invocation shape cannot be excused by a match against another shape's value. +# Opaque rather than descriptive for the reason above: a marker spelling out +# its own case (`case-plaintext-no-stack-...`) shares the run `plaintext` with +# the `--plaintext` flag that same argv legitimately carries. +ARGV_CASE_MARKERS = { + ("secret", "no-stack"): "b4k9w2-7t5m1x8r", + ("secret", "with-stack"): "c6p3z8-2h9v4n1s", + ("plaintext", "no-stack"): "d1r7y5-8j2c6q0f", + ("plaintext", "with-stack"): "e9s2u4-3g7l5b1k", +} # --------------------------------------------------------------------------- @@ -169,6 +199,36 @@ def load_repo_config(repo: Path) -> Config: return load_config(path) +def recorded_pulumi_calls(argv_dump: Path, *values: str) -> list[list[str]]: + """Every `pulumi` invocation the stub recorded -- having first asserted + that none of `values` appears in any argument of any of them. + + Global Constraint 6 ("values reach external commands on stdin, never + argv") used to be proved for exactly one invocation shape: one test, one + `secret` entry, no `--stack`. `--stack` is the production shape for any + real rotation, and `plaintext` goes through the same + `_pulumi_config_set` code path, so appending the value to argv under + either of those conditions passed the whole suite. Every test in this + file that dumps argv now reads it back through this function, so the + absence assertion cannot be forgotten by the next test that dumps argv + for some unrelated reason. + + `assert_no_leak`, not `value not in arg`: a truncated or re-encoded value + in `ps` output discloses the credential just as completely as the whole + string does. Checked against the joined argv as well as each argument + separately -- the join is what would catch a value split across two + arguments, which `ps` renders back as one line anyway. + """ + calls = [json.loads(line) for line in argv_dump.read_text().splitlines()] + assert calls, "no pulumi invocation was recorded" + for call in calls: + for value in values: + assert_no_leak(" ".join(call), value, what="the resolved value") + for arg in call: + assert_no_leak(arg, value, what="the resolved value") + return calls + + # --------------------------------------------------------------------------- # `parse_env_file` # --------------------------------------------------------------------------- @@ -205,6 +265,65 @@ def test_parse_env_file_only_strips_one_matched_pair(tmp_path): assert parse_env_file(path) == {"DB_PASSWORD": '"nested"'} +def test_parse_env_file_keeps_a_hash_inside_a_value(tmp_path): + """`#` opens a comment only at the *start* of a line. + + No test anywhere wrote a value containing one, so truncating at an inline + `#` -- the behaviour several `.env` parsers actually have -- passed the + whole suite. A `#` is ordinary in a generated credential, and truncating + at it publishes a different, shorter value that looks entirely correct in + every log and every `pulumi config` listing: the failure only ever + surfaces as an authentication error somewhere else, later. + """ + path = tmp_path / ".env" + value = "p4x#z9q#mk2" + path.write_text(f"DB_PASSWORD={value}\n") + path.chmod(0o600) + assert parse_env_file(path) == {"DB_PASSWORD": value} + + +def test_parse_env_file_splits_at_the_first_equals_only(tmp_path): + """A value may contain `=`, and everything after the first one is the + value. + + Also untested until now, and equally ordinary: base64 padding ends in + `=`, and a connection string or a query-shaped token carries them + throughout. Splitting at the last `=` instead would move part of the + value into the key, so the name would silently resolve to nothing and the + entry would be reported as a clean skip. + """ + path = tmp_path / ".env" + value = "p4x=z9q==" + path.write_text(f"DB_PASSWORD={value}\n") + path.chmod(0o600) + assert parse_env_file(path) == {"DB_PASSWORD": value} + + +@pytest.mark.parametrize( + "value", + [ + '"p4xz9q', # an opening quote with nothing closing it + 'p4xz9q"', # a closing quote with nothing opening it + "\'p4xz9q\"", # two quote characters, but not a matched pair + "xp4xz9qx", # a matched pair -- of something that is not a quote + ], +) +def test_parse_env_file_strips_quotes_only_as_an_anchored_matched_pair(tmp_path, value): + """Both halves of the anchoring, each on its own: the two characters must + be *equal*, and they must be *quotes*. + + Dropping either half silently eats the first and last character of a + value that was never quoted -- which, for a credential, means publishing + something that is wrong by two characters and looks right everywhere. + Each case here fails under exactly one of the two weakenings and passes + under the correct rule. + """ + path = tmp_path / ".env" + path.write_text(f"DB_PASSWORD={value}\n") + path.chmod(0o600) + assert parse_env_file(path) == {"DB_PASSWORD": value} + + def test_parse_env_file_warns_on_a_permissive_mode(tmp_path, capfd): path = tmp_path / ".env" path.write_text(f"DB_PASSWORD={MARKER_DB_PASSWORD}\n") @@ -213,7 +332,7 @@ def test_parse_env_file_warns_on_a_permissive_mode(tmp_path, capfd): err = capfd.readouterr().err assert str(path) in err assert "0644" in err or "644" in err - assert MARKER_DB_PASSWORD not in err + assert_no_leak(err, MARKER_DB_PASSWORD, what="the file's own value") def test_parse_env_file_no_warning_at_exactly_0600(tmp_path, capfd): @@ -720,7 +839,7 @@ def poison(*args, **kwargs): out = capfd.readouterr().out assert "would set" in out assert "db.password" in out - assert MARKER_DB_PASSWORD not in out + assert_no_leak(out, MARKER_DB_PASSWORD, what="the resolved value") def test_dry_run_reports_a_required_unresolved_path_as_a_failure(repo, monkeypatch, capfd): @@ -738,15 +857,38 @@ def test_dry_run_reports_a_required_unresolved_path_as_a_failure(repo, monkeypat assert "db.password" in err -def test_value_reaches_the_subprocess_via_stdin_and_never_via_argv( - repo, monkeypatch, capfd, stub_pulumi, tmp_path +@pytest.mark.parametrize("table", ["secret", "plaintext"]) +@pytest.mark.parametrize( + ("stack_args", "stack_label"), + [([], "no-stack"), (["--stack", "placeholder-stack"], "with-stack")], + ids=["no-stack", "with-stack"], +) +def test_a_value_reaches_pulumi_on_stdin_and_never_in_argv( + repo, monkeypatch, capfd, stub_pulumi, tmp_path, table, stack_args, stack_label ): + """Global Constraint 6, over every shape this command actually produces. + + This was one test: one `secret` entry, no `--stack`. Both of the + conditions it did not cover are the ordinary ones -- `--stack` is how any + real rotation is invoked, and `plaintext` entries go through the same + `_pulumi_config_set` call with a different flag -- and appending the + value to argv under either of them passed the entire suite. `--stack` in + particular is the more dangerous shape: it is the branch that *already* + appends to `args`, so a value appended one line later reads as part of + the same edit. + + Each case carries its own marker, so a leak under one parameter cannot be + excused by a match against another parameter's value; and the marker is + resolved from the environment, so what lands on stdin is the same string + the assertion is made about. + """ + value = ARGV_CASE_MARKERS[table, stack_label] write_repo_config( repo, - '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n', + f'[secrets."."]\n{table} = {{ "managed.value" = "VALUE_NAME" }}\n', ) monkeypatch.chdir(repo) - monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + monkeypatch.setenv("VALUE_NAME", value) fake_pulumi(monkeypatch, stub_pulumi) argv_dump = tmp_path / "argv.log" @@ -754,21 +896,23 @@ def test_value_reaches_the_subprocess_via_stdin_and_never_via_argv( monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) monkeypatch.setenv("STUB_DUMP_STDIN_TO", str(stdin_dump)) - assert main(["set-secrets"]) == 0 + assert main(["set-secrets", *stack_args]) == 0 - argv_calls = [json.loads(line) for line in argv_dump.read_text().splitlines()] - assert len(argv_calls) == 1 - call_argv = argv_calls[0] - assert "--secret" in call_argv - assert "--path" in call_argv and "db.password" in call_argv - assert not any(MARKER_DB_PASSWORD in arg for arg in call_argv) + calls = recorded_pulumi_calls(argv_dump, value) + assert len(calls) == 1 + call_argv = calls[0] + assert f"--{table}" in call_argv + assert "--path" in call_argv and "managed.value" in call_argv + assert ("--stack" in call_argv) is bool(stack_args) - stdin_content = stdin_dump.read_bytes() - assert MARKER_DB_PASSWORD.encode() in stdin_content + # The other half of the same claim: it really did reach the child, on + # stdin, where `ps` cannot see it. Absent from argv alone would also be + # satisfied by never sending it at all. + assert value.encode() in stdin_dump.read_bytes() captured = capfd.readouterr() - assert MARKER_DB_PASSWORD not in captured.out - assert MARKER_DB_PASSWORD not in captured.err + assert_no_leak(captured.out, value, what="the resolved value") + assert_no_leak(captured.err, value, what="the resolved value") def test_a_plaintext_entry_is_published_with_plaintext_and_not_secret( @@ -782,7 +926,7 @@ def test_a_plaintext_entry_is_published_with_plaintext_and_not_secret( '[secrets."."]\nplaintext = { "registry.user" = "REGISTRY_USER" }\n', ) monkeypatch.chdir(repo) - monkeypatch.setenv("REGISTRY_USER", "a-plaintext-value") + monkeypatch.setenv("REGISTRY_USER", MARKER_PLAINTEXT) fake_pulumi(monkeypatch, stub_pulumi) argv_dump = tmp_path / "argv.log" @@ -792,12 +936,12 @@ def test_a_plaintext_entry_is_published_with_plaintext_and_not_secret( assert main(["set-secrets"]) == 0 - call_argv = json.loads(argv_dump.read_text().splitlines()[0]) + (call_argv,) = recorded_pulumi_calls(argv_dump, MARKER_PLAINTEXT) assert "--plaintext" in call_argv assert "--secret" not in call_argv assert "--path" in call_argv and "registry.user" in call_argv - assert b"a-plaintext-value" in stdin_dump.read_bytes() + assert MARKER_PLAINTEXT.encode() in stdin_dump.read_bytes() def test_a_secret_entry_is_never_published_with_plaintext( @@ -818,7 +962,7 @@ def test_a_secret_entry_is_never_published_with_plaintext( monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) assert main(["set-secrets"]) == 0 - call_argv = json.loads(argv_dump.read_text().splitlines()[0]) + (call_argv,) = recorded_pulumi_calls(argv_dump, MARKER_DB_PASSWORD) assert "--secret" in call_argv assert "--plaintext" not in call_argv @@ -836,14 +980,14 @@ def test_secret_and_plaintext_entries_are_both_published_in_one_run( ) monkeypatch.chdir(repo) monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) - monkeypatch.setenv("REGISTRY_USER", "a-plaintext-value") + monkeypatch.setenv("REGISTRY_USER", MARKER_PLAINTEXT) fake_pulumi(monkeypatch, stub_pulumi) argv_dump = tmp_path / "argv.log" monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) assert main(["set-secrets"]) == 0 - calls = [json.loads(line) for line in argv_dump.read_text().splitlines()] + calls = recorded_pulumi_calls(argv_dump, MARKER_DB_PASSWORD, MARKER_PLAINTEXT) assert len(calls) == 2 secret_call = next(c for c in calls if "db.password" in c) @@ -865,13 +1009,13 @@ def test_stack_flag_is_forwarded_to_pulumi(repo, monkeypatch, capfd, stub_pulumi monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) assert main(["set-secrets", "--stack", "placeholder-stack"]) == 0 - call_argv = json.loads(argv_dump.read_text().splitlines()[0]) + (call_argv,) = recorded_pulumi_calls(argv_dump, MARKER_DB_PASSWORD) assert "--stack" in call_argv assert "placeholder-stack" in call_argv captured = capfd.readouterr() - assert MARKER_DB_PASSWORD not in captured.out - assert MARKER_DB_PASSWORD not in captured.err + assert_no_leak(captured.out, MARKER_DB_PASSWORD, what="the resolved value") + assert_no_leak(captured.err, MARKER_DB_PASSWORD, what="the resolved value") def test_stack_flag_omitted_when_not_given(repo, monkeypatch, capfd, stub_pulumi, tmp_path): @@ -887,12 +1031,12 @@ def test_stack_flag_omitted_when_not_given(repo, monkeypatch, capfd, stub_pulumi monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) assert main(["set-secrets"]) == 0 - call_argv = json.loads(argv_dump.read_text().splitlines()[0]) + (call_argv,) = recorded_pulumi_calls(argv_dump, MARKER_DB_PASSWORD) assert "--stack" not in call_argv captured = capfd.readouterr() - assert MARKER_DB_PASSWORD not in captured.out - assert MARKER_DB_PASSWORD not in captured.err + assert_no_leak(captured.out, MARKER_DB_PASSWORD, what="the resolved value") + assert_no_leak(captured.err, MARKER_DB_PASSWORD, what="the resolved value") def test_required_unresolved_path_fails_the_run(repo, monkeypatch, stub_pulumi, capfd): @@ -945,6 +1089,65 @@ def test_a_non_canonical_required_shape_is_rejected_not_silently_ignored( assert "paths" in captured.err +def test_a_scalar_where_the_secret_table_belongs_is_rejected_not_read_as_empty( + repo, monkeypatch, capfd +): + """`secret = "DB_PASSWORD"` -- the shape a manifest author writes when + they forget the `path = name` mapping -- is a hard load-time refusal. + + Read instead as "this project declares no entries", it produces `0 set, + 0 skipped, 0 failed` and exit 0: a rotation that published nothing, + reported in the language of success. Exit 0 with no work done is the + worst of the three possible outcomes here, because nothing downstream has + any reason to look again. + + The `summary:` line's absence, not the exit code, is what proves the + refusal happened at load time rather than in the publishing loop. + """ + write_repo_config(repo, '[secrets."."]\nsecret = "DB_PASSWORD"\n') + monkeypatch.chdir(repo) + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + + assert main(["set-secrets"]) == 2 + captured = capfd.readouterr() + assert "summary:" not in captured.out + assert "must be a table" in captured.err + assert "secret" in captured.err + assert_no_leak(captured.out, MARKER_DB_PASSWORD, what="the resolved value") + assert_no_leak(captured.err, MARKER_DB_PASSWORD, what="the resolved value") + + +def test_the_summary_line_counts_every_outcome(repo, monkeypatch, capfd, stub_pulumi): + """One run with one of each outcome, and the exact counts asserted. + + The `summary:` line is the whole report for a run nobody reads closely, + and no test anywhere asserted its numbers -- hard-coding + `0 set, 0 skipped, 0 failed` passed all 781. Three entries in a fixed, + known declaration order: one that resolves, one that does not and is not + required, and one that does not and is. + """ + write_repo_config( + repo, + '[secrets."."]\n' + "secret = { " + '"a.set" = "NAME_SET", ' + '"b.skip" = "NAME_SKIP", ' + '"c.fail" = "NAME_FAIL" ' + "}\n\n" + '[secrets.".".required]\n' + 'paths = ["c.fail"]\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("NAME_SET", "a-value-that-resolves") + monkeypatch.delenv("NAME_SKIP", raising=False) + monkeypatch.delenv("NAME_FAIL", raising=False) + fake_pulumi(monkeypatch, stub_pulumi) + + assert main(["set-secrets"]) == 2 + captured = capfd.readouterr() + assert "summary: 1 set, 1 skipped, 1 failed" in captured.out + + def test_a_path_declared_in_both_secret_and_plaintext_is_rejected_not_downgraded( repo, monkeypatch, capfd, stub_pulumi ): @@ -969,8 +1172,8 @@ def test_a_path_declared_in_both_secret_and_plaintext_is_rejected_not_downgraded captured = capfd.readouterr() assert "summary:" not in captured.out assert "db.password" in captured.err - assert MARKER_DB_PASSWORD not in captured.out - assert MARKER_DB_PASSWORD not in captured.err + assert_no_leak(captured.out, MARKER_DB_PASSWORD, what="the resolved value") + assert_no_leak(captured.err, MARKER_DB_PASSWORD, what="the resolved value") def test_a_missing_pulumi_binary_fails_that_entry_and_is_reported(repo, monkeypatch, capfd): @@ -986,8 +1189,8 @@ def test_a_missing_pulumi_binary_fails_that_entry_and_is_reported(repo, monkeypa captured = capfd.readouterr() assert "not found on PATH" in captured.err assert "db.password" in captured.err - assert MARKER_DB_PASSWORD not in captured.out - assert MARKER_DB_PASSWORD not in captured.err + assert_no_leak(captured.out, MARKER_DB_PASSWORD, what="the resolved value") + assert_no_leak(captured.err, MARKER_DB_PASSWORD, what="the resolved value") def test_a_timeout_in_the_middle_does_not_abort_the_remaining_entries( @@ -1054,8 +1257,12 @@ def test_a_pulumi_failure_never_echoes_the_value_it_was_given( assert main(["set-secrets"]) == 2 captured = capfd.readouterr() - assert MARKER_DB_PASSWORD not in captured.out - assert MARKER_DB_PASSWORD not in captured.err + # `assert_no_leak`, not `not in`: the reason this module builds for a + # non-zero exit (`pulumi exited 1`) is printed by `_print_outcome`, and a + # reason quoting even the first eight characters of the value would pass a + # whole-string check while disclosing the credential. + assert_no_leak(captured.out, MARKER_DB_PASSWORD, what="the resolved value") + assert_no_leak(captured.err, MARKER_DB_PASSWORD, what="the resolved value") # --------------------------------------------------------------------------- @@ -1130,10 +1337,12 @@ def test_drift_warns_when_the_values_differ(repo, monkeypatch, capfd, stub_pulum assert "BOOTSTRAP_NAME" in captured.err assert "MANAGED_NAME" in captured.err assert "managed.path" in captured.err - assert MARKER_PUBLISHED not in captured.err - assert MARKER_BOOTSTRAP not in captured.err - assert MARKER_PUBLISHED not in captured.out - assert MARKER_BOOTSTRAP not in captured.out + # The drift warning names the two logical names and the config path, and + # must never quote either value -- not whole, and not the leading run of + # one, which a `not in` check would have let through. + for stream in (captured.err, captured.out): + assert_no_leak(stream, MARKER_PUBLISHED, what="the published value") + assert_no_leak(stream, MARKER_BOOTSTRAP, what="the bootstrap value") def test_drift_is_silent_when_the_values_agree(repo, monkeypatch, capfd, stub_pulumi): @@ -1201,7 +1410,7 @@ def test_drift_warns_when_the_managed_name_is_ambiguous(repo, monkeypatch, capfd assert main(["set-secrets"]) == 0 captured = capfd.readouterr() assert "MANAGED_NAME" in captured.err - assert MARKER_BOOTSTRAP not in captured.err + assert_no_leak(captured.err, MARKER_BOOTSTRAP, what="the bootstrap value") def test_drift_finds_a_managed_name_declared_in_plaintext(repo, monkeypatch, capfd, stub_pulumi): @@ -1212,7 +1421,7 @@ def test_drift_finds_a_managed_name_declared_in_plaintext(repo, monkeypatch, cap secret" branch instead of comparing the values at all.""" write_repo_config(repo, _PLAINTEXT_MANAGED_DRIFT_MANIFEST) monkeypatch.chdir(repo) - monkeypatch.setenv("MANAGED_NAME", "a-plaintext-value") + monkeypatch.setenv("MANAGED_NAME", MARKER_PLAINTEXT) monkeypatch.setenv("BOOTSTRAP_NAME", MARKER_BOOTSTRAP) fake_pulumi(monkeypatch, stub_pulumi) monkeypatch.setenv( @@ -1224,7 +1433,7 @@ def test_drift_finds_a_managed_name_declared_in_plaintext(repo, monkeypatch, cap assert "does not name exactly one" not in captured.err assert "drift" in captured.err assert "managed.path" in captured.err - assert MARKER_BOOTSTRAP not in captured.err + assert_no_leak(captured.err, MARKER_BOOTSTRAP, what="the bootstrap value") def test_a_second_drift_pair_is_evaluated_and_the_run_reaches_its_summary( From e6e54a5b22afd589078fdf7b5b1210d9230fcbee Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 16:24:28 -0700 Subject: [PATCH 36/44] test: the artifact's own bytes are checked by nothing but this parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `build_artifact` removes the artifact from its own `sources` map, so a hand edit or a bad merge in the committed JSON passes every freshness check. That makes the parse-time guards the only integrity check the file has, and four of them had never executed: a non-boolean `secret` mark (`0` reads as "unmarked" and is a one-character diff), a null container interior (drops the whole subtree), a recorded source deleted from the index rather than changed, and an empty `check.stack_models`. The last two needed the right assertion, not just the right exit code. Both `verify_sources` branches exit 2 saying "stale", so removing the missing-source branch merely reports a deleted file as *changed*; the test asserts the wording. And `sync` with the empty-`stack_models` guard removed exits 0 having written an artifact whose `roots` is `{}`, which `_verify_roots` then passes as `set() == set()` — so the test asserts the artifact was never written, and that `check-config` afterwards refuses on a credential only the model net could name. `_verify_roots` is kept and now exercised end to end, against exactly the edit it is the last defence for: drop `.stackward.toml` from `sources` and a namespace from `roots`, and every remaining source still matches the index. Its stated rationale was stale — nested-class roots cannot arise, because `regen._import_object` resolves targets with a flat `getattr` and refuses `declared:Outer.Inner` at sync time. The reachable divergence is a module-level alias or a re-export, which now has its own test; comparing `.items()` instead of key sets fails it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/commands/sync_declared.py | 15 +- src/stackward/nets/model.py | 47 +++++- tests/test_model_net.py | 208 ++++++++++++++++++++++++ 3 files changed, 261 insertions(+), 9 deletions(-) diff --git a/src/stackward/commands/sync_declared.py b/src/stackward/commands/sync_declared.py index 18cb495..f9fe8e9 100644 --- a/src/stackward/commands/sync_declared.py +++ b/src/stackward/commands/sync_declared.py @@ -231,7 +231,20 @@ def build_artifact( The artifact never records **itself**: its own blob id changes on every regeneration, so a self-reference would make every artifact stale the - moment it was written. + moment it was written. (A recorded hash of everything *except* the + `sources` entry would be self-consistent, but it would be a second + integrity scheme to specify, generate and verify, and a wrong one would + fail closed on correct artifacts — the exclusion is the cheaper trade.) + + That exclusion has a price, and it is paid in `nets.model.parse_net`: the + committed JSON's own bytes are checked by nothing else, so a hand edit or + a bad three-way merge in it meets no freshness check at all. Every + parse-time guard on the reading side is therefore load-bearing rather + than defensive, and must stay exhaustive — a shape the parser accepts is + a shape an edit can introduce and no other check will ever question. The + call to `parse_net` below is the same idea applied to the writing side: + the artifact this command produces is read back through the reader's own + parser before it is written. """ files = _relative_sources( root, diff --git a/src/stackward/nets/model.py b/src/stackward/nets/model.py index 25a67a3..3c4512c 100644 --- a/src/stackward/nets/model.py +++ b/src/stackward/nets/model.py @@ -239,6 +239,19 @@ def parse_net(payload: Any) -> tuple[ModelNet, dict[str, str]]: unrecognised version, a missing or misshapen section, a root or a child naming a model the artifact does not define, or an empty `sources` map (which would leave freshness unverifiable, and so unverified). + + **These guards are the artifact's only integrity check, not a second + one.** `sync_declared.build_artifact` deliberately removes the artifact + from its own `sources` map (a self-reference would make every artifact + stale the moment it was written), so the committed file's own bytes are + compared against nothing: `verify_sources` checks the files that + *contributed* to it, and every one of them can be untouched while the + artifact itself has been hand-edited or badly merged. Everything this + function accepts is therefore something an edit can introduce and nothing + downstream will question — which is why a shape that merely *reads* + wrong, such as `"secret": 0` for `false` or a null container interior, is + refused here rather than tolerated. Both of those quietly shrink the net; + neither is anything the generator can emit. """ document = _require_mapping(payload, "top level") @@ -668,14 +681,32 @@ def _verify_roots(net: ModelNet, check: CheckConfig) -> None: """The artifact must start from exactly the namespaces the policy declares. A second, independent guard on the same drift the recorded blob id of - `.stackward.toml` catches — and it needs no blob id at all, so it still - holds if that file is somehow not among the sources. A namespace declared - in `check.stack_models` with no root in the artifact is not a smaller - answer; it is *no* answer for that namespace, silently. - - **Key sets only.** A model id is `module:QualName` while the config names - `module:Class`, and those diverge for a nested class — comparing values - would refuse a correct artifact. + `.stackward.toml` catches. A namespace declared in `check.stack_models` + with no root in the artifact is not a smaller answer; it is *no* answer + for that namespace, silently. + + **When it is the only guard left.** `build_artifact` records + `.stackward.toml` as a source, so for any artifact this tool generated, + `verify_sources` runs first and catches a changed policy before this + function is reached. What it does not cover is the artifact's own bytes, + which it deliberately does not record (see `parse_net`): an edit that + removes `.stackward.toml` from `sources` *and* a namespace from `roots` + leaves every remaining recorded source matching the index exactly. This + comparison is what still refuses that file. + + **Key sets only.** A root's model id is `module:QualName` — what the + class says about itself — while `check.stack_models` names whatever + attribute the operator imported it as. A module-level alias, or a class + re-exported from a package `__init__`, makes the two diverge for a + completely correct artifact, and comparing pairs would refuse it while + calling it staleness. Regenerating would then produce the identical file + and the refusal would repeat forever. + + (This previously justified the same choice by nested classes. That case + cannot arise: `bootstrap.regen._import_object` resolves a target with a + flat `getattr`, so `declared:Outer.Inner` is refused at sync time and + never reaches an artifact. The alias form is the one that does occur, and + `tests/test_model_net.py` exercises it end to end.) """ declared = set(check.stack_models) present = set(net.roots) diff --git a/tests/test_model_net.py b/tests/test_model_net.py index 5e2c48d..9fa05d8 100644 --- a/tests/test_model_net.py +++ b/tests/test_model_net.py @@ -394,6 +394,55 @@ def test_an_artifact_with_an_unknown_child_kind_is_refused(): ) +def test_a_non_boolean_secret_mark_is_refused_rather_than_silently_unmarking(): + """`"secret": 0` where `false` belongs -- and, just as importantly, where + `true` belonged. + + The artifact records no blob id for **itself** (see + `sync_declared.build_artifact`: a self-reference would make every artifact + stale the moment it was written), so a hand edit or a bad three-way merge + in the committed JSON is checked by nothing except this parser. That makes + every guard here load-bearing rather than defensive. + + This one in particular: `0` is falsy, so an accepted `"secret": 0` leaves + the field unmarked and every credential at or beneath it unreported -- + while reading, in a diff, as a one-character change to a line that still + says `secret`. + """ + message = parse_should_fail( + { + "version": ARTIFACT_VERSION, + "models": {"declared:Root": {"token": {"secret": 0}}}, + "roots": {"app:app": "declared:Root"}, + "sources": {"declared.py": "0" * 40}, + } + ) + assert "must be a boolean" in message + assert "token" in message + + +def test_a_null_container_interior_is_refused_rather_than_dropping_the_subtree(): + """`{"kind": "list", "item": null}`. + + The generator never writes this: a container that cannot hold a model is + written as an ordinary scalar field, so a null interior can only come from + an edit. Accepted, it produces `ListOf(None)` and the walk has nowhere to + go -- every model below that field disappears from the net, silently, and + the same document that used to be refused now reports clean. + """ + message = parse_should_fail( + { + "version": ARTIFACT_VERSION, + "models": { + "declared:Root": {"peers": {"child": {"kind": "list", "item": None}}} + }, + "roots": {}, + "sources": {"declared.py": "0" * 40}, + } + ) + assert "must not be null" in message + + def test_an_artifact_with_no_sources_is_refused(): """An artifact recording no sources can never be found stale, so it would be trusted forever.""" @@ -1720,6 +1769,165 @@ def test_a_malformed_artifact_in_the_index_exits_2(repo, monkeypatch, capsys): assert "invalid JSON" in capsys.readouterr().err +def test_a_recorded_source_removed_from_the_index_is_refused_and_named( + repo, monkeypatch, capsys +): + """The sibling of "blob changed": a recorded source that is not in the + index at all. + + Both branches of `verify_sources` end in exit 2 naming the regeneration + command, so an exit-code assertion cannot tell them apart -- and with the + `None` branch removed the comparison below it fires instead, reporting a + file as *changed* when it has actually been deleted or unstaged. So the + wording is what this test asserts: the reader is told which of the two + things happened, because the fix differs. + """ + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + sync_and_commit(repo, monkeypatch, capsys) + + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + git(repo, "add", "Pulumi.dev.yaml") + git(repo, "rm", "-q", "--cached", "declared.py") + + assert check(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert "declared.py" in captured.err + assert "no longer in the index" in captured.err + assert "sync-declared-secrets" in captured.err + assert captured.out == "" + + +def test_an_empty_stack_models_is_refused_rather_than_covering_nothing( + repo, monkeypatch, capsys +): + """`model_net = "artifact"` with nothing declared to walk. + + Without this refusal the whole chain reads as success: `sync` writes an + artifact whose `roots` is `{}`, `_verify_roots` compares `set() == set()` + and passes, and `check-config` exits 0 having scanned with a net that + covers nothing -- a silent fallback to no model net at all, in a + repository that explicitly asked for one. That is the single outcome this + tool's fail-closed rule exists to forbid, and it is invisible. + + The artifact's *absence*, not the exit code, is what proves the refusal + happened before anything was written: a run that generated an empty net + and then failed for some other reason would exit 2 as well. + """ + write(repo, "declared.py", HEURISTIC_BLIND_MODEL) + write( + repo, + ".stackward.toml", + f""" + python = "{sys.executable}" + + [check] + model_net = "artifact" + stack_models = {{}} + """, + ) + git(repo, "add", "-A") + + assert sync(repo, monkeypatch) == 2 + assert not (repo / ARTIFACT_PATH).exists() + error = capsys.readouterr().err + assert "stack_models is empty" in error + assert 'model_net = "none"' in error + + # And the consequence, end to end, on a credential only the model net + # could ever name: with no artifact there is nothing to scan with, and + # `check-config` says so instead of reporting clean. + write(repo, "Pulumi.dev.yaml", BLIND_DOCUMENT) + git(repo, "add", "-A") + assert check(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert ARTIFACT_PATH in captured.err + assert captured.out == "" + + +def test_a_hand_edited_artifact_that_drops_a_root_is_refused_by_the_roots_check( + repo, monkeypatch, capsys +): + """The scenario `_verify_roots` is the only remaining defence for. + + Ordinarily it is unreachable: `build_artifact` records `.stackward.toml` + as a source, so any change to `check.stack_models` trips the blob-id check + in `verify_sources` first -- which is exactly why `return` as this guard's + first statement costs nothing but its own unit test. + + The artifact records no blob id for itself, though, so a hand edit or a + bad merge in the committed JSON meets nothing but the parser. Drop + `.stackward.toml` from `sources` *and* a namespace from `roots`, and + `verify_sources` has nothing left to disagree with: every source still + recorded matches the index exactly. Only the roots comparison can still + say the artifact covers less than the repository declares -- and the + credential in the dropped namespace is one the heuristic net cannot name. + """ + write(repo, "declared.py", TWO_MODELS) + write( + repo, + ".stackward.toml", + f""" + python = "{sys.executable}" + + [check] + model_net = "artifact" + stack_models = {{ "app:app" = "declared:Root", "app:other" = "declared:Other" }} + """, + ) + git(repo, "add", "-A") + sync_and_commit(repo, monkeypatch, capsys) + + edited = artifact(repo) + del edited["sources"][".stackward.toml"] + del edited["roots"]["app:other"] + (repo / ARTIFACT_PATH).write_text(json.dumps(edited, sort_keys=True, indent=2) + "\n") + write(repo, "Pulumi.dev.yaml", "config:\n app:other:\n binding: leaked\n") + git(repo, "add", "-A") + + assert check(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert "app:other" in captured.err + assert "sync-declared-secrets" in captured.err + # Nothing reported: without this guard the namespace is simply skipped and + # its credential never looked at, and the command exits 0. + assert captured.out == "" + + +def test_a_root_declared_through_a_module_level_alias_still_verifies( + repo, monkeypatch, capsys +): + """Why `_verify_roots` compares **key sets** and not `(namespace, model)` + pairs. + + A root's model id is `module:QualName` -- what the class says about itself + -- while `check.stack_models` names whatever attribute the operator + actually imported it as. A module-level alias, or a class re-exported from + a package's `__init__`, makes the two diverge for a completely correct + artifact. Comparing pairs would refuse it, and would call it staleness, + which is not what happened; regenerating would produce the identical file + and the refusal would repeat forever. + + (The docstring here used to justify the same choice by nested classes. + That case is unreachable: `bootstrap.regen._import_object` resolves a + target with a flat `getattr`, so `declared:Outer.Inner` is refused at sync + time and never reaches an artifact. An alias is the form that does occur.) + """ + write(repo, "declared.py", TWO_MODELS + "\n Alias = Root\n") + declare(repo, models="declared:Alias") + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + + # The divergence itself, made explicit: the config asked for `Alias`, the + # artifact records what the class calls itself. + assert written["roots"] == {"app:app": "declared:Root"} + + write(repo, "Pulumi.dev.yaml", "config:\n app:app:\n binding: \"\"\n") + assert check(repo, monkeypatch) == 0 + assert capsys.readouterr().out == "" + + def test_none_mode_skips_cleanly_with_no_artifact_present(repo, monkeypatch, capsys): """A declared mode, not a fallback: nothing is read, so the absence of the artifact directory entirely cannot rescue a test that would otherwise From 1ff3b50e59b32ba172531cf97d30f5323ab52c86 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 16:25:53 -0700 Subject: [PATCH 37/44] docs: two absolute claims in the README were false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "No storage vendor is named anywhere in this codebase" is not true: `commands/session.py` defines `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`, composes `s3://` for the component backend form, and sets `s3ForcePathStyle`. All three are Pulumi's own vocabulary for a DIY backend rather than a choice about anyone's provider, so the code is right and the sentence was wrong. The claim it replaces says what is actually true: no bucket, region, endpoint, account, domain, namespace or model class appears here, and the two de-facto-standard names that do are protocol. "No backend assumptions" holds for a passed-through `backend_url` and not for the component form, which can only ever produce an `s3://` URL. Split and qualified. The status banner still described `v0.1.0` as shipping `--version` and `doctor` only, and the table was headed "What it will do". Every row now exists — except one: `credentials` was never a subcommand. The store is implemented and `login`/`exec`/`shell` read it, but nothing on the command line writes to it, so that row is removed from the table and the gap stated plainly instead of being listed as though it shipped. `src/stackward/cli.py:3` carries the same stale v0.1.0 claim; it is not in this wave's files. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- README.md | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3c3480d..649ddc8 100644 --- a/README.md +++ b/README.md @@ -10,22 +10,31 @@ at the top of the file is what makes it safe. What makes it *un*safe is a value hand-edited in as plaintext, which looks exactly like every other line in the diff. `stackward` exists to make that mistake hard. -> **Status: early.** `v0.1.0` ships `--version` and `doctor` only. It exists to -> prove the release and install path before features are built on top of it. +> **Status: early.** `v0.1.0` shipped `--version` and `doctor` alone, to prove +> the release and install path before anything was built on top of it. That +> path held, and every command in the table below is now implemented. One gap +> remains, noted under it: there is still no command for *writing* to the +> credential store. Interfaces may change. -## What it will do +## What it does | Command | Purpose | |---|---| | `check-config` | Refuse plaintext credentials in a stack config file | | `pre-commit` | The above, over staged content, as a git hook | | `hooks install` | Install that hook, honouring `core.hooksPath` | -| `credentials` | Keep backend credentials in one encrypted, profile-scoped store | -| `exec` / `shell` | Run `pulumi` with those credentials injected into the child process | | `login` | Point Pulumi at a profile's backend | +| `exec` / `shell` | Run `pulumi` with a profile's credentials injected into the child process | | `set-secrets` | Publish stack secrets from a declared manifest, values on stdin | | `check-passphrase` | Verify a passphrase actually decrypts a stack, rather than merely exiting 0 | | `sync-declared-secrets` | Regenerate the committed graph of secrets a repo's own models declare | +| `doctor` | Report the resolved configuration and environment | + +The encrypted, profile-scoped credential store those three session commands +read from is implemented, but **nothing on the command line writes to it +yet** — there is no `credentials` subcommand. Until there is, a profile's +secrets have to be sealed by another route, or supplied through the `env` +provider, which reads them straight from the process environment. ## Design commitments @@ -39,9 +48,21 @@ These are constraints, not aspirations — each has a test. mechanism; which keys are sensitive, which paths are managed and which model declares them are all read from the repo's `.stackward.toml`. The tool ships no defaults about anyone's infrastructure. -- **No backend assumptions.** A profile carries a Pulumi backend URL and it is - passed through, so `s3://`, `gs://`, `azblob://` and `file://` all work. No - storage vendor is named anywhere in this codebase. +- **A backend URL is passed through, not interpreted.** A profile carrying + `backend_url` gets exactly that string handed to Pulumi, so `s3://`, `gs://`, + `azblob://` and `file://` all work without this tool knowing which is which. + The exception is the optional *component* form (`bucket`, `prefix`, + `endpoint`, `region`), which is a convenience for S3-compatible storage and + can only ever compose an `s3://` URL — use `backend_url` for anything else. +- **Nobody's own infrastructure is named here.** No bucket, region, endpoint, + account, domain, namespace or model class appears anywhere in this + repository, and the hook and CI job described under *Development* below are + what enforce that. Two de-facto-standard names do appear, and are not + choices this tool makes: `exec` and `shell` inject `AWS_ACCESS_KEY_ID` and + `AWS_SECRET_ACCESS_KEY`, because that is the vocabulary Pulumi's own DIY + backend reads out of a child process's environment; and the component form + above composes an `s3://` URL, because that is the scheme Pulumi uses for + every S3-compatible store. - **Values reach the Pulumi CLI on stdin**, never in a command line, so they do not appear in `ps` or in shell history. - **Failures are closed.** If the tool cannot determine whether a file is safe, From cc731259a7a9dcf8a3eb88d7de311e2f8a270367 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 16:29:23 -0700 Subject: [PATCH 38/44] test: carry the leak-check respell across to the provider suite `tests/test_providers.py` kept its own copy of `MARKER_DB_PASSWORD` at the old spelling, so the two suites disagreed about the constant and the next leak assertion added here would have hit the same `password`-run false positive against `db.password` -- with the explanation sitting in the other file. Both suites now spell their markers the same opaque way, and this one says why in place. Its three whole-string absence assertions are upgraded to `assert_no_leak`: a dry run that helpfully printed the first few characters of what it would set, so an operator could confirm the right value, passed the old form. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- tests/test_providers.py | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/tests/test_providers.py b/tests/test_providers.py index fdb08d5..1273064 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -56,16 +56,27 @@ from stackward.providers.file import FileCredentialStore from stackward.store import Profile, StoreError +from leakcheck import assert_no_leak + PASSWORD = "the store password" WRONG_PASSWORD = "not the store password" -# Placeholder values only -- see GC4. Distinctive enough that an accidental -# substring match elsewhere in captured output would be implausible. -MARKER_KEY = "marker-access-key-1a2b3c4d" -MARKER_SECRET = "marker-secret-key-5e6f7a8b" -MARKER_PASSPHRASE = "marker-passphrase-9c0d1e2f" -MARKER_DB_PASSWORD = "marker-db-password-1a2b3c4d" -MARKER_API_TOKEN = "marker-api-token-5e6f7a8b" +# Placeholder values only -- see GC4. +# +# Opaque rather than descriptive, and deliberately so: the absence assertions +# below go through `leakcheck.assert_no_leak`, which fails on any +# eight-character run of the value as well as on the whole string. A marker +# spelled `marker-db-password-...` shares the run `password` with the config +# path `db.password` that the same output legitimately prints, so the check +# would fire on output disclosing nothing -- and a leak check that cries wolf +# is a leak check that gets deleted. No two of these share an eight-character +# run with each other either. `tests/test_set_secrets.py` carries the same +# constraint for the same reason. +MARKER_KEY = "kp2v9s-4x7b1m6e-8t3j5c0z" +MARKER_SECRET = "vn6h3q-9d1f8k4r-2y7l5w0a" +MARKER_PASSPHRASE = "bz4j8t-6m2c9p1s-5g7v3n0k" +MARKER_DB_PASSWORD = "rf6t2y-8w4q1n7v-3m5k9b0x" +MARKER_API_TOKEN = "sl8g4d-1c7z5j2p-6h3n0r9e" # The one backend URL every profile in the three-provider contract test # carries. Named so the contract assertion can be anchored to it rather than @@ -432,7 +443,7 @@ def test_command_secret_source_never_prints_the_resolved_value(monkeypatch, remo monkeypatch.setenv("STUB_VALUES", json.dumps({"DB_PASSWORD": MARKER_DB_PASSWORD})) provider = CommandSecretSource(remote_command(remote_stub)) provider.resolve("DB_PASSWORD") - assert MARKER_DB_PASSWORD not in capfd.readouterr().out + assert_no_leak(capfd.readouterr().out, MARKER_DB_PASSWORD, what="the fetched value") def test_command_credential_store_parses_a_json_object_from_stdout(monkeypatch, remote_stub): @@ -776,9 +787,13 @@ def test_dotenv_and_a_fake_remote_produce_identical_dry_run_outcomes( assert dotenv_output == command_output assert "would set" in dotenv_output assert "skip" in dotenv_output + # `assert_no_leak`, not `not in`: a dry run that helpfully printed the + # first few characters of what it would set -- so an operator could + # confirm it was the right value -- passes a whole-string check while + # disclosing the credential. for marker in (MARKER_DB_PASSWORD, MARKER_API_TOKEN): - assert marker not in dotenv_output - assert marker not in command_output + assert_no_leak(dotenv_output, marker, what="the resolved value") + assert_no_leak(command_output, marker, what="the resolved value") def test_dotenv_and_a_fake_remote_produce_identical_pulumi_invocations( From cf92973cd5cc4abf564c330922461f34519bfdf4 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 16:31:49 -0700 Subject: [PATCH 39/44] fix: pin what the credential layer's tests only appeared to check A mutation review found nine assertions in the session, store, passphrase and hook-install layers that no implementation could fail. Each one is replaced by an assertion that a specific defective implementation breaks, and the defects the vacuous ones were hiding are fixed. The passphrase fingerprint was pinned to a length and to nothing else, so `passphrase.encode("utf-8").hex()[:16]` -- the first eight bytes of the passphrase -- passed every test while `check-passphrase` printed it on stdout. The digest is pinned against known values now. Note that `assert_no_leak` alone does not catch this: it checks whole encodings and runs of the *raw* value, and a truncated hex encoding is neither. The tests hand it the hex encoding as the value, which is what closes it. `exec`'s most likely failure -- a mistyped command -- had no test at all, so adding the child environment to that error message passed the suite while printing every injected credential. `shell` had no exactly-these-keys test either. And no test ever put a credential name in the *parent* environment, which left `env.setdefault(name, ...)` indistinguishable from `env[name] = ...`: a developer with `AWS_ACCESS_KEY_ID` exported would have got their own key paired with another profile's backend. The autouse fixture now seeds all three names, which fixes the blindness and the reverse problem in one place -- those same tests failed outright for anyone who had the variables exported. `_tty_available` had never executed: pointing it at `/dev/null` passed the suite, and that is the fail-open where `getpass` falls back to the child's stdin and echoes the typed password. It is now exercised in a real process with no controlling terminal. `config` was blessed at 0644 on the stated grounds that it "holds backend identity and never a credential", while `postgres://user:password@host/db` is a documented Pulumi backend form that `backend_url` passes through verbatim. Same false premise justified quoting tomllib's message back; tomllib does echo document text (`Cannot declare ('...',) twice`). The mode is 0600, the claim is corrected, and only the position is quoted. The store's own mode tests compared the module against itself -- `CREDENTIALS_MODE = 0o606` and `DIR_MODE = 0o777` passed all 781 -- and now use literals. `hooks install` matched git's translated strings, so it exited 2 in every repository under a non-English locale; every git call in that module now goes through one runner that sets `LC_ALL=C`. Its git fixtures ran `git init` with no config isolation, so a global `core.hooksPath` -- what husky and lefthook set, and what the command exists to handle -- errored 62 tests. Also: the missing-policy warning the `TODO(final-review)` in `check_config` described, and the store, timestamp and encoding paths that had never run. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- src/stackward/commands/check_config.py | 12 - src/stackward/commands/install_hooks.py | 109 +++++- src/stackward/commands/session.py | 11 +- src/stackward/store.py | 75 +++- tests/test_check_passphrase.py | 100 ++++- tests/test_install_hooks.py | 311 ++++++++++++++- tests/test_pre_commit.py | 38 +- tests/test_session.py | 496 +++++++++++++++++++++--- tests/test_store.py | 265 ++++++++++++- 9 files changed, 1291 insertions(+), 126 deletions(-) diff --git a/src/stackward/commands/check_config.py b/src/stackward/commands/check_config.py index c1ba7d6..e993f93 100644 --- a/src/stackward/commands/check_config.py +++ b/src/stackward/commands/check_config.py @@ -307,18 +307,6 @@ def _load_check_policy() -> CheckConfig: return load_config(config_path).check -# TODO(final-review): `hooks install` should refuse, or at least warn, when -# the repository has no `.stackward.toml` — a repo that installs the hook -# before writing a policy now discovers that at someone's first blocked -# commit rather than at install time. The change belongs in -# `commands.install_hooks.cmd_install_hooks` (not owned by this wave): after -# the hook is written, call `config.find_repo_config()` and, on `None`, -# print a warning naming the minimal file (`check_config.MINIMAL_POLICY`). -# A warning rather than a refusal, because installing the hook first and -# writing the policy second is a legitimate order to do things in, and -# `install_hooks` exits 1/2 only for reasons that make the *install* wrong. - - @fail_closed def cmd_check_config(args: argparse.Namespace) -> int: """Entry point for `stackward check-config FILE...`. diff --git a/src/stackward/commands/install_hooks.py b/src/stackward/commands/install_hooks.py index 69ec2f6..c63f39b 100644 --- a/src/stackward/commands/install_hooks.py +++ b/src/stackward/commands/install_hooks.py @@ -77,7 +77,8 @@ from datetime import datetime, timezone from pathlib import Path -from .check_config import fail_closed +from ..config import CONFIG_FILENAME, find_repo_config +from .check_config import MINIMAL_POLICY, fail_closed _HOOK_NAME = "pre-commit" @@ -97,6 +98,44 @@ class InstallError(Exception): *content* at all, only git's own metadata about paths.""" +def _git(args: list[str]) -> subprocess.CompletedProcess[str]: + """Run `git ` in the current working directory, under `LC_ALL=C`, + and hand the completed process back for the caller to interpret. + + **Every** git invocation in this module goes through here, including the + one whose exit code is not simply pass/fail (`_is_tracked`). That is the + point of it existing separately from `_run_git`: `_is_tracked` has to + read git's own stderr *text* to tell "this path is not tracked" apart + from "git could not answer", and it previously called `subprocess.run` + directly to do so -- a bypass that already produced one bug, and that + hid this one. Git's diagnostics are gettext-marked, so under any + non-English locale those strings are translated, `_is_tracked` matches + neither, and `hooks install` raises and exits 2 in *every* repository -- + not merely a repository with a redirected `core.hooksPath`. The command + that installs the gate stops working, for a reason that has nothing to + do with the repository it is run in. + + The environment is *merged*, never replaced, exactly as + `commands.pre_commit._run_git` merges it: `git` sets `GIT_DIR`, + `GIT_INDEX_FILE` and friends for a hook process, and a bare + `env={"LC_ALL": "C"}` would drop them and point this command at a + different repository than the one it was invoked for. + + Raises `InstallError` only for the `git` executable being missing -- + every other interpretation of the result belongs to the caller. + """ + try: + return subprocess.run( + ["git", *args], + capture_output=True, + text=True, + check=False, + env={**os.environ, "LC_ALL": "C"}, + ) + except OSError as exc: + raise InstallError(f"cannot run git {' '.join(args)}: {exc}") from exc + + def _run_git(args: list[str]) -> str: """Run `git ` in the current working directory and return its stdout, stripped of surrounding whitespace. @@ -105,10 +144,7 @@ def _run_git(args: list[str]) -> str: proceed: the `git` executable missing, or the invoked subcommand failing for any git-reported reason (most notably, the current directory not being inside a git working tree at all).""" - try: - proc = subprocess.run(["git", *args], capture_output=True, text=True, check=False) - except OSError as exc: - raise InstallError(f"cannot run git {' '.join(args)}: {exc}") from exc + proc = _git(args) if proc.returncode != 0: stderr = proc.stderr.strip() raise InstallError(f"git {' '.join(args)} failed: {stderr}") @@ -152,13 +188,15 @@ def _is_tracked(path: Path) -> bool: any non-zero exit rather than guessing -- so an unrecognised failure raises `InstallError` here too, mapped by the caller to the same could-not-determine exit code as every other git failure in this - command.""" - proc = subprocess.run( - ["git", "ls-files", "--error-unmatch", str(path)], - capture_output=True, - text=True, - check=False, - ) + command. + + Both recognised strings are gettext-marked in git's own source, so the + match below is only meaningful under a fixed locale. That is why this + goes through `_git`, which sets `LC_ALL=C` -- see its docstring; run + under a translated locale without it, *every* invocation in *every* + repository falls through to the raise below and `hooks install` exits + 2.""" + proc = _git(["ls-files", "--error-unmatch", str(path)]) if proc.returncode == 0: return True stderr = proc.stderr @@ -305,14 +343,18 @@ def cmd_install_hooks(_args: argparse.Namespace) -> int: must not fall through to Python's own default exit code of 1, which this tool reserves for a specific, different meaning everywhere else it appears. + + A repository with no policy file gets a **warning** after the hook is + written, never a refusal, and the exit code stays 0. Installing the hook + before writing the policy is a legitimate order to do things in, and + exit 1 and 2 here mean specific things about the *install* that this is + not one of -- but without the warning, a repository that installed the + gate and never declared a policy discovers that at somebody's first + blocked commit (`pre-commit` refuses a repository that declares none) + rather than at the moment it could still be fixed in one line. """ try: hooks_dir = _hooks_dir() - except InstallError as exc: - print(f"error: {exc}", file=sys.stderr) - return 2 - - try: tracked = _is_tracked(hooks_dir) except InstallError as exc: print(f"error: {exc}", file=sys.stderr) @@ -336,4 +378,37 @@ def cmd_install_hooks(_args: argparse.Namespace) -> int: if backup_path is not None: print(f"Existing hook backed up to {backup_path}") print(f"Installed pre-commit hook -> {hook_path}") + _warn_when_no_policy() return 0 + + +def _warn_when_no_policy() -> None: + """Warn on stderr when the repository declares no `.stackward.toml`. + + Deliberately *after* the hook is written and deliberately not an error: + see `cmd_install_hooks`'s own docstring. On stderr, not stdout, for the + same reason `store.warn_if_permissive` puts its warning there -- this + command's stdout says where the hook landed, and a caller reading that + must not have to filter advisory text out of it. + + `find_repo_config` returns `None` for absence rather than raising, so + this cannot turn a legitimate install into a failure. A + `.stackward.toml` that exists but does not parse is *present* as far as + this is concerned -- reporting it is the gate's job, at the point it + actually reads it, and duplicating that judgement here would give one + repository two different verdicts on the same file. + """ + if find_repo_config() is not None: + return + print( + f"warning: this repository declares no {CONFIG_FILENAME}, and " + "`stackward pre-commit` refuses a repository that declares none -- " + "the hook is installed, but every commit will be blocked until one " + "exists.", + file=sys.stderr, + ) + print( + f"Create {CONFIG_FILENAME} at the repository root with at least:\n" + + "\n".join(f" {line}" for line in MINIMAL_POLICY.splitlines()), + file=sys.stderr, + ) diff --git a/src/stackward/commands/session.py b/src/stackward/commands/session.py index 94bc2c2..0c19ce0 100644 --- a/src/stackward/commands/session.py +++ b/src/stackward/commands/session.py @@ -29,9 +29,14 @@ (`PATH`, `HOME`, `TERM` and everything else the child would otherwise need survive untouched); they never construct an environment from the four names alone. `tests/test_session.py` asserts this by diffing parent and child -environments, not by counting keys in the child -- an implementation that -happened to also inherit the right ambient variables by accident would still -fail a diff-based test if it dropped or altered anything else. +environments rather than by counting keys in the child -- and, since the +diff alone proved less than it appeared to, by seeding all three credential +names into the *parent* environment with different values, so that each of +the four is asserted by value and not merely by presence. Without that +seeding the diff was blind to the substitution that matters most: +`env.setdefault(name, ...)` in place of `env[name] = ...` passed the entire +suite, and hands a developer's own exported `AWS_ACCESS_KEY_ID` to a child +pointed at a different profile's backend. **The backend guard applies to `exec` and `shell`, never to `login`.** `login` is the command that *sets* Pulumi's persisted backend; gating it on diff --git a/src/stackward/store.py b/src/stackward/store.py index 6ac3aaa..61e30f0 100644 --- a/src/stackward/store.py +++ b/src/stackward/store.py @@ -3,14 +3,28 @@ A profile is a Pulumi state backend plus the credentials that unlock it. That pairing is split across two files in `${XDG_CONFIG_HOME:-~/.config}/stackward/`: - config TOML, mode 0644 — backend identity, and never a credential + config TOML, mode 0600 — backend identity credentials JSON, mode 0600 — one sealed envelope per profile -The split is the point. `config` says *which* bucket, endpoint and region a -profile names; it is safe to read, diff, paste into a ticket, and back up. Only -`credentials` holds anything secret, so only it needs the restrictive mode, and -a user who wants to check what a profile points at never has to open the file -that would show them a key if they mistyped a command. +The split is what makes `config` diffable, reviewable and safe to reason about +without decrypting anything: it says *which* bucket, endpoint and region a +profile names, and a user checking what a profile points at never has to open +the file that would show them a key if they mistyped a command. + +**`config` is not, however, credential-free, and must not be treated as such.** +It once carried mode 0644 on the stated grounds that it "holds backend identity +and never a credential". That claim was wrong, and in the one place it mattered +most: `pulumi login --help` documents `postgres://user:password@host/db` as a +supported backend form, `Profile.backend_url` passes a URL through verbatim, +and `commands.session` exists partly to redact exactly that password wherever +it prints one. A supported, documented configuration therefore put a live +credential in a world-readable file. Both files are 0600 now, and `config` is +covered by the same permissiveness warning `credentials` always was. A +`backend_url` carrying userinfo is still accepted — refusing it would make a +Pulumi-supported backend unusable through this tool, and there is nowhere else +to put it, since `credentials` holds only the sealed name/value envelopes the +session commands inject — but it is a credential, in a file whose mode and +error messages now assume so. **Everything else in this module follows from three refusals.** @@ -69,7 +83,10 @@ CREDENTIALS_FILENAME = "credentials" DIR_MODE = 0o700 -CONFIG_MODE = 0o644 +# 0600, not 0644: a `backend_url` of the documented `postgres://user:password@ +# host/db` form puts a live credential in `config`, so it gets the same +# owner-only mode `credentials` has. See the module docstring. +CONFIG_MODE = 0o600 CREDENTIALS_MODE = 0o600 ENV_PROFILE = "STACKWARD_PROFILE" @@ -395,6 +412,44 @@ def _build_profile(name: str, raw: Any) -> Profile: return profile +# The `(at line L, column C)` coordinate `tomllib` appends to every message +# it raises, and the only part of that message safe to quote back — see +# `_toml_position`. +_TOML_POSITION = re.compile(r"\(at (?:line \d+, column \d+|end of document)\)") + + +def _toml_position(exc: tomllib.TOMLDecodeError) -> str: + """Just the coordinate out of a `TOMLDecodeError`, as ` (at line L, + column C)`, or `""` when the message does not carry one. + + This exists because `config` can hold a credential and the parser's own + message can quote the document. Both halves of that were previously + believed false, and this call site quoted `exc` in full on the strength + of it: "`config` holds backend identity and never a credential, so + quoting tomllib's message -- which may include the offending line -- is + safe here in a way it would not be for `credentials`." + + Neither half survived checking. A `backend_url` of the documented + `postgres://user:password@host/db` form is a credential in `config` (see + the module docstring). And `tomllib` does echo document text: most of its + messages are a fixed description plus a coordinate, but not all of them + -- `tomllib.loads("[a]\\nx=1\\n[a]\\n")` raises `Cannot declare + ('a',) twice`, naming the key back. `credentials` is handled by exactly + this rule already, one function below, and for exactly this reason; the + two files differ in how *likely* a pasted secret is, not in whether the + parser can read one back. + + The coordinate is kept because it is what makes the error actionable and + it is structural, not quoted text. `TOMLDecodeError` exposes no + `lineno`/`colno` before 3.13 and this project supports 3.11, so it is + matched out of the message rather than read off the exception; a message + shape this does not recognise simply yields no coordinate, which loses + diagnostics and never discloses anything. + """ + match = _TOML_POSITION.search(str(exc)) + return f" {match.group(0)}" if match else "" + + def load_store_config(directory: Path | None = None) -> StoreConfig: """Parse `config`, or return an empty one when the file does not exist. @@ -415,10 +470,8 @@ def load_store_config(directory: Path | None = None) -> StoreConfig: except OSError as exc: raise StoreError(f"{path}: cannot read: {exc}") from exc except tomllib.TOMLDecodeError as exc: - # `config` holds backend identity and never a credential, so quoting - # tomllib's message — which may include the offending line — is safe - # here in a way it would not be for `credentials`. - raise StoreError(f"{path}: invalid TOML: {exc}") from exc + # Position only, never the parser's message — see `_toml_position`. + raise StoreError(f"{path}: invalid TOML{_toml_position(exc)}") from exc try: return _build_store_config(data) diff --git a/tests/test_check_passphrase.py b/tests/test_check_passphrase.py index f6cab21..055f7ed 100644 --- a/tests/test_check_passphrase.py +++ b/tests/test_check_passphrase.py @@ -46,6 +46,8 @@ import pytest from cryptography.hazmat.primitives import hashes + +from leakcheck import assert_no_leak from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC @@ -60,9 +62,15 @@ ) # Placeholder values only -- see GC4. Distinctive enough that an accidental -# substring match elsewhere in captured output would be implausible. -MARKER_PASSPHRASE = "marker-passphrase-7c8d9e0f" -WRONG_PASSPHRASE = "marker-wrong-passphrase-1a2b3c" +# substring match elsewhere in captured output would be implausible, and +# deliberately free of any word this command itself prints: the leak checks +# below look for any eight-character run of these values, and a placeholder +# spelled `marker-passphrase-...` collides with the command's own +# `passphrase fingerprint:` line on the run `passphra`. A placeholder that +# embeds the tool's vocabulary makes a partial-disclosure check unusable, +# which is how such a check ends up deleted. +MARKER_PASSPHRASE = "marker-candidate-7c8d9e0f" +WRONG_PASSPHRASE = "decoy-input-1a2b3c4d5e6f" # --------------------------------------------------------------------------- # A real vector, captured once from an actual `pulumi stack export` against @@ -79,6 +87,27 @@ REAL_PULUMI_PASSPHRASE = "placeholder-passphrase-marker" +def assert_no_disclosure(text: str, value: str, *, what: str) -> None: + """`assert_no_leak`, plus the one case it cannot see on its own here: a + **truncated** re-encoding of the value. + + `assert_no_leak` checks each whole encoding (so `value.hex()` in full is + caught) and every eight-character run of the *raw* value. This command + prints a fixed-length value derived from the passphrase, so the shape + that matters is neither: `passphrase.encode("utf-8").hex()[:16]` is a + 16-character *prefix* of the hex encoding, so the whole-encoding check + never matches it, and it shares no raw run with the passphrase because + it is hex. Verified rather than assumed -- that exact defective + implementation passes a bare `assert_no_leak(fingerprint(p), p)`. + + Handing the hex encoding in as the *value* is what closes it: the + run-based half of the check then operates on hex runs, and a truncated + hex encoding of any length above eight characters is caught. + """ + assert_no_leak(text, value, what=what) + assert_no_leak(text, value.encode("utf-8").hex(), what=f"{what}, hex-encoded") + + def _encrypt_pulumi_style(passphrase: str, plaintext: bytes = b"pulumi") -> str: """Build a `v1::v1::` string the same way Pulumi's own passphrase secrets provider does -- see the module @@ -197,12 +226,49 @@ def test_a_ciphertext_shorter_than_the_gcm_tag_errors_rather_than_being_reported # --------------------------------------------------------------------------- +# SHA-256 of the exact UTF-8 bytes, truncated to 16 hex characters, written +# out as literals. Computed once with `hashlib` at a Python prompt and pasted; +# deliberately not recomputed inside the test from `hashlib.sha256(...)`, which +# would only prove this file and `check_passphrase` agree on how to spell the +# same call rather than pinning the digest to a known value. +FINGERPRINT_OF_X = "2d711642b726b044" +FINGERPRINT_OF_MARKER = "7e742b5d41ffb360" + + +def test_fingerprint_is_the_truncated_sha256_of_the_passphrase_bytes(): + """The digest itself, pinned against known values. + + Nothing pinned it before. "16 hex characters", "stable", "differs for + different inputs" and "does not contain the passphrase" are jointly + satisfied by `passphrase.encode("utf-8").hex()[:16]` -- which is not a + digest at all, but the passphrase's own first eight bytes, and which + `cmd_check_passphrase` prints to stdout on every run. Every one of those + four tests passed against it. This is the assertion that does not. + """ + assert fingerprint("x") == FINGERPRINT_OF_X + assert fingerprint(MARKER_PASSPHRASE) == FINGERPRINT_OF_MARKER + + def test_fingerprint_is_16_hex_characters(): fp = fingerprint(MARKER_PASSPHRASE) assert len(fp) == 16 int(fp, 16) # raises ValueError if it is not hex +def test_fingerprint_is_over_the_exact_bytes_and_not_a_normalised_form(): + """The module's stated rule -- the fingerprint covers the identical bytes + handed to PBKDF2, unlike `crypto.py`'s stored passwords, which are NFC + normalised because they must reproduce across sessions. Two spellings of + the same grapheme derive two different keys, so they must fingerprint + differently; a fingerprint that normalised would report one candidate + for two that behave differently, which is the one thing it exists to + prevent.""" + composed = "caf\u00e9" # e-acute as a single code point + decomposed = "cafe\u0301" # 'e' plus a combining acute + assert composed != decomposed + assert fingerprint(composed) != fingerprint(decomposed) + + def test_fingerprint_is_stable_for_the_same_passphrase(): assert fingerprint(MARKER_PASSPHRASE) == fingerprint(MARKER_PASSPHRASE) @@ -214,8 +280,16 @@ def test_fingerprint_differs_for_different_passphrases(): assert fingerprint(MARKER_PASSPHRASE) != fingerprint(WRONG_PASSPHRASE) -def test_fingerprint_never_contains_the_passphrase_itself(): - assert MARKER_PASSPHRASE not in fingerprint(MARKER_PASSPHRASE) +def test_fingerprint_never_discloses_the_passphrase_whole_or_in_part(): + """`MARKER_PASSPHRASE not in fingerprint(...)` was a tautology: the + fingerprint is pinned to 16 characters by the test above and the + passphrase is 26, so no implementation could ever fail it. + `assert_no_leak` is what actually discriminates -- it checks the hex + encoding and every eight-character run, which is exactly the shape a + `passphrase.encode("utf-8").hex()[:16]` "fingerprint" takes.""" + assert_no_disclosure( + fingerprint(MARKER_PASSPHRASE), MARKER_PASSPHRASE, what="the passphrase" + ) # --------------------------------------------------------------------------- @@ -557,15 +631,21 @@ def test_the_passphrase_never_appears_in_output_on_any_outcome(stub_pulumi, monk main(["check-passphrase", "stack-accept", "stack-error"]) combined = "".join(capfd.readouterr()) - assert MARKER_PASSPHRASE not in combined + # `assert_no_disclosure`, not `not in`: this command *prints* a value + # derived from the passphrase on every run (`passphrase fingerprint: + # ...`), so a whole-string check here is exactly the assertion a partial + # or re-encoded disclosure walks straight through -- and did. + assert_no_disclosure(combined, MARKER_PASSPHRASE, what="the passphrase") # A rejected outcome too, in a separate run against a fresh capture. fake_pulumi(monkeypatch, stub_pulumi) feed_stdin(monkeypatch, WRONG_PASSPHRASE) main(["check-passphrase", "stack-accept"]) combined_reject = "".join(capfd.readouterr()) - assert MARKER_PASSPHRASE not in combined_reject - assert WRONG_PASSPHRASE not in combined_reject + assert_no_disclosure(combined_reject, MARKER_PASSPHRASE, what="the passphrase") + assert_no_disclosure( + combined_reject, WRONG_PASSPHRASE, what="the wrong passphrase" + ) def test_the_passphrase_never_reaches_pulumis_argv(stub_pulumi, monkeypatch, tmp_path): @@ -578,4 +658,6 @@ def test_the_passphrase_never_reaches_pulumis_argv(stub_pulumi, monkeypatch, tmp main(["check-passphrase", "stack-a"]) - assert MARKER_PASSPHRASE not in argv_dump.read_text() + assert_no_disclosure( + argv_dump.read_text(), MARKER_PASSPHRASE, what="the passphrase" + ) diff --git a/tests/test_install_hooks.py b/tests/test_install_hooks.py index 34e2184..a8a6dda 100644 --- a/tests/test_install_hooks.py +++ b/tests/test_install_hooks.py @@ -10,9 +10,12 @@ from __future__ import annotations +import os import stat import subprocess import sys +import time +from datetime import datetime, timezone from pathlib import Path import pytest @@ -32,7 +35,42 @@ def _git(repo: Path, *args: str) -> subprocess.CompletedProcess: @pytest.fixture -def repo(tmp_path: Path) -> Path: +def isolated_git(tmp_path: Path, monkeypatch) -> Path: + """Cut every `git` these tests run off from the machine's own git config. + + Without this, a developer (or a CI image) with a *global* + `core.hooksPath` -- what husky, lefthook and the `pre-commit` framework + all set, and precisely the configuration this command exists to handle + -- makes `git init` here inherit that hook, and sixty-odd tests across + this file and `test_pre_commit.py` error out for a reason that has + nothing to do with the code under test. Worse here than anywhere else + in the suite: a global `core.hooksPath` also silently moves where + `hooks install` writes, so tests asserting on `.git/hooks/pre-commit` + would be examining a directory the command was never pointed at. + + All four sources git consults are redirected, not only the global one: + `GIT_CONFIG_SYSTEM` covers `/etc/gitconfig`, which is exactly where an + org-wide `hooksPath` lives; `HOME` and `XDG_CONFIG_HOME` cover + `~/.gitconfig` and `$XDG_CONFIG_HOME/git/config`, which git still reads + on a path that leaves `GIT_CONFIG_GLOBAL` unset. `/dev/null` is git's + own documented spelling of "this configuration file does not exist" for + the two `GIT_CONFIG_*` variables. + + The redirect is set with `monkeypatch`, so it is still in place while + the *test body* runs `git commit` -- not only while the fixture built + the repository. + """ + home = tmp_path / "git-home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / "config")) + monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) + monkeypatch.setenv("GIT_CONFIG_SYSTEM", os.devnull) + return home + + +@pytest.fixture +def repo(tmp_path: Path, isolated_git: Path) -> Path: """A real, minimal git repository with one initial commit that declares a `.stackward.toml`. @@ -291,18 +329,92 @@ def test_existing_foreign_hook_is_backed_up(repo, monkeypatch, capsys): assert str(found[0]) in capsys.readouterr().out +def backup_stamp(hooks_dir: Path) -> str: + [backup] = backups(hooks_dir) + assert backup.name.startswith("pre-commit.backup.") + return backup.name.removeprefix("pre-commit.backup.") + + +@pytest.fixture +def local_time_far_from_utc(): + """Move this process's *local* time twelve hours away from UTC. + + `XXX-12` is a POSIX `TZ` string -- a made-up zone abbreviation and a + numeric offset, no geography and no place name -- so nothing here depends + on the tz database or on where the suite happens to run. `time.tzset()` + is what makes `datetime.now()` (which reads local time) actually observe + it. + + Restored by hand rather than through `monkeypatch`, because `tzset()` + caches the zone in the C library: undoing the variable without calling + `tzset()` again would leave every later test in the session running with + a shifted local clock. + """ + previous = os.environ.get("TZ") + os.environ["TZ"] = "XXX-12" + time.tzset() + try: + yield + finally: + if previous is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = previous + time.tzset() + + def test_backup_filename_matches_hook_dot_backup_dot_timestamp(repo, monkeypatch): + """The suffix parses as the full timestamp, not merely "starts with + eight digits". + + `stamp[:8].isdigit()` was satisfied by any date-shaped prefix, so + neither the microseconds (what stops two installs in the same second + from colliding) nor the format was pinned by it. + """ hooks_dir = repo / ".git" / "hooks" (hooks_dir / "pre-commit").write_text("#!/bin/sh\necho foreign\n") + before = datetime.now(timezone.utc) install(repo, monkeypatch) + after = datetime.now(timezone.utc) - [backup] = backups(hooks_dir) - assert backup.name.startswith("pre-commit.backup.") - # The suffix is a real timestamp, not an arbitrary string. - stamp = backup.name.removeprefix("pre-commit.backup.") - assert stamp # non-empty - assert stamp[:8].isdigit() # YYYYMMDD at minimum + stamp = backup_stamp(hooks_dir) + parsed = datetime.strptime(stamp, "%Y%m%dT%H%M%S.%fZ").replace(tzinfo=timezone.utc) + assert before <= parsed <= after + assert parsed.microsecond or "." in stamp # sub-second resolution is present + + +def test_backup_timestamp_is_utc_and_not_local_time( + repo, monkeypatch, local_time_far_from_utc +): + """The `Z` in the filename has to mean UTC. + + Nothing pinned it: every existing assertion on this name held equally + for `datetime.now()`, and on a machine whose local zone *is* UTC the two + are indistinguishable -- which is most CI images, so the gap would never + have surfaced there either. With local time twelve hours away, a + timestamp taken in local time cannot fall inside a UTC bracket around + the call. + + It matters because these names sort, and because two clones on machines + in different zones would otherwise produce backups whose apparent order + is wrong -- for files whose whole job is to be the recoverable copy of + somebody else's hook. + """ + hooks_dir = repo / ".git" / "hooks" + (hooks_dir / "pre-commit").write_text("#!/bin/sh\necho foreign\n") + + before = datetime.now(timezone.utc) + install(repo, monkeypatch) + after = datetime.now(timezone.utc) + + parsed = datetime.strptime(backup_stamp(hooks_dir), "%Y%m%dT%H%M%S.%fZ").replace( + tzinfo=timezone.utc + ) + assert before <= parsed <= after + # Guard against the fixture silently not taking effect, which would make + # the assertion above prove nothing. + assert abs((datetime.now() - datetime.now(timezone.utc).replace(tzinfo=None)).total_seconds()) > 3600 # --------------------------------------------------------------------------- @@ -578,6 +690,191 @@ def boom(_hooks_dir, _executable_path): assert "RuntimeError" in captured.err +# --------------------------------------------------------------------------- +# Git is invoked under a fixed locale, and the fixtures are hermetic. +# --------------------------------------------------------------------------- + + +def test_every_git_invocation_runs_under_a_fixed_locale(repo, monkeypatch): + """`LC_ALL=C` on every git call, with the environment merged, not + replaced. + + Hardening for `_run_git`, which branches on the exit code -- but a live + bug for `_is_tracked`, which branches on git's *message text*. Both + strings it recognises (`did not match any file`, `is outside + repository`) are gettext-marked in git's own source, so under any + non-English locale neither matches, `_is_tracked` raises, and `hooks + install` exits 2 in **every** repository, not only one with a redirected + `core.hooksPath`. The command that installs the gate stops working. + + The merge half matters as much as the override: `git` sets `GIT_DIR` and + friends for a hook process, and a bare `env={"LC_ALL": "C"}` would drop + them and point this command at a different repository than the one it + was invoked for. + """ + monkeypatch.setenv("STACKWARD_TEST_MARKER", "inherited") + seen: list[dict[str, str] | None] = [] + real_run = subprocess.run + + def recording_run(args, **kwargs): + if args and args[0] == "git": + seen.append(kwargs.get("env")) + return real_run(args, **kwargs) + + monkeypatch.setattr(subprocess, "run", recording_run) + assert install(repo, monkeypatch) == 0 + + assert seen, "no git invocation was recorded" + for env in seen: + assert env is not None, "a git invocation inherited the ambient locale" + assert env.get("LC_ALL") == "C" + assert env.get("STACKWARD_TEST_MARKER") == "inherited" + + +def test_hooks_install_survives_a_git_that_translates_its_diagnostics( + repo, monkeypatch, capsys +): + """The live half of the bug above, at the level where it actually bites. + + `_is_tracked` recognises "not tracked" by matching two English phrases, + and both are gettext-marked in git's own source. Simply exporting a + non-English `LC_ALL` here would prove nothing on a machine with no git + translations installed -- which is most CI images, and this one -- so + the translation is modelled at the subprocess boundary instead, exactly + as gettext behaves: git's stderr comes back translated **unless** the + invocation asked for the C locale. + + Deterministic everywhere, and it goes red the moment the `LC_ALL=C` + override is dropped: `_is_tracked` then matches neither phrase, raises, + and `hooks install` exits 2 in an ordinary repository with an ordinary + `.git/hooks` -- the command that installs the gate, refusing to install + it, for a reason that has nothing to do with the repository. + """ + real_run = subprocess.run + translations = { + "did not match any file": "ne correspond a aucun fichier", + "is outside repository": "est en dehors du depot", + } + + def translating_run(args, **kwargs): + proc = real_run(args, **kwargs) + env = kwargs.get("env") or os.environ + if list(args[:1]) == ["git"] and env.get("LC_ALL") != "C": + stderr = proc.stderr + if isinstance(stderr, str): + for english, translated in translations.items(): + stderr = stderr.replace(english, translated) + return subprocess.CompletedProcess( + args, proc.returncode, proc.stdout, stderr + ) + return proc + + monkeypatch.setattr(subprocess, "run", translating_run) + + assert install(repo, monkeypatch) == 0 + assert (repo / ".git" / "hooks" / "pre-commit").is_file() + assert capsys.readouterr().err == "" + + +def test_the_repo_fixture_ignores_a_global_core_hookspath(repo, isolated_git): + """The fixture's isolation, asserted rather than assumed. + + A global `core.hooksPath` -- husky, lefthook, `pre-commit`, or an + org-wide `/etc/gitconfig` -- silently moves where git looks for hooks, + so without isolation the tests in this file examine a directory the + command was never pointed at, and sixty-odd tests across this file and + `test_pre_commit.py` error out. + + The positive control is the point: it proves git *would* honour the file + written below, so the first assertion is about the fixture suppressing + it and not about the file being ineffective. + """ + hostile = isolated_git / ".gitconfig" + hostile.write_text("[core]\n\thooksPath = /nonexistent/global-hooks\n") + + def hooks_path(env: dict[str, str]) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", "config", "--get", "core.hooksPath"], + cwd=repo, + capture_output=True, + text=True, + env=env, + ) + + control = hooks_path({**os.environ, "GIT_CONFIG_GLOBAL": str(hostile)}) + assert control.stdout.strip() == "/nonexistent/global-hooks" + + isolated = hooks_path(dict(os.environ)) + assert isolated.returncode == 1 + assert isolated.stdout.strip() == "" + + +# --------------------------------------------------------------------------- +# A repository with no policy file: a warning, never a refusal. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def repo_without_policy(repo, monkeypatch) -> Path: + """`repo` with its `.stackward.toml` removed from the working tree. + + `find_repo_config` reads the filesystem, not the index -- deliberately, + since `hooks install` is not the gate and does not scan staged content + -- so removing the file is enough to make the repository policy-less + from this command's point of view. + """ + (repo / ".stackward.toml").unlink() + return repo + + +def test_no_policy_warns_on_stderr_and_still_installs(repo_without_policy, monkeypatch, capsys): + """A repository that has not declared a policy yet gets told so, at the + moment it can still be fixed in one line -- rather than at somebody's + first blocked commit, since `pre-commit` refuses a repository that + declares none. + + A warning, not a refusal: installing the hook before writing the policy + is a legitimate order to do things in, and this command's exit 1 and 2 + mean specific things about the *install*, which this is not one of. + """ + assert install(repo_without_policy, monkeypatch) == 0 + assert (repo_without_policy / ".git" / "hooks" / "pre-commit").is_file() + + captured = capsys.readouterr() + assert "warning" in captured.err.lower() + assert ".stackward.toml" in captured.err + # Names the minimal file to write, not merely that one is missing. + for line in install_hooks_module.MINIMAL_POLICY.splitlines(): + assert line in captured.err + # stdout stays the machine-readable "where the hook went" line. + assert "warning" not in captured.out.lower() + assert str(repo_without_policy / ".git" / "hooks" / "pre-commit") in captured.out + + +def test_a_repository_that_declares_a_policy_gets_no_warning(repo, monkeypatch, capsys): + """The other half: the warning must not fire for the normal case, or it + is noise everyone learns to ignore.""" + assert (repo / ".stackward.toml").is_file() + assert install(repo, monkeypatch) == 0 + assert "warning" not in capsys.readouterr().err.lower() + + +def test_the_policy_warning_does_not_change_the_exit_code_of_a_refusal( + repo_without_policy, monkeypatch, capsys +): + """A tracked hooks directory is still exit 1, and the hook was not + written -- so there is nothing to warn about and the refusal is not + diluted by a second message about a different problem.""" + (repo_without_policy / "githooks").mkdir() + (repo_without_policy / "githooks" / "pre-commit").write_text("#!/bin/sh\n") + _git(repo_without_policy, "add", "githooks") + _git(repo_without_policy, "commit", "-q", "-m", "commit shared hooks") + _git(repo_without_policy, "config", "core.hooksPath", "githooks") + + assert install(repo_without_policy, monkeypatch) == 1 + assert "no .stackward.toml" not in capsys.readouterr().err + + # --------------------------------------------------------------------------- # Wiring. # --------------------------------------------------------------------------- diff --git a/tests/test_pre_commit.py b/tests/test_pre_commit.py index 33a605b..ad0f000 100644 --- a/tests/test_pre_commit.py +++ b/tests/test_pre_commit.py @@ -11,6 +11,7 @@ from __future__ import annotations +import os import subprocess from pathlib import Path @@ -37,7 +38,42 @@ def _git(repo: Path, *args: str) -> subprocess.CompletedProcess: @pytest.fixture -def bare_repo(tmp_path: Path) -> Path: +def isolated_git(tmp_path: Path, monkeypatch) -> Path: + """Cut every `git` these tests run off from the machine's own git config. + + Without this, a developer (or a CI image) with a *global* + `core.hooksPath` -- what husky, lefthook and the `pre-commit` framework + all set, and precisely the configuration `hooks install` exists to + handle -- makes `git init`/`git commit` here inherit that hook, and + sixty-odd tests across this file and `test_install_hooks.py` error out + for a reason that has nothing to do with the code under test. A suite + whose result depends on the developer's `~/.gitconfig` is not testing + what it claims to. + + All four sources git consults are redirected, not only the global one: + `GIT_CONFIG_SYSTEM` covers `/etc/gitconfig`, which is exactly where an + org-wide `hooksPath` lives; `HOME` and `XDG_CONFIG_HOME` cover + `~/.gitconfig` and `$XDG_CONFIG_HOME/git/config`, which git still reads + on a path that leaves `GIT_CONFIG_GLOBAL` unset. `/dev/null` is git's + own documented spelling of "this configuration file does not exist" for + the two `GIT_CONFIG_*` variables. + + The redirect is set with `monkeypatch`, so it is still in place while + the *test body* runs `git commit` -- not only while the fixture built + the repository. `test_install_hooks.py` has its own copy for the same + reason, rather than importing this one across test modules. + """ + home = tmp_path / "git-home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / "config")) + monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) + monkeypatch.setenv("GIT_CONFIG_SYSTEM", os.devnull) + return home + + +@pytest.fixture +def bare_repo(tmp_path: Path, isolated_git: Path) -> Path: """A real git repository with one empty commit and **no** policy file. What every repository looked like before `.stackward.toml` was a diff --git a/tests/test_session.py b/tests/test_session.py index e8e396b..0ea7ad1 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -34,6 +34,7 @@ import pytest +from leakcheck import assert_no_leak from stackward import store from stackward.cli import main from stackward.commands import session @@ -47,8 +48,15 @@ ) from stackward.store import Profile, ProfileError, StoreError -PASSWORD = "the store password" -WRONG_PASSWORD = "not the store password" +# Placeholder values only -- see GC4. Deliberately free of any word this +# module itself prints: the leak checks below look for any eight-character +# run of these values, and the previous spelling, `the store password`, +# collides with `_read_store_password`'s own refusal ("no store password +# available: set STACKWARD_PASSWORD...") on the run ` store p`. A placeholder +# that embeds the tool's vocabulary makes a partial-disclosure check +# unusable, which is how such a check ends up deleted. +PASSWORD = "store-unlock-4d7a2f9c1e8b" +WRONG_PASSWORD = "wrong-unlock-6b3e0d5a2f7c" # Placeholder values only -- see GC4 in the task brief. Distinctive enough # that an accidental substring match elsewhere in a test's own output would @@ -64,6 +72,30 @@ } ALL_MARKERS = (MARKER_KEY, MARKER_SECRET, MARKER_PASSPHRASE) +# What a developer who already has credentials exported in their own shell +# looks like -- a different, non-secret set of values under the *same* three +# names, seeded into the parent environment of every test in this file by the +# autouse fixture below. Distinct from the MARKERs on purpose: "the child got +# the profile's value" and "the child inherited the shell's value" are +# different outcomes, and a test whose parent environment does not carry these +# names at all cannot tell them apart. +AMBIENT_KEY = "ambient-access-key-2f4e6a8c" +AMBIENT_SECRET = "ambient-secret-key-1d3f5b7d" +AMBIENT_PASSPHRASE = "ambient-config-value-0e2c4a6e" +AMBIENT_CREDENTIALS = { + AWS_ACCESS_KEY_ID: AMBIENT_KEY, + AWS_SECRET_ACCESS_KEY: AMBIENT_SECRET, + PULUMI_CONFIG_PASSPHRASE: AMBIENT_PASSPHRASE, +} + +# A backend URL that carries a password in its userinfo -- the +# `postgres://user:password@host/db` form `pulumi login --help` documents and +# `_redact_url` exists for. Used wherever a test would otherwise only ever see +# a `file://` URL, for which `_redact_url` is the identity function and every +# assertion about redaction is therefore vacuous. +USERINFO_BACKEND_URL = "postgres://dbuser:super-secret-password@db.invalid:5432/state" +USERINFO_PASSWORD = "super-secret-password" + @pytest.fixture(autouse=True) def cheap_kdf(monkeypatch): @@ -79,12 +111,41 @@ def cheap_kdf(monkeypatch): @pytest.fixture(autouse=True) def no_real_pulumi_state(monkeypatch, tmp_path): - """Point `PULUMI_HOME` at an empty, per-test directory, and clear - `PULUMI_BACKEND_URL`, so no test in this file can read or depend on this + """Fix this file's *parent* environment: the four names `exec`/`shell` + overlay, and nothing about them left to whoever's shell runs the suite. + + `PULUMI_HOME` points at an empty, per-test directory and + `PULUMI_BACKEND_URL` is cleared, so no test can read or depend on this machine's real Pulumi state -- `_current_backend()` consults both, in - that order, once `PULUMI_BACKEND_URL` is set.""" + that order, once `PULUMI_BACKEND_URL` is set. + + The three credential names are **set**, not cleared, and that is the + single decision this fixture exists to make for the whole file. It does + two jobs at once, which were previously two separate bugs: + + *Isolation.* A developer with `AWS_ACCESS_KEY_ID` or + `AWS_SECRET_ACCESS_KEY` exported in their own shell -- an entirely + ordinary state -- failed the environment-delta tests below, which + computed `child_keys - parent_keys` and got a smaller set than they + expected. The suite's result must not depend on the shell it is run + from. + + *Discrimination.* Nothing in this suite ever put a credential name in the + parent environment, which made the delta tests blind to the one + substitution that matters: `env[name] = credentials[name]` weakened to + `env.setdefault(name, credentials[name])` passed every test in this file. + That implementation hands a developer's ambient key to a child paired + with a *different* profile's backend -- credentials for one account + pointed at another account's state. `session.py`'s own module docstring + claims the diff-based tests catch "an implementation that happened to + also inherit the right ambient variables"; this is precisely the case + they could not see, and seeding these names is what makes the assertions + below about the child's *values* rather than merely its set of keys. + """ monkeypatch.setenv("PULUMI_HOME", str(tmp_path / "pulumi-home")) monkeypatch.delenv(PULUMI_BACKEND_URL, raising=False) + for name, value in AMBIENT_CREDENTIALS.items(): + monkeypatch.setenv(name, value) @pytest.fixture @@ -213,6 +274,22 @@ def test_component_form_with_endpoint_forces_path_style(): assert "s3ForcePathStyle=true" in url +def test_component_form_percent_encodes_a_scheme_bearing_endpoint(): + """An `endpoint` is routinely a full URL (`https://host:port`), and it + goes into a *query parameter*, where `://` and `:` are reserved. Correct + by construction today because `urlencode` quotes its values -- but + nothing exercised it, so hand-assembling the query string (the obvious + "simplification") would produce a URL Pulumi parses differently and no + test would notice.""" + profile = Profile( + name="p", bucket="placeholder-bucket", endpoint="https://placeholder.invalid:9000" + ) + url = session._compose_backend_url(profile) + assert "endpoint=https%3A%2F%2Fplaceholder.invalid%3A9000" in url + assert "://placeholder.invalid" not in url.removeprefix("s3://placeholder-bucket") + assert "s3ForcePathStyle=true" in url + + def test_component_form_without_an_endpoint_never_adds_path_style(): profile = Profile(name="p", bucket="placeholder-bucket", region="placeholder-region") assert "s3ForcePathStyle" not in session._compose_backend_url(profile) @@ -360,6 +437,40 @@ def test_guard_refuses_a_mismatch_naming_both(monkeypatch): assert "file:///other" in message +@pytest.mark.parametrize( + ("current", "resolved"), + [ + # The resolved URL is a *prefix* of the current one, and vice versa. + # Two different buckets, two different backends -- containment is not + # a match, and a substring comparison would silently allow the pair. + ("s3://placeholder-bucket-two/prefix", "s3://placeholder-bucket"), + ("s3://placeholder-bucket", "s3://placeholder-bucket-two/prefix"), + # A trailing path segment, the same shape by a different route. + ("file:///backend/inner", "file:///backend"), + ("file:///backend", "file:///backend/inner"), + ], +) +def test_guard_refuses_a_backend_that_merely_contains_the_other( + monkeypatch, current, resolved +): + """Containment is not equality, and the guard must not treat it as such. + + Every existing guard test pairs two URLs where neither contains the + other, so `current != resolved_url` weakened to `resolved_url not in + current` -- a substring test -- passes all of them. It would let a + profile pointing at one bucket run against a live login to a + differently-named bucket whose name merely starts the same way: a + different backend, silently accepted, which is the entire failure the + guard exists to prevent. + + The comparison in `session.py` is already an exact `!=`; this pins it + there so it cannot be relaxed into containment by a later "fix". + """ + monkeypatch.setattr(session, "_current_backend", lambda: current) + with pytest.raises(SessionError, match="backend mismatch"): + session._check_backend_guard(resolved) + + def test_guard_message_redacts_userinfo_in_both_urls(monkeypatch): """The guard's "naming both" message must never print a password that happened to be embedded in a backend URL (`postgres://user:pass@host/db` @@ -505,6 +616,52 @@ def poisoned_stdin(monkeypatch): monkeypatch.setattr(sys, "stdin", _PoisonedStdin()) +def test_tty_available_asks_for_dev_tty_specifically_and_closes_it(monkeypatch): + """The function body itself, which no test had ever executed. + + Every existing test either set `STACKWARD_PASSWORD` or monkeypatched + `_tty_available` away, so redirecting the probe from `/dev/tty` to + `/dev/null` -- which always opens -- passed the whole suite. That + substitution makes the function answer "yes, there is a terminal" in + every environment, including the ones it exists to refuse, so the path is + asserted here and not merely the boolean. + + `/dev/tty` is the point: it is the controlling terminal, reachable + independently of `sys.stdin`, which for `exec`/`shell` belongs to the + child. Asking about anything else answers a different question. + """ + opened: list[str] = [] + closed: list[int] = [] + real_open, real_close = os.open, os.close + + def spy_open(path, flags, *args, **kwargs): + opened.append(path) + return real_open(os.devnull, os.O_RDWR) + + def spy_close(fd): + closed.append(fd) + return real_close(fd) + + monkeypatch.setattr(session.os, "open", spy_open) + monkeypatch.setattr(session.os, "close", spy_close) + + assert session._tty_available() is True + + assert opened == ["/dev/tty"] + assert closed, "the probe leaked its file descriptor" + + +def test_tty_available_is_false_when_dev_tty_cannot_be_opened(monkeypatch): + """The refusal branch, exercised through the real function rather than + by patching it out.""" + + def refuse(path, *args, **kwargs): + raise OSError("simulated: no controlling terminal") + + monkeypatch.setattr(session.os, "open", refuse) + assert session._tty_available() is False + + def test_password_from_env_var_is_used_without_prompting(monkeypatch): monkeypatch.setenv(ENV_STORE_PASSWORD, "from-the-environment") @@ -569,6 +726,16 @@ def test_extra_names_beyond_the_three_required_are_accepted(): def test_child_env_overlays_exactly_four_names_on_a_copy_of_the_parent(monkeypatch): + """Every one of the four names carries the *profile's* value, over an + otherwise untouched copy of the parent environment. + + The parent already carries all three credential names, with different + values (see `no_real_pulumi_state`), so this is an assertion about + overwriting and not merely about setting: `env.setdefault(name, ...)` + leaves the ambient value in place and fails here. Without that seeding + the whole test reduced to a set-difference over key *names*, which + `setdefault` satisfies exactly. + """ # Injected, not merely assumed absent: this is the exact non-interactive # path (`STACKWARD_PASSWORD=x stackward exec -- ...`) the scrubbing # protects, and a difference-based assertion that never puts the name in @@ -581,20 +748,16 @@ def test_child_env_overlays_exactly_four_names_on_a_copy_of_the_parent(monkeypat parent_keys = set(parent_before) child_keys = set(env) - assert child_keys - parent_keys == { - AWS_ACCESS_KEY_ID, - AWS_SECRET_ACCESS_KEY, - PULUMI_CONFIG_PASSPHRASE, - PULUMI_BACKEND_URL, - } + # `PULUMI_BACKEND_URL` is the only name the parent does not already have. + assert child_keys - parent_keys == {PULUMI_BACKEND_URL} # The one name that must be *dropped*, not merely left alone. assert parent_keys - child_keys == {ENV_STORE_PASSWORD} assert ENV_STORE_PASSWORD not in env - for key in parent_keys - {ENV_STORE_PASSWORD}: + for key in parent_keys - {ENV_STORE_PASSWORD} - set(AMBIENT_CREDENTIALS): assert env[key] == parent_before[key] # nothing else changed - assert env[AWS_ACCESS_KEY_ID] == MARKER_KEY - assert env[AWS_SECRET_ACCESS_KEY] == MARKER_SECRET - assert env[PULUMI_CONFIG_PASSPHRASE] == MARKER_PASSPHRASE + assert env[AWS_ACCESS_KEY_ID] == MARKER_KEY != AMBIENT_KEY + assert env[AWS_SECRET_ACCESS_KEY] == MARKER_SECRET != AMBIENT_SECRET + assert env[PULUMI_CONFIG_PASSPHRASE] == MARKER_PASSPHRASE != AMBIENT_PASSPHRASE assert env[PULUMI_BACKEND_URL] == "file:///backend" assert PASSWORD not in env.values() @@ -918,6 +1081,42 @@ def test_prepare_env_profile_with_no_credentials_set_raises(store_directory, mon # --------------------------------------------------------------------------- +def assert_expected_session_env( + child_env: dict[str, str], parent_before: dict[str, str], *, backend_url: str +) -> None: + """The full contract `exec` and `shell` share, in one place. + + Both commands overlay the same four names onto a copy of the caller's + environment and scrub the same one, so both get the same assertion block. + `shell` previously had no equivalent at all -- the one test on it checked + two keys, and popping `PULUMI_CONFIG_PASSPHRASE` out of `shell`'s child + environment passed the whole suite while failing two `exec` tests. + + The four names are asserted by *value* against the profile's, and against + the different values the same names carry in the parent (see + `no_real_pulumi_state`), so an implementation that inherited rather than + overlaid them fails here rather than satisfying a set-difference over + names. + """ + parent_keys = set(parent_before) + child_keys = set(child_env) + # `PULUMI_BACKEND_URL` is the only name the parent does not already carry. + assert child_keys - parent_keys == {PULUMI_BACKEND_URL} + # The one name that must be *dropped*, not merely left alone: the store + # master password unlocks every profile, not just this one, and must + # never reach caller-supplied code. + assert parent_keys - child_keys == {ENV_STORE_PASSWORD} + assert ENV_STORE_PASSWORD not in child_env + untouched = parent_keys - {"STUB_DUMP_ENV_TO", ENV_STORE_PASSWORD} + for key in untouched - set(AMBIENT_CREDENTIALS): + assert child_env[key] == parent_before[key] + assert child_env[AWS_ACCESS_KEY_ID] == MARKER_KEY != AMBIENT_KEY + assert child_env[AWS_SECRET_ACCESS_KEY] == MARKER_SECRET != AMBIENT_SECRET + assert child_env[PULUMI_CONFIG_PASSPHRASE] == MARKER_PASSPHRASE != AMBIENT_PASSPHRASE + assert child_env[PULUMI_BACKEND_URL] == backend_url + assert PASSWORD not in child_env.values() + + @pytest.fixture def cli_store(tmp_path, monkeypatch) -> Path: """Points the real config-home lookup (`XDG_CONFIG_HOME`) at an isolated @@ -979,31 +1178,14 @@ def test_exec_runs_the_child_with_exactly_the_expected_environment_delta( assert code == 0 child_env = json.loads(dump.read_text()) - parent_keys = set(parent_before) - child_keys = set(child_env) - assert child_keys - parent_keys == { - AWS_ACCESS_KEY_ID, - AWS_SECRET_ACCESS_KEY, - PULUMI_CONFIG_PASSPHRASE, - PULUMI_BACKEND_URL, - } - # The one name that must be *dropped*, not merely left alone: the store - # master password unlocks every profile, not just this one, and must - # never reach caller-supplied code. - assert parent_keys - child_keys == {ENV_STORE_PASSWORD} - assert ENV_STORE_PASSWORD not in child_env - for key in parent_keys - {"STUB_DUMP_ENV_TO", ENV_STORE_PASSWORD}: - assert child_env[key] == parent_before[key] - assert child_env[AWS_ACCESS_KEY_ID] == MARKER_KEY - assert child_env[AWS_SECRET_ACCESS_KEY] == MARKER_SECRET - assert child_env[PULUMI_CONFIG_PASSPHRASE] == MARKER_PASSPHRASE - assert child_env[PULUMI_BACKEND_URL] == "file:///staging-backend" - assert PASSWORD not in child_env.values() + assert_expected_session_env( + child_env, parent_before, backend_url="file:///staging-backend" + ) captured = capfd.readouterr() for marker in (*ALL_MARKERS, PASSWORD): - assert marker not in captured.out - assert marker not in captured.err + assert_no_leak(captured.out, marker, what="a credential") + assert_no_leak(captured.err, marker, what="a credential") def test_exec_passes_extra_flags_through_to_the_child_argv( @@ -1092,6 +1274,62 @@ def test_exec_propagates_a_signal_terminated_child(cli_store, monkeypatch, stub, assert marker not in captured.err +def test_exec_reports_a_missing_command_without_disclosing_the_environment( + cli_store, monkeypatch, tmp_path, stub, capfd +): + """`stackward exec -- puluim up` -- an ordinary typo -- must not print + the credentials it had just put in that child's environment. + + This is `exec`'s single most likely failure, and it was the one path + where a decrypted environment is fully built and live at the moment + something goes wrong. Nothing covered it: the only test on + `_run_child`'s `OSError` branch called it directly with `env=None`, and + no `exec` or `shell` test ever named a command that does not exist, so + adding `env={env}` to that message passed all 781 tests. + + `capfd`, not `capsys`: the message is this process's own, but the whole + point of the path is a real spawn attempt, and this file's convention is + file-descriptor capture wherever a child is involved. + """ + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + missing = tmp_path / "puluim" + + code = main(["exec", "--profile", "staging", "--", str(missing)]) + + assert code == 2 + captured = capfd.readouterr() + # It still says which command it could not run -- the message has to stay + # useful, and the name is the caller's own argv, never a credential. + assert "puluim" in captured.err + for marker in (*ALL_MARKERS, PASSWORD): + assert_no_leak(captured.out, marker, what="a credential") + assert_no_leak(captured.err, marker, what="a credential") + + +def test_shell_reports_a_missing_shell_without_disclosing_the_environment( + cli_store, monkeypatch, tmp_path, capfd +): + """The same path through `shell`, whose `$SHELL` can equally name + something that is not there (a shell uninstalled since login).""" + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + monkeypatch.setenv("SHELL", str(tmp_path / "no-such-shell")) + + code = main(["shell", "--profile", "staging"]) + + assert code == 2 + captured = capfd.readouterr() + assert "no-such-shell" in captured.err + for marker in (*ALL_MARKERS, PASSWORD): + assert_no_leak(captured.out, marker, what="a credential") + assert_no_leak(captured.err, marker, what="a credential") + + def test_exec_refuses_a_backend_mismatch_and_never_runs_the_child( cli_store, monkeypatch, tmp_path, stub, capsys ): @@ -1161,6 +1399,92 @@ def test_exec_with_no_password_available_refuses_and_never_runs_the_child( assert marker not in captured.err +def _run_detached(argv: list[str], env: dict[str, str], *, stdin: str, cwd: Path): + """Run `stackward ` in a real process with **no controlling + terminal**, feeding `stdin` to it. + + `start_new_session=True` is `setsid()`: the child gets a fresh session and + therefore no controlling terminal at all, so `open("/dev/tty")` fails with + `ENXIO` exactly as it does in a CI job, a git hook run from a GUI client, + or a `nohup`. That state cannot be produced inside the test process, which + has a terminal whenever the suite is run from one -- and monkeypatching + `_tty_available` to `False`, which is what every existing test does, is + assuming the answer rather than provoking it. + + The environment is passed through wholesale (minus what the caller + changes) so that `PYTHONPATH` and the rest of the interpreter's own setup + reach the child. + """ + program = "import sys; from stackward.cli import main; sys.exit(main(sys.argv[1:]))" + return subprocess.run( + [sys.executable, "-c", program, *argv], + input=stdin, + capture_output=True, + text=True, + env=env, + cwd=str(cwd), + start_new_session=True, + timeout=120, + ) + + +def test_no_tty_and_no_password_refuses_instead_of_reading_the_childs_stdin( + cli_store, tmp_path, stub +): + """The fail-open `_tty_available` exists to prevent, provoked for real. + + Without the check, `getpass.getpass` cannot open `/dev/tty` either -- and + falls back to reading `sys.stdin`, with echo. For `exec` and `shell` that + descriptor is the *child's*: the password is consumed before the child + ever sees it, and printed on screen on the way past. The refusal has to + happen first. + + Two halves, and both are needed. The exit code alone does not + discriminate: with the probe pointed at `/dev/null` instead of + `/dev/tty`, `getpass` reads the password off the pipe below, the store + opens, and the child *runs* -- so the marker file is what says whether + the fallback happened. The positive control at the end proves the marker + file would have appeared, in this same detached setup, had a password + been available at all. + """ + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + marker_file = tmp_path / "the-child-ran" + + env = dict(os.environ) + env.pop(ENV_STORE_PASSWORD, None) + env.pop(PULUMI_BACKEND_URL, None) + # An empty, per-test Pulumi home: otherwise the backend guard could refuse + # first, for an unrelated reason, and this test would pass having never + # reached the password at all. + env["PULUMI_HOME"] = str(tmp_path / "empty-pulumi-home") + env["STUB_DUMP_ARGV_TO"] = str(marker_file) + + argv = ["exec", "--profile", "staging", "--", str(stub)] + result = _run_detached(argv, env, stdin=PASSWORD + "\n", cwd=tmp_path) + + assert result.returncode == 2 + # This module's own refusal, naming the non-interactive escape hatch -- + # not a traceback, and not a generic fail-closed catch-all. + assert ENV_STORE_PASSWORD in result.stderr + assert "Traceback" not in result.stderr + assert not marker_file.exists(), "the child ran: stdin was consumed as a password" + for marker in (*ALL_MARKERS, PASSWORD): + assert_no_leak(result.stdout, marker, what="a credential") + assert_no_leak(result.stderr, marker, what="a credential") + + # Positive control: the same detached process, with the password supplied + # the way it is supposed to be, does run the child. Without this, the + # `not marker_file.exists()` assertion above would also be satisfied by a + # setup that could never have produced the file at all. + control = _run_detached( + argv, {**env, ENV_STORE_PASSWORD: PASSWORD}, stdin="", cwd=tmp_path + ) + assert control.returncode == 0 + assert marker_file.exists() + + def test_exec_reports_an_unexpected_exception_via_fail_closed( cli_store, monkeypatch, stub, capsys ): @@ -1206,8 +1530,16 @@ def test_exec_uses_the_wired_repo_profile_tier_end_to_end( def test_shell_runs_stub_as_the_shell_with_the_expected_environment_delta( - cli_store, monkeypatch, tmp_path, stub + cli_store, monkeypatch, tmp_path, stub, capfd ): + """`shell` gets the same assertion block as `exec`, not a weaker one. + + This test previously checked two keys despite its name, which left + `shell`'s child environment almost entirely unconstrained: popping + `PULUMI_CONFIG_PASSPHRASE` out of it before `_run_child` passed the whole + suite, while the identical pop in `cmd_exec` failed two tests. The two + commands make the same promise and now carry the same proof of it. + """ seed_profile( cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS ) @@ -1215,13 +1547,55 @@ def test_shell_runs_stub_as_the_shell_with_the_expected_environment_delta( monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) dump = tmp_path / "env.json" monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump)) + parent_before = dict(os.environ) code = main(["shell", "--profile", "staging"]) assert code == 0 child_env = json.loads(dump.read_text()) - assert child_env[PULUMI_BACKEND_URL] == "file:///staging-backend" - assert child_env[AWS_ACCESS_KEY_ID] == MARKER_KEY + assert_expected_session_env( + child_env, parent_before, backend_url="file:///staging-backend" + ) + + captured = capfd.readouterr() + for marker in (*ALL_MARKERS, PASSWORD): + assert_no_leak(captured.out, marker, what="a credential") + assert_no_leak(captured.err, marker, what="a credential") + + +@pytest.mark.parametrize("command", ["exec", "shell"]) +def test_a_userinfo_bearing_backend_url_reaches_the_child_verbatim( + cli_store, monkeypatch, tmp_path, stub, command +): + """`PULUMI_BACKEND_URL` is delivered as written, never redacted. + + `_redact_url` is for *printing* a backend URL. Applying it on the + injection path instead would hand `pulumi` a URL with `@` where + the credentials belong, and the backend would simply not work. Nothing + caught that: every environment-dumping test used a `file://` URL, for + which `_redact_url` is the identity function, so + `env[PULUMI_BACKEND_URL] = _redact_url(backend_url)` passed the entire + suite -- in both `cmd_exec`'s helper and `cmd_login`. + + A `postgres://user:password@host/db` backend, which `pulumi login --help` + documents, is the case that tells the two apart. + """ + seed_profile( + cli_store, "staging", backend_url=USERINFO_BACKEND_URL, credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv("SHELL", str(stub)) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + dump = tmp_path / "env.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump)) + argv = ["exec", "--profile", "staging", "--", str(stub)] + if command == "shell": + argv = ["shell", "--profile", "staging"] + + assert main(argv) == 0 + + child_env = json.loads(dump.read_text()) + assert child_env[PULUMI_BACKEND_URL] == USERINFO_BACKEND_URL + assert "" not in child_env[PULUMI_BACKEND_URL] def test_shell_propagates_a_signal_terminated_child(cli_store, monkeypatch, stub, capfd): @@ -1334,16 +1708,33 @@ def test_login_scrubs_the_store_password_from_pulumis_environment( assert PASSWORD not in child_env.values() -def test_login_never_puts_a_userinfo_bearing_url_in_argv_or_stderr( - cli_store, monkeypatch, tmp_path, stub, capsys +def test_login_never_puts_a_userinfo_bearing_url_in_argv_or_output( + cli_store, monkeypatch, tmp_path, stub, capfd ): """A `postgres://user:password@host/db` profile -- a form `pulumi login --help` documents -- must never put its password where - `ps` or a printed message could show it.""" - secret_url = "postgres://dbuser:super-secret-password@db.invalid:5432/state" - seed_profile(cli_store, "staging", backend_url=secret_url) + `ps` or a printed message could show it. + + `capfd`, not `capsys`. This is the one leak test in this file guarding a + real secret-bearing URL, and it drives a real child process: `capsys` + replaces this interpreter's `sys.stdout`/`sys.stderr` objects and sees + nothing the child writes to file descriptors 1 and 2. Against `capsys` a + `print(url, file=sys.stderr)` is caught and an `os.write(2, url)` -- or + anything the child itself prints -- is not, which is the wrong half to be + blind to for a test whose whole subject is a real subprocess. This file's + own header states `capfd` as the convention for exactly this reason. + + The environment is dumped as well as argv, for two reasons: it proves the + URL was genuinely live in this run rather than the test passing because + nothing ever resolved (`assert_no_leak` against a run that never had the + secret proves nothing), and it pins the delivery mechanism -- via + `PULUMI_BACKEND_URL`, verbatim, never through `argv`. + """ + seed_profile(cli_store, "staging", backend_url=USERINFO_BACKEND_URL) argv_dump = tmp_path / "argv.json" + env_dump = tmp_path / "env.json" monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(env_dump)) fake_pulumi(monkeypatch, stub) code = main(["login", "--profile", "staging"]) @@ -1351,11 +1742,16 @@ def test_login_never_puts_a_userinfo_bearing_url_in_argv_or_stderr( assert code == 0 argv = json.loads(argv_dump.read_text()) assert argv == ["login"] - for text in argv: - assert "super-secret-password" not in text - captured = capsys.readouterr() - assert "super-secret-password" not in captured.out - assert "super-secret-password" not in captured.err + assert_no_leak(json.dumps(argv), USERINFO_PASSWORD, what="the backend password") + + # The secret really was in play: it reached the child, verbatim, by the + # one route that is not visible in `ps`. + child_env = json.loads(env_dump.read_text()) + assert child_env[PULUMI_BACKEND_URL] == USERINFO_BACKEND_URL + + captured = capfd.readouterr() + assert_no_leak(captured.out, USERINFO_PASSWORD, what="the backend password") + assert_no_leak(captured.err, USERINFO_PASSWORD, what="the backend password") def test_login_reports_a_missing_pulumi_executable(cli_store, monkeypatch, capsys): diff --git a/tests/test_store.py b/tests/test_store.py index 7758688..592c5f2 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -24,10 +24,13 @@ import base64 import json import os +import pathlib import stat +import tomllib import pytest +from leakcheck import assert_no_leak from stackward import crypto, store from stackward.store import ( CONFIG_MODE, @@ -87,9 +90,11 @@ def write_config(store_directory, text: str): path = store_directory / "config" path.write_text(text) # At the required mode, so that these tests do not trip the permission - # warning as a side effect of whatever umask the suite runs under. The - # warning has its own tests below. - os.chmod(path, CONFIG_MODE) + # warning as a side effect of whatever umask the suite runs under. A + # literal rather than `CONFIG_MODE`: this helper is used by the test that + # pins the call site's constant, which cannot pin anything if the helper + # moves with it. + os.chmod(path, 0o600) return path @@ -194,6 +199,112 @@ def test_an_invalid_config_raises_rather_than_reading_as_absent(store_directory) load_store_config(store_directory) +def test_an_invalid_config_is_reported_by_position_and_never_by_quoting_it( + store_directory, +): + """`config` can hold a credential, so its parse error must not quote it. + + This call site used to interpolate `tomllib`'s own message in full, on + the stated grounds that "`config` holds backend identity and never a + credential". Both halves of that were wrong. A `backend_url` of the + documented `postgres://user:password@host/db` form is a credential in + `config`; and `tomllib` does read document text back -- most of its + messages are a fixed description plus a coordinate, but a duplicated + table declaration names the key, which is the shape written below + (`Cannot declare ('...',) twice`). + + The coordinate is asserted too: dropping the message entirely would also + satisfy a bare no-leak check, and the position is what makes the error + actionable. + """ + secret = "supersecret-marker-9f1e2d" + write_config( + store_directory, + f'[{secret}]\nbackend_url = "file:///placeholder"\n[{secret}]\n', + ) + with pytest.raises(StoreError) as raised: + load_store_config(store_directory) + + message = str(raised.value) + assert_no_leak(message, secret, what="a name written into `config`") + assert "invalid TOML" in message + assert "line 3" in message + + +def test_a_config_value_is_never_quoted_back_by_a_syntax_error(store_directory): + """The same rule for a credential in the *value* position -- the shape a + real `postgres://user:password@host/db` backend URL takes.""" + secret = "supersecret-marker-4b7c0a" + write_config( + store_directory, + f'[profile.one]\nbackend_url = "postgres://u:{secret}@h.invalid/db"\n[broken\n', + ) + with pytest.raises(StoreError) as raised: + load_store_config(store_directory) + assert_no_leak(str(raised.value), secret, what="a backend URL password") + + +def test_a_credential_at_the_end_of_the_document_is_not_quoted_back(store_directory): + """The other coordinate `tomllib` produces, `(at end of document)`. + + `_toml_position` matches two message shapes and every other test here + exercises only the first, which would leave the second silently + unrecognised -- and an unrecognised shape yields no coordinate at all, + so the branch is worth a test of its own. An unterminated string is the + natural way to reach it, and is also the shape a half-pasted backend URL + actually takes. + """ + secret = "supersecret-marker-2e8d6c" + write_config(store_directory, f'backend_url = "postgres://u:{secret}@h.invalid/db') + with pytest.raises(StoreError) as raised: + load_store_config(store_directory) + + message = str(raised.value) + assert_no_leak(message, secret, what="a backend URL password") + assert "invalid TOML" in message + assert "end of document" in message + + +def test_a_toml_message_with_no_coordinate_loses_the_coordinate_not_the_secrecy( + store_directory, monkeypatch +): + """The fallback: an unrecognised message shape must degrade to no + position, never to quoting the message. + + `TOMLDecodeError`'s text is not an API and this project supports three + Python versions, so `_toml_position` has to cope with a message it + cannot parse. The safe degradation is losing the diagnostic; the unsafe + one is falling back to the whole message, which is exactly what this + call site used to do unconditionally. + """ + secret = "supersecret-marker-5a3f1b" + write_config(store_directory, '[profile.one]\nbucket = "b"\n') + + def raise_unparseable(*_args, **_kwargs): + raise tomllib.TOMLDecodeError(f"a shape from some future release: {secret}") + + monkeypatch.setattr(tomllib, "load", raise_unparseable) + with pytest.raises(StoreError) as raised: + load_store_config(store_directory) + + message = str(raised.value) + assert_no_leak(message, secret, what="the parser's own message") + assert message.endswith("invalid TOML") + + +def test_an_unknown_top_level_key_is_refused_naming_it(store_directory): + """The profile-level analogue of this is tested; the top-level one was + not, so `_build_store_config`'s own refusal never ran at all. An + unrecognised top-level key must not be ignored: a misspelled + `default_profile` silently dropped would make the store fall through to + "no profile selected" with nothing pointing at the typo.""" + write_config( + store_directory, 'defualt_profile = "one"\n[profile.one]\nbucket = "b"\n' + ) + with pytest.raises(StoreError, match="defualt_profile"): + load_store_config(store_directory) + + # --------------------------------------------------------------------------- # Profile selection precedence # --------------------------------------------------------------------------- @@ -312,34 +423,73 @@ def test_an_unspellable_profile_name_is_rejected(name): def test_the_store_directory_is_created_private_whatever_the_umask(store_directory): + """0o700 as a literal, deliberately -- never `DIR_MODE`. + + Asserting `== DIR_MODE` compares the module against itself: the + expectation moves with the constant, so `DIR_MODE = 0o777` produces a + world-writable store directory and this test still passes. The number is + the requirement, so the number is what is written here. + """ previous = os.umask(0) try: store.ensure_store_dir(store_directory) finally: os.umask(previous) - assert stat.S_IMODE(store_directory.stat().st_mode) == DIR_MODE + assert stat.S_IMODE(store_directory.stat().st_mode) == 0o700 def test_the_credentials_file_is_written_private_whatever_the_umask(store_directory): + """0o600 as a literal -- see the sibling test above on why not + `CREDENTIALS_MODE`. `CREDENTIALS_MODE = 0o606` leaves the credentials + file world-*writable* and passes an `== CREDENTIALS_MODE` assertion.""" previous = os.umask(0) try: init_store(PASSWORD, directory=store_directory) finally: os.umask(previous) mode = stat.S_IMODE((store_directory / "credentials").stat().st_mode) - assert mode == CREDENTIALS_MODE + assert mode == 0o600 + + +def test_the_config_file_mode_the_store_expects_is_owner_only(store_directory, capsys): + """The mode `config` is *held to*, asserted where the module actually + applies it rather than against the constant it applies. + + Nothing in this module writes `config` -- it is hand-edited -- so the + only place `CONFIG_MODE` has an effect is the permissiveness warning + `load_store_config` raises, and that is therefore where it has to be + pinned. A unit test of `warn_if_permissive(path, 0o600)` proves the + function; it says nothing about which constant the call site passes, + and `CONFIG_MODE = 0o644` leaves that unit test green. + + 0644 is the specific mode that matters: `config` can hold a credential + (a `backend_url` of the documented `postgres://user:password@host/db` + form), so a world-readable one has to be reported. + """ + write_config(store_directory, '[profile.one]\nbackend_url = "file:///placeholder"\n') + os.chmod(store_directory / "config", 0o644) + + load_store_config(store_directory) + + captured = capsys.readouterr() + assert "0644" in captured.err + assert str(store_directory / "config") in captured.err + assert captured.out == "" -@pytest.mark.parametrize("mode", [CREDENTIALS_MODE, CONFIG_MODE]) +@pytest.mark.parametrize("mode", [0o600, 0o644]) def test_the_mode_is_set_before_the_rename_not_after(store_directory, monkeypatch, mode): """Setting the mode after the rename leaves a window in which the finished file is visible at its real path with whatever mode it was created with. This records the mode of the temporary file at the moment of the rename. - Both modes are checked because only one of them discriminates: `mkstemp` - already creates at 0600, so for the credentials file a mode set after the - rename would look identical and this test would prove nothing. 0644 is what - makes the ordering observable. + Two modes are checked because only one of them discriminates: `mkstemp` + already creates at 0600, so at that mode a `chmod` after the rename would + look identical and this test would prove nothing. 0644 is what makes the + ordering observable -- which is why it is written as a literal and not as + `CONFIG_MODE`, whose value is now also 0600. Parametrising over the + module's two constants would have quietly turned this into two copies of + the case that proves nothing the moment `CONFIG_MODE` changed. """ store_directory.mkdir(parents=True) real_replace = os.replace @@ -404,17 +554,26 @@ def boom(*_args, **_kwargs): @pytest.mark.parametrize( ("filename", "allowed", "mode", "expected"), [ - ("credentials", CREDENTIALS_MODE, 0o600, False), - ("credentials", CREDENTIALS_MODE, 0o640, True), - ("credentials", CREDENTIALS_MODE, 0o644, True), - ("config", CONFIG_MODE, 0o644, False), - ("config", CONFIG_MODE, 0o600, False), - ("config", CONFIG_MODE, 0o666, True), + ("credentials", 0o600, 0o600, False), + ("credentials", 0o600, 0o640, True), + ("credentials", 0o600, 0o644, True), + ("config", 0o600, 0o600, False), + ("config", 0o600, 0o644, True), + ("config", 0o600, 0o666, True), + # More restrictive than allowed is not "more permissive". + ("credentials", 0o644, 0o600, False), ], ) def test_a_more_permissive_mode_than_required_is_warned_about( store_directory, capsys, filename, allowed, mode, expected ): + """`allowed` is a literal on both halves, deliberately. + + Passing `allowed=CREDENTIALS_MODE` made every row compare the module + against itself: widen the constant and the expectation widens with it, + so the parametrisation kept passing while describing a different, more + permissive policy than the one written down here. + """ store_directory.mkdir(parents=True) path = store_directory / filename path.write_text("") @@ -571,6 +730,80 @@ def test_an_unsupported_store_version_is_refused(initialised): resolve_credentials("one", PASSWORD, directory=initialised) +def test_a_store_with_no_verifier_is_refused_rather_than_opened(initialised): + """The verifier is what makes a mistyped password fail immediately + instead of weeks later, so a store that has none cannot be treated as + "no check to run" -- that would silently restore the failure mode the + verifier exists to remove. None of `_check_password`'s three refusals + had ever executed.""" + path = initialised / "credentials" + document = json.loads(path.read_bytes()) + del document[store.VERIFIER_KEY] + path.write_text(json.dumps(document)) + + with pytest.raises(StoreError, match="verifier"): + resolve_credentials("one", PASSWORD, directory=initialised) + + +def test_an_unusable_verifier_envelope_is_a_store_error_not_a_wrong_password( + initialised, +): + """A structurally broken verifier envelope is damage, and must be + reported as damage: telling a user "wrong password" for a store their + password is fine for sends them to retype it forever.""" + path = initialised / "credentials" + document = json.loads(path.read_bytes()) + document[store.VERIFIER_KEY] = {"v": 1, "kdf": "not an object"} + path.write_text(json.dumps(document)) + + with pytest.raises(StoreError) as raised: + resolve_credentials("one", PASSWORD, directory=initialised) + assert not isinstance(raised.value, PasswordError) + assert "unusable" in str(raised.value) + + +def test_a_verifier_that_opens_to_the_wrong_plaintext_is_refused(initialised): + """The known-plaintext half of the check, which the GCM tag alone does + not provide: an envelope sealed under the right password and the right + AAD but carrying different content still is not this tool's verifier.""" + path = initialised / "credentials" + document = json.loads(path.read_bytes()) + document[store.VERIFIER_KEY] = crypto.seal_json( + "not the verifier plaintext", PASSWORD, store.VERIFIER_AAD + ) + path.write_text(json.dumps(document)) + + with pytest.raises(StoreError, match="did not match"): + resolve_credentials("one", PASSWORD, directory=initialised) + + +def test_the_temporary_file_is_created_in_the_target_directory( + store_directory, monkeypatch +): + """`atomic_write`'s atomicity rests entirely on the rename being within + one filesystem, which rests entirely on `mkstemp(dir=...)`. Nothing + pinned it: dropping `dir=directory` puts the temporary file under + `$TMPDIR`, and `os.replace` across filesystems raises `OSError` -- or, + worse, on a setup where `$TMPDIR` happens to share the filesystem, + succeeds while writing sealed credentials into a world-traversable + directory first. + + Recorded at the moment of the rename, which is the only point the + temporary file is guaranteed to still exist. + """ + store_directory.mkdir(parents=True) + real_replace = os.replace + observed: list[pathlib.Path] = [] + + def spy(src, dst): + observed.append(pathlib.Path(src).parent) + return real_replace(src, dst) + + monkeypatch.setattr(os, "replace", spy) + atomic_write(store_directory / "credentials", b"content", 0o600) + assert observed == [store_directory] + + # --------------------------------------------------------------------------- # Password rotation # --------------------------------------------------------------------------- From 2faf415f98945c829d7eebb67c5ec892d868fe9c Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 16:38:27 -0700 Subject: [PATCH 40/44] test: catch a truncated encoding, not only a complete one The helper checked whole encodings and runs of the raw value only. Wave 2 showed the gap while fixing the passphrase fingerprint: hex()[:16] contains neither the raw value nor the complete hex encoding, so assert_no_leak passed on text that bytes.fromhex turns straight back into the first eight characters of the credential -- the exact defect the tautological test hid. Runs are now checked for the byte-aligned encodings too, at lengths that each recover about as much plaintext as the raw run length does. The URL-quoted forms are deliberately left whole-string only: percent-encoding is variable-width, so a run boundary can land mid-escape and the recovered text is not what the run implies. Verified by mutation: with fingerprint() returning the passphrase's own hex, the leak assertion now fails on its own, independently of the digest pin added alongside it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- tests/leakcheck.py | 56 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/tests/leakcheck.py b/tests/leakcheck.py index 632d37d..d6bc57b 100644 --- a/tests/leakcheck.py +++ b/tests/leakcheck.py @@ -11,6 +11,16 @@ So the check is: the value, any run of it long enough to be identifying, and the encodings it plausibly passes through on its way to an output stream. + +**Choose opaque markers.** The partial-run check compares against the text +a command legitimately prints, and a marker built out of real words will +collide with it. `marker-db-password-1a2b3c4d` shares the run "password" +with the config path `db.password` that the same test asserts is reported, +so the helper fires on output that discloses nothing. That is the helper +working, not a bug in it -- a credential really can be recovered a run at a +time, and the checker cannot know which run was a coincidence. Give the +marker no substring in common with any path, flag or message under test, +and put the reason in the variable name rather than in the value. """ from __future__ import annotations @@ -24,6 +34,24 @@ # assertion flaky, which is how a leak check gets deleted. MIN_IDENTIFYING_RUN = 8 +# A *truncated* encoding is still a disclosure, and the whole-string check +# above cannot see one: `value.encode("utf-8").hex()[:16]` contains neither +# the raw value nor the complete hex encoding, yet `bytes.fromhex` turns it +# straight back into the first eight characters of the credential. That is +# not hypothetical -- it is the exact shape of the passphrase-fingerprint +# defect this branch shipped and the tautological test that hid it. +# +# So the run check applies to the byte-aligned encodings too, at run +# lengths that each recover about the same amount of plaintext as +# MIN_IDENTIFYING_RUN does. Runs are NOT checked for the URL-quoted forms: +# percent-encoding is variable-width, so a run boundary can land mid-escape +# and the recovered text is not what the run implies. +RUN_LENGTHS = { + "raw": MIN_IDENTIFYING_RUN, + "hex": MIN_IDENTIFYING_RUN * 2, # 2 hex characters per byte + "base64": ((MIN_IDENTIFYING_RUN + 2) // 3) * 4, # 4 characters per 3 bytes +} + def _encodings(value: str) -> dict[str, str]: """The forms a credential can reach an output stream in. @@ -64,16 +92,18 @@ def assert_no_leak(text: str, value: str, *, what: str = "value") -> None: f"(at offset {text.index(encoded)})" ) - # Partial disclosure: any identifying-length run of the raw value. - # Only the raw form -- a run of a base64 encoding is not recoverable - # without its alignment, and checking it produces false positives. - for start in range(0, max(1, len(value) - MIN_IDENTIFYING_RUN + 1)): - run = value[start : start + MIN_IDENTIFYING_RUN] - if len(run) < MIN_IDENTIFYING_RUN: - break - if run in text: - raise AssertionError( - f"{what} partially leaked into output: a " - f"{MIN_IDENTIFYING_RUN}-character run starting at index " - f"{start} of the value appears at offset {text.index(run)}" - ) + # Partial disclosure: an identifying-length run of the value, or of an + # encoding a reader can decode a fragment of. + encodings = _encodings(value) + for name, run_length in RUN_LENGTHS.items(): + candidate = encodings[name] + for start in range(0, max(1, len(candidate) - run_length + 1)): + run = candidate[start : start + run_length] + if len(run) < run_length: + break + if run in text: + raise AssertionError( + f"{what} partially leaked into output: a {run_length}-" + f"character run of its {name} form, starting at index " + f"{start}, appears at offset {text.index(run)}" + ) From a5729f3bb8cac74f6583bba01af8c08f90efec24 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 17:25:37 -0700 Subject: [PATCH 41/44] feat: ship `credentials`, and fix the dispatch that hid every lazy command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `credentials` was named in the design (§4, migration step 2) and in the README table, but no task owned the CLI surface, so the store could be read by login/exec/shell and populated by nothing. The only working path for a fresh user was the env provider, which is what the encrypted store exists to replace. init / set / list / show / rotate now exist, built on the store API that was already there: values arrive as KEY=VALUE on stdin and never as arguments, `show` prints names and never values, and a wrong master password fails on the store verifier rather than weeks later. The larger find came out of building it. Making the gate import-clean had turned dispatch into `importlib.import_module(f".commands.{name}")`, and PyInstaller resolves imports by reading literal import statements out of bytecode -- it cannot see a name assembled at runtime. So in the shipped binary, *every* lazily dispatched command was absent: `stackward exec` died on ModuleNotFoundError with a traceback and exit code 1, the code this tool reserves for "a credential was found". Verified by building both ways: at the previous commit set-secrets, sync-declared-secrets and login are all missing from the bundle, while --version and doctor -- the only two things the release job checked, and both registered directly -- pass throughout. Dispatch is now an if-chain of literal imports, still function-local so laziness and gate isolation hold. The release job gained a check that runs two lazily dispatched commands from two modules against the bundle; it rejects "invalid choice" as well as ModuleNotFoundError, because an unregistered subcommand also exits 2 having loaded nothing, and it runs from an empty directory because min_version enforcement precedes dispatch. Confirmed discriminating: the check fails the old bundle and passes the new. Also: config.py no longer quotes tomllib's message, which echoes document text (the fix store.py already had, now shared rather than duplicated); describe_value is pinned in both directions; AWS_SESSION_TOKEN and AWS_PROFILE are scrubbed from the child, seeded in the fixture so the assertion tests a removal rather than an absence; and isolated_git moved to conftest, after a global core.hooksPath was found not just to break 65 tests but to make one of them write a hook into that global directory, outside tmp_path, where it persisted after the run. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- .github/workflows/release.yml | 44 ++ README.md | 38 +- src/stackward/cli.py | 118 +++- src/stackward/commands/credentials.py | 382 ++++++++++++ src/stackward/commands/session.py | 59 +- src/stackward/commands/set_secrets.py | 64 +- src/stackward/config.py | 56 +- src/stackward/store.py | 48 +- tests/conftest.py | 59 +- tests/test_cli.py | 89 ++- tests/test_config.py | 90 +++ tests/test_credentials.py | 828 ++++++++++++++++++++++++++ tests/test_crypto.py | 79 +++ tests/test_gate_isolation.py | 16 +- tests/test_install_hooks.py | 35 -- tests/test_model_net.py | 11 +- tests/test_pre_commit.py | 36 -- tests/test_session.py | 101 +++- tests/test_store.py | 27 +- 19 files changed, 2005 insertions(+), 175 deletions(-) create mode 100644 src/stackward/commands/credentials.py create mode 100644 tests/test_credentials.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5611374..3987cba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,6 +88,50 @@ jobs: ./dist/stackward/stackward doctor | tee doctor.txt grep -q "^model walker regen.py readable" doctor.txt + # A lazily dispatched command must actually load in the bundle. + # `cli._dispatch` imports each command module only when its + # subcommand runs, and PyInstaller can only see an import it can + # read as a literal statement: while that dispatch named its module + # dynamically, every one of these commands was missing from the + # binary and died on `ModuleNotFoundError` with a traceback and + # exit code 1 — the code reserved for "a credential was found" — + # while `--version` and `doctor` (both registered directly) kept + # passing this very step. + # + # Two commands, from two different modules, and neither needs a + # credential, a store or a network: each refuses with exit code 2 + # after its module has loaded, which is exactly the evidence + # wanted. `set -e` is off for these because a non-zero exit is the + # expected outcome. + # Run from an empty directory, not the checkout: `cli.main` + # enforces a repository's `min_version` floor *before* dispatch, so + # a `.stackward.toml` above the working directory could make both + # commands exit 2 without either module ever loading -- the check + # would then pass on exactly the evidence it exists to reject. + bundle="$PWD/dist/stackward/stackward" + for command in "credentials list" "exec"; do + set +e + # shellcheck disable=SC2086 + out="$(cd "$(mktemp -d)" && XDG_CONFIG_HOME="$PWD" "$bundle" $command 2>&1)" + code=$? + set -e + if [ "$code" -ne 2 ]; then + echo "::error::\`stackward $command\` exited $code, expected 2" >&2 + printf '%s\n' "$out" >&2 + exit 1 + fi + case "$out" in + *ModuleNotFoundError*|*Traceback*|*"invalid choice"*) + # "invalid choice" is the other way this passes for the wrong + # reason: an unregistered subcommand also exits 2, from + # argparse, having loaded nothing at all. + echo "::error::\`stackward $command\` did not load in the bundle" >&2 + printf '%s\n' "$out" >&2 + exit 1 + ;; + esac + done + # The other half of that arrangement: `regen.py` ships as data # precisely so nothing imports it and pydantic stays test-only. If it # ever became an import, the bundle would still build, doctor would diff --git a/README.md b/README.md index 649ddc8..200fbbc 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,8 @@ diff. `stackward` exists to make that mistake hard. > **Status: early.** `v0.1.0` shipped `--version` and `doctor` alone, to prove > the release and install path before anything was built on top of it. That -> path held, and every command in the table below is now implemented. One gap -> remains, noted under it: there is still no command for *writing* to the -> credential store. Interfaces may change. +> path held, and every command in the table below is now implemented. +> Interfaces may change. ## What it does @@ -23,6 +22,7 @@ diff. `stackward` exists to make that mistake hard. | `check-config` | Refuse plaintext credentials in a stack config file | | `pre-commit` | The above, over staged content, as a git hook | | `hooks install` | Install that hook, honouring `core.hooksPath` | +| `credentials` | Create the encrypted store, seal a profile's credentials into it, list what is there, rotate the password | | `login` | Point Pulumi at a profile's backend | | `exec` / `shell` | Run `pulumi` with a profile's credentials injected into the child process | | `set-secrets` | Publish stack secrets from a declared manifest, values on stdin | @@ -30,11 +30,19 @@ diff. `stackward` exists to make that mistake hard. | `sync-declared-secrets` | Regenerate the committed graph of secrets a repo's own models declare | | `doctor` | Report the resolved configuration and environment | -The encrypted, profile-scoped credential store those three session commands -read from is implemented, but **nothing on the command line writes to it -yet** — there is no `credentials` subcommand. Until there is, a profile's -secrets have to be sealed by another route, or supplied through the `env` -provider, which reads them straight from the process environment. +`credentials` is what fills the encrypted, profile-scoped store the three +session commands read from. A value never reaches it as an argument: +`credentials set` reads `KEY=VALUE` lines on stdin — the format of the `.env` +file it exists to replace — or prompts for each name without echo. Nothing it +prints is ever a value: `list` prints profile names, `show` prints the +credential *names* an envelope carries, and neither prints what is beside +them. + +```sh +stackward credentials init # once, per machine +stackward credentials set --profile NAME < .env # then delete the .env +stackward credentials show --profile NAME # names only, never values +``` ## Design commitments @@ -57,12 +65,14 @@ These are constraints, not aspirations — each has a test. - **Nobody's own infrastructure is named here.** No bucket, region, endpoint, account, domain, namespace or model class appears anywhere in this repository, and the hook and CI job described under *Development* below are - what enforce that. Two de-facto-standard names do appear, and are not - choices this tool makes: `exec` and `shell` inject `AWS_ACCESS_KEY_ID` and - `AWS_SECRET_ACCESS_KEY`, because that is the vocabulary Pulumi's own DIY - backend reads out of a child process's environment; and the component form - above composes an `s3://` URL, because that is the scheme Pulumi uses for - every S3-compatible store. + what enforce that. A few de-facto-standard names do appear, and none of them + is a choice this tool makes: `exec` and `shell` inject `AWS_ACCESS_KEY_ID` + and `AWS_SECRET_ACCESS_KEY`, because that is the vocabulary Pulumi's own DIY + backend reads out of a child process's environment; they also *remove* + `AWS_SESSION_TOKEN` and `AWS_PROFILE` from the child, because either one + sitting beside a freshly injected key pair contradicts it and the resulting + failure names neither; and the component form above composes an `s3://` URL, + because that is the scheme Pulumi uses for every S3-compatible store. - **Values reach the Pulumi CLI on stdin**, never in a command line, so they do not appear in `ps` or in shell history. - **Failures are closed.** If the tool cannot determine whether a file is safe, diff --git a/src/stackward/cli.py b/src/stackward/cli.py index 696c724..b85e1af 100644 --- a/src/stackward/cli.py +++ b/src/stackward/cli.py @@ -1,15 +1,20 @@ """Command dispatch. -v0.1.0 deliberately ships only `--version` and `doctor`. The point of this -release is to prove the distribution path — build, sign, publish, download, -install, run — while it is still cheap to change. Features land on top of a -distribution mechanism that is already known to work, not the other way round. +`v0.1.0` shipped `--version` and `doctor` alone, deliberately: the point of +that release was to prove the distribution path — build, sign, publish, +download, install, run — while it was still cheap to change. That path held, +and the commands below landed on top of a distribution mechanism already +known to work, rather than the other way round. + +Every subcommand that can touch a credential is registered through +`_dispatch`, which imports its module only when the subcommand actually +runs. That is not a startup-time optimisation; see `_dispatch` for what it +is. """ from __future__ import annotations import argparse -import importlib import os import platform import shutil @@ -42,6 +47,52 @@ VERSION_CHECK_EXEMPT = frozenset({"doctor", "self-update"}) +# Every command module reached through `_dispatch`, as literal `import` +# statements. See `_import_command` for why this is not a dictionary, a +# registry, or `importlib.import_module`. +def _import_command(module_name: str): + """The `commands` submodule named by `module_name`, imported now. + + Written as one literal `import` statement per module rather than as + `importlib.import_module(f".commands.{module_name}", __package__)`, + which is what this was, because of what a *frozen* build can see. + PyInstaller resolves imports by reading `import` statements out of + compiled bytecode -- including statements inside a function body, which + is exactly what keeps these lazy (`crypto_selftest`'s own function-level + `import cryptography` is why the cryptography backend is in the bundle at + all). It cannot resolve a module name assembled at runtime. + + So with the f-string form, **none** of the lazily dispatched commands was + in the shipped binary. Verified against the release workflow's own build + command: `stackward exec` in the bundle died with `ModuleNotFoundError: + No module named 'stackward.commands.session'`, a raw traceback, and exit + code 1 -- the code this tool reserves exclusively for "a credential was + found". The failure existed only in the artifact everyone installs, which + is the same shape as the two failures `doctor`'s self-tests were added + to catch, and for the same reason it is not something a source checkout + can notice. + + An `if` chain, and not a mapping of names to import thunks, because a + mapping built at module scope would run every one of those thunks when + this module is imported -- which is the whole cost being avoided. The + same shape, for the same reason, as + `session._build_credential_store`'s provider dispatch. + """ + if module_name == "session": + from .commands import session as module + elif module_name == "credentials": + from .commands import credentials as module + elif module_name == "set_secrets": + from .commands import set_secrets as module + elif module_name == "sync_declared": + from .commands import sync_declared as module + elif module_name == "check_passphrase": + from .commands import check_passphrase as module + else: # pragma: no cover - a name `build_parser` never passes + raise RuntimeError(f"no command module named {module_name!r}") + return module + + def _dispatch(module_name: str, function_name: str): """A subcommand entry point that imports its module only when the subcommand is actually run. @@ -67,8 +118,7 @@ def _dispatch(module_name: str, function_name: str): """ def dispatch(args: argparse.Namespace) -> int: - module = importlib.import_module(f".commands.{module_name}", __package__) - return getattr(module, function_name)(args) + return getattr(_import_command(module_name), function_name)(args) dispatch.__name__ = function_name dispatch.__qualname__ = f"{module_name}.{function_name}" @@ -249,6 +299,60 @@ def build_parser() -> argparse.ArgumentParser: ) sync_declared.set_defaults(func=_dispatch("sync_declared", "cmd_sync_declared")) + # `credentials`, like the session commands, is registered through + # `_dispatch`: its module imports `store` -> `crypto` -> `cryptography`, + # and `tests/test_gate_isolation.py` asserts that a `check-config` run + # loads none of those. Nothing in the help text below may name anything + # from that module either -- an f-string interpolating + # `session.CREDENTIAL_NAMES` here would import it at *this* module's + # scope and break the same property in a way no reader would connect to + # a help string. + credentials = sub.add_parser( + "credentials", help="manage the encrypted credential store" + ) + credentials_sub = credentials.add_subparsers( + dest="credentials_command", metavar="" + ) + + credentials_init = credentials_sub.add_parser( + "init", help="create the credential store (asks for a new password twice)" + ) + credentials_init.set_defaults(func=_dispatch("credentials", "cmd_credentials_init")) + + credentials_set = credentials_sub.add_parser( + "set", + help=( + "seal a profile's credentials, read as KEY=VALUE lines on stdin " + "(replaces the profile's whole envelope; never takes a value as " + "an argument)" + ), + ) + credentials_set.add_argument( + "--profile", help="profile to use (overrides selection precedence)" + ) + credentials_set.set_defaults(func=_dispatch("credentials", "cmd_credentials_set")) + + credentials_list = credentials_sub.add_parser( + "list", help="list the profiles that have credentials sealed (names only)" + ) + credentials_list.set_defaults(func=_dispatch("credentials", "cmd_credentials_list")) + + credentials_show = credentials_sub.add_parser( + "show", + help="list the credential names a profile carries, never their values", + ) + credentials_show.add_argument( + "--profile", help="profile to use (overrides selection precedence)" + ) + credentials_show.set_defaults(func=_dispatch("credentials", "cmd_credentials_show")) + + credentials_rotate = credentials_sub.add_parser( + "rotate", help="re-seal every envelope under a new password, or none" + ) + credentials_rotate.set_defaults( + func=_dispatch("credentials", "cmd_credentials_rotate") + ) + check_passphrase = sub.add_parser( "check-passphrase", help="prove a passphrase decrypts one or more stacks (never as an argument)", diff --git a/src/stackward/commands/credentials.py b/src/stackward/commands/credentials.py new file mode 100644 index 0000000..ae269c9 --- /dev/null +++ b/src/stackward/commands/credentials.py @@ -0,0 +1,382 @@ +"""`credentials`: the only supported way to *write* to the encrypted store. + +**Why this module exists.** `store.py` implemented the store as a library and +`commands/session.py` implemented the three commands that *read* from it, and +between the two nothing ever owned the command line that fills it in. The +result shipped as a store that could be opened and never populated: a fresh +user's only working path was the `env` credential provider, which reads +credentials straight out of the process environment and so gives up the thing +the encrypted store exists for. Every function called below +(`store.init_store`, `store.set_credentials`, `store.resolve_credentials`, +`store.rotate_password`) was already written, reviewed and tested. This is +the surface over them, and nothing else. + +**Nothing here reimplements a mechanism `session.py` already owns.** The +master password is read by `session._read_store_password` — one place that +decides `STACKWARD_PASSWORD`, the `/dev/tty` check and the refusal when +neither is available. The profile is selected by `session._resolve_profile`, +so `--profile`, `STACKWARD_PROFILE`, the repository's `profile =` and +`default_profile` mean here exactly what they mean for `exec`. The names a +profile must carry come from `session.CREDENTIAL_NAMES` via +`session._require_credential_names`, so `credentials set` refuses the same +envelope `exec` would refuse — at the point it is written, rather than at +the point it is needed. A second copy of any of those would be a second +answer to a question the tool must only have one answer to. + +**A value is never an argument.** `credentials set` reads `KEY=VALUE` lines +from **stdin** (the format of the `.env` file this command exists to +replace, parsed by the same `set_secrets.parse_env_text` that reads one), or +prompts for each name without echo when stdin is a terminal. There is no +positional value and no `--value` flag: a command line is visible in `ps` +for the life of the call and lands in shell history forever. Unlike +`session.py`, this module is free to read `sys.stdin` — it spawns no child +that would need those bytes. + +**Nothing here prints a credential value, on any path.** `list` prints +profile names; `show` prints credential *names* out of a decrypted envelope +and never the values beside them; `set` prints a count. Prompts are +`getpass`, so nothing typed is echoed either. + +**A wrong master password is caught by the store's verifier**, not by +anything here: `set_credentials` and `rotate_password` both open the +known-plaintext envelope before they write, so a mistyped password refuses +with `PasswordError` rather than sealing something the rest of the store +cannot open. That is the failure mode the verifier was built for, and this +module's job is to report it, not to re-detect it. + +**Rotation is all-or-nothing, and this module does not soften that.** +`store.rotate_password` rebuilds the whole document in memory and writes it +once; any failure leaves the store exactly as it was. The refusal is +surfaced verbatim — a partial rotation reported as success would be the +worst outcome available, and so would a total failure reported as one. +""" + +from __future__ import annotations + +import argparse +import getpass +import os +import sys + +from .. import store +from ..store import StoreError +from . import session +from .check_config import fail_closed +from .session import ENV_STORE_PASSWORD +from .set_secrets import SetSecretsError, parse_env_text + +# Prompts, kept together so that the wording of the two-step "type it twice" +# read is visible in one place. Each is passed to +# `session._read_store_password`, which is what decides whether a prompt +# happens at all -- see this module's docstring. +NEW_PASSWORD_PROMPT = "new credential store password: " +CONFIRM_PASSWORD_PROMPT = "confirm new credential store password: " +OLD_PASSWORD_PROMPT = "current credential store password: " + + +class CredentialsError(Exception): + """Something specific to this module stopped a `credentials` subcommand: + input that is not `KEY=VALUE`, a confirmation that did not match, an + empty value for a name that must have one. + + Never carries a credential value -- every message here names a profile, a + credential name, a line number or an environment variable.""" + + +# The errors these entry points recognise and report as one line rather than +# letting `fail_closed` render them as "could not run ()". `StoreError` +# covers `ProfileError` and `PasswordError`, which subclass it; +# `session.SessionError` arrives from the shared profile-selection and +# password-reading helpers. +_KNOWN_ERRORS = (StoreError, session.SessionError, CredentialsError) + + +def _report(exc: Exception) -> int: + """Print `exc` as `error: ...` on stderr and return 2 -- the "could not + run" code every refusal in this module and in `session.py` uses. Never 1: + that code is reserved, across this whole tool, for "a credential was + found" by the gate.""" + print(f"error: {exc}", file=sys.stderr) + return 2 + + +def _read_new_password() -> str: + """A password that is about to be *set*, read twice and compared. + + Both reads go through `session._read_store_password`, so a store password + is obtained in exactly one way in this codebase whatever it is wanted + for; only the wording differs. When `STACKWARD_PASSWORD` is set, that + function answers both reads from it without prompting and the comparison + is trivially satisfied -- which is correct for the non-interactive path + and is why `_refuse_unrotatable` exists to catch the one case where it + silently means "nothing would change". + + The confirmation is not ceremony: a typo in a password that seals a store + is not recoverable later by any means, because there is nothing else that + can open what it sealed. + """ + first = session._read_store_password(NEW_PASSWORD_PROMPT) + second = session._read_store_password(CONFIRM_PASSWORD_PROMPT) + if first != second: + raise CredentialsError( + "the two passwords do not match; nothing has been changed" + ) + return first + + +def _prompt_for_values() -> dict[str, str]: + """Ask for each required credential, without echo. + + `getpass`, not `input`: these are the values the whole store exists to + keep off disk and out of scrollback. Exactly the names `exec`/`shell` + inject are asked for; an envelope may hold more than that (see + `session._require_credential_names`), but a name this tool does not + consume is not one it should be inventing a prompt for -- pipe them in + instead. + + A value is taken exactly as typed. The stdin path strips surrounding + whitespace (see `set_secrets.parse_env_text`, whose `.env` semantics this + command inherits on purpose); nothing is stripped here, because there is + no file convention to match and no way to quote an intentional space at + a prompt. + """ + return {name: getpass.getpass(f"{name}: ") for name in session.CREDENTIAL_NAMES} + + +def _read_values() -> dict[str, str]: + """The credentials to seal: `KEY=VALUE` lines from stdin, or prompts when + stdin is a terminal. + + The branch is on `sys.stdin.isatty()` -- the literal question "was + something piped into *this* descriptor", which is the right one to ask + because this command, unlike `session.py`, is free to consume stdin. + (`session.py` asks a different question, `/dev/tty`, for the different + reason stated there: its stdin belongs to a child it is about to run.) + + `parse_env_text` raises in `set_secrets`'s vocabulary, which is re-raised + here in this module's. Forwarding its message is safe, and deliberately + so: it names an origin and a line number and never the line's own text. + """ + if sys.stdin.isatty(): + return _prompt_for_values() + + text = sys.stdin.read() + if not text.strip(): + raise CredentialsError( + "no credentials on stdin: pipe KEY=VALUE lines in " + "(for example `stackward credentials set --profile NAME < .env`), " + "or run this from an interactive terminal to be prompted" + ) + try: + return parse_env_text(text, "stdin") + except SetSecretsError as exc: + raise CredentialsError(str(exc)) from exc + + +def _require_values(values: dict[str, str], profile: str) -> None: + """Refuse an envelope `exec` would refuse, before it is written. + + Two checks, in the order that produces the more useful message. + `session._require_credential_names` is the authority on *which* names + must be present -- reused rather than restated, so that the set cannot + drift between the command that writes an envelope and the command that + reads it. + + A name present with an **empty** value is refused too, which that + function does not do: it asks about presence, since an envelope is + resolved by a provider that may legitimately have nothing for a name. At + the moment of sealing there is no such ambiguity -- `AWS_ACCESS_KEY_ID=` + in a piped `.env` is a placeholder line, not a credential, and sealing it + produces a store that opens fine and then fails to authenticate with + nothing pointing back here. Only the required names are checked: an extra + name carried along in the same file is not this command's business. + """ + session._require_credential_names(values, profile) + empty = sorted( + name for name in session.CREDENTIAL_NAMES if not values[name] + ) + if empty: + raise CredentialsError( + f"profile {profile!r}: no value given for {', '.join(empty)}; " + "an empty credential would seal cleanly and then fail to " + "authenticate" + ) + + +def _refuse_unrotatable(old: str, new: str) -> None: + """Refuse a rotation that would change nothing, naming the actual cause. + + The cause is almost always `STACKWARD_PASSWORD`: `_read_store_password` + answers from it without prompting, so with it set both the old and the + new password are the same string and the user was never asked anything. + Reporting that as a bare "the passwords are the same" would describe a + sameness they did not type and cannot explain, so the variable is named + and the fix with it. + """ + if old != new: + return + if ENV_STORE_PASSWORD in os.environ: + raise CredentialsError( + f"{ENV_STORE_PASSWORD} is set, so the current and the new password " + f"were both read from it and rotation would change nothing: unset " + f"{ENV_STORE_PASSWORD} and run this from an interactive terminal" + ) + raise CredentialsError( + "the new password is the same as the current one; nothing to rotate" + ) + + +@fail_closed +def cmd_credentials_init(_args: argparse.Namespace) -> int: + """`stackward credentials init`: create the store. + + Refuses to overwrite an existing one -- `store.init_store` enforces that, + because overwriting would discard every sealed envelope irrecoverably in + response to a command a person could plausibly run twice. + + Says which password was used when it came from the environment. That is + the supported non-interactive path, but a store created from an ambient + variable the user has forgotten about is a store they cannot open + tomorrow, and silence is what makes that possible. + """ + from_env = ENV_STORE_PASSWORD in os.environ + try: + password = _read_new_password() + store.init_store(password) + except _KNOWN_ERRORS as exc: + return _report(exc) + + print(f"credential store created at {store.credentials_path()}") + if from_env: + print(f"(sealed with the password in {ENV_STORE_PASSWORD})") + print("next: stackward credentials set --profile ") + return 0 + + +@fail_closed +def cmd_credentials_set(args: argparse.Namespace) -> int: + """`stackward credentials set [--profile NAME]`: seal a profile's + credentials. + + **Replaces the profile's whole envelope**, and does not merge into it: + `store.set_credentials` writes what it is given. That is why every + required name must be supplied on every run -- a "set one name" that + silently dropped the other two would be far worse than a refusal. + + The password is asked for last, so that malformed input, a missing name + or an empty value is reported before anyone types it. + """ + try: + profile = session._resolve_profile(args.profile) + values = _read_values() + _require_values(values, profile.name) + password = session._read_store_password() + store.set_credentials(profile.name, values, password) + except _KNOWN_ERRORS as exc: + return _report(exc) + + print(f"sealed {len(values)} credential(s) for profile {profile.name!r}") + print(f"check with: stackward credentials show --profile {profile.name}") + return 0 + + +@fail_closed +def cmd_credentials_list(_args: argparse.Namespace) -> int: + """`stackward credentials list`: the profiles that have an envelope. + + Opens nothing and asks for no password -- `store.store_profiles` reads + the document's keys. A profile that appears here has credentials sealed + for it; one that does not appear may still exist in `config` and be + perfectly usable with `login`, which never reads this file. + + The reverse case is real too, and is why this listing and `show` can + disagree: an envelope whose `[profile.]` table has been deleted + from `config` is still in this file (deliberately -- see + `store.rotate_password`, which re-seals it rather than destroying it) + and is listed here, while `show` refuses it, because selecting a profile + means finding its backend and there is no longer one to find. That is + the honest answer in both places: the envelope exists, and nothing can + currently use it. + """ + try: + profiles = store.store_profiles() + except _KNOWN_ERRORS as exc: + return _report(exc) + + if not profiles: + print( + "no profile has credentials in the store yet: " + "stackward credentials set --profile ", + file=sys.stderr, + ) + return 0 + for name in profiles: + print(name) + return 0 + + +@fail_closed +def cmd_credentials_show(args: argparse.Namespace) -> int: + """`stackward credentials show [--profile NAME]`: which credential names + a profile carries. + + **Names only, never values.** This is the command that answers "did that + `set` land, and is anything missing" without anyone having to run `exec` + against a real backend to find out. It decrypts, because the names live + inside the envelope and there is nowhere else to read them from. + + Exit code 0 even when a required name is absent, with the shortfall + noted on stderr: the question this command was asked -- what does this + profile carry -- was answered correctly, and the store is not in a state + anything needs to refuse. `exec` is what refuses an incomplete envelope, + and it already does. + """ + try: + profile = session._resolve_profile(args.profile) + password = session._read_store_password() + credentials = store.resolve_credentials(profile.name, password) + except _KNOWN_ERRORS as exc: + return _report(exc) + + names = sorted(credentials) + print(f"profile {profile.name!r} carries {len(names)} credential(s):") + for name in names: + print(f" {name}") + + missing = [name for name in session.CREDENTIAL_NAMES if name not in credentials] + if missing: + print( + f"missing, and required by exec/shell: {', '.join(missing)}", + file=sys.stderr, + ) + return 0 + + +@fail_closed +def cmd_credentials_rotate(_args: argparse.Namespace) -> int: + """`stackward credentials rotate`: re-seal every envelope under a new + password. + + Every envelope in the credentials file, not every profile in `config`: + `store.rotate_password` re-seals what is actually there, so an envelope + whose `[profile.]` table was removed is carried across rather than + quietly destroyed. + + All-or-nothing. If any envelope fails to open, `store.rotate_password` + raises and writes nothing, and this reports that refusal as-is -- the + store still opens under the current password, which is what the message + must not obscure. + """ + try: + old = session._read_store_password(OLD_PASSWORD_PROMPT) + new = _read_new_password() + _refuse_unrotatable(old, new) + store.rotate_password(old, new) + except _KNOWN_ERRORS as exc: + return _report(exc) + + # No "remember to update STACKWARD_PASSWORD" note here: reaching this + # line means the variable was unset, because `_refuse_unrotatable` above + # cannot be passed while it is set. + print("every envelope in the credential store was re-sealed") + return 0 diff --git a/src/stackward/commands/session.py b/src/stackward/commands/session.py index 0c19ce0..3b454ac 100644 --- a/src/stackward/commands/session.py +++ b/src/stackward/commands/session.py @@ -24,6 +24,13 @@ profile, a key, a file or a redacted backend URL, and never a credential value. +**Two AWS names are removed from the child, and never supplied.** +`AWS_SESSION_TOKEN` and `AWS_PROFILE` (`SCRUBBED_NAMES`) are dropped by +`exec`/`shell` -- not because they are secrets this tool owns, but because +either one *next to* a freshly injected long-lived key pair is a live +misconfiguration whose failure names neither of them. See `SCRUBBED_NAMES` +for the full argument, including why `login` keeps both. + **`shell` inherits, it does not replace.** Both `exec` and `shell` overlay exactly four names onto a *copy* of the caller's own environment (`PATH`, `HOME`, `TERM` and everything else the child would otherwise need @@ -165,6 +172,30 @@ CREDENTIAL_NAMES = (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, PULUMI_CONFIG_PASSPHRASE) +# Removed from the child's environment by `exec`/`shell`, and never put +# back. Neither is ever *injected*: the store holds no value for either, and +# `CREDENTIAL_NAMES` above stays the complete list of what is supplied. +# +# They are scrubbed rather than left alone because of what they do *beside* +# a freshly injected long-lived key pair, which is exactly the situation +# `exec` creates. `AWS_SESSION_TOKEN` is only valid for the temporary key +# pair it was issued with: next to this profile's own keys it is not merely +# stale, it makes every request fail to authenticate, and the error names +# neither the token nor the mismatch -- it reads as "your credentials are +# wrong", about credentials that are fine. `AWS_PROFILE` names a section of +# the caller's own shared credentials file, a separate source of keys and +# region entirely; a child that reads it has been told two different things +# about which account it is talking to, and which one wins depends on the +# SDK and the code path rather than on anything this tool said. Both are +# ordinary things to have exported (an assumed role, a shell with a profile +# selected), so this is the common case and not a rare one. +# +# `login` deliberately does not scrub them: it injects no credentials, so +# neither name can contradict one there, and reaching a backend through the +# caller's own ambient AWS configuration is a working setup that `login` +# has no business breaking. +SCRUBBED_NAMES = ("AWS_SESSION_TOKEN", "AWS_PROFILE") + class SessionError(Exception): """Something specific to this module stopped `login`/`exec`/`shell` from @@ -380,7 +411,10 @@ def _tty_available() -> bool: return True -def _read_store_password() -> str: +DEFAULT_PASSWORD_PROMPT = "stackward credential store password: " + + +def _read_store_password(prompt: str = DEFAULT_PASSWORD_PROMPT) -> str: """`STACKWARD_PASSWORD` if set, otherwise an interactive prompt. Never reads `sys.stdin`. The environment variable exists for the @@ -389,6 +423,15 @@ def _read_store_password() -> str: against); the prompt exists for everyone else, and refuses outright, rather than degrading to a visible, stdin-consuming fallback, when `_tty_available` says `/dev/tty` cannot be opened. + + `prompt` only changes the wording. It exists so that + `commands.credentials` -- which asks for a password it is about to + *set*, and asks twice to confirm it -- can say which password it wants + without introducing a second way of obtaining one: the environment + variable, the `/dev/tty` check and the refusal are all decided here, in + one place, for every command that needs the store password. A caller + passing its own wording gets none of that logic to reimplement, and + cannot accidentally skip the parts of it that are load-bearing. """ from_env = os.environ.get(ENV_STORE_PASSWORD) if from_env is not None: @@ -398,7 +441,7 @@ def _read_store_password() -> str: f"no store password available: set {ENV_STORE_PASSWORD}, or run " "this command from an interactive terminal" ) - return getpass.getpass("stackward credential store password: ") + return getpass.getpass(prompt) def _require_credential_names(credentials: Mapping[str, str], profile: str) -> None: @@ -433,9 +476,17 @@ def _environ_without_store_password() -> dict[str, str]: def _child_env(credentials: Mapping[str, str], backend_url: str) -> dict[str, str]: - """The caller's own environment, minus the store password, plus exactly - the four session names.""" + """The caller's own environment, minus the store password and the two + `SCRUBBED_NAMES`, plus exactly the four session names. + + The removals happen here rather than in `_environ_without_store_password` + on purpose: that helper is shared with `login`, and `login` must keep + them (see `SCRUBBED_NAMES` for why the hazard is specific to a child + that has just been handed a different set of keys). + """ env = _environ_without_store_password() + for name in SCRUBBED_NAMES: + env.pop(name, None) for name in CREDENTIAL_NAMES: env[name] = credentials[name] env[PULUMI_BACKEND_URL] = backend_url diff --git a/src/stackward/commands/set_secrets.py b/src/stackward/commands/set_secrets.py index 131026f..5b5b34f 100644 --- a/src/stackward/commands/set_secrets.py +++ b/src/stackward/commands/set_secrets.py @@ -480,29 +480,31 @@ def _build_secret_source( ) -def parse_env_file(path: Path) -> dict[str, str]: - """Parse one `KEY=VALUE` file: blank lines and `#` comments are ignored, a +def parse_env_text(text: str, origin: str) -> dict[str, str]: + """Parse `KEY=VALUE` text: blank lines and `#` comments are ignored, a leading `export ` is stripped from the key side, and exactly one matched pair of surrounding quotes is stripped from the value. - Warns on stderr (via `store.warn_if_permissive`, the same message shape - used for the credential store's own files) when `path` is more permissive - than `0o600` — this file can hold a bootstrap secret in plaintext on disk, - so a mode that lets another local user read it is worth flagging, even - though refusing to read it outright would only lock a user out of their - own secrets after the exposure already happened. + `origin` names where the text came from, so a malformed line can be + reported as `:` — a filesystem path for + `parse_env_file` below, and `"stdin"` for `commands.credentials`, which + reads the same format off a pipe (a `.env` file being sealed into the + store is the migration this format exists to serve at both ends). A non-blank, non-comment line with no `=` is a `SetSecretsError` naming - the file and line number, never the line's own text — this is fail-closed - on a genuinely malformed bootstrap file rather than silently skipping a - line that was probably meant to define something. + the origin and line number, **never the line's own text** — this is + fail-closed on a genuinely malformed bootstrap file rather than silently + skipping a line that was probably meant to define something, and the + message stays safe to forward to a caller with a different vocabulary + (see `commands.credentials`, which re-raises it) precisely because it + carries no content. + + Whitespace around a value is stripped, and quoting is how a value that + genuinely ends in a space survives — the same rule a shell applies to + `KEY=value` and the same one every `.env` reader applies, which is what + makes "pipe the file you already have" mean the same thing here as it + did there. """ - warn_if_permissive(path, 0o600) - try: - text = path.read_text() - except OSError as exc: - raise SetSecretsError(f"{path}: cannot read: {exc}") from exc - values: dict[str, str] = {} for line_number, raw_line in enumerate(text.splitlines(), start=1): line = raw_line.strip() @@ -511,18 +513,42 @@ def parse_env_file(path: Path) -> dict[str, str]: if line.startswith(_EXPORT_PREFIX): line = line[len(_EXPORT_PREFIX) :] if "=" not in line: - raise SetSecretsError(f"{path}:{line_number}: not a KEY=VALUE line") + raise SetSecretsError(f"{origin}:{line_number}: not a KEY=VALUE line") key, _, value = line.partition("=") key = key.strip() value = value.strip() if len(value) >= 2 and value[0] == value[-1] and value[0] in _QUOTE_CHARS: value = value[1:-1] if not key: - raise SetSecretsError(f"{path}:{line_number}: empty key") + raise SetSecretsError(f"{origin}:{line_number}: empty key") values[key] = value return values +def parse_env_file(path: Path) -> dict[str, str]: + """`parse_env_text` over one file's contents. + + Warns on stderr (via `store.warn_if_permissive`, the same message shape + used for the credential store's own files) when `path` is more permissive + than `0o600` — this file can hold a bootstrap secret in plaintext on disk, + so a mode that lets another local user read it is worth flagging, even + though refusing to read it outright would only lock a user out of their + own secrets after the exposure already happened. + + The parsing itself is in `parse_env_text` because two commands need it + over two different sources: this one reads a declared source file, and + `commands.credentials` reads the same format off stdin. A second + implementation of "what does a `.env` line mean" is how the two come to + disagree about a quoted value on the day it matters. + """ + warn_if_permissive(path, 0o600) + try: + text = path.read_text() + except OSError as exc: + raise SetSecretsError(f"{path}: cannot read: {exc}") from exc + return parse_env_text(text, str(path)) + + # --------------------------------------------------------------------------- # Talking to `pulumi` # --------------------------------------------------------------------------- diff --git a/src/stackward/config.py b/src/stackward/config.py index dcb4925..7e016ff 100644 --- a/src/stackward/config.py +++ b/src/stackward/config.py @@ -34,6 +34,7 @@ from __future__ import annotations +import re import tomllib from dataclasses import dataclass, field from pathlib import Path @@ -332,6 +333,53 @@ def _build_config(data: dict[str, Any]) -> Config: ) +# The `(at line L, column C)` coordinate `tomllib` appends to most of its +# messages, and the only part of such a message that is safe to quote back — +# see `toml_position`. +_TOML_POSITION = re.compile(r"\(at (?:line \d+, column \d+|end of document)\)") + + +def toml_position(exc: tomllib.TOMLDecodeError) -> str: + """Just the coordinate out of a `TOMLDecodeError`, as ` (at line L, + column C)`, or `""` when the message does not carry one. + + **`tomllib` echoes document text.** Most of its messages are a fixed + description plus a coordinate, but not all: `tomllib.loads("[a]\\nx=1\\n + [a]\\n")` raises `Cannot declare ('a',) twice`, naming the key back. So + a call site that interpolates `exc` is quoting whatever the parser + decided to quote, which is a property of the input file rather than a + decision this code made — and Global Constraint 4 forbids printing the + matching text. + + That was found first in `store.py`, whose `config` file can hold a + credential (a `backend_url` of the documented + `postgres://user:password@host/db` form), and the same defect was still + in `load_config_text` below, guarded only by the claim that + `.stackward.toml` holds path and key *names* and never a value. The + claim is a reasonable reading of what that file is *for*, and it is not + something this error path can check: the text it is reporting on is, by + definition, a file that failed to parse — half-pasted, mid-edit, or + written by someone who misunderstood it. "The file should not contain a + credential" is exactly the assumption a mistake violates, and the error + that catches the mistake is the last place that should read one back. + + One definition, in `config.py` rather than in `store.py`, because both + modules need it and only this direction of import is available: + `store.py` imports `crypto`, and `config.py` is on the gate path, where + Global Constraint 2 forbids reaching either. So `store` imports this, + and never the reverse. + + The coordinate is kept because it is what makes the error actionable and + it is structural, not quoted text. `TOMLDecodeError` exposes no + `lineno`/`colno` before 3.13 and this project supports 3.11, so it is + matched out of the message rather than read off the exception; a message + shape this does not recognise simply yields no coordinate, which loses + diagnostics and never discloses anything. + """ + match = _TOML_POSITION.search(str(exc)) + return f" {match.group(0)}" if match else "" + + def load_config_text(text: str, origin: str) -> Config: """Parse and validate `text` as `.stackward.toml` content. @@ -359,10 +407,10 @@ def load_config_text(text: str, origin: str) -> Config: try: data = tomllib.loads(text) except tomllib.TOMLDecodeError as exc: - # `.stackward.toml` holds path and key *names* only, never a - # credential value, so it is safe for this message to include - # whatever tomllib quotes from the offending line. - raise ConfigError(f"{origin}: invalid TOML: {exc}") from exc + # Position only, never the parser's message — see `toml_position`, + # and `store.load_store_config`, which is the same refusal for the + # same reason. + raise ConfigError(f"{origin}: invalid TOML{toml_position(exc)}") from exc try: return _build_config(data) diff --git a/src/stackward/store.py b/src/stackward/store.py index 61e30f0..88f4ce7 100644 --- a/src/stackward/store.py +++ b/src/stackward/store.py @@ -70,6 +70,7 @@ from pathlib import Path from typing import Any +from .config import toml_position from .crypto import ( CryptoError, DecryptionError, @@ -412,44 +413,6 @@ def _build_profile(name: str, raw: Any) -> Profile: return profile -# The `(at line L, column C)` coordinate `tomllib` appends to every message -# it raises, and the only part of that message safe to quote back — see -# `_toml_position`. -_TOML_POSITION = re.compile(r"\(at (?:line \d+, column \d+|end of document)\)") - - -def _toml_position(exc: tomllib.TOMLDecodeError) -> str: - """Just the coordinate out of a `TOMLDecodeError`, as ` (at line L, - column C)`, or `""` when the message does not carry one. - - This exists because `config` can hold a credential and the parser's own - message can quote the document. Both halves of that were previously - believed false, and this call site quoted `exc` in full on the strength - of it: "`config` holds backend identity and never a credential, so - quoting tomllib's message -- which may include the offending line -- is - safe here in a way it would not be for `credentials`." - - Neither half survived checking. A `backend_url` of the documented - `postgres://user:password@host/db` form is a credential in `config` (see - the module docstring). And `tomllib` does echo document text: most of its - messages are a fixed description plus a coordinate, but not all of them - -- `tomllib.loads("[a]\\nx=1\\n[a]\\n")` raises `Cannot declare - ('a',) twice`, naming the key back. `credentials` is handled by exactly - this rule already, one function below, and for exactly this reason; the - two files differ in how *likely* a pasted secret is, not in whether the - parser can read one back. - - The coordinate is kept because it is what makes the error actionable and - it is structural, not quoted text. `TOMLDecodeError` exposes no - `lineno`/`colno` before 3.13 and this project supports 3.11, so it is - matched out of the message rather than read off the exception; a message - shape this does not recognise simply yields no coordinate, which loses - diagnostics and never discloses anything. - """ - match = _TOML_POSITION.search(str(exc)) - return f" {match.group(0)}" if match else "" - - def load_store_config(directory: Path | None = None) -> StoreConfig: """Parse `config`, or return an empty one when the file does not exist. @@ -470,8 +433,10 @@ def load_store_config(directory: Path | None = None) -> StoreConfig: except OSError as exc: raise StoreError(f"{path}: cannot read: {exc}") from exc except tomllib.TOMLDecodeError as exc: - # Position only, never the parser's message — see `_toml_position`. - raise StoreError(f"{path}: invalid TOML{_toml_position(exc)}") from exc + # Position only, never the parser's message — see + # `config.toml_position`, which is where that judgement lives for + # both this file and `.stackward.toml`. + raise StoreError(f"{path}: invalid TOML{toml_position(exc)}") from exc try: return _build_store_config(data) @@ -573,7 +538,8 @@ def _read_document(directory: Path | None = None) -> dict[str, Any]: raw = path.read_bytes() except FileNotFoundError as exc: raise StoreError( - f"{path}: no credential store here yet — initialise one first" + f"{path}: no credential store here yet — run " + "`stackward credentials init`" ) from exc except OSError as exc: raise StoreError(f"{path}: cannot read: {exc}") from exc diff --git a/tests/conftest.py b/tests/conftest.py index c366828..b117aec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,12 @@ -"""Fixtures shared by the end-to-end tests that drive a real `git commit`. +"""Fixtures shared by the tests that drive a real `git`. -Two test files now need the *installed* `stackward` console script — the one +Two of them, for two reasons that are not the same. `isolated_git` (below) +belongs here because four files need it and a judgement about which git +configuration a test may see must not exist in four copies. This one +belongs here because the rule about when a missing console script is a +skip and when it is a failure is a judgement that must not drift either. + +Two test files need the *installed* `stackward` console script — the one that proves a git hook really runs, rather than that a file landed at the expected path. The rule about when its absence is a skip and when it is a failure is a judgement that must not exist in two places and drift: a second @@ -58,3 +64,52 @@ def real_executable(monkeypatch) -> str: executable = real_stackward_executable() monkeypatch.setattr(install_hooks_module, "_running_executable", lambda: executable) return executable + + +@pytest.fixture +def isolated_git(tmp_path: Path, monkeypatch) -> Path: + """Cut every `git` a test runs off from the machine's own git config. + + Without this, a developer (or a CI image) with a *global* + `core.hooksPath` -- what husky, lefthook and the `pre-commit` framework + all set, and precisely the configuration `hooks install` exists to + handle -- makes `git init`/`git commit` inherit that hook, and dozens of + tests across four files error out for a reason that has nothing to do + with the code under test. A suite whose result depends on the + developer's `~/.gitconfig` is not testing what it claims to. + + Two consequences are worse than a confusing error, and are why this is + a fixture every git-touching test takes rather than a convention: + + * A global `core.hooksPath` silently moves where `hooks install` + *writes*, so tests asserting on `.git/hooks/pre-commit` would be + examining a directory the command was never pointed at. + * It also moves where `hooks install` writes to a directory **outside + `tmp_path`**. Reproduced on this branch: with a global `core.hooksPath` + set, `tests/test_model_net.py` installed a stackward pre-commit hook + into the developer's own global hooks directory, where it stayed after + the run and then ran against every unrelated repository on the + machine. That is an escape, not merely a failure. + + All four sources git consults are redirected, not only the global one: + `GIT_CONFIG_SYSTEM` covers `/etc/gitconfig`, which is exactly where an + org-wide `hooksPath` lives; `HOME` and `XDG_CONFIG_HOME` cover + `~/.gitconfig` and `$XDG_CONFIG_HOME/git/config`, which git still reads + on a path that leaves `GIT_CONFIG_GLOBAL` unset. `/dev/null` is git's + own documented spelling of "this configuration file does not exist" for + the two `GIT_CONFIG_*` variables. + + The redirect is set with `monkeypatch`, so it is still in place while + the *test body* runs `git commit` -- not only while the fixture built + the repository. It lives here, in `conftest.py`, because it was + previously two identical copies in two files and two other files had + none; a third copy is how the two that had it drift apart, and having + none is what let the escape above happen. + """ + home = tmp_path / "git-home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / "config")) + monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) + monkeypatch.setenv("GIT_CONFIG_SYSTEM", os.devnull) + return home diff --git a/tests/test_cli.py b/tests/test_cli.py index 8115283..bebe7c5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,11 +2,17 @@ from __future__ import annotations +import argparse +import ast +import inspect + import pytest -from stackward import __version__ +from stackward import __version__, cli from stackward.cli import ( VERSION_CHECK_EXEMPT, + _import_command, + build_parser, config_home, crypto_selftest, main, @@ -105,3 +111,84 @@ def test_doctor_still_works_when_repo_config_is_malformed(tmp_path, monkeypatch, monkeypatch.chdir(tmp_path) assert main(["doctor"]) == 0 assert str(tmp_path / ".stackward.toml") in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# Lazy dispatch, and the frozen bundle +# --------------------------------------------------------------------------- + + +def _dispatched_module_names(parser: argparse.ArgumentParser) -> set[str]: + """Every command module reached through `cli._dispatch`, read off the + parser rather than from a list a test keeps in step by hand. + + `_dispatch` stamps its thunk's `__qualname__` as `.`; + a directly registered entry point (`cmd_doctor`, `cmd_check_config`) has + no dot in its qualname and is therefore not one of these. + """ + found: set[str] = set() + for action in parser._actions: + if not isinstance(action, argparse._SubParsersAction): + continue + for sub in action.choices.values(): + found |= _dispatched_module_names(sub) + func = sub.get_default("func") + qualname = getattr(func, "__qualname__", "") + if func is not None and "." in qualname: + found.add(qualname.partition(".")[0]) + return found + + +def test_every_lazily_dispatched_command_module_can_actually_be_imported(): + """A command registered through `_dispatch` but missing from + `_import_command`'s chain would raise `RuntimeError` the first time + anyone ran it, and nothing else in the suite would notice: every other + test either calls an entry point directly or goes through a subcommand + that already works. + + The membership check is a vacuity guard -- a `_dispatched_module_names` + that silently returned nothing would satisfy the loop below against any + implementation at all. + """ + names = _dispatched_module_names(build_parser()) + assert {"session", "credentials"} <= names + + for name in names: + assert _import_command(name).__name__ == f"stackward.commands.{name}" + + +def test_a_command_module_is_never_named_dynamically(): + """The one property no in-process test can observe, asserted against the + module's own syntax tree instead. + + PyInstaller resolves imports by reading `import` statements out of + bytecode; it cannot resolve a module name assembled at runtime. While + dispatch used `importlib.import_module(f".commands.{name}", ...)`, every + lazily dispatched command -- `login`, `exec`, `shell`, `set-secrets`, + `sync-declared-secrets`, `check-passphrase` -- was simply absent from + the shipped binary and died on `ModuleNotFoundError` with a traceback + and exit code 1, the code reserved for "a credential was found". Source + checkouts, this suite included, cannot see that: they import from a real + filesystem where the module is there to be found. + + The tree, not the text: this module's own docstrings quote the old call + on purpose, and a substring search over the source would fail on the + explanation of the fix rather than on the defect. + + So this asserts the mechanism, and the release workflow's smoke step + asserts the consequence by running a lazily dispatched command against + the real bundle. Neither is sufficient alone: this one cannot prove the + bundle works, and that one only runs on a release. + """ + tree = ast.parse(inspect.getsource(cli)) + called = { + node.func.attr + for node in ast.walk(tree) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + } + assert "import_module" not in called + assert "__import__" not in { + node.func.id + for node in ast.walk(tree) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + } diff --git a/tests/test_config.py b/tests/test_config.py index 4e49068..757a6c5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,8 +9,12 @@ from __future__ import annotations +import tomllib + import pytest +from leakcheck import assert_no_leak + from stackward import __version__ from stackward.config import ( BUILTIN_SENSITIVE_KEYS, @@ -491,6 +495,92 @@ def test_load_config_text_names_its_origin_in_an_error(): assert ":.stackward.toml" in str(exc_info.value) +# --------------------------------------------------------------------------- +# A parse failure reports a position, never the parser's own message. +# +# `tomllib` quotes document text in some of its messages, and this call site +# interpolated `exc` in full on the strength of a claim about what +# `.stackward.toml` is allowed to contain -- which is exactly the assumption +# a file that failed to parse has already broken. `store.py` fixed the same +# defect for the store's own `config`; both now go through +# `config.toml_position`. +# --------------------------------------------------------------------------- + +# Opaque -- see `tests/leakcheck.py`. The messages under test print the +# origin label and the words "invalid TOML", and a marker built out of real +# words would share an eight-character run with them. +PASTED_INTO_THE_POLICY = "Xr4Nb8Kw2Vd6Ty9Qm3Zs7Fj" + + +def test_a_duplicate_declaration_is_reported_by_position_and_never_quoted_back(): + """`tomllib` really does read a document's own text back. + + Not hypothetical, and not reachable through the malformed-syntax tests + above: nearly every `TOMLDecodeError` is a fixed description plus a + coordinate, and only a handful name something from the file. A + re-declared table is the one this project has actually confirmed -- + `Cannot declare ('a',) twice` -- so it is the shape worth pinning. + """ + text = ( + f'[secrets."{PASTED_INTO_THE_POLICY}"]\n' + f'[secrets."{PASTED_INTO_THE_POLICY}"]\n' + ) + # Guard: if a future tomllib stops quoting the key, this test would pass + # against the unfixed call site too, and would need to be rewritten + # rather than quietly kept. + try: + tomllib.loads(text) + except tomllib.TOMLDecodeError as exc: + assert PASTED_INTO_THE_POLICY in str(exc), "tomllib no longer quotes the key" + + with pytest.raises(ConfigError) as exc_info: + load_config_text(text, ":.stackward.toml") + + message = str(exc_info.value) + assert_no_leak(message, PASTED_INTO_THE_POLICY, what="text from the policy file") + assert message.startswith(":.stackward.toml: invalid TOML") + assert "line 2" in message + + +def test_a_parser_message_is_reduced_to_its_coordinate(monkeypatch): + """The general rule, independent of which messages this `tomllib` happens + to produce: whatever the parser says, only the coordinate survives.""" + + def raise_quoting_the_document(*_args, **_kwargs): + raise tomllib.TOMLDecodeError( + f"Cannot declare ('{PASTED_INTO_THE_POLICY}',) twice " + "(at line 3, column 3)" + ) + + monkeypatch.setattr(tomllib, "loads", raise_quoting_the_document) + with pytest.raises(ConfigError) as exc_info: + load_config_text("irrelevant", "") + + message = str(exc_info.value) + assert_no_leak(message, PASTED_INTO_THE_POLICY, what="the parser's own message") + assert message == ": invalid TOML (at line 3, column 3)" + + +def test_a_message_with_no_coordinate_loses_the_coordinate_not_the_secrecy(monkeypatch): + """The fallback must degrade to *no position*, never to the whole + message -- which is what this call site used to do unconditionally. + `TOMLDecodeError`'s text is not an API and this project supports three + Python versions, so an unrecognised shape has to be survivable.""" + + def raise_an_unparseable_shape(*_args, **_kwargs): + raise tomllib.TOMLDecodeError( + f"a shape from some future release: {PASTED_INTO_THE_POLICY}" + ) + + monkeypatch.setattr(tomllib, "loads", raise_an_unparseable_shape) + with pytest.raises(ConfigError) as exc_info: + load_config_text("irrelevant", "") + + message = str(exc_info.value) + assert_no_leak(message, PASTED_INTO_THE_POLICY, what="the parser's own message") + assert message == ": invalid TOML" + + def test_load_config_applies_the_same_validation_as_load_config_text(tmp_path): """The file reader must not be a second, drifting implementation: it is a reader in front of the same validator, so an invalid key is rejected diff --git a/tests/test_credentials.py b/tests/test_credentials.py new file mode 100644 index 0000000..be87cc6 --- /dev/null +++ b/tests/test_credentials.py @@ -0,0 +1,828 @@ +"""Tests for `credentials init|set|list|show|rotate`. + +Three things this file is careful about, for the reasons `test_store.py` and +`test_session.py` state them: + +*No assertion depends on a credential value being displayed.* Where a value +must round-trip it is compared against a constant; where it must not appear +the assertion is on absence, through `leakcheck.assert_no_leak`, which also +catches a truncated or re-encoded disclosure. + +*The markers are opaque.* `assert_no_leak` fails on any eight-character run +of a value, and these commands legitimately print profile names, store paths +and the literal string `AWS_ACCESS_KEY_ID`. A marker built out of real words +collides with that and makes the check unusable — see `tests/leakcheck.py`. + +*A "must not be called" control is a `BaseException`.* Every entry point here +is wrapped in `@fail_closed`, which turns any `Exception` into `return 2`, so +an `AssertionError` raised inside one is swallowed and the test then asserts +on an exit code that looks like an ordinary refusal. `_PromptViolation` below +inherits from `BaseException` instead, and is not `KeyboardInterrupt`, which +pytest would treat as "the human wants out" and use to abort the session. +""" + +from __future__ import annotations + +import io +import json +import os +import stat +import subprocess +import sys +from pathlib import Path + +import pytest + +from leakcheck import assert_no_leak +from stackward import store +from stackward.cli import main +from stackward.commands import credentials, session +from stackward.commands.credentials import ( + CONFIRM_PASSWORD_PROMPT, + NEW_PASSWORD_PROMPT, + OLD_PASSWORD_PROMPT, +) +from stackward.commands.session import ( + AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY, + ENV_STORE_PASSWORD, + PULUMI_BACKEND_URL, + PULUMI_CONFIG_PASSPHRASE, +) + +# Placeholder values only (GC4), and deliberately wordless: every one of +# these is passed to `assert_no_leak` against output that also contains the +# tool's own vocabulary. +PASSWORD = "Kx7Rq2Vn9Ld4Ts6Bw" +WRONG_PASSWORD = "Zj3Mp8Hc5Yf1Gd7Nr" +NEW_PASSWORD = "Qv6Wb4Xs2Tk9Pm3Ld" + +MARKER_KEY = "Ab7Kd2Ns9Rv4Tq6Xw" +MARKER_SECRET = "Cf3Jm8Ph5Lz1Yb7Gd" +MARKER_PASSPHRASE = "Ew9Ur4Vt2Kn6Sx8Qc" +ALL_MARKERS = (MARKER_KEY, MARKER_SECRET, MARKER_PASSPHRASE) + +FULL_CREDENTIALS = { + AWS_ACCESS_KEY_ID: MARKER_KEY, + AWS_SECRET_ACCESS_KEY: MARKER_SECRET, + PULUMI_CONFIG_PASSPHRASE: MARKER_PASSPHRASE, +} + +BACKEND_URL = "file:///placeholder-backend" + + +def env_text(values: dict[str, str]) -> str: + return "".join(f"{name}={value}\n" for name, value in values.items()) + + +class _PromptViolation(BaseException): + """Raised when something asks for input a test did not expect it to ask + for. See this module's docstring for why it is not an `Exception`.""" + + +class _Terminal(io.StringIO): + """Stands in for a `sys.stdin` attached to a terminal. `io.StringIO` + already reports `isatty()` as `False`, which is the piped case, so only + this direction needs a subclass.""" + + def isatty(self) -> bool: + return True + + +@pytest.fixture(autouse=True) +def cheap_kdf(monkeypatch): + """Lower the Argon2id cost for this module only -- the same fixture + `test_store.py` and `test_session.py` carry, for the same reason: these + tests perform several real seal/open round trips each and none of them is + about how expensive a guess is. `test_crypto.py` pins the shipped + parameters.""" + from stackward import crypto + + monkeypatch.setattr(crypto, "ARGON2ID_MEMORY_KIB", 8192) + monkeypatch.setattr(crypto, "ARGON2ID_ITERATIONS", 1) + monkeypatch.setattr(crypto, "ARGON2ID_LANES", 1) + + +@pytest.fixture(autouse=True) +def no_ambient_state(monkeypatch, tmp_path): + """Nothing about the store, the profile or the backend left to whoever's + shell runs the suite. Each test opts back in to whatever it needs.""" + for name in ( + ENV_STORE_PASSWORD, + store.ENV_PROFILE, + PULUMI_BACKEND_URL, + *session.CREDENTIAL_NAMES, + *session.SCRUBBED_NAMES, + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("PULUMI_HOME", str(tmp_path / "pulumi-home")) + + +@pytest.fixture +def cli_store(tmp_path, monkeypatch) -> Path: + """Point the real config-home lookup at an isolated directory -- the way + `cli.config_home` is actually consulted by every `cmd_*` entry point, + rather than through the `directory=` seam `store.py`'s own unit tests + use.""" + home = tmp_path / "xdg" + monkeypatch.setenv("XDG_CONFIG_HOME", str(home)) + return home / "stackward" + + +@pytest.fixture +def configured(cli_store) -> Path: + """A store directory whose `config` declares one profile, and no + `credentials` file at all -- the state a user is in before + `credentials init`.""" + cli_store.mkdir(parents=True, exist_ok=True) + config = cli_store / store.CONFIG_FILENAME + config.write_text(f'[profile.staging]\nbackend_url = "{BACKEND_URL}"\n') + config.chmod(store.CONFIG_MODE) + return cli_store + + +@pytest.fixture +def initialised(configured, monkeypatch) -> Path: + """`configured`, plus a store initialised under `PASSWORD`. + + The variable is removed again once the store exists. Leaving it set + would be an ambient answer to every later password read in the test, and + the tests that most need it *not* to be set -- rotation, which refuses + outright while it is -- are exactly the ones that would then be testing + the fixture rather than the command. Each test opts back in. + """ + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + assert main(["credentials", "init"]) == 0 + monkeypatch.delenv(ENV_STORE_PASSWORD) + return configured + + +def pipe(monkeypatch, text: str) -> None: + """Make `sys.stdin` a pipe carrying `text`.""" + monkeypatch.setattr(sys, "stdin", io.StringIO(text)) + + +def terminal(monkeypatch, text: str = "") -> None: + monkeypatch.setattr(sys, "stdin", _Terminal(text)) + + +def answer_prompts(monkeypatch, answers: dict[str, str]) -> None: + """Answer `getpass` by prompt text, and refuse any prompt not listed. + + Keying on the prompt is what lets one seam serve both the master + password and the per-name value prompts, and what makes "it asked for + something else" a failure rather than a silently wrong answer. + """ + + def fake_getpass(prompt: str = "") -> str: + if prompt not in answers: + raise _PromptViolation(f"unexpected prompt: {prompt!r}") + return answers[prompt] + + monkeypatch.setattr(credentials.getpass, "getpass", fake_getpass) + monkeypatch.setattr(session, "_tty_available", lambda: True) + + def refuse_input(*_args, **_kwargs): + raise _PromptViolation("a prompt echoed: builtins.input was used") + + monkeypatch.setattr("builtins.input", refuse_input) + + +# --------------------------------------------------------------------------- +# `credentials init` +# --------------------------------------------------------------------------- + + +def test_init_creates_a_store_the_session_commands_can_open(configured, monkeypatch): + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + + assert main(["credentials", "init"]) == 0 + + path = configured / store.CREDENTIALS_FILENAME + assert path.is_file() + assert stat.S_IMODE(path.stat().st_mode) == store.CREDENTIALS_MODE + assert stat.S_IMODE(configured.stat().st_mode) == store.DIR_MODE + # Empty, and openable: the verifier is what proves the password took. + assert store.store_profiles(configured) == [] + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=configured) + + +def test_init_refuses_to_overwrite_an_existing_store(initialised, monkeypatch, capsys): + """Overwriting would discard every sealed envelope irrecoverably, in + response to a command someone could plausibly run twice.""" + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=initialised) + before = (initialised / store.CREDENTIALS_FILENAME).read_bytes() + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + + assert main(["credentials", "init"]) == 2 + + assert (initialised / store.CREDENTIALS_FILENAME).read_bytes() == before + assert store.resolve_credentials("staging", PASSWORD, directory=initialised) + assert "already exists" in capsys.readouterr().err + + +def test_init_asks_for_the_new_password_twice_and_refuses_a_mismatch( + configured, monkeypatch, capsys +): + """A typo in the password that seals a store is not recoverable by any + means afterwards, because nothing else can open what it sealed. The + second read is what catches it, and the refusal must leave no store + behind -- a half-created one would be opened by neither answer.""" + answer_prompts( + monkeypatch, + {NEW_PASSWORD_PROMPT: PASSWORD, CONFIRM_PASSWORD_PROMPT: WRONG_PASSWORD}, + ) + + assert main(["credentials", "init"]) == 2 + + assert not (configured / store.CREDENTIALS_FILENAME).exists() + assert "do not match" in capsys.readouterr().err + + +def test_init_prompts_without_echoing_and_seals_what_was_typed( + configured, monkeypatch +): + """The interactive path, end to end: `getpass` (never `input`, which + echoes -- `answer_prompts` fails the test if `input` is used at all), and + the store really opens under the typed password afterwards.""" + answer_prompts( + monkeypatch, + {NEW_PASSWORD_PROMPT: PASSWORD, CONFIRM_PASSWORD_PROMPT: PASSWORD}, + ) + + assert main(["credentials", "init"]) == 0 + + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=configured) + assert ( + store.resolve_credentials("staging", PASSWORD, directory=configured) + == FULL_CREDENTIALS + ) + + +def test_init_says_when_it_took_the_password_from_the_environment( + configured, monkeypatch, capsys +): + """A store sealed with an ambient variable the user has forgotten about + is a store they cannot open tomorrow. The name is printed; the value + never is.""" + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + + assert main(["credentials", "init"]) == 0 + + out = capsys.readouterr().out + assert ENV_STORE_PASSWORD in out + assert_no_leak(out, PASSWORD, what="the store password") + + +# --------------------------------------------------------------------------- +# `credentials set` +# --------------------------------------------------------------------------- + + +def test_set_seals_values_read_from_stdin(initialised, monkeypatch): + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + pipe(monkeypatch, env_text(FULL_CREDENTIALS)) + + assert main(["credentials", "set", "--profile", "staging"]) == 0 + + assert ( + store.resolve_credentials("staging", PASSWORD, directory=initialised) + == FULL_CREDENTIALS + ) + + +def test_the_sealed_file_holds_none_of_the_values(initialised, monkeypatch): + """A `set` that stringified or base64-encoded its input instead of + sealing it would pass the round-trip test above and fail here.""" + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + pipe(monkeypatch, env_text(FULL_CREDENTIALS)) + assert main(["credentials", "set", "--profile", "staging"]) == 0 + + written = (initialised / store.CREDENTIALS_FILENAME).read_text() + for marker in ALL_MARKERS: + assert_no_leak(written, marker, what="a sealed credential") + assert_no_leak(written, PASSWORD, what="the store password") + + +def test_set_prints_no_value_of_its_own(initialised, monkeypatch, capfd): + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + pipe(monkeypatch, env_text(FULL_CREDENTIALS)) + + assert main(["credentials", "set", "--profile", "staging"]) == 0 + + captured = capfd.readouterr() + for marker in ALL_MARKERS: + assert_no_leak(captured.out + captured.err, marker, what="a credential") + + +def test_set_takes_no_value_as_an_argument(initialised, monkeypatch): + """Global Constraint 6, pinned at the parser rather than by inspection: + a command line is visible in `ps` for the life of the call and stays in + shell history afterwards, so there must be no positional and no + `--value` for a value to be passed *through*. argparse rejects the + attempt itself, before any command code runs. + """ + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + for argv in ( + ["credentials", "set", "--profile", "staging", f"{AWS_ACCESS_KEY_ID}={MARKER_KEY}"], + ["credentials", "set", "--profile", "staging", "--value", MARKER_KEY], + ): + with pytest.raises(SystemExit) as raised: + main(argv) + assert raised.value.code == 2 + assert store.store_profiles(initialised) == [] + + +def test_set_refuses_a_missing_required_name_and_writes_nothing( + initialised, monkeypatch, capsys +): + """The same refusal `exec` would make, made at the point the envelope is + written instead of at the point it is needed -- and `set_credentials` + *replaces* an envelope, so accepting a partial one would silently drop + the names that were not supplied.""" + before = (initialised / store.CREDENTIALS_FILENAME).read_bytes() + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + pipe(monkeypatch, env_text({AWS_ACCESS_KEY_ID: MARKER_KEY})) + + assert main(["credentials", "set", "--profile", "staging"]) == 2 + + assert (initialised / store.CREDENTIALS_FILENAME).read_bytes() == before + err = capsys.readouterr().err + assert AWS_SECRET_ACCESS_KEY in err and PULUMI_CONFIG_PASSPHRASE in err + assert_no_leak(err, MARKER_KEY, what="a credential") + + +def test_set_refuses_an_empty_value_for_a_required_name( + initialised, monkeypatch, capsys +): + """A placeholder line in a `.env` (`AWS_ACCESS_KEY_ID=`) seals perfectly + cleanly and then fails to authenticate with nothing pointing back at + this command, so it is refused here where the cause is still visible.""" + before = (initialised / store.CREDENTIALS_FILENAME).read_bytes() + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + pipe(monkeypatch, env_text({**FULL_CREDENTIALS, AWS_ACCESS_KEY_ID: ""})) + + assert main(["credentials", "set", "--profile", "staging"]) == 2 + + assert (initialised / store.CREDENTIALS_FILENAME).read_bytes() == before + assert AWS_ACCESS_KEY_ID in capsys.readouterr().err + + +def test_set_refuses_a_malformed_line_without_echoing_it( + initialised, monkeypatch, capsys +): + """A line with no `=` is fail-closed, not silently skipped -- and the + refusal names a line number, never the line, because the thing that + failed to parse may be exactly what a value was pasted into.""" + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + pipe(monkeypatch, f"{env_text(FULL_CREDENTIALS)}{MARKER_SECRET}\n") + + assert main(["credentials", "set", "--profile", "staging"]) == 2 + + err = capsys.readouterr().err + assert "stdin:4" in err + assert_no_leak(err, MARKER_SECRET, what="a malformed input line") + + +def test_set_refuses_empty_stdin_rather_than_sealing_nothing( + initialised, monkeypatch, capsys +): + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + pipe(monkeypatch, "\n \n") + + assert main(["credentials", "set", "--profile", "staging"]) == 2 + + assert "no credentials on stdin" in capsys.readouterr().err + assert store.store_profiles(initialised) == [] + + +def test_set_with_a_wrong_password_is_refused_by_the_verifier( + initialised, monkeypatch, capsys +): + """The store-level verifier is what makes this immediate: without it, an + envelope sealed under a mistyped password would be written happily and + fail weeks later, at first use, as unexplained damage.""" + before = (initialised / store.CREDENTIALS_FILENAME).read_bytes() + monkeypatch.setenv(ENV_STORE_PASSWORD, WRONG_PASSWORD) + pipe(monkeypatch, env_text(FULL_CREDENTIALS)) + + assert main(["credentials", "set", "--profile", "staging"]) == 2 + + assert (initialised / store.CREDENTIALS_FILENAME).read_bytes() == before + err = capsys.readouterr().err + assert "wrong password" in err + assert_no_leak(err, WRONG_PASSWORD, what="the store password") + + +def test_set_replaces_the_whole_envelope(initialised, monkeypatch): + """Documented behaviour, pinned: `store.set_credentials` writes what it + is given rather than merging, which is why every required name has to be + supplied on every run.""" + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + first = {**FULL_CREDENTIALS, "EXTRA_NAME": "Nt4Bq8Wz2Ck6Hm9Rd"} + pipe(monkeypatch, env_text(first)) + assert main(["credentials", "set", "--profile", "staging"]) == 0 + + pipe(monkeypatch, env_text(FULL_CREDENTIALS)) + assert main(["credentials", "set", "--profile", "staging"]) == 0 + + assert ( + store.resolve_credentials("staging", PASSWORD, directory=initialised) + == FULL_CREDENTIALS + ) + + +def test_set_prompts_for_each_name_without_echo_when_stdin_is_a_terminal( + initialised, monkeypatch +): + """The interactive path. `answer_prompts` refuses `builtins.input` + outright, so an implementation that echoed what was typed fails here + rather than passing quietly.""" + terminal(monkeypatch) + answer_prompts( + monkeypatch, + { + f"{AWS_ACCESS_KEY_ID}: ": MARKER_KEY, + f"{AWS_SECRET_ACCESS_KEY}: ": MARKER_SECRET, + f"{PULUMI_CONFIG_PASSPHRASE}: ": MARKER_PASSPHRASE, + session.DEFAULT_PASSWORD_PROMPT: PASSWORD, + }, + ) + + assert main(["credentials", "set", "--profile", "staging"]) == 0 + + assert ( + store.resolve_credentials("staging", PASSWORD, directory=initialised) + == FULL_CREDENTIALS + ) + + +def test_set_refuses_a_profile_that_has_no_table_in_config( + initialised, monkeypatch, capsys +): + """Profile selection is `session._resolve_profile`, unchanged: a name + that no `[profile.]` declares is an error rather than a silent + fall-through, so credentials cannot be sealed under a typo that nothing + will ever read.""" + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + pipe(monkeypatch, env_text(FULL_CREDENTIALS)) + + assert main(["credentials", "set", "--profile", "typo"]) == 2 + + assert "typo" in capsys.readouterr().err + assert store.store_profiles(initialised) == [] + + +def test_set_honours_the_same_profile_precedence_as_exec(initialised, monkeypatch): + """`STACKWARD_PROFILE` with no `--profile`: the tier below the flag. If + this module selected profiles its own way, the two commands could seal + and read different envelopes from the same environment.""" + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + monkeypatch.setenv(store.ENV_PROFILE, "staging") + pipe(monkeypatch, env_text(FULL_CREDENTIALS)) + + assert main(["credentials", "set"]) == 0 + + assert store.store_profiles(initialised) == ["staging"] + + +# --------------------------------------------------------------------------- +# `credentials list` and `credentials show` +# --------------------------------------------------------------------------- + + +def test_list_prints_the_profiles_that_have_credentials( + initialised, monkeypatch, capsys +): + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=initialised) + + assert main(["credentials", "list"]) == 0 + + captured = capsys.readouterr() + assert captured.out.splitlines() == ["staging"] + for marker in ALL_MARKERS: + assert_no_leak(captured.out + captured.err, marker, what="a credential") + + +def test_list_needs_no_password_at_all(initialised, monkeypatch): + """`store_profiles` reads the document's keys and opens nothing, so a + listing must not prompt -- and must not fail on a machine where no + password is available.""" + + def refuse(*_args, **_kwargs): + raise _PromptViolation("listing asked for the store password") + + monkeypatch.setattr(session, "_read_store_password", refuse) + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=initialised) + + assert main(["credentials", "list"]) == 0 + + +def test_list_before_init_names_the_command_that_fixes_it(configured, capsys): + """Global Constraint 3: a refusal says what to do about it.""" + assert main(["credentials", "list"]) == 2 + assert "credentials init" in capsys.readouterr().err + + +def test_list_says_so_when_no_profile_has_credentials_yet(initialised, capsys): + assert main(["credentials", "list"]) == 0 + + captured = capsys.readouterr() + assert captured.out == "" + assert "credentials set" in captured.err + + +def test_show_lists_names_and_never_values(initialised, monkeypatch, capfd): + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=initialised) + + assert main(["credentials", "show", "--profile", "staging"]) == 0 + + captured = capfd.readouterr() + for name in session.CREDENTIAL_NAMES: + assert name in captured.out + for marker in ALL_MARKERS: + assert_no_leak(captured.out + captured.err, marker, what="a credential") + + +def test_show_reports_a_shortfall_without_failing(initialised, monkeypatch, capfd): + """The question was answered correctly and the store is not in a state + anything needs to refuse, so this exits 0 -- `exec` is what refuses an + incomplete envelope, and already does.""" + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + partial = {AWS_ACCESS_KEY_ID: MARKER_KEY} + store.set_credentials("staging", partial, PASSWORD, directory=initialised) + + assert main(["credentials", "show", "--profile", "staging"]) == 0 + + captured = capfd.readouterr() + assert AWS_SECRET_ACCESS_KEY in captured.err + assert_no_leak(captured.out + captured.err, MARKER_KEY, what="a credential") + + +def test_show_on_a_profile_with_no_envelope_says_so(initialised, monkeypatch, capsys): + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + + assert main(["credentials", "show", "--profile", "staging"]) == 2 + + assert "no credentials" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# `credentials rotate` +# --------------------------------------------------------------------------- + + +def test_rotate_reseals_every_envelope_under_the_new_password( + initialised, monkeypatch +): + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=initialised) + store.set_credentials("other", FULL_CREDENTIALS, PASSWORD, directory=initialised) + answer_prompts( + monkeypatch, + { + OLD_PASSWORD_PROMPT: PASSWORD, + NEW_PASSWORD_PROMPT: NEW_PASSWORD, + CONFIRM_PASSWORD_PROMPT: NEW_PASSWORD, + }, + ) + + assert main(["credentials", "rotate"]) == 0 + + for name in ("staging", "other"): + assert ( + store.resolve_credentials(name, NEW_PASSWORD, directory=initialised) + == FULL_CREDENTIALS + ) + with pytest.raises(store.PasswordError): + store.resolve_credentials(name, PASSWORD, directory=initialised) + + +def test_rotate_refuses_a_mismatched_confirmation_and_changes_nothing( + initialised, monkeypatch, capsys +): + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=initialised) + before = (initialised / store.CREDENTIALS_FILENAME).read_bytes() + answer_prompts( + monkeypatch, + { + OLD_PASSWORD_PROMPT: PASSWORD, + NEW_PASSWORD_PROMPT: NEW_PASSWORD, + CONFIRM_PASSWORD_PROMPT: WRONG_PASSWORD, + }, + ) + + assert main(["credentials", "rotate"]) == 2 + + assert (initialised / store.CREDENTIALS_FILENAME).read_bytes() == before + assert "do not match" in capsys.readouterr().err + + +def test_rotate_refuses_when_the_environment_variable_is_the_only_source( + initialised, monkeypatch, capsys +): + """`STACKWARD_PASSWORD` answers every read of the store password, so with + it set the "old" and "new" passwords are the same string and the user was + asked nothing. Reporting that as a bare sameness would describe something + they never typed, so the refusal names the variable and the fix.""" + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=initialised) + before = (initialised / store.CREDENTIALS_FILENAME).read_bytes() + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + + assert main(["credentials", "rotate"]) == 2 + + assert (initialised / store.CREDENTIALS_FILENAME).read_bytes() == before + err = capsys.readouterr().err + assert ENV_STORE_PASSWORD in err + assert "unset" in err + + +def test_rotate_that_cannot_reseal_one_envelope_changes_nothing( + initialised, monkeypatch, capsys +): + """All-or-nothing, surfaced honestly. A partial rotation would leave a + store whose profiles need different passwords with nothing recording + which is which -- the worst outcome available -- and reporting a failed + rotation as success would be the second worst.""" + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=initialised) + path = initialised / store.CREDENTIALS_FILENAME + document = json.loads(path.read_text()) + document["profiles"]["damaged"] = document["profiles"]["staging"] + path.write_text(json.dumps(document)) + before = path.read_bytes() + + answer_prompts( + monkeypatch, + { + OLD_PASSWORD_PROMPT: PASSWORD, + NEW_PASSWORD_PROMPT: NEW_PASSWORD, + CONFIRM_PASSWORD_PROMPT: NEW_PASSWORD, + }, + ) + + assert main(["credentials", "rotate"]) == 2 + + assert path.read_bytes() == before + assert ( + store.resolve_credentials("staging", PASSWORD, directory=initialised) + == FULL_CREDENTIALS + ) + assert "nothing has been changed" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# Dispatch: registered, and off the gate path +# --------------------------------------------------------------------------- + + +_IMPORT_PROBE = """ +import sys +from stackward.cli import build_parser +build_parser() +print("loaded", "stackward.commands.credentials" in sys.modules) +""" + + +def test_building_the_parser_does_not_import_the_credentials_module(): + """`credentials` imports `store` -> `crypto` -> `cryptography`, and + `build_parser` runs on *every* invocation, `check-config` and + `pre-commit` included. `tests/test_gate_isolation.py` asserts the + property over the union of forbidden modules; this asserts the mechanism + that keeps it true for this command specifically, so that a regression + here is reported as what it is rather than as a module-list diff. + + A subprocess, because this test session has already imported the module + directly at the top of this file. + """ + proc = subprocess.run( + [sys.executable, "-c", _IMPORT_PROBE], + capture_output=True, + text=True, + check=True, + ) + assert "loaded False" in proc.stdout + + +@pytest.mark.parametrize( + "argv", + [ + ["credentials", "init"], + ["credentials", "set"], + ["credentials", "list"], + ["credentials", "show"], + ["credentials", "rotate"], + ], +) +def test_every_subcommand_is_registered_and_dispatches(argv, cli_store, monkeypatch): + """Wiring only. Each must reach its own entry point -- which then refuses, + because `cli_store` points `XDG_CONFIG_HOME` at a directory holding no + store and no profile -- rather than argparse's "invalid choice", which + would raise `SystemExit` here instead of returning. + + `_tty_available` is forced false so that the two subcommands which read a + password (`init`, `rotate`) refuse instead of prompting: without it this + test would block on `/dev/tty` for anyone running the suite from a + terminal, and pass only on machines that have none. + """ + monkeypatch.setattr(session, "_tty_available", lambda: False) + assert main(argv) == 2 + + +def test_a_bare_credentials_command_prints_help_rather_than_guessing(capsys): + assert main(["credentials"]) == 2 + assert "usage:" in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# The migration the plan's Step 2 exit criterion names, end to end. +# --------------------------------------------------------------------------- + + +_STUB = """\ +import json, os, sys +with open(os.environ["STUB_DUMP_ENV_TO"], "w") as handle: + json.dump(dict(os.environ), handle) +""" + + +def test_a_dotenv_file_can_be_sealed_and_then_deleted_and_exec_still_works( + configured, monkeypatch, tmp_path, capfd +): + """The plan's Step 2 exit criterion, walked end to end. + + "`stackward exec -- pulumi ...` works with the repo `.env` **renamed**, + then delete the `.env` bootstrap copies." Every part of that is asserted + rather than assumed: + + * the values reach the child from the **store**, not from the + environment -- the three credential names are deleted from this + process's own environment first, and the assertion at the end is that + no `STACKWARD_*` variable survived either, so the `env` provider and + the master-password variable are both out of the picture; + * no `.env` exists anywhere under `tmp_path` when `exec` runs, so the + `dotenv` provider cannot be what answered; + * the password is *typed*, not exported, which is the path a fresh user + actually has; + * and nothing leaks: the whole run's output is checked at + file-descriptor level (`capfd`, not `capsys` -- a real child's writes + do not pass through `sys.stdout`). + + A stub child rather than `pulumi`, for the reason `test_session.py` + gives: what is under test is this tool's environment handling, not + Pulumi's presence on the machine. + """ + # 1. The bootstrap file a repository has today. + bootstrap = tmp_path / "bootstrap-values" + bootstrap.write_text(env_text(FULL_CREDENTIALS)) + + repo = tmp_path / "repo" + repo.mkdir() + monkeypatch.chdir(repo) + answer_prompts( + monkeypatch, + { + NEW_PASSWORD_PROMPT: PASSWORD, + CONFIRM_PASSWORD_PROMPT: PASSWORD, + session.DEFAULT_PASSWORD_PROMPT: PASSWORD, + }, + ) + + # 2. Seal it, exactly as `stackward credentials set --profile staging + # < .env` would. + assert main(["credentials", "init"]) == 0 + pipe(monkeypatch, bootstrap.read_text()) + assert main(["credentials", "set", "--profile", "staging"]) == 0 + + # 3. Delete the bootstrap copy. Nothing on disk holds these values in + # plaintext from here on. + bootstrap.unlink() + assert list(tmp_path.rglob("*.env")) == [] + assert list(tmp_path.rglob(".env")) == [] + # Named, not swept by prefix. A `STACKWARD_*` prefix check would also + # catch `STACKWARD_DENYLIST`, which this repository's own README tells + # developers to export and `scripts/pre-push` reads -- so the test would + # fail on the machine of anyone who followed the instructions, for a + # reason with nothing to do with the store. These three names are what + # the claim is actually about. + assert ENV_STORE_PASSWORD not in os.environ + assert store.ENV_PROFILE not in os.environ + for name in session.CREDENTIAL_NAMES: + assert name not in os.environ + + # 4. `exec` a child, and read back what it actually received. + stub = tmp_path / "stub.py" + stub.write_text(_STUB) + dump = tmp_path / "child-env.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump)) + + assert main(["exec", "--profile", "staging", "--", sys.executable, str(stub)]) == 0 + + child_env = json.loads(dump.read_text()) + assert child_env[AWS_ACCESS_KEY_ID] == MARKER_KEY + assert child_env[AWS_SECRET_ACCESS_KEY] == MARKER_SECRET + assert child_env[PULUMI_CONFIG_PASSPHRASE] == MARKER_PASSPHRASE + assert child_env[PULUMI_BACKEND_URL] == BACKEND_URL + + captured = capfd.readouterr() + for marker in (*ALL_MARKERS, PASSWORD): + assert_no_leak(captured.out + captured.err, marker, what="a credential") diff --git a/tests/test_crypto.py b/tests/test_crypto.py index 755e3c6..2b43e3f 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -26,6 +26,8 @@ import pytest from cryptography.exceptions import InvalidTag, UnsupportedAlgorithm +from leakcheck import assert_no_leak + from stackward import crypto from stackward.crypto import ( ARGON2ID_ITERATIONS, @@ -40,6 +42,7 @@ DecryptionError, EmptyPasswordError, EnvelopeError, + describe_value, seal, seal_json, unseal, @@ -180,6 +183,82 @@ def test_an_unknown_kdf_is_refused(): unseal(envelope, PASSWORD, AAD) +# --------------------------------------------------------------------------- +# `describe_value` -- the guard on the two header fields whose *contents* get +# printed back. Untested until now: `return repr(value)` for every input +# passed the entire suite, because the two tests that reach the branch +# (`test_an_unsupported_envelope_version_is_refused` and +# `test_an_unknown_kdf_is_refused`) assert only that something was raised. +# --------------------------------------------------------------------------- + +# Opaque on purpose -- see `tests/leakcheck.py`. A marker containing a real +# word would share an eight-character run with the words these very messages +# print ("version", "unsupported"), and the run check would fire on output +# that disclosed nothing. +PASTED_INTO_A_HEADER_FIELD = "Zq7Xv4Rm2Kt9Lp5Nc8Wd6Hb3Jf" + + +@pytest.mark.parametrize( + "value", + [ + "some string", + PASTED_INTO_A_HEADER_FIELD, + ["a", "list"], + {"a": "dict"}, + b"some bytes", + ], + # Named, so that a value never reaches a test id: pytest builds ids out + # of the parameters themselves, and a failure report is output like any + # other (GC4). + ids=["str", "pasted-credential", "list", "dict", "bytes"], +) +def test_describe_value_reports_a_type_and_never_the_value(value): + described = describe_value(value) + assert described == f"<{type(value).__name__}>" + assert str(value) not in described + + +@pytest.mark.parametrize("value", [None, 0, 1, -7, 1.5, True]) +def test_describe_value_shows_what_cannot_carry_a_credential(value): + """A number, a bool and `None` are shown, because the message is far more + useful naming the version it actually found than reporting `` -- and + none of the three can be a pasted credential. `True` is included + deliberately: `bool` is an `int` subclass, so it takes the shown branch, + and a future rewrite that tightened the check to `type(value) is int` + would silently start reporting ``.""" + assert describe_value(value) == repr(value) + + +def test_a_credential_pasted_into_the_version_field_is_not_read_back(): + """`v` is hand-editable and this message is printed. A user who pasted a + credential one field too high must not have it echoed by the error that + caught the mistake.""" + envelope = seal(MARKER, PASSWORD, AAD) + envelope["v"] = PASTED_INTO_A_HEADER_FIELD + + with pytest.raises(EnvelopeError) as raised: + unseal(envelope, PASSWORD, AAD) + + message = str(raised.value) + assert_no_leak(message, PASTED_INTO_A_HEADER_FIELD, what="a pasted header value") + assert "" in message + + +def test_a_credential_pasted_into_the_kdf_name_is_not_read_back(): + """The second call site, which the version test above cannot cover: the + two are separate `describe_value` calls and a fix applied to one only + would leave the other echoing.""" + envelope = seal(MARKER, PASSWORD, AAD) + envelope["kdf"]["name"] = PASTED_INTO_A_HEADER_FIELD + + with pytest.raises(EnvelopeError) as raised: + unseal(envelope, PASSWORD, AAD) + + message = str(raised.value) + assert_no_leak(message, PASTED_INTO_A_HEADER_FIELD, what="a pasted header value") + assert "" in message + + @pytest.mark.parametrize( ("key", "value"), [ diff --git a/tests/test_gate_isolation.py b/tests/test_gate_isolation.py index 4265692..6c5e334 100644 --- a/tests/test_gate_isolation.py +++ b/tests/test_gate_isolation.py @@ -158,6 +158,16 @@ def test_check_config_neither_prompts_nor_connects_on_a_finding( def _repo_with_policy(tmp_path: Path) -> Path: + """A real repository with a minimal policy, committed. + + Every caller takes the `isolated_git` fixture (see `tests/conftest.py`) + before calling this: `git commit` below runs whatever hook the machine's + own `core.hooksPath` points at otherwise, which fails these two tests + for a reason that has nothing to do with the gate. Taken as a fixture by + the tests rather than as a parameter here, since what it does is set + environment variables for the duration of the test, not return a value + this function needs. + """ repo = tmp_path / "repo" repo.mkdir() for args in ( @@ -176,7 +186,9 @@ def _repo_with_policy(tmp_path: Path) -> Path: return repo -def test_pre_commit_neither_prompts_nor_connects(sealed, tmp_path, monkeypatch): +def test_pre_commit_neither_prompts_nor_connects( + sealed, tmp_path, monkeypatch, isolated_git +): """The half that matters most: `pre-commit` is the command a `git commit` runs, so a prompt here blocks the commit on a terminal and hangs it everywhere else.""" @@ -191,7 +203,7 @@ def test_pre_commit_neither_prompts_nor_connects(sealed, tmp_path, monkeypatch): def test_pre_commit_neither_prompts_nor_connects_on_a_finding( - sealed, tmp_path, monkeypatch + sealed, tmp_path, monkeypatch, isolated_git ): repo = _repo_with_policy(tmp_path) (repo / "Pulumi.dev.yaml").write_text("config:\n myproject:dbPassword: hunter2\n") diff --git a/tests/test_install_hooks.py b/tests/test_install_hooks.py index a8a6dda..dd9fd80 100644 --- a/tests/test_install_hooks.py +++ b/tests/test_install_hooks.py @@ -34,41 +34,6 @@ def _git(repo: Path, *args: str) -> subprocess.CompletedProcess: ) -@pytest.fixture -def isolated_git(tmp_path: Path, monkeypatch) -> Path: - """Cut every `git` these tests run off from the machine's own git config. - - Without this, a developer (or a CI image) with a *global* - `core.hooksPath` -- what husky, lefthook and the `pre-commit` framework - all set, and precisely the configuration this command exists to handle - -- makes `git init` here inherit that hook, and sixty-odd tests across - this file and `test_pre_commit.py` error out for a reason that has - nothing to do with the code under test. Worse here than anywhere else - in the suite: a global `core.hooksPath` also silently moves where - `hooks install` writes, so tests asserting on `.git/hooks/pre-commit` - would be examining a directory the command was never pointed at. - - All four sources git consults are redirected, not only the global one: - `GIT_CONFIG_SYSTEM` covers `/etc/gitconfig`, which is exactly where an - org-wide `hooksPath` lives; `HOME` and `XDG_CONFIG_HOME` cover - `~/.gitconfig` and `$XDG_CONFIG_HOME/git/config`, which git still reads - on a path that leaves `GIT_CONFIG_GLOBAL` unset. `/dev/null` is git's - own documented spelling of "this configuration file does not exist" for - the two `GIT_CONFIG_*` variables. - - The redirect is set with `monkeypatch`, so it is still in place while - the *test body* runs `git commit` -- not only while the fixture built - the repository. - """ - home = tmp_path / "git-home" - home.mkdir() - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(home / "config")) - monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) - monkeypatch.setenv("GIT_CONFIG_SYSTEM", os.devnull) - return home - - @pytest.fixture def repo(tmp_path: Path, isolated_git: Path) -> Path: """A real, minimal git repository with one initial commit that declares diff --git a/tests/test_model_net.py b/tests/test_model_net.py index 9fa05d8..76cafb3 100644 --- a/tests/test_model_net.py +++ b/tests/test_model_net.py @@ -481,7 +481,16 @@ def git(repo: Path, *args: str) -> subprocess.CompletedProcess: @pytest.fixture -def repo(tmp_path: Path) -> Path: +def repo(tmp_path: Path, isolated_git: Path) -> Path: + """A real git repository, isolated from the machine's own git config. + + `isolated_git` (see `tests/conftest.py`) is not optional decoration + here. Without it, a global `core.hooksPath` -- husky, lefthook, the + `pre-commit` framework -- makes every `git commit` below inherit that + hook, which errored sixty-five tests in this file; and the tests that + run `hooks install` wrote a stackward hook into that global directory, + outside `tmp_path`, where it stayed after the run. + """ path = tmp_path / "repo" path.mkdir() git(path, "init", "-q") diff --git a/tests/test_pre_commit.py b/tests/test_pre_commit.py index ad0f000..6140cf6 100644 --- a/tests/test_pre_commit.py +++ b/tests/test_pre_commit.py @@ -11,7 +11,6 @@ from __future__ import annotations -import os import subprocess from pathlib import Path @@ -37,41 +36,6 @@ def _git(repo: Path, *args: str) -> subprocess.CompletedProcess: MINIMAL_POLICY = '[check]\nmodel_net = "none"\n' -@pytest.fixture -def isolated_git(tmp_path: Path, monkeypatch) -> Path: - """Cut every `git` these tests run off from the machine's own git config. - - Without this, a developer (or a CI image) with a *global* - `core.hooksPath` -- what husky, lefthook and the `pre-commit` framework - all set, and precisely the configuration `hooks install` exists to - handle -- makes `git init`/`git commit` here inherit that hook, and - sixty-odd tests across this file and `test_install_hooks.py` error out - for a reason that has nothing to do with the code under test. A suite - whose result depends on the developer's `~/.gitconfig` is not testing - what it claims to. - - All four sources git consults are redirected, not only the global one: - `GIT_CONFIG_SYSTEM` covers `/etc/gitconfig`, which is exactly where an - org-wide `hooksPath` lives; `HOME` and `XDG_CONFIG_HOME` cover - `~/.gitconfig` and `$XDG_CONFIG_HOME/git/config`, which git still reads - on a path that leaves `GIT_CONFIG_GLOBAL` unset. `/dev/null` is git's - own documented spelling of "this configuration file does not exist" for - the two `GIT_CONFIG_*` variables. - - The redirect is set with `monkeypatch`, so it is still in place while - the *test body* runs `git commit` -- not only while the fixture built - the repository. `test_install_hooks.py` has its own copy for the same - reason, rather than importing this one across test modules. - """ - home = tmp_path / "git-home" - home.mkdir() - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(home / "config")) - monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) - monkeypatch.setenv("GIT_CONFIG_SYSTEM", os.devnull) - return home - - @pytest.fixture def bare_repo(tmp_path: Path, isolated_git: Path) -> Path: """A real git repository with one empty commit and **no** policy file. diff --git a/tests/test_session.py b/tests/test_session.py index 0ea7ad1..fb010b4 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -88,6 +88,20 @@ PULUMI_CONFIG_PASSPHRASE: AMBIENT_PASSPHRASE, } +# The other half of what "a developer who already has AWS credentials +# exported" looks like: a session token from an assumed role, and a selected +# shared-config profile. Seeded into the parent environment for the same +# reason `AMBIENT_CREDENTIALS` is -- a test whose parent never carries these +# names cannot tell "scrubbed" from "was never there", which is precisely +# the assertion `SCRUBBED_NAMES` needs. Values, not just names, so nothing +# here depends on whether the developer running the suite happens to have +# either exported. +AMBIENT_SESSION_TOKEN = "ambient-session-token-3a5c7e9b" +AMBIENT_AWS_PROFILE = "ambient-config-profile-8d0f2b4a" +AMBIENT_SCRUBBED = dict( + zip(session.SCRUBBED_NAMES, (AMBIENT_SESSION_TOKEN, AMBIENT_AWS_PROFILE)) +) + # A backend URL that carries a password in its userinfo -- the # `postgres://user:password@host/db` form `pulumi login --help` documents and # `_redact_url` exists for. Used wherever a test would otherwise only ever see @@ -146,6 +160,10 @@ def no_real_pulumi_state(monkeypatch, tmp_path): monkeypatch.delenv(PULUMI_BACKEND_URL, raising=False) for name, value in AMBIENT_CREDENTIALS.items(): monkeypatch.setenv(name, value) + # See AMBIENT_SCRUBBED: set for the same reason the three above are, so + # that the delta assertions can see a removal rather than an absence. + for name, value in AMBIENT_SCRUBBED.items(): + monkeypatch.setenv(name, value) @pytest.fixture @@ -750,10 +768,11 @@ def test_child_env_overlays_exactly_four_names_on_a_copy_of_the_parent(monkeypat child_keys = set(env) # `PULUMI_BACKEND_URL` is the only name the parent does not already have. assert child_keys - parent_keys == {PULUMI_BACKEND_URL} - # The one name that must be *dropped*, not merely left alone. - assert parent_keys - child_keys == {ENV_STORE_PASSWORD} + # The names that must be *dropped*, not merely left alone. + assert parent_keys - child_keys == {ENV_STORE_PASSWORD, *session.SCRUBBED_NAMES} assert ENV_STORE_PASSWORD not in env - for key in parent_keys - {ENV_STORE_PASSWORD} - set(AMBIENT_CREDENTIALS): + dropped = {ENV_STORE_PASSWORD, *session.SCRUBBED_NAMES} + for key in parent_keys - dropped - set(AMBIENT_CREDENTIALS): assert env[key] == parent_before[key] # nothing else changed assert env[AWS_ACCESS_KEY_ID] == MARKER_KEY != AMBIENT_KEY assert env[AWS_SECRET_ACCESS_KEY] == MARKER_SECRET != AMBIENT_SECRET @@ -762,6 +781,41 @@ def test_child_env_overlays_exactly_four_names_on_a_copy_of_the_parent(monkeypat assert PASSWORD not in env.values() +def test_a_stale_session_token_and_shared_profile_are_dropped_from_the_child(): + """The two `SCRUBBED_NAMES`, each present in the parent with a value. + + Not a restatement of the delta assertion above: this one names the two + variables literally, so it stays honest if `SCRUBBED_NAMES` is ever + emptied -- a delta written as `{ENV_STORE_PASSWORD, *SCRUBBED_NAMES}` + passes against an empty tuple, since both sides shrink together. + """ + assert os.environ["AWS_SESSION_TOKEN"] == AMBIENT_SESSION_TOKEN + assert os.environ["AWS_PROFILE"] == AMBIENT_AWS_PROFILE + + env = session._child_env(FULL_CREDENTIALS, "file:///backend") + + assert "AWS_SESSION_TOKEN" not in env + assert "AWS_PROFILE" not in env + + +def test_a_scrubbed_name_sealed_into_the_envelope_is_still_not_injected(): + """Scrubbed *and* never supplied. + + An envelope may legitimately hold names beyond the three required ones + (`test_extra_names_beyond_the_three_required_are_accepted`), so "the + store has a value for it" is a reachable state. It still must not reach + the child: a token sealed months ago is exactly the stale one the scrub + exists to remove, and injecting it would make the store a way to + reintroduce the problem. + """ + sealed = dict(FULL_CREDENTIALS) + sealed["AWS_SESSION_TOKEN"] = "envelope-session-token-6c8a0e2d" + + env = session._child_env(sealed, "file:///backend") + + assert "AWS_SESSION_TOKEN" not in env + + # --------------------------------------------------------------------------- # Exit status translation, including a signal # --------------------------------------------------------------------------- @@ -1102,12 +1156,21 @@ def assert_expected_session_env( child_keys = set(child_env) # `PULUMI_BACKEND_URL` is the only name the parent does not already carry. assert child_keys - parent_keys == {PULUMI_BACKEND_URL} - # The one name that must be *dropped*, not merely left alone: the store + # The names that must be *dropped*, not merely left alone: the store # master password unlocks every profile, not just this one, and must - # never reach caller-supplied code. - assert parent_keys - child_keys == {ENV_STORE_PASSWORD} + # never reach caller-supplied code; the two `SCRUBBED_NAMES` would sit + # beside the freshly injected key pair and contradict it. All three are + # in the parent with real values (see `no_real_pulumi_state`), so this + # asserts a removal and not an absence. + assert parent_keys - child_keys == {ENV_STORE_PASSWORD, *session.SCRUBBED_NAMES} assert ENV_STORE_PASSWORD not in child_env - untouched = parent_keys - {"STUB_DUMP_ENV_TO", ENV_STORE_PASSWORD} + for name in session.SCRUBBED_NAMES: + assert name not in child_env + untouched = ( + parent_keys + - {"STUB_DUMP_ENV_TO", ENV_STORE_PASSWORD} + - set(session.SCRUBBED_NAMES) + ) for key in untouched - set(AMBIENT_CREDENTIALS): assert child_env[key] == parent_before[key] assert child_env[AWS_ACCESS_KEY_ID] == MARKER_KEY != AMBIENT_KEY @@ -1708,6 +1771,30 @@ def test_login_scrubs_the_store_password_from_pulumis_environment( assert PASSWORD not in child_env.values() +def test_login_keeps_the_ambient_aws_names_it_does_not_contradict( + cli_store, monkeypatch, tmp_path, stub +): + """The deliberate asymmetry, asserted rather than merely documented. + + `login` injects no credentials, so neither scrubbed name can conflict + with one there -- and a backend the caller reaches through their own + assumed role or shared-config profile is a working setup. Scrubbing in + `_environ_without_store_password` (shared with `login`) instead of in + `_child_env` would break it silently, and every other test in this file + would still pass. + """ + seed_profile(cli_store, "staging", backend_url="file:///staging-backend") + dump = tmp_path / "env.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump)) + fake_pulumi(monkeypatch, stub) + + assert main(["login", "--profile", "staging"]) == 0 + + child_env = json.loads(dump.read_text()) + assert child_env["AWS_SESSION_TOKEN"] == AMBIENT_SESSION_TOKEN + assert child_env["AWS_PROFILE"] == AMBIENT_AWS_PROFILE + + def test_login_never_puts_a_userinfo_bearing_url_in_argv_or_output( cli_store, monkeypatch, tmp_path, stub, capfd ): diff --git a/tests/test_store.py b/tests/test_store.py index 592c5f2..cdeb286 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -247,7 +247,7 @@ def test_a_config_value_is_never_quoted_back_by_a_syntax_error(store_directory): def test_a_credential_at_the_end_of_the_document_is_not_quoted_back(store_directory): """The other coordinate `tomllib` produces, `(at end of document)`. - `_toml_position` matches two message shapes and every other test here + `toml_position` matches two message shapes and every other test here exercises only the first, which would leave the second silently unrecognised -- and an unrecognised shape yields no coordinate at all, so the branch is worth a test of its own. An unterminated string is the @@ -272,7 +272,7 @@ def test_a_toml_message_with_no_coordinate_loses_the_coordinate_not_the_secrecy( position, never to quoting the message. `TOMLDecodeError`'s text is not an API and this project supports three - Python versions, so `_toml_position` has to cope with a message it + Python versions, so `toml_position` has to cope with a message it cannot parse. The safe degradation is losing the diagnostic; the unsafe one is falling back to the whole message, which is exactly what this call site used to do unconditionally. @@ -730,6 +730,29 @@ def test_an_unsupported_store_version_is_refused(initialised): resolve_credentials("one", PASSWORD, directory=initialised) +def test_a_credential_pasted_into_the_store_version_is_not_read_back(initialised): + """`v` is the one hand-editable header field in the file that is + *expected* to hold secrets, and its contents are printed back. The test + above sets an integer, which `describe_value` shows on purpose, so it + could never see a version field that echoed a string. + + Opaque marker -- see `tests/leakcheck.py`: this message also prints the + store path, and a marker built from real words shares runs with it. + """ + pasted = "Yw3Kd8Qr5Vn2Tx7Bz4Ms9Gj6" + path = initialised / "credentials" + document = json.loads(path.read_bytes()) + document["v"] = pasted + path.write_text(json.dumps(document)) + + with pytest.raises(StoreError) as raised: + resolve_credentials("one", PASSWORD, directory=initialised) + + message = str(raised.value) + assert_no_leak(message, pasted, what="a pasted store version") + assert "" in message + + def test_a_store_with_no_verifier_is_refused_rather_than_opened(initialised): """The verifier is what makes a mistyped password fail immediately instead of weeks later, so a store that has none cannot be treated as From 511ee6bf59416a9e346f4941e2861b81e5a8b13f Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sat, 5 Sep 2026 18:22:57 -0700 Subject: [PATCH 42/44] fix: clear eight minor items, two of which were prose authorising a revert M1 A repo declaring `min_version = "9.9.9"` *and* a key this binary does not know -- forward-compatibility, the case the floor exists for -- reported `unknown key 'future_key'` and ran anyway, because `_build_config` rejects the unknown key before `min_version` is read and `_min_version_refusal`'s broad guard then returns None. Every command still exited 2 for its own reasons; `hooks install`, which reads no policy, exited 0 and installed the gate. `config.read_min_version` now reads the floor alone, past any key this binary cannot validate, and the refusal uses it. Not fixed by re-ordering `_build_config`, as the item proposed: that function has neither `command` nor `installed`, so it cannot raise `MinVersionError`, and comparing against `__version__` there would break `doctor`, which is exempt. Doing both would leave two guards and neither falsifiable -- the failure `_min_version_refusal`'s own docstring warns against. One mechanism, so that reverting it is red. The broad guard is unchanged: it is what keeps `doctor` working on the broken config you run `doctor` to diagnose. M2 `hooks install`'s missing-policy warning read the worktree while the gate it installs reads the index, so a `.stackward.toml` that existed but was unstaged got no warning and then blocked every commit. It now asks the index, through the existing `_is_tracked`, and says "not staged" for that state rather than "declares none", which is visibly false to someone looking at the file. A git failure here stays silent: this runs after the hook is written, inside a `@fail_closed` command. One bounded gap (in the index, deleted from the worktree) is recorded in the docstring rather than closed with a second copy of `pre_commit._index_config_path`. M3 A vacuous test from Task 9: its control raised `AssertionError` inside `@fail_closed`, which swallows it into `return 2` -- the exact code the test asserts. Replacing the early refusal with `except CheckPassphraseError: passphrase = ""` left the file at 40 passed. The control is now a private `BaseException`, as two other suites already do, and that mutation fails it. M4 `check_config.describe_yaml_error` still said `config.load_config` "safely echoes tomllib's message in full because `.stackward.toml` holds only path and key names" -- the false premise a later wave corrected, left standing where it would authorise reverting the fix. Rewritten to what both modules now do. M5 `test_pre_commit.py`'s locale docstring called `nets.model.run_git` locale-less and `install_hooks._is_tracked` a live bug. Both were fixed afterwards and both are mutation-killed; it now says so and cites the tests. M6 The publish job smoke-tested `--version` and `doctor` -- the two commands registered directly, and so the only two that kept passing through the bundle regression that removed every lazily dispatched one. Both jobs now run `scripts/check-bundle.sh` against their own binary, one definition rather than two that can drift. Every `mktemp` in it passes an explicit template, which GNU and BSD both accept: the matrix includes a macOS runner and this workflow only fires on a tag, so a bare `mktemp` would fail first during a release. M7 `credentials set` seals every name in a piped `.env` while `exec` injects three, and nothing said so. Sealing the extras is intended -- refusing them would break the documented `< .env` workflow and contradict `_require_values` and `_prompt_for_values`, which both already say an envelope may hold more -- so `show` now names them. M8 The plan's Task 6 still required `config` to be "mode 0o644 ... no credentials". The code deliberately changed both, because `backend_url` is passed through verbatim and may carry userinfo. Recorded as a revision with its reason. 897 -> 902 tests. Each new test was confirmed red against a mutation of the source it covers; M6 is a workflow change and carries none, checked instead by running the script against a stub that passes `--version` and `doctor` and fails on `credentials list`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- .github/workflows/release.yml | 95 ++++++--------------- README.md | 8 +- docs/implementation-plan.md | 13 ++- scripts/check-bundle.sh | 104 +++++++++++++++++++++++ src/stackward/cli.py | 22 ++++- src/stackward/commands/check_config.py | 23 ++++- src/stackward/commands/credentials.py | 33 ++++++++ src/stackward/commands/install_hooks.py | 106 ++++++++++++++++++------ src/stackward/config.py | 91 +++++++++++++++++--- tests/test_check_passphrase.py | 26 +++++- tests/test_cli.py | 31 +++++++ tests/test_credentials.py | 52 +++++++++++- tests/test_install_hooks.py | 75 ++++++++++++++++- tests/test_pre_commit.py | 23 +++-- 14 files changed, 575 insertions(+), 127 deletions(-) create mode 100755 scripts/check-bundle.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3987cba..fe5690d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,73 +70,23 @@ jobs: entry.py - name: It must actually run - # A bundle that builds is not a bundle that works. doctor performs a - # real Argon2id + AES-GCM round trip, so this catches a native - # extension that failed to freeze — the classic failure of this route. - # - # doctor also reads `regen.py` back out of the bundle through the same - # accessor `sync-declared-secrets` uses. That file is carried by the - # `--add-data` entry above rather than by import analysis, so dropping - # it would produce a binary that builds, starts, and then fails at the - # one command that needs it — in the shipped artifact and nowhere - # else. doctor exits non-zero on that, and the grep below states the - # expectation so a future edit to doctor's output cannot silently - # retire this check. + # A bundle that builds is not a bundle that works. The checks, and + # the reasoning behind each, live in `scripts/check-bundle.sh` -- + # one definition, run here against what this job just built and + # again in `publish` against the artifact actually being released. + # Two copies of this is how `publish` came to test only `--version` + # and `doctor`, the two commands a missing lazily-dispatched module + # cannot break. run: | set -euo pipefail - ./dist/stackward/stackward --version - ./dist/stackward/stackward doctor | tee doctor.txt - grep -q "^model walker regen.py readable" doctor.txt - - # A lazily dispatched command must actually load in the bundle. - # `cli._dispatch` imports each command module only when its - # subcommand runs, and PyInstaller can only see an import it can - # read as a literal statement: while that dispatch named its module - # dynamically, every one of these commands was missing from the - # binary and died on `ModuleNotFoundError` with a traceback and - # exit code 1 — the code reserved for "a credential was found" — - # while `--version` and `doctor` (both registered directly) kept - # passing this very step. - # - # Two commands, from two different modules, and neither needs a - # credential, a store or a network: each refuses with exit code 2 - # after its module has loaded, which is exactly the evidence - # wanted. `set -e` is off for these because a non-zero exit is the - # expected outcome. - # Run from an empty directory, not the checkout: `cli.main` - # enforces a repository's `min_version` floor *before* dispatch, so - # a `.stackward.toml` above the working directory could make both - # commands exit 2 without either module ever loading -- the check - # would then pass on exactly the evidence it exists to reject. - bundle="$PWD/dist/stackward/stackward" - for command in "credentials list" "exec"; do - set +e - # shellcheck disable=SC2086 - out="$(cd "$(mktemp -d)" && XDG_CONFIG_HOME="$PWD" "$bundle" $command 2>&1)" - code=$? - set -e - if [ "$code" -ne 2 ]; then - echo "::error::\`stackward $command\` exited $code, expected 2" >&2 - printf '%s\n' "$out" >&2 - exit 1 - fi - case "$out" in - *ModuleNotFoundError*|*Traceback*|*"invalid choice"*) - # "invalid choice" is the other way this passes for the wrong - # reason: an unregistered subcommand also exits 2, from - # argparse, having loaded nothing at all. - echo "::error::\`stackward $command\` did not load in the bundle" >&2 - printf '%s\n' "$out" >&2 - exit 1 - ;; - esac - done + scripts/check-bundle.sh ./dist/stackward/stackward - # The other half of that arrangement: `regen.py` ships as data - # precisely so nothing imports it and pydantic stays test-only. If it - # ever became an import, the bundle would still build, doctor would - # still pass, and the only visible symptom would be a fatter - # download — so the absence has to be asserted, not assumed. + # Not in the shared script: this one inspects the PyInstaller + # output tree, which only this job has. `regen.py` ships as data + # precisely so nothing imports it and pydantic stays test-only. If + # it ever became an import, the bundle would still build, doctor + # would still pass, and the only visible symptom would be a fatter + # download -- so the absence has to be asserted, not assumed. if find dist -iname '*pydantic*' -print -quit | grep -q .; then echo "::error::pydantic was bundled; it is a test-only dependency" >&2 find dist -iname '*pydantic*' | head -20 >&2 @@ -182,16 +132,23 @@ jobs: - name: Verify the install path before publishing it # Install exactly as a user would, from the artifacts about to be - # released, and run the smoke test. A release that cannot be installed - # is deleted, not patched: the tag is already immutable in people's - # lockfiles and instructions. + # released, and run the same check the build job ran -- against the + # bytes actually being published, which is the only artifact whose + # correctness this job can still act on. A release that cannot be + # installed is deleted, not patched: the tag is already immutable in + # people's lockfiles and instructions. + # + # `scripts/check-bundle.sh`, not a second smoke test written here: + # this step used to run `--version` and `doctor` alone, and those are + # exactly the two commands that stayed green through a bundle that + # was missing every lazily dispatched one. run: | set -euo pipefail cd artifacts sha256sum --check stackward-Linux-x86_64.tar.gz.sha256 mkdir -p /tmp/probe && tar -xzf stackward-Linux-x86_64.tar.gz -C /tmp/probe - /tmp/probe/stackward/stackward --version - /tmp/probe/stackward/stackward doctor + # Absolute: the working directory is `artifacts`, not the checkout. + "$GITHUB_WORKSPACE/scripts/check-bundle.sh" /tmp/probe/stackward/stackward - name: Publish env: diff --git a/README.md b/README.md index 200fbbc..774d6f8 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,15 @@ prints is ever a value: `list` prints profile names, `show` prints the credential *names* an envelope carries, and neither prints what is beside them. +`set` seals **every** name it is given, while `exec`/`shell` inject only the +three they need — so a `.env` carrying more than those three has the extras +sealed too. That is deliberate: a real `.env` holds more, and refusing it +would break the very workflow this command exists for. `show` names any such +extra, so it is visible rather than merely true. + ```sh stackward credentials init # once, per machine -stackward credentials set --profile NAME < .env # then delete the .env +stackward credentials set --profile NAME < .env # every name in it; then rm .env stackward credentials show --profile NAME # names only, never values ``` diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index f4d904b..31d8071 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -208,8 +208,19 @@ being `0o755`; the body containing an absolute path. **Requirements.** - Location: `${XDG_CONFIG_HOME:-~/.config}/stackward/`, holding `config` - (TOML, mode 0o644, backend identity, no credentials) and `credentials` + (TOML, mode 0o600, backend identity) and `credentials` (mode 0o600, directory 0o700). + - **Revised during implementation**, from "mode 0o644, backend identity, no + credentials". Both halves were superseded by the requirement below that + `backend_url` be passed through **verbatim**: `postgres://user:password@host/db` + is a documented, supported backend form, so `config` can hold a credential + after all. Enforcing "no credentials" would mean parsing a URL this project + deliberately does not parse, and would reject a backend Pulumi accepts. + The consequences are carried instead: mode `0o600` (`store.CONFIG_MODE`), + and a parse failure reports a coordinate rather than tomllib's own message, + which quotes document text (`config.toml_position`). Recorded here rather + than corrected silently, because "no credentials" reads like a rule the + code broke when it is a premise the code disproved. - `config` holds `default_profile` and `[profile.]` tables. A profile carries **either** `backend_url` (passed through verbatim — `s3://`, `gs://`, `azblob://`, `file://` must all work) **or** the component form `bucket`, diff --git a/scripts/check-bundle.sh b/scripts/check-bundle.sh new file mode 100755 index 0000000..26890d4 --- /dev/null +++ b/scripts/check-bundle.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Prove that a built bundle actually works. Takes the binary to check as $1. +# +# **One definition, run by both release jobs.** `build` runs it against what +# it has just produced; `publish` runs it against the artifact it is about to +# release, unpacked exactly as a user would. A second, separately-maintained +# copy is precisely how the publish job came to smoke-test only `--version` +# and `doctor` -- the two commands registered directly rather than through +# `cli._dispatch`, and so the only two that kept passing while every lazily +# dispatched command was missing from the binary entirely. The bundle that +# regression would have shipped passes a `--version`/`doctor` check by +# construction. +# +# bash, not sh: `set -o pipefail` is not POSIX and is silently absent under +# dash, which would let the `doctor | tee` pipeline below report the exit +# status of `tee`. +set -euo pipefail + +bundle="${1:?usage: check-bundle.sh }" +# Resolved to an absolute path once, here: the checks below run from +# elsewhere on purpose, and each caller invokes this from a different +# directory. +case "$bundle" in + /*) ;; + *) bundle="$PWD/$bundle" ;; +esac + +echo "Checking bundle: $bundle" + +# A bundle that builds is not a bundle that works. doctor performs a real +# Argon2id + AES-GCM round trip, so this catches a native extension that +# failed to freeze -- the classic failure of this route. +# +# doctor also reads `regen.py` back out of the bundle through the same +# accessor `sync-declared-secrets` uses. That file is carried by the +# `--add-data` entry in the build job rather than by import analysis, so +# dropping it would produce a binary that builds, starts, and then fails at +# the one command that needs it -- in the shipped artifact and nowhere else. +# doctor exits non-zero on that, and the grep states the expectation so a +# future edit to doctor's output cannot silently retire this check. +"$bundle" --version + +# A temporary file, not `doctor.txt` in the working directory: one caller +# runs this from the checkout and the other from the directory it unpacked +# the release artifacts into. +# +# Every `mktemp` here passes an explicit `X`-suffixed template, which GNU and +# BSD both accept. The build matrix includes a macOS runner and this workflow +# only ever fires on a tag, so a bare `mktemp` that BSD rejects would first be +# discovered during a release. +doctor_out="$(mktemp "${TMPDIR:-/tmp}/stackward-doctor.XXXXXX")" +trap 'rm -f "$doctor_out"' EXIT +"$bundle" doctor | tee "$doctor_out" +grep -q "^model walker regen.py readable" "$doctor_out" + +# A lazily dispatched command must actually load in the bundle. +# `cli._dispatch` imports each command module only when its subcommand runs, +# and PyInstaller can only see an import it can read as a literal statement: +# while that dispatch named its module dynamically, every one of these +# commands was missing from the binary and died on `ModuleNotFoundError` with +# a traceback and exit code 1 -- the code reserved for "a credential was +# found" -- while `--version` and `doctor` kept passing this very check. +# +# Two commands, from two different modules, and neither needs a credential, a +# store or a network: each refuses with exit code 2 after its module has +# loaded, which is exactly the evidence wanted. `set -e` is off for these +# because a non-zero exit is the expected outcome. +# +# Run from an empty directory, not the caller's: `cli.main` enforces a +# repository's `min_version` floor *before* dispatch, so a `.stackward.toml` +# above the working directory could make both commands exit 2 without either +# module ever loading -- the check would then pass on exactly the evidence it +# exists to reject. That floor is now read on its own, past any key the +# binary does not recognise (`config.read_min_version`), so an *invalid* +# config above the working directory can produce the refusal too, which makes +# the empty directory load-bearing rather than merely tidy. +for command in "credentials list" "exec"; do + # Made before `set -e` is lifted: a `mktemp` that fails is a broken runner, + # not evidence about the bundle, and must not be read as one. + probe="$(mktemp -d "${TMPDIR:-/tmp}/stackward-probe.XXXXXX")" + set +e + # shellcheck disable=SC2086 + out="$(cd "$probe" && XDG_CONFIG_HOME="$PWD" "$bundle" $command 2>&1)" + code=$? + set -e + if [ "$code" -ne 2 ]; then + echo "::error::\`stackward $command\` exited $code, expected 2" >&2 + printf '%s\n' "$out" >&2 + exit 1 + fi + rm -rf "$probe" + case "$out" in + *ModuleNotFoundError*|*Traceback*|*"invalid choice"*) + # "invalid choice" is the other way this passes for the wrong reason: + # an unregistered subcommand also exits 2, from argparse, having loaded + # nothing at all. + echo "::error::\`stackward $command\` did not load in the bundle" >&2 + printf '%s\n' "$out" >&2 + exit 1 + ;; + esac +done + +echo "Bundle check passed: $bundle" diff --git a/src/stackward/cli.py b/src/stackward/cli.py index b85e1af..a075483 100644 --- a/src/stackward/cli.py +++ b/src/stackward/cli.py @@ -28,10 +28,11 @@ from .commands.install_hooks import cmd_install_hooks from .commands.pre_commit import cmd_pre_commit from .config import ( + Config, MinVersionError, enforce_min_version, find_repo_config, - load_config, + read_min_version, ) # Commands that must keep working even when a repo demands a newer stackward @@ -380,6 +381,21 @@ def _min_version_refusal(command: str) -> int | None: A second, outer guard around this would be worse than none: with two, neither is falsifiable, and a test that deletes either one still passes. + The same rule is why the floor is read by `read_min_version` rather than + by `load_config` *and* re-ordered inside `_build_config`: one mechanism, + so that deleting it turns a test red. + + `read_min_version`, not `load_config`, because the case this refusal + exists for is the case a full load cannot report. A repository that + demands a newer stackward is very likely to be using something that + newer stackward added, and an unrecognised top-level key makes + `load_config` raise before `min_version` is ever looked at -- so the + broad guard below would swallow it and the command would run, having + reported an unknown key rather than the upgrade that explains it. + Reading the floor alone keeps this refusal working on exactly the + forward-compatible config it is for. Nothing else about the file is + trusted here, and nothing else needs to be: it is validated in full by + whichever command reads it for its own purposes. It reads the *working tree*, deliberately, even though `pre-commit` reads its `[check]` policy from the index. `min_version` is a statement @@ -389,8 +405,8 @@ def _min_version_refusal(command: str) -> int | None: """ try: path = find_repo_config() - config = load_config(path) if path is not None else None - enforce_min_version(config, command) + min_version = read_min_version(path) if path is not None else None + enforce_min_version(Config(min_version=min_version), command) except MinVersionError as exc: print(f"error: {exc}", file=sys.stderr) return 2 diff --git a/src/stackward/commands/check_config.py b/src/stackward/commands/check_config.py index e993f93..cc61faa 100644 --- a/src/stackward/commands/check_config.py +++ b/src/stackward/commands/check_config.py @@ -262,10 +262,25 @@ def describe_yaml_error(exc: yaml.YAMLError) -> str: character some other way. `tests/test_heuristic.py`'s parameterized redaction test is what would catch a future wording change that escapes this pattern; this docstring does not promise it can't happen. - Contrast `config.py`'s `load_config`, which safely echoes tomllib's - message in full because `.stackward.toml` holds only path and key - *names* — this file holds the opposite, so it gets the more careful, - still-bounded treatment. + **`config.py` is not the contrast this comment used to draw.** It said + `load_config` safely echoed tomllib's message in full, because + `.stackward.toml` holds only path and key *names*. Both halves were + wrong, and both have since been fixed in the code. `tomllib` does quote + document text — `tomllib.loads("[a]\nx=1\n[a]\n")` raises `Cannot + declare ('a',) twice (at line 3, column 3)`, naming the key back — and + "the file holds no value" is exactly the assumption a file that failed + to parse has already broken, which is the one thing an error path + reporting on such a file must not lean on. + + What the two modules actually do, so neither is reverted on the + strength of a sentence about the other: `config.toml_position` matches + the `(at line L, column C)` coordinate out of the message and emits + *only* that, which it can afford because nearly every tomllib message + is a fixed description plus that coordinate. PyYAML's `problem` carries + most of the information instead, so this function keeps it and strips + every repr-quoted run out of it. Two bounded treatments of one rule — + a parser's message is never passed through as it came — and a licence, + anywhere, to pass one through in full is not among them. """ if isinstance(exc, yaml.MarkedYAMLError) and exc.problem is not None: problem = _QUOTED_FRAGMENT.sub("", exc.problem) diff --git a/src/stackward/commands/credentials.py b/src/stackward/commands/credentials.py index ae269c9..0b42598 100644 --- a/src/stackward/commands/credentials.py +++ b/src/stackward/commands/credentials.py @@ -263,6 +263,16 @@ def cmd_credentials_set(args: argparse.Namespace) -> int: required name must be supplied on every run -- a "set one name" that silently dropped the other two would be far worse than a refusal. + **It also seals every *extra* name it is given, on purpose.** `< .env` + is the documented way to fill the store and a real `.env` carries more + than the three names `exec`/`shell` inject; refusing the extras would + make the documented workflow fail on the ordinary file it was written + for, and would contradict `_require_values` and `_prompt_for_values`, + which both already say an envelope may legitimately hold more than this + tool consumes. What was missing was not a refusal but a sentence: + `credentials show` now names the sealed-but-not-injected ones, which is + where a person looking at their own store would notice them. + The password is asked for last, so that malformed input, a missing name or an empty value is reported before anyone types it. """ @@ -330,6 +340,12 @@ def cmd_credentials_show(args: argparse.Namespace) -> int: profile carry -- was answered correctly, and the store is not in a state anything needs to refuse. `exec` is what refuses an incomplete envelope, and it already does. + + Both notes go to stderr, so stdout stays the parseable list of names. + The second one -- names sealed that nothing injects -- exists because + this is the only place the asymmetry is visible: `set` seals what it is + given and `exec` injects three, so `set < .env` on a `.env` holding + anything else seals it with no word said anywhere. """ try: profile = session._resolve_profile(args.profile) @@ -349,6 +365,23 @@ def cmd_credentials_show(args: argparse.Namespace) -> int: f"missing, and required by exec/shell: {', '.join(missing)}", file=sys.stderr, ) + + # The other half of the same question, and the one nothing else in the + # tool says out loud: `set` seals every name it is given, `exec`/`shell` + # inject only `CREDENTIAL_NAMES`. `< .env` is the documented way to fill + # the store, and a `.env` that carries anything beyond those three seals + # the extras silently. That is intended (see `cmd_credentials_set`), and + # this is where someone would notice it -- `show` is the command that + # answers "what is in there", so an unexplained fourth name in its own + # listing is exactly what needs a sentence beside it. + extra = [name for name in names if name not in session.CREDENTIAL_NAMES] + if extra: + print( + f"sealed, but never injected by exec/shell: {', '.join(extra)} " + "(`credentials set` seals every name it is given; only " + f"{', '.join(session.CREDENTIAL_NAMES)} are injected)", + file=sys.stderr, + ) return 0 diff --git a/src/stackward/commands/install_hooks.py b/src/stackward/commands/install_hooks.py index c63f39b..a021b8a 100644 --- a/src/stackward/commands/install_hooks.py +++ b/src/stackward/commands/install_hooks.py @@ -165,10 +165,16 @@ def _is_tracked(path: Path) -> bool: --error-unmatch ` -- a directory pathspec matches if git tracks anything underneath it. Passing the pre-resolved absolute path, rather than a relative one, keeps this correct regardless of what the current - working directory happens to be. Git's own `.git/hooks`, unredirected, - never matches: paths under `.git/` are not part of the tracked - namespace at all, so this reliably distinguishes that default location - from a `core.hooksPath` deliberately pointed at a tracked directory. + working directory happens to be. The same command answers for a *file* + path, where it degenerates to "is this in the index" -- which is what + `_warn_when_no_policy` asks it about `.stackward.toml`, so that both + callers get their answer from one place rather than from two spellings + of `git ls-files` that could come to disagree. + + Git's own `.git/hooks`, unredirected, never matches: paths under + `.git/` are not part of the tracked namespace at all, so this reliably + distinguishes that default location from a `core.hooksPath` + deliberately pointed at a tracked directory. A non-zero exit is deliberately *not* read as "not tracked" on its own -- only two specific, recognised git failures are: the ordinary @@ -383,32 +389,80 @@ def cmd_install_hooks(_args: argparse.Namespace) -> int: def _warn_when_no_policy() -> None: - """Warn on stderr when the repository declares no `.stackward.toml`. + """Warn on stderr when the hook just installed will not find a policy. + + **The question is what `pre-commit` will see, not what is on disk.** + That gate reads its `[check]` policy from the *index* -- deliberately, + since a policy read from the working tree could relax the rules for a + commit that does not itself carry the relaxation -- so a + `.stackward.toml` that exists but has never been staged is, to the hook + being installed here, no policy at all. Asking `find_repo_config` alone + reads the working tree and answers a different question than the one + this warning is for: the repository got no warning and then blocked + every commit. That state is a *more* likely one right after `hooks + install` than a wholly absent policy, since writing the file and + installing the hook is one sitting and staging it is a separate act. + + Three outcomes, and the third is the reason the other two are worth + distinguishing: + + * Nothing in the working tree: say so, and name the minimal file. + * Present but not in the index: say *that*, and name `git add` -- the + remedy is one word, and "you have no policy" would be visibly false + to someone looking at the file. + * In the index: silence, whatever the file's content. A + `.stackward.toml` that does not parse is present as far as this is + concerned -- reporting it is the gate's job, at the point it actually + reads it, and duplicating that judgement here would give one + repository two different verdicts on the same file. + + One bounded gap, stated so it is a decision and not an oversight: a + policy that is in the index but deleted from the working tree gets the + "declares none" message, though the hook would in fact find it. Closing + it means a second copy of `pre_commit._index_config_path`'s + nearest-first candidate search, and two copies of that would be a worse + defect than the wrong wording on a state nobody reaches by accident. Deliberately *after* the hook is written and deliberately not an error: - see `cmd_install_hooks`'s own docstring. On stderr, not stdout, for the - same reason `store.warn_if_permissive` puts its warning there -- this - command's stdout says where the hook landed, and a caller reading that - must not have to filter advisory text out of it. - - `find_repo_config` returns `None` for absence rather than raising, so - this cannot turn a legitimate install into a failure. A - `.stackward.toml` that exists but does not parse is *present* as far as - this is concerned -- reporting it is the gate's job, at the point it - actually reads it, and duplicating that judgement here would give one - repository two different verdicts on the same file. + see `cmd_install_hooks`'s own docstring. That is also why a git failure + here is silence rather than a raise -- this function runs after the + install has already succeeded, inside a `@fail_closed` command, so an + escaping `InstallError` would report exit 2 for a hook that is on disk + and working. Nothing is lost: the gate itself reports a policy it + cannot find, in better words, at the moment it matters. + + On stderr, not stdout, for the same reason `store.warn_if_permissive` + puts its warning there -- this command's stdout says where the hook + landed, and a caller reading that must not have to filter advisory text + out of it. """ - if find_repo_config() is not None: + path = find_repo_config() + if path is None: + print( + f"warning: this repository declares no {CONFIG_FILENAME}, and " + "`stackward pre-commit` refuses a repository that declares none " + "-- the hook is installed, but every commit will be blocked " + "until one exists.", + file=sys.stderr, + ) + print( + f"Create {CONFIG_FILENAME} at the repository root with at least:\n" + + "\n".join(f" {line}" for line in MINIMAL_POLICY.splitlines()), + file=sys.stderr, + ) return + + try: + if _is_tracked(path): + return + except InstallError: + return + print( - f"warning: this repository declares no {CONFIG_FILENAME}, and " - "`stackward pre-commit` refuses a repository that declares none -- " - "the hook is installed, but every commit will be blocked until one " - "exists.", - file=sys.stderr, - ) - print( - f"Create {CONFIG_FILENAME} at the repository root with at least:\n" - + "\n".join(f" {line}" for line in MINIMAL_POLICY.splitlines()), + f"warning: {path} exists but is not staged, and `stackward " + "pre-commit` reads its policy from the index rather than the " + "working tree -- the hook is installed, but every commit will be " + "blocked until the policy is staged too.", file=sys.stderr, ) + print(f"Stage it: git add {CONFIG_FILENAME}", file=sys.stderr) diff --git a/src/stackward/config.py b/src/stackward/config.py index 7e016ff..8ea03a3 100644 --- a/src/stackward/config.py +++ b/src/stackward/config.py @@ -30,6 +30,12 @@ `load_config` is the thin file reader in front of it. `commands.pre_commit` needs the second half without the first, because its policy comes out of the git index rather than the working tree. + +**`read_min_version` is deliberately narrower than either.** It answers only +"what floor does this file declare", for a caller that must get an answer +even when the rest of the file is something this binary cannot validate -- +which is exactly the case a floor is declared for. See it for why that is not +a second, laxer config loader. """ from __future__ import annotations @@ -310,19 +316,33 @@ def _build_secrets(raw: Any) -> dict[str, dict[str, Any]]: return result -def _build_config(data: dict[str, Any]) -> Config: - unknown = set(data) - _TOP_LEVEL_KEYS - if unknown: - raise ConfigError(f"unknown key {sorted(unknown)[0]!r}") +def _min_version_of(data: dict[str, Any]) -> str | None: + """The `min_version` a parsed document declares, validated, or `None`. - profile = _require_str(data, "profile", "profile") - python = _require_str(data, "python", "python") + One definition rather than two, because two callers need the identical + answer for different reasons: `_build_config` below, which is building a + whole `Config` and by then knows every other key is one it recognises; + and `read_min_version`, which needs the floor out of a document this + binary may be *unable* to validate the rest of. See `read_min_version` + for why that second case exists at all. + """ min_version = _require_str(data, "min_version", "min_version") if min_version is not None: try: _parse_version(min_version) except ValueError as exc: raise ConfigError(f"min_version: {exc}") from exc + return min_version + + +def _build_config(data: dict[str, Any]) -> Config: + unknown = set(data) - _TOP_LEVEL_KEYS + if unknown: + raise ConfigError(f"unknown key {sorted(unknown)[0]!r}") + + profile = _require_str(data, "profile", "profile") + python = _require_str(data, "python", "python") + min_version = _min_version_of(data) return Config( profile=profile, @@ -418,22 +438,69 @@ def load_config_text(text: str, origin: str) -> Config: raise ConfigError(f"{origin}: {exc}") from exc -def load_config(path: Path) -> Config: - """Parse and validate the file at `path` as `.stackward.toml`. +def _read_config_text(path: Path) -> str: + """`path`'s content as text, or `ConfigError`. - A thin reader in front of `load_config_text`, which does all the - validation; see there for what is raised and why the two are separate. An unreadable file and one that is not UTF-8 are both `ConfigError`, never a silent fall back to defaults: TOML is defined as UTF-8, so bytes that are not are a broken config file, not an absent one. """ try: - text = path.read_text(encoding="utf-8") + return path.read_text(encoding="utf-8") except OSError as exc: raise ConfigError(f"{path}: cannot read: {exc}") from exc except UnicodeDecodeError as exc: raise ConfigError(f"{path}: cannot read: not valid UTF-8: {exc}") from exc - return load_config_text(text, str(path)) + + +def load_config(path: Path) -> Config: + """Parse and validate the file at `path` as `.stackward.toml`. + + A thin reader in front of `load_config_text`, which does all the + validation; see there for what is raised and why the two are separate. + """ + return load_config_text(_read_config_text(path), str(path)) + + +def read_min_version(path: Path) -> str | None: + """The `min_version` the file at `path` declares — and *nothing else + about that file*. + + This exists because a full `load_config` cannot answer the question in + the one case the floor is most needed for. `_build_config` rejects an + unrecognised top-level key before it gets as far as building anything, + so a repository that declares both a floor this binary does not meet + **and** a key this binary does not know — which is precisely + forward-compatibility, the situation `min_version` exists to make + survivable — used to report `unknown key 'future_key'` and let the + command run, instead of saying "upgrade stackward". The unknown key is + a *consequence* of the unmet floor there, not an independent problem, + and reporting the consequence sends the reader to fix the wrong thing. + + So the floor is read on its own terms: parse the document, take + `min_version`, validate that one value, and form no opinion on any + other key. Everything else about the file is still checked, with better + words, by whichever command actually needs it — both gate commands + refuse outright on a policy they cannot parse or validate. + + Raises `ConfigError` for an unreadable file, invalid TOML, or a + `min_version` that is not a parseable version string. Its sole caller, + `cli._min_version_refusal`, prints none of those: it treats every one of + them as "there is no floor I can establish here" and lets the command + run. The messages are shaped anyway, rather than left as bare + exceptions, so that a second caller cannot inherit an error it would be + unable to report. + """ + text = _read_config_text(path) + try: + data = tomllib.loads(text) + except tomllib.TOMLDecodeError as exc: + # Position only, never the parser's message — see `toml_position`. + raise ConfigError(f"{path}: invalid TOML{toml_position(exc)}") from exc + try: + return _min_version_of(data) + except ConfigError as exc: + raise ConfigError(f"{path}: {exc}") from exc def enforce_min_version( diff --git a/tests/test_check_passphrase.py b/tests/test_check_passphrase.py index 055f7ed..f1dc906 100644 --- a/tests/test_check_passphrase.py +++ b/tests/test_check_passphrase.py @@ -603,11 +603,35 @@ def test_a_pulumi_timeout_is_reported_as_error_without_aborting_other_stacks( assert "stack-fast: accepted" in captured.out +class _ControlViolation(BaseException): + """Raised by a control that must never run. `BaseException`, not + `Exception`, and that is the whole point of it. + + `cmd_check_passphrase` is `@fail_closed` *and* its per-stack loop has + its own `except Exception`, so a control raised as an `AssertionError` + inside either is swallowed into `all_accepted = False` and the command + returns 2 -- the same 2 the test below expects for the right reason. + The assertion then holds whether the early refusal exists or not, which + is not a test. `tests/test_credentials.py`'s `_PromptViolation` and + `tests/test_gate_isolation.py`'s `GateViolation` are the same device for + the same reason. Not `KeyboardInterrupt`, which pytest treats as a + request to abort the whole session. + """ + + def test_no_passphrase_provided_on_stdin_errors_before_any_stack_is_checked(monkeypatch): + """The refusal must happen *before* the loop, not be absorbed by it. + + Both halves of that are load-bearing: without a passphrase there is + nothing to check, and a per-stack "could not check" would report the + same exit code while having run `pulumi` against every named stack. + Only a control that `fail_closed` cannot swallow can tell the two + apart -- see `_ControlViolation`. + """ monkeypatch.setattr(check_passphrase.sys, "stdin", _FakeStdin(isatty=False, lines=[])) def boom(*a, **k): - raise AssertionError("no stack should be checked without a passphrase") + raise _ControlViolation("no stack should be checked without a passphrase") monkeypatch.setattr(check_passphrase, "_check_stack", boom) assert main(["check-passphrase", "stack-a"]) == 2 diff --git a/tests/test_cli.py b/tests/test_cli.py index bebe7c5..244f814 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -113,6 +113,37 @@ def test_doctor_still_works_when_repo_config_is_malformed(tmp_path, monkeypatch, assert str(tmp_path / ".stackward.toml") in capsys.readouterr().out +def test_an_unmet_floor_is_reported_beside_a_key_this_binary_does_not_know( + tmp_path, monkeypatch, capsys +): + """The floor must win over the unknown key, because the unknown key is + a *consequence* of the unmet floor. + + A repository that demands a newer stackward is very likely to be using + something that newer stackward added, so an unrecognised top-level key + next to an unmet `min_version` is the expected shape of this config + rather than an exotic one -- it is forward-compatibility, which is the + situation the floor exists to make survivable. Reporting `unknown key + 'future_key'` here names the symptom and sends the reader to delete the + one line that would have explained what to do. + + The exit code is 2 either way (`check-config` refuses a config it + cannot validate), so this asserts on the *message*: that is the whole + difference, and an exit-code-only assertion would hold whether the + refusal reads the floor first or not. + """ + (tmp_path / ".git").mkdir() + (tmp_path / ".stackward.toml").write_text( + 'min_version = "9.9.9"\nfuture_key = "a key only a newer stackward knows"\n' + ) + monkeypatch.chdir(tmp_path) + + assert main(["check-config", "Pulumi.dev.yaml"]) == 2 + err = capsys.readouterr().err + assert "requires stackward >= 9.9.9" in err + assert "future_key" not in err + + # --------------------------------------------------------------------------- # Lazy dispatch, and the frozen bundle # --------------------------------------------------------------------------- diff --git a/tests/test_credentials.py b/tests/test_credentials.py index be87cc6..a424b0d 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -60,7 +60,12 @@ MARKER_KEY = "Ab7Kd2Ns9Rv4Tq6Xw" MARKER_SECRET = "Cf3Jm8Ph5Lz1Yb7Gd" MARKER_PASSPHRASE = "Ew9Ur4Vt2Kn6Sx8Qc" -ALL_MARKERS = (MARKER_KEY, MARKER_SECRET, MARKER_PASSPHRASE) +MARKER_EXTRA = "Hq5Zt3Bw8Nk2Ly6Fp" +ALL_MARKERS = (MARKER_KEY, MARKER_SECRET, MARKER_PASSPHRASE, MARKER_EXTRA) + +# A name a real `.env` would carry that this tool never injects. Generic on +# purpose: no vendor, product or environment appears in this repository. +EXTRA_NAME = "DATABASE_URL" FULL_CREDENTIALS = { AWS_ACCESS_KEY_ID: MARKER_KEY, @@ -560,6 +565,51 @@ def test_show_reports_a_shortfall_without_failing(initialised, monkeypatch, capf assert_no_leak(captured.out + captured.err, MARKER_KEY, what="a credential") +def test_show_names_what_is_sealed_but_never_injected( + initialised, monkeypatch, capfd +): + """`set` seals every name it is given; `exec`/`shell` inject three. + + Sealing the extras is intended -- `< .env` is the documented workflow + and a real `.env` carries more than three names -- so the defect was + never the behaviour, it was that nothing anywhere said so. `show` is + where a person would notice a fourth name, so it is where the sentence + belongs; without it the listing states a fact and leaves its meaning to + be guessed at. + """ + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + store.set_credentials( + "staging", + {**FULL_CREDENTIALS, EXTRA_NAME: MARKER_EXTRA}, + PASSWORD, + directory=initialised, + ) + + assert main(["credentials", "show", "--profile", "staging"]) == 0 + + captured = capfd.readouterr() + # Sealed and listed, exactly as before -- the note explains it, it does + # not hide it. + assert EXTRA_NAME in captured.out + assert EXTRA_NAME in captured.err + assert "never injected" in captured.err + for marker in ALL_MARKERS: + assert_no_leak(captured.out + captured.err, marker, what="a credential") + + +def test_show_says_nothing_about_extras_when_there_are_none( + initialised, monkeypatch, capfd +): + """The other half: a note that fires on the ordinary envelope is noise, + and noise is how the useful half stops being read.""" + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=initialised) + + assert main(["credentials", "show", "--profile", "staging"]) == 0 + + assert "never injected" not in capfd.readouterr().err + + def test_show_on_a_profile_with_no_envelope_says_so(initialised, monkeypatch, capsys): monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) diff --git a/tests/test_install_hooks.py b/tests/test_install_hooks.py index dd9fd80..419a57a 100644 --- a/tests/test_install_hooks.py +++ b/tests/test_install_hooks.py @@ -775,7 +775,7 @@ def hooks_path(env: dict[str, str]) -> subprocess.CompletedProcess: # --------------------------------------------------------------------------- -# A repository with no policy file: a warning, never a refusal. +# No policy the *hook* will find: a warning, never a refusal. # --------------------------------------------------------------------------- @@ -824,6 +824,42 @@ def test_a_repository_that_declares_a_policy_gets_no_warning(repo, monkeypatch, assert "warning" not in capsys.readouterr().err.lower() +@pytest.fixture +def repo_with_unstaged_policy(repo) -> Path: + """`repo` with its `.stackward.toml` still on disk but no longer in the + index. + + `--cached` removes the index entry and leaves the working-tree file + alone, which is the state a repository is in between writing a policy + and staging it -- and that is the moment somebody runs `hooks install`. + """ + _git(repo, "rm", "-q", "--cached", ".stackward.toml") + return repo + + +def test_an_unstaged_policy_warns_that_it_is_unstaged( + repo_with_unstaged_policy, monkeypatch, capsys +): + """The warning has to answer the question the hook will ask. + + `pre-commit` reads its policy from the index, so a file sitting + unstaged in the working tree is no policy to it -- and this repository + used to be told nothing at all, then block every commit. Saying + "declares none" here would be worse than silence: it is visibly false + to someone looking straight at the file, which is how a warning teaches + people to stop reading warnings. + """ + assert install(repo_with_unstaged_policy, monkeypatch) == 0 + assert (repo_with_unstaged_policy / ".git" / "hooks" / "pre-commit").is_file() + + err = capsys.readouterr().err + assert "warning" in err.lower() + assert "not staged" in err + assert f"git add {install_hooks_module.CONFIG_FILENAME}" in err + # Not the absent-policy wording: the file is right there. + assert "declares no" not in err + + def test_the_policy_warning_does_not_change_the_exit_code_of_a_refusal( repo_without_policy, monkeypatch, capsys ): @@ -840,6 +876,43 @@ def test_the_policy_warning_does_not_change_the_exit_code_of_a_refusal( assert "no .stackward.toml" not in capsys.readouterr().err +# --------------------------------------------------------------------------- +# A repository that demands a newer stackward than this one. +# --------------------------------------------------------------------------- + + +def test_an_unmet_min_version_floor_refuses_before_any_hook_is_written( + repo, monkeypatch, capsys +): + """`hooks install` is the command with the most to lose from a floor + check that quietly gives up. + + Every other command refuses a `.stackward.toml` it cannot validate, so + an unreported floor still costs them only the wrong *message*. This one + reads no policy at all: with the refusal silenced it installs the hook + and exits 0, and the repository is then gated by a binary its own + policy says is too old to understand the policy. The floor is what + catches that, and it has to survive the config carrying a key this + binary does not know -- which is the shape a repository demanding a + newer stackward actually has. + + Staged, not merely written, so that the missing-policy warning has + nothing to say here and the only thing under test is the floor. + """ + # Top-level keys before the `[check]` table -- after it they would be + # members of it, and this would test something else entirely. + (repo / ".stackward.toml").write_text( + 'min_version = "9.9.9"\n' + 'future_key = "a key only a newer stackward knows"\n' + '[check]\nmodel_net = "none"\n' + ) + _git(repo, "add", ".stackward.toml") + + assert install(repo, monkeypatch) == 2 + assert not (repo / ".git" / "hooks" / "pre-commit").exists() + assert "requires stackward >= 9.9.9" in capsys.readouterr().err + + # --------------------------------------------------------------------------- # Wiring. # --------------------------------------------------------------------------- diff --git a/tests/test_pre_commit.py b/tests/test_pre_commit.py index 6140cf6..bf4e8de 100644 --- a/tests/test_pre_commit.py +++ b/tests/test_pre_commit.py @@ -899,14 +899,21 @@ def test_git_runs_under_a_fixed_locale(repo, monkeypatch): read the same in a bug report as it did on the machine that hit it. Scope, so this does not become a trap for whoever changes the fixture - next: the loop covers every git invocation the command makes, and it - holds only because `repo` declares `model_net = "none"`. Under - `model_net = "artifact"`, `nets.model.run_git` also runs, and it sets no - locale -- a second call site with the same gap, in a file this wave does - not own. `commands.install_hooks._is_tracked` is a third, and there it - is a live bug rather than hardening: it branches on git's *translated* - strings, so under a non-English locale it raises and `hooks install` - exits 2 in every repository. + next: the loop covers every git invocation *this command* makes, and + that holds only because `repo` declares `model_net = "none"`. Under + `model_net = "artifact"` a second call site runs as well + (`nets.model.run_git`), and `commands.install_hooks._git` is a third. + This docstring used to record both as gaps -- and `install_hooks`, which + matches git's *translated* strings, as a live bug rather than + hardening. Both were closed afterwards: each sets `LC_ALL=C` with the + environment merged, and each is pinned by a test in its own file that + goes red if the override is dropped: `test_model_net.py` and + `test_install_hooks.py` each carry their own + `test_every_git_invocation_runs_under_a_fixed_locale`, and the live half + is pinned at the level where it bit by + `test_hooks_install_survives_a_git_that_translates_its_diagnostics`. + What must stay true is that a *new* call site brings its own file's + test, not that this loop grows to reach across modules. The merge half matters more than the override: git sets `GIT_INDEX_FILE` and `GIT_DIR` for a hook process, and passing a bare `env={"LC_ALL": From e79921e3d53cb0bbdf41dd15a943e8c4cd888b99 Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sun, 6 Sep 2026 01:30:32 -0700 Subject: [PATCH 43/44] test: make the TypedDict fixture portable below 3.12 CI caught what three local runs could not. pydantic refuses `typing.TypedDict` below 3.12 and demands the typing_extensions one, so on 3.11 the generated module failed to import and sync-declared-secrets refused for pydantic's reason rather than for the walkability reason this test exists to pin. The exit code is 2 either way. Only the assertion on the message told them apart -- had the test stopped at `== 2`, it would have passed on 3.11 while proving nothing, which is the failure mode this branch has been hunting all along. Verified on 3.11, 3.12 and 3.13: 902 passed on each. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- tests/test_model_net.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_model_net.py b/tests/test_model_net.py index 76cafb3..7c549a0 100644 --- a/tests/test_model_net.py +++ b/tests/test_model_net.py @@ -872,10 +872,17 @@ class Root(BaseModel): class Peer: handshake: str = Field(default="", json_schema_extra={"secret": True}) """, + # `typing_extensions.TypedDict`, not `typing`'s: pydantic refuses the + # stdlib one below 3.12, so on 3.11 the import fails and the command + # refuses for pydantic's reason instead of the walkability reason this + # test exists to pin. The exit code is 2 either way -- only the + # assertion on the message tells the two apart, which is why this + # surfaced in CI and not locally. "typed_dict": """ - from typing import Annotated, TypedDict + from typing import Annotated from pydantic import Field + from typing_extensions import TypedDict class Peer(TypedDict): From 63acaaa10318e81657ab879493fb2aa805a2882c Mon Sep 17 00:00:00 2001 From: Srinath Janakiraman Date: Sun, 6 Sep 2026 12:18:13 -0700 Subject: [PATCH 44/44] chore: never commit agent scratch directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .superpowers/ held the review ledger, eleven task reports and twenty-eight review diffs for this branch — prose about how the work was done, written for a private audience. On a public repository that is a worse exposure than a stray artefact: the neutrality gate greps for known terms and cannot catch a novel detail described in a sentence. Nothing was ever published; the only thing that prevented it was an ignore file the tooling drops inside its own directory. That file is untracked, so it protected this checkout and no other, and covered only the one subdirectory it sat in. A tracked rule protects every clone, and covers the sibling paths the nested one never did. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014GNW1Z8CmJgdKvBpgXfJ14 --- .gitignore | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.gitignore b/.gitignore index 0f55415..d7d3a25 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,17 @@ state.json .env .env.* !.env.example + +# Agent and tooling scratch directories. These hold working notes, review +# transcripts and plan files — prose about how the work was done, written +# for a private audience. On a public repository that is a worse exposure +# than a stray build artefact: the neutrality gate greps for known terms, +# and cannot catch a novel detail described in a sentence. +# +# Listed here rather than relying on an ignore file the tooling drops +# inside its own directory. That file is untracked, so it protects only the +# checkout that happened to create it — a fresh clone gets nothing, and +# anything written outside the subdirectory it covers was never protected +# at all. +.superpowers/ +.claude/