From 5b0064dcf4f4ff1a185f759323deaefb31dfecbb Mon Sep 17 00:00:00 2001 From: Dusty <42273218+DustyStudy@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:06:27 -0500 Subject: [PATCH 1/2] fix: satisfy ruff format line-length on test_aws_config_sync.py --- tests/test_aws_config_sync.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_aws_config_sync.py b/tests/test_aws_config_sync.py index a48751b..db3fe78 100644 --- a/tests/test_aws_config_sync.py +++ b/tests/test_aws_config_sync.py @@ -83,9 +83,7 @@ def test_dry_run_writes_nothing(tmp_path): def test_skips_account_with_multiple_roles_and_no_default(tmp_path): - cfg = _cfg( - prod=Account(alias="prod", account_id="111111111111", roles=["read-only", "admin"]) - ) + cfg = _cfg(prod=Account(alias="prod", account_id="111111111111", roles=["read-only", "admin"])) written, skipped, conflicts, path = aws_config_sync.sync(cfg) assert written == [] From 831d86ecf6d194830ff782a4d72190350c40f4c3 Mon Sep 17 00:00:00 2001 From: Dusty <42273218+DustyStudy@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:04:18 -0500 Subject: [PATCH 2/2] docs: add threat model; test: cover exec_cmd guardrail branching and cloudwatch push --- .github/SECURITY.md | 5 + README.md | 8 + docs/THREAT_MODEL.md | 270 +++++++++++++++++++++++++++++ src/orgctl/audit.py | 8 + tests/test_audit_cloudwatch.py | 124 ++++++++++++++ tests/test_exec_cmd.py | 300 +++++++++++++++++++++++++++++++++ 6 files changed, 715 insertions(+) create mode 100644 docs/THREAT_MODEL.md create mode 100644 tests/test_audit_cloudwatch.py create mode 100644 tests/test_exec_cmd.py diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 184f043..1372ee3 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -1,5 +1,10 @@ # Security Policy +For the full threat model — assets, trust boundaries, per-scenario +mitigations, residual risk, and explicit non-goals — see +[`docs/THREAT_MODEL.md`](../docs/THREAT_MODEL.md). This document covers +vulnerability reporting and CI/CD supply-chain hardening specifically. + ## Reporting a vulnerability If you find a security issue in `orgctl`, please open a private report via diff --git a/README.md b/README.md index 5e6b003..9af8bbe 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,10 @@ account IDs and role names. Two optional top-level fields: - `max_session_hours` (default 8) — force a fresh browser login after this many hours, independent of the SSO token's own server-side expiry. - `cloudwatch_log_group` — set this to enable `orgctl audit-log --push-cloudwatch`. + Each push resends the last `n` local entries with no "since last push" + tracking, so calling it repeatedly with overlapping history produces + duplicate CloudWatch events — push right after each command, or dedupe + downstream, if that matters for your use case. ### `~/.orgctl/guardrails.yaml` (optional) @@ -252,6 +256,10 @@ recursive `s3 rm`, etc.). - Guardrails and the audit log are local-only conveniences, not a substitute for IAM permission boundaries, SCPs, or CloudTrail. +See [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md) for the full breakdown — +assets, trust boundaries, per-scenario mitigations and residual risk, and +what's explicitly out of scope. + ## Development ```bash diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md new file mode 100644 index 0000000..4958684 --- /dev/null +++ b/docs/THREAT_MODEL.md @@ -0,0 +1,270 @@ +# Threat model + +This document walks through what `orgctl` protects, who the relevant actors +are, and — for each threat scenario considered — what mitigates it and what +residual risk is left over. It's meant to complement, not repeat, +[`SECURITY.md`](../.github/SECURITY.md) (vulnerability reporting, code areas +under review, CI/CD supply-chain hardening) and the README's "Security +model" section (the one-paragraph summary). This is the longer version: the +reasoning behind that summary, spelled out scenario by scenario. + +Threat modeling is only useful if it's kept honest, so this document is +written from the position that `orgctl` is a **convenience and friction +layer on top of AWS SSO**, not a security boundary in itself. Every +mitigation described below assumes IAM, SCPs, and Identity Center's own +controls are doing the actual enforcement — `orgctl` just tries not to make +things worse, and to add friction/visibility on top. + +## Assets + +What this tool has custody of, at some point, and what protecting it means: + +| Asset | Where it lives | What "protected" means | +|---|---|---| +| SSO access token | OS keychain (preferred) or `~/.orgctl/` cache file (fallback) | Not written to disk in plaintext when a keychain is available; 0600 permissions and expiry-checked reads when it isn't | +| Short-lived role credentials (`AccessKeyId`/`SecretAccessKey`/`SessionToken`) | Same cache, keyed per account+role | Same as above, plus never exported outside the one child process/shell that requested them | +| `orgs.yaml` (account registry) | `~/.orgctl/orgs.yaml` (or `ORGCTL_CONFIG`) | Contains account IDs and role names only — no secrets — but is still the map an attacker would want to see, and its contents drive which guardrails apply | +| `guardrails.yaml` | `~/.orgctl/guardrails.yaml` (or `ORGCTL_GUARDRAILS`) | Governs which commands get blocked/confirmed — its integrity matters more than its confidentiality | +| Local audit log | `~/.orgctl/audit.log` (or `ORGCTL_HOME`) | A record of what was run against which account, for the operator's own review; append-only in practice, not append-only *enforced* (see below) | +| `~/.aws/config` profiles written by `sync-aws-config` | Standard AWS config location | Must not silently absorb or overwrite a profile the tool didn't create | + +## Actors and trust boundaries + +- **The operator** (you, running the CLI) — fully trusted. `orgctl` assumes + whoever is running it on the local machine is authorized to act as + themselves; it does no local authentication of its own beyond what the OS + session already provides. +- **AWS IAM Identity Center** — trusted as the source of truth for + identity and for issuing credentials. `orgctl` never second-guesses an + Identity Center authorization decision; it only adds *pre*-AWS friction + (guardrails) and *post*-hoc local logging (audit log). +- **Anyone else with access to the same machine/account** (a second local + user, malware, another process running as the same OS user) — explicitly + **not** trusted, and is the actor most of the scenarios below are about. +- **A person who can modify this repository's source or its published + package** (a compromised maintainer account, a malicious PR that gets + merged, a compromised PyPI upload if this is ever published there) — + covered separately by `SECURITY.md`'s CI/CD supply-chain section + (SHA-pinned actions, harden-runner, branch protection); not duplicated + here. + +## Threat scenarios + +### 1. Laptop is lost, stolen, or accessed by someone else while unlocked + +**Threat:** an attacker with filesystem access wants long-lived or +long-enough-to-matter AWS access. + +**Mitigation:** there is no long-lived credential to steal in the first +place — every credential `orgctl` handles comes from `GetRoleCredentials` +and expires on its own (typically ~1 hour for role credentials, up to +`max_session_hours` for the SSO token itself). When a keychain backend is +available, the SSO token isn't even on disk in a form the attacker can +read without also compromising the OS-level keychain protections (e.g. the +user's login password, on most desktop keychains). When it isn't (headless +Linux with no keychain, `keyring` extra not installed), the fallback cache +file is created 0600 and every read checks expiry before trusting it. + +**Residual risk:** if the attacker gets the disk *and* an unlocked session +(or the keychain unlock secret) within the credential's remaining lifetime, +they get exactly what the legitimate operator could have gotten — same +account, same role, until expiry. This is inherent to any tool that caches +credentials locally at all, not something a config change in `orgctl` +fixes; it's why `max_session_hours` exists (force re-auth sooner than AWS's +own token expiry) and why `orgctl logout` clearing the cache immediately is +part of the documented incident-response step. + +### 2. A malicious or careless command is run against the wrong account + +**Threat:** the classic "wrong terminal tab" incident — an operator (or an +agent/script driving this CLI) runs a destructive command intending one +account and hits another, or runs something destructive on purpose without +realizing the blast radius. + +**Mitigation:** `guardrails.py`'s deny-patterns, protected-account-ids, and +require-confirmation-patterns catch the known-bad shapes (leaving an org, +closing an account, recursive `s3 rm`, etc.) before the command ever +reaches AWS, and the optional `--check-action`/`check-policy` pre-check can +flag when a command would be denied by the role's own identity-based +policy anyway. + +**Residual risk — and this is the important one:** guardrails are +pattern-matched (`fnmatch` globs) against the literal command string. They +are trivially bypassed by rephrasing the same command (a different flag +order, an alias, a wrapper script, `aws s3api` instead of `aws s3`). This +is **by design, not an oversight** — see "Explicitly out of scope" below — +but it means guardrails should be read as "catches the accidental case," +not "prevents the determined case." The actual enforcement boundary for +"this role cannot do X" has to be an IAM permission boundary or SCP, full +stop. The code, the README, and `SECURITY.md` all say this in three +different words; this document is the fourth. + +### 3. `orgs.yaml` or `guardrails.yaml` is tampered with + +**Threat:** a second local process, a malicious dependency in an unrelated +project the operator also runs, or a synced-dotfiles mistake modifies +either config file — e.g. removing a `protected_account_ids` entry, adding +a deny-pattern that's actually a decoy, or pointing `sso_start_url` at an +attacker-controlled Identity Center instance. + +**Mitigation:** both files are loaded fresh on every command (no +in-memory trust carried across invocations), and `yaml.safe_load` is used +throughout — no arbitrary object construction or code execution via a +crafted YAML file, unlike `yaml.load` with the default loader. + +**Residual risk:** neither file is integrity-checked (no signature, no +checksum pinned elsewhere) — if an attacker can write to +`~/.orgctl/*.yaml`, they can silently change guardrail behavior or, more +seriously, redirect `sso_start_url`/`sso_region` to an attacker-controlled +endpoint and phish the operator's next device-authorization approval. This +requires local write access to the operator's home directory already, +which is a fairly high bar (roughly equivalent to "attacker already has +code execution as this user"), but it's worth naming explicitly rather +than leaving implicit. Anyone deploying `orgctl` fleet-wide should treat +`~/.orgctl/orgs.yaml`'s `sso_start_url` the same way they'd treat any other +security-relevant config pushed to endpoints — via a managed/attested +channel, not an ad-hoc copy. + +### 4. Credentials leak out of the intended child process + +**Threat:** short-lived role credentials end up somewhere they shouldn't — +shell history, a subprocess the operator didn't intend to grant them to, +or the parent shell's own environment. + +**Mitigation:** `exec_cmd._creds_to_env()` builds a **copy** of the +environment (`os.environ.copy()`), so the parent shell's own `os.environ` +is never mutated — credentials exist only in the memory of the one +`subprocess.run()` child. `AWS_PROFILE` is explicitly stripped from that +copy so a long-lived profile configured in the parent shell can't get +picked up by mistake alongside the short-lived creds. `subprocess.run()` is +called with a list (`command`), never `shell=True` with a joined string — +so there's no shell-injection surface from account aliases, role names, or +arguments containing shell metacharacters. + +**Residual risk:** once credentials are in a child process's environment, +`orgctl` has no control over what that child process (or anything *it* +spawns) does with them — a command that itself echoes `$AWS_SECRET_ACCESS_KEY` +to a log file, or a `orgctl shell` session where the operator runs +something that dumps `env`, is the operator's own action at that point, not +something this tool can prevent from inside `exec_cmd.py`. `spawn_shell`'s +subshell prompt tag (`[account:role]`) is a mitigation for the *adjacent* +risk — forgetting *which* context you're in — not for env-leakage itself. + +### 5. `sync-aws-config` overwrites a profile it didn't create + +**Threat:** a profile name collision between `orgs.yaml` and something the +operator already had in `~/.aws/config` (hand-written, from `aws +configure`, from another tool) results in silent data loss or, worse, a +`credential_process` pointing somewhere unexpected. + +**Mitigation:** fixed as of the per-section managed-marker change (see +`aws_config_sync.py`) — a name collision with a section that doesn't carry +orgctl's own marker is left completely untouched and reported back as a +conflict, never silently mutated. A `.bak` copy is made before any real +write regardless. + +**Residual risk:** none identified beyond the general "back up your own +dotfiles" hygiene that applies to any tool that writes to +`~/.aws/config`. + +### 6. Audit log data pushed to CloudWatch is read or tampered with + +**Threat:** `orgctl audit-log --push-cloudwatch` sends recent local audit +entries (account IDs, roles, commands run, free-text `--reason` values) to +a CloudWatch Logs group. Anyone who can read that log group sees an +operator's command history; anyone who can write to it could inject +forged entries. + +**Mitigation:** this is opt-in (`cloudwatch_log_group` must be explicitly +set) and uses whatever credentials are already active in the calling +shell — meaning the operator controls, via their own IAM setup, exactly +which role has `logs:PutLogEvents`/`logs:CreateLogStream` on that group. +`orgctl` itself requests no broader permission than that. + +**Residual risk:** this is entirely a function of how the operator +provisions and secures the destination log group (encryption at rest, log +group resource policy, who has `logs:GetLogEvents` on it) — outside this +tool's control by design. `orgctl` does not create the log group and does +not set access policy on it; see the companion +[`aws-observability-dashboards`](https://github.com/DustyStudy/aws-observability-dashboards) +repo for that side of the setup. Worth calling out explicitly: the +`--reason` field is free text the operator types, not validated against a +ticketing system, so treat it as a note-to-self / good-faith annotation, +not a tamper-evident justification. + +### 7. The local audit log itself is edited or deleted after the fact + +**Threat:** an operator (or something running as them) wants to remove +evidence of a command that was run. + +**Mitigation:** none, and this is intentional — see "Explicitly out of +scope." + +**Residual risk:** total. `~/.orgctl/audit.log` is a plain, appendable text +file with no protection against an operator (or anything running with +their OS privileges) editing or truncating it. This is fine for its stated +purpose — "what did I run against prod last Tuesday," a convenience for +the operator's own recall — and actively wrong to rely on for anything +resembling non-repudiation or compliance evidence. **CloudTrail is the +actual tamper-evident record of what happened in AWS**, independent of +anything this CLI does locally. If you need a defensible audit trail, +that's what to point an auditor at. + +## Explicitly out of scope + +Naming these directly, rather than leaving them as an implied gap: + +- **A determined operator bypassing their own guardrails.** Guardrails are + a speed bump for the *accidental* case, not an access-control mechanism. + Anyone with legitimate `orgctl`/AWS access who wants to run a + guardrail-matched command already has a dozen ways around client-side + pattern matching (see scenario 2). The correct control for "this + identity must never be able to do X" is IAM/SCPs, which don't care what + CLI or wrapper script the request came through. +- **Malware or a compromised process already running as the operator's OS + user.** If an attacker already has arbitrary code execution as the + logged-in user, they can read anything that user's session can read — + including an unlocked keychain, environment variables of processes they + spawn, and (per scenario 3) the config files that drive guardrail + behavior. No client-side tool can fully defend against this; it's the + same trust boundary every local CLI (including the AWS CLI itself) + operates within. +- **A compromised or malicious Identity Center administrator.** `orgctl` + trusts Identity Center's authorization decisions completely — it has no + mechanism to detect or resist a case where Identity Center itself has + been misconfigured or its admin access compromised. That's an identity + provider's own security posture, not something a client tool layered on + top of it can compensate for. +- **Network-level attacks against the SSO OIDC device-authorization flow** + (e.g. an attacker intercepting the verification URL before the operator + approves it). This is AWS SSO OIDC's own protocol design, not something + `orgctl` implements or could add mitigations to beyond what the protocol + already provides (short-lived device codes, user-driven approval in a + separate, trusted browser context). +- **Auditability/non-repudiation of the local audit log** — see scenario 7. + +## Recommended compensating controls (for whoever deploys this) + +None of these are things `orgctl` does for you — they're the actual +enforcement layer this tool assumes exists around it: + +- IAM permission boundaries and/or SCPs on every role `orgctl` can assume, + scoped to least privilege for that role's actual job. +- CloudTrail enabled org-wide, as the real (tamper-evident, AWS-side) + record of API activity — not the local audit log. +- MFA required at the Identity Center level for the device-authorization + flow itself. +- If using `--push-cloudwatch`: encryption at rest on the destination log + group, a resource policy restricting who can read it, and a retention + policy appropriate for your compliance requirements. +- Session policies / `max_session_hours` tuned to your organization's risk + tolerance for "how long should a stolen laptop's cached token remain + useful," independent of AWS's own token expiry. + +## Status + +This is a living document — if you find a scenario it doesn't cover, or a +mitigation described here that doesn't hold up, please open an issue (or, +for anything that looks like an actual exploitable gap rather than a +documentation gap, follow the private reporting process in +[`SECURITY.md`](../.github/SECURITY.md) instead of a public issue). diff --git a/src/orgctl/audit.py b/src/orgctl/audit.py index 19946dd..cb5f2b7 100644 --- a/src/orgctl/audit.py +++ b/src/orgctl/audit.py @@ -67,6 +67,14 @@ def push_to_cloudwatch(log_group: str, region: str, n: int = 100) -> int: / logs:CreateLogStream on this log group). Returns the number of entries pushed. Requires boto3 — imported lazily so the rest of this module has no hard AWS dependency. + + Note: this has no "since last push" cursor. Every call re-sends the last + `n` *local* entries regardless of whether they were already pushed on a + previous call — calling this repeatedly with overlapping local history + will produce duplicate CloudWatch log events (same message, different + ingestion time). This is intentional for a simple, stateless, ad-hoc + push — if you need exactly-once delivery, track your own high-water mark + externally, or push with a smaller `n` right after each command runs. """ import boto3 diff --git a/tests/test_audit_cloudwatch.py b/tests/test_audit_cloudwatch.py new file mode 100644 index 0000000..1355038 --- /dev/null +++ b/tests/test_audit_cloudwatch.py @@ -0,0 +1,124 @@ +"""Tests for audit.push_to_cloudwatch() using moto's mocked CloudWatch Logs. + +Note: push_to_cloudwatch does not create the log group itself (only the log +stream) — per the README, `cloudwatch_log_group` is expected to already exist +(e.g. provisioned via CloudFormation/Terraform ahead of time) and the +credentials used only need logs:PutLogEvents / logs:CreateLogStream, not +logs:CreateLogGroup. These tests reflect that expectation. +""" + +from __future__ import annotations + +import json + +import boto3 +import pytest +from moto import mock_aws + +from orgctl import audit + +REGION = "us-east-1" +LOG_GROUP = "/orgctl/audit" + + +@pytest.fixture(autouse=True) +def _home(tmp_path, monkeypatch): + monkeypatch.setenv("ORGCTL_HOME", str(tmp_path)) + return tmp_path + + +@pytest.fixture +def logs_client(): + with mock_aws(): + client = boto3.client("logs", region_name=REGION) + client.create_log_group(logGroupName=LOG_GROUP) + yield client + + +def _get_events(client) -> list[dict]: + streams = client.describe_log_streams(logGroupName=LOG_GROUP)["logStreams"] + if not streams: + return [] + events = [] + for stream in streams: + resp = client.get_log_events( + logGroupName=LOG_GROUP, logStreamName=stream["logStreamName"], startFromHead=True + ) + events.extend(resp["events"]) + return events + + +def test_push_with_no_local_entries_pushes_nothing(logs_client): + n = audit.push_to_cloudwatch(LOG_GROUP, REGION) + assert n == 0 + assert _get_events(logs_client) == [] + + +def test_push_sends_all_recorded_entries(logs_client): + audit.record( + action="exec", account_id="111111111111", role="admin", command=["aws", "s3", "ls"] + ) + audit.record(action="shell", account_id="222222222222", role="read-only", reason="JIRA-42") + + n = audit.push_to_cloudwatch(LOG_GROUP, REGION) + assert n == 2 + + events = _get_events(logs_client) + assert len(events) == 2 + messages = [json.loads(e["message"]) for e in events] + actions = {m["action"] for m in messages} + assert actions == {"exec", "shell"} + + +def test_push_respects_n_limit(logs_client): + for i in range(5): + audit.record(action="exec", account_id=str(i), role="r") + + n = audit.push_to_cloudwatch(LOG_GROUP, REGION, n=2) + assert n == 2 + + events = _get_events(logs_client) + assert len(events) == 2 + messages = [json.loads(e["message"]) for e in events] + # tail(n) returns the most recent n entries, in order. + assert [m["account_id"] for m in messages] == ["3", "4"] + + +def test_repeated_push_reuses_stream_but_resends_overlapping_entries(logs_client): + """push_to_cloudwatch has no "since last push" cursor (see its docstring) + — every call resends the last `n` *local* entries regardless of what was + already pushed. Two local entries, pushed once each call, means the + first entry gets sent twice (once per call) and the second once — 3 + CloudWatch events total from 2 local audit-log lines. This is documented, + intentional behavior, not a bug: it's what "stateless, ad-hoc push" + means. What must still hold is the log *stream* itself — same + host+user should reuse one stream across calls, not create a new one + each time (create_log_stream's ResourceAlreadyExistsException must be + swallowed). + """ + audit.record(action="exec", account_id="111", role="admin") + audit.push_to_cloudwatch(LOG_GROUP, REGION) + + audit.record(action="exec", account_id="222", role="admin") + n = audit.push_to_cloudwatch(LOG_GROUP, REGION) + + assert n == 2 # this call pushed both local entries (111 again, 222 new) + + streams = logs_client.describe_log_streams(logGroupName=LOG_GROUP)["logStreams"] + assert len(streams) == 1 # one stream reused, not recreated + + events = _get_events(logs_client) + assert len(events) == 3 # 1 (first call) + 2 (second call, one a resend) + account_ids = [json.loads(e["message"])["account_id"] for e in events] + assert account_ids.count("111") == 2 # resent — expected, see docstring + assert account_ids.count("222") == 1 + + +def test_push_fails_loudly_if_log_group_does_not_exist(): + # No log group created here — push_to_cloudwatch does not create one + # itself, so this should surface AWS's real error rather than silently + # doing nothing. + with mock_aws(): + audit.record(action="exec", account_id="111", role="admin") + with pytest.raises(Exception, match="ResourceNotFoundException|does not exist"): + audit.push_to_cloudwatch("/does/not/exist", REGION) diff --git a/tests/test_exec_cmd.py b/tests/test_exec_cmd.py new file mode 100644 index 0000000..c2fa21e --- /dev/null +++ b/tests/test_exec_cmd.py @@ -0,0 +1,300 @@ +"""Tests for exec_cmd.run()'s guardrail branching: block / confirm / proceed. + +Credentials and the actual subprocess are faked throughout — these tests are +about which branch `run()` takes and what it records to the audit log, not +about SSO or process execution themselves (those are covered by test_sso.py +equivalents / manual testing, same rationale as test_policy_check.py). +""" + +from __future__ import annotations + +import json + +import pytest + +from orgctl import exec_cmd, guardrails +from orgctl.config import Account, OrgConfig + +FAKE_CREDS = { + "AccessKeyId": "AKIAFAKE", + "SecretAccessKey": "fake-secret", + "SessionToken": "fake-token", + "Expiration": 9999999999000, +} + + +@pytest.fixture +def cfg(): + return OrgConfig( + name="test", + sso_start_url="https://example.awsapps.com/start", + sso_region="us-east-1", + default_region="us-east-1", + accounts={ + "prod": Account(alias="prod", account_id="111111111111", roles=["admin"]), + }, + ) + + +@pytest.fixture(autouse=True) +def _home(tmp_path, monkeypatch): + # Route the audit log to a temp dir instead of the real ~/.orgctl. + monkeypatch.setenv("ORGCTL_HOME", str(tmp_path)) + return tmp_path + + +@pytest.fixture +def fake_get_creds(monkeypatch): + calls = [] + + def _fake(sso_token, account_id, role_name): + calls.append((account_id, role_name)) + return dict(FAKE_CREDS) + + monkeypatch.setattr(exec_cmd, "get_role_credentials", _fake) + return calls + + +@pytest.fixture +def fake_subprocess(monkeypatch): + calls = [] + + class _FakeCompletedProcess: + returncode = 0 + + def _fake_run(command, env): + calls.append((command, env)) + return _FakeCompletedProcess() + + monkeypatch.setattr(exec_cmd.subprocess, "run", _fake_run) + return calls + + +def _last_audit_entry(tmp_path) -> dict: + lines = (tmp_path / "audit.log").read_text().strip().splitlines() + return json.loads(lines[-1]) + + +def test_blocked_command_never_fetches_creds_or_runs( + cfg, fake_get_creds, fake_subprocess, tmp_path +): + gcfg = guardrails.GuardrailConfig(deny_patterns=["aws s3 rb*"]) + rc = exec_cmd.run( + cfg, + sso_token=None, + account_alias_or_id="prod", + role="admin", + command=["aws", "s3", "rb", "s3://important-bucket"], + gcfg=gcfg, + ) + + assert rc == 2 + assert fake_get_creds == [] # never even tried to get credentials + assert fake_subprocess == [] # and definitely never ran the command + + entry = _last_audit_entry(tmp_path) + assert entry["result"] == "blocked" + assert "deny pattern" in entry["detail"] + + +def test_protected_account_id_blocks_regardless_of_command(cfg, fake_get_creds, fake_subprocess): + gcfg = guardrails.GuardrailConfig(protected_account_ids=["111111111111"]) + rc = exec_cmd.run( + cfg, + sso_token=None, + account_alias_or_id="prod", + role="admin", + command=["aws", "s3", "ls"], # innocuous command, but account is protected + gcfg=gcfg, + ) + + assert rc == 2 + assert fake_get_creds == [] + + +def test_confirmation_declined_stops_before_running( + cfg, fake_get_creds, fake_subprocess, monkeypatch, tmp_path +): + gcfg = guardrails.GuardrailConfig( + require_confirmation_patterns=["aws ec2 terminate-instances*"] + ) + monkeypatch.setattr("builtins.input", lambda _: "n") + + rc = exec_cmd.run( + cfg, + sso_token=None, + account_alias_or_id="prod", + role="admin", + command=["aws", "ec2", "terminate-instances", "--instance-ids", "i-123"], + gcfg=gcfg, + ) + + assert rc == 1 + assert fake_get_creds == [] + assert fake_subprocess == [] + assert _last_audit_entry(tmp_path)["result"] == "cancelled" + + +def test_confirmation_accepted_via_prompt_proceeds( + cfg, fake_get_creds, fake_subprocess, monkeypatch +): + gcfg = guardrails.GuardrailConfig( + require_confirmation_patterns=["aws ec2 terminate-instances*"] + ) + monkeypatch.setattr("builtins.input", lambda _: "y") + + rc = exec_cmd.run( + cfg, + sso_token=None, + account_alias_or_id="prod", + role="admin", + command=["aws", "ec2", "terminate-instances", "--instance-ids", "i-123"], + gcfg=gcfg, + ) + + assert rc == 0 + assert fake_get_creds == [("111111111111", "admin")] + assert len(fake_subprocess) == 1 + + +def test_assume_yes_skips_prompt_entirely(cfg, fake_get_creds, fake_subprocess, monkeypatch): + gcfg = guardrails.GuardrailConfig( + require_confirmation_patterns=["aws ec2 terminate-instances*"] + ) + + def _fail_if_called(_): + raise AssertionError("input() should never be called when assume_yes=True") + + monkeypatch.setattr("builtins.input", _fail_if_called) + + rc = exec_cmd.run( + cfg, + sso_token=None, + account_alias_or_id="prod", + role="admin", + command=["aws", "ec2", "terminate-instances", "--instance-ids", "i-123"], + gcfg=gcfg, + assume_yes=True, + ) + + assert rc == 0 + assert len(fake_subprocess) == 1 + + +def test_ordinary_command_runs_with_no_guardrail_config(cfg, fake_get_creds, fake_subprocess): + rc = exec_cmd.run( + cfg, + sso_token=None, + account_alias_or_id="prod", + role="admin", + command=["aws", "sts", "get-caller-identity"], + gcfg=guardrails.GuardrailConfig(), + ) + + assert rc == 0 + assert fake_get_creds == [("111111111111", "admin")] + ((ran_command, env),) = fake_subprocess + assert ran_command == ["aws", "sts", "get-caller-identity"] + + +def test_credentials_placed_in_child_env_and_profile_stripped( + cfg, fake_get_creds, fake_subprocess, monkeypatch +): + monkeypatch.setenv("AWS_PROFILE", "some-other-profile") + + exec_cmd.run( + cfg, + sso_token=None, + account_alias_or_id="prod", + role="admin", + command=["aws", "sts", "get-caller-identity"], + gcfg=guardrails.GuardrailConfig(), + ) + + ((_, env),) = fake_subprocess + assert env["AWS_ACCESS_KEY_ID"] == FAKE_CREDS["AccessKeyId"] + assert env["AWS_SECRET_ACCESS_KEY"] == FAKE_CREDS["SecretAccessKey"] + assert env["AWS_SESSION_TOKEN"] == FAKE_CREDS["SessionToken"] + assert env["AWS_DEFAULT_REGION"] == "us-east-1" + # A long-lived profile from the parent shell must never leak into a + # child process that's supposed to be running under short-lived SSO creds. + assert "AWS_PROFILE" not in env + + +def test_exit_code_from_child_process_is_propagated(cfg, fake_get_creds, monkeypatch): + class _FailingCompletedProcess: + returncode = 137 + + monkeypatch.setattr(exec_cmd.subprocess, "run", lambda command, env: _FailingCompletedProcess()) + + rc = exec_cmd.run( + cfg, + sso_token=None, + account_alias_or_id="prod", + role="admin", + command=["aws", "sts", "get-caller-identity"], + gcfg=guardrails.GuardrailConfig(), + ) + + assert rc == 137 + + +def test_policy_precheck_warns_but_does_not_block_on_denial( + cfg, fake_get_creds, fake_subprocess, monkeypatch, capsys +): + """check_action is advisory-only per the module's own docstring — a + predicted deny must print a warning but still let the real command run. + """ + from orgctl import policy_check + + monkeypatch.setattr( + policy_check, + "resolve_role_arn", + lambda creds, region: "arn:aws:iam::111111111111:role/admin", + ) + monkeypatch.setattr( + policy_check, + "simulate", + lambda role_arn, action, resource: policy_check.PolicyCheckResult( + action=action, resource=resource, decision="explicitDeny", matched_statements=[] + ), + ) + + rc = exec_cmd.run( + cfg, + sso_token=None, + account_alias_or_id="prod", + role="admin", + command=["aws", "s3", "rm", "s3://bucket/key"], + gcfg=guardrails.GuardrailConfig(), + check_action="s3:DeleteObject", + ) + + assert rc == 0 # advisory only — proceeds regardless + assert len(fake_subprocess) == 1 + assert "would be explicitDeny" in capsys.readouterr().err + + +def test_policy_precheck_failure_warns_but_does_not_crash( + cfg, fake_get_creds, fake_subprocess, monkeypatch, capsys +): + from orgctl import policy_check + + def _boom(creds, region): + raise RuntimeError("no IAM permission to simulate") + + monkeypatch.setattr(policy_check, "resolve_role_arn", _boom) + + rc = exec_cmd.run( + cfg, + sso_token=None, + account_alias_or_id="prod", + role="admin", + command=["aws", "s3", "ls"], + gcfg=guardrails.GuardrailConfig(), + check_action="s3:ListBucket", + ) + + assert rc == 0 + assert len(fake_subprocess) == 1 + assert "policy pre-check failed to run" in capsys.readouterr().err