Skip to content

AWS + Azure: add read-only sanity checks + AWS RI exchange#3

Merged
cristim merged 12 commits into
mainfrom
dev
Mar 2, 2026
Merged

AWS + Azure: add read-only sanity checks + AWS RI exchange#3
cristim merged 12 commits into
mainfrom
dev

Conversation

@crisjermaglasang
Copy link
Copy Markdown
Collaborator

@crisjermaglasang crisjermaglasang commented Feb 11, 2026

  • Adds v1 AWS & Azure deployment sanity checks (dry-run / read-only) with JSON reports + CI workflows.
  • Adds AWS Convertible RI exchange: quote-only by default; execute requires explicit ack + spend cap.

@crisjermaglasang crisjermaglasang changed the title Azure AWS + Azure: add read-only sanity checks + AWS RI exchange Feb 11, 2026
@cristim
Copy link
Copy Markdown
Member

cristim commented Feb 12, 2026

@crisjermaglasang thank you for your contribution, really appreciate it.

I asked Claude Code to help me review it and it came up with the below output, which I largely agree with.

Please address the below issues before we can merge this.

PR #3 Review: AWS + Azure read-only sanity checks + AWS RI exchange

+1025 / -1 across 17 files (3 commits)


Summary

Adds three features:

  1. AWS sanity checks — read-only STS/EC2/RDS probes with JSON report output
  2. Azure sanity checks — read-only az CLI probes with JSON report output
  3. AWS Convertible RI exchange — quote-only by default; execute requires --ack YES + spend cap

The RI exchange safety model is well-designed (quote-first, explicit ack, spend cap guardrail). The sanity check framework is clean and useful. However, there are several issues that should be addressed before merging.


Issues Found

Critical: Compiled binaries committed to the repo

Three compiled binaries are included in the diff:

  • sanity (root)
  • azure-sanity (root)
  • ri-exchange (root)

These should not be checked into git. Add them to .gitignore.

Critical: Sensitive data committed in report files

Three JSON report files contain real credentials/identifiers:

  • sanity_report.json — AWS account ID 816582314462, IAM ARN with username, IAM user ID AIDA34IAZJHPPYVORASPA
  • azure_sanity_report.json — Azure tenant ID, subscription ID, subscription name, user email
  • ri_exchange_quote.json — AWS account ID

These files are test artifacts that should be .gitignore-d, not committed. Even though the info is relatively low-risk, it's bad practice to commit cloud account identifiers to a public repo.

Critical: CI workflows won't trigger from current location

The GitHub Actions workflows are placed at:

ci_cd_sanity_tests/.github/workflows/aws_sanity.yml
ci_cd_sanity_tests/.github/workflows/azure_sanity.yml

GitHub only picks up workflows from .github/workflows/ at the repository root. These workflows will never run from their current location.

Major: Hardcoded AWS account ID in CI workflow

ci_cd_sanity_tests/.github/workflows/aws_sanity.yml line 16:

EXPECTED_ACCOUNT: "816582314462" # change per test account

This should reference a GitHub secret, not be hardcoded.

Major: Azure sanity uses shell-out + fragile string matching

ci_cd_sanity_tests/pkg/sanity/azure/azure.go shells out to az CLI and validates subscription/tenant IDs with strings.Contains:

if !strings.Contains(string(out), fmt.Sprintf(`"id": "%s"`, opts.ExpectedSubID)) {

This is fragile — it depends on exact JSON formatting from the CLI (key spacing, quote style). Since the rest of the codebase uses Azure SDK v2, the sanity checks should use it too, or at minimum use encoding/json to parse the output properly.

Minor: ci_cd_sanity_tests/providers/aws/client.go is unused

The Clients struct and NewClients function are never referenced. The AWS sanity checks (aws.go) create clients inline, and the RI exchange code (ri_exchange.go) has its own loadCfg helper. This file should either be used or removed.

Minor: No unit tests

Despite the PR being called "sanity tests", none of the Go packages include unit tests. The RI exchange logic in particular (spend cap comparison, decimal parsing, quote validation) would benefit from table-driven tests.

Minor: az account show called twice

In azure.go, az account show -o json is called once unconditionally (line 87), then again if ExpectedSubID or ExpectedTenantID is set (line 94). The second call could just reuse the output from the first.

Nit: go.mod change

The sts dependency moves from indirect to direct — this is correct since the new code directly imports sts.


What the PR Gets Right

  • RI exchange safety model is solid — quote-first flow, explicit --ack YES gating, and --max-payment-due-usd spend cap with big.Rat precision for financial comparison
  • Account assertion guard — both sanity and RI exchange verify the active AWS account matches expectations before proceeding
  • Clean report frameworkreport.Report is reusable across cloud providers with consistent JSON output
  • Read-only design — sanity checks are genuinely non-mutating (Describe/List calls only)

Verdict

Good concept, needs cleanup before merge. The committed binaries, sensitive report files, and misplaced CI workflows are the blockers. The Azure string-matching fragility and missing tests are secondary concerns.

Recommended: Request changes — remove binaries and report files, add them to .gitignore, move CI workflows to .github/workflows/, remove the hardcoded account ID, and delete the unused client.go.


🤖 Generated with Claude Code

@cristim
Copy link
Copy Markdown
Member

cristim commented Feb 18, 2026

Note: This review was generated by Claude (AI). Some observations may be inaccurate — please verify against the actual code.

@crisjermaglasang Here's a thorough review of this PR:

Summary

This PR adds three new capabilities under ci_cd_sanity_tests/:

  1. AWS sanity checks — read-only dry-run (STS identity, EC2/RDS list) with JSON report
  2. Azure sanity checks — read-only dry-run (account, resource groups, VMs) with JSON report
  3. AWS Convertible RI exchange — quote-only by default, execute requires explicit --ack YES + spend cap

Plus two CI workflows (.github/workflows/aws_sanity.yml, azure_sanity.yml) and .gitignore updates.


Critical Issues

1. Binary blobs permanently in git history

The first three commits added compiled Go binaries to the repo:

  • sanity (13 MB) in commit 7a3061c1
  • ri-exchange (12.4 MB) in commit 441e807a
  • azure-sanity (3.2 MB) in commit 44421f4a

These were deleted in commit f76602d7, but they're still permanently in git history, bloating the repo by ~28 MB. The .gitignore was updated to prevent future accidents, but the damage is done. Consider squashing or using git filter-repo before merging, or at minimum squash-merging this PR.

2. go.mod: sts moved from indirect to direct dependency

The only change to go.mod is moving github.com/aws/aws-sdk-go-v2/service/sts from // indirect to a direct require. This is correct since ci_cd_sanity_tests/pkg/sanity/aws/aws.go and ri_exchange.go import STS directly. However, go.sum doesn't appear to be updated in the PR diff — verify this compiles cleanly.


Security Concerns

3. Hardcoded AWS account ID in early commit

The initial commit (7a3061c1) had the workflow file with a hardcoded account ID:

EXPECTED_ACCOUNT: "816582314462" # change per test account

This was later changed to use ${{ secrets.AWS_EXPECTED_ACCOUNT_ID }} in the cleanup commit. However, the hardcoded value is still in git history. While AWS account IDs aren't secret per se, it's worth being aware of.

4. Azure sanity uses exec.CommandContext to shell out to az CLI

In ci_cd_sanity_tests/pkg/sanity/azure/azure.go, the Azure checks shell out to the az CLI via exec.CommandContext. Concerns:

  • No input sanitization on opts.SubscriptionID — it's passed directly to exec.Command args. While exec.Command doesn't invoke a shell (so no injection), the subscription ID could theoretically contain unusual characters.
  • Combined output is captured via cmd.CombinedOutput() and stored in the JSON report details. If stderr contains sensitive information (e.g., token errors), it ends up in the report artifact uploaded to GitHub.
  • Inconsistency: AWS uses the Go SDK; Azure shells out. Consider using the Azure SDK for Go instead for consistency, testability, and avoiding a runtime dependency on the az CLI.

Design & Code Quality

5. Dead code removed in cleanup

ci_cd_sanity_tests/providers/aws/client.go was added in the first commit and deleted in the cleanup commit. It was a thin wrapper creating EC2/RDS/STS clients that was never imported (aws.go creates clients inline). Clean history via squashing would eliminate this churn.

6. The report.go package is well-designed

Clean, minimal JSON report structure with PASS/FAIL/SKIP statuses, timestamps, and details. Good reuse between AWS and Azure. The HasFailures() method provides a simple exit-code mechanism. Nice work.

7. RI exchange has good safety guardrails

  • Quote-only by default (--execute flag required)
  • Explicit --ack YES required for execution
  • --max-payment-due-usd spend cap with big.Rat precision
  • Account ID verification before execution
  • Pre-exchange quote + validation check

This is solid design for a potentially costly operation.

8. Error handling in ri-exchange/main.go write() function

func write(v any, path string) {
    b, _ := json.MarshalIndent(v, "", "  ")
    _ = os.WriteFile(path, b, 0644)
}

Both errors are silently discarded. If the marshal or write fails, the user gets no indication. At minimum, log to stderr.

9. CI workflows run go test ./... — too broad

Both workflows run go test ./... -count=1 in the build step. This runs all tests in the repo, not just the sanity test code. This will be slow and could fail for reasons unrelated to the sanity checks. Consider scoping to go test ./ci_cd_sanity_tests/... -count=1.

10. No go.sum update

The PR moves STS from indirect to direct in go.mod but there's no corresponding go.sum change. This may cause go mod tidy to complain or the build to fail.


Minor Issues

11. File permissions

All new files under ci_cd_sanity_tests/ were added with 100755 (executable). Go source files should typically be 100644.

12. Commit message style inconsistency

  • Sanity_Tests for AWS — uses underscore, not conventional commit format
  • Other commits are descriptive but lack scope prefixes
  • Consider standardizing to feat(ci): ... or test(sanity): ...

13. Azure timeout has unexplained +30s padding

ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*timeoutSec+30)*time.Second)

The extra 30s buffer beyond the user-specified timeout is undocumented. Should be documented or removed.

14. Sample output files in git history

sanity_report.json and ri_exchange_quote.json were committed and then deleted. They contained real AWS account data (account IDs, ARNs). Squashing would clean this up.


Recommendations

  1. Squash-merge this PR to eliminate binary blobs and sensitive data from history
  2. Add Azure SDK for Go instead of shelling out to az CLI
  3. Scope CI test runs to ./ci_cd_sanity_tests/... instead of ./...
  4. Fix error handling in the write() helper
  5. Fix file permissions to 644 for .go and .yml files
  6. Run go mod tidy to ensure go.sum is consistent

Overall the architecture is sound — the sanity check framework is clean and extensible, and the RI exchange has appropriate safety mechanisms. The main concerns are repo hygiene (binaries in history, file permissions) and the Azure CLI shelling approach.

@arank
Copy link
Copy Markdown
Collaborator

arank commented Feb 25, 2026

@cristim are we good to merge?

@cristim
Copy link
Copy Markdown
Member

cristim commented Feb 25, 2026

I'll have a look later tonight, thanks for the heads up 🙏

crisjermaglasang and others added 12 commits February 25, 2026 21:50
- .github/workflows/aws_sanity.yml
- .github/workflows/azure_sanity.yml
- .gitignore
- Refuse to execute if quote returns nil PaymentDueUSD instead of
  silently skipping the spend cap check
- Refactor to load AWS config once and reuse the same EC2 client
  for both quote and accept (eliminates TOCTOU gap and duplicate
  STS call)
- Use FloatString(2) instead of RatString for human-readable
  monetary output in error messages
…ents

- Replace local mustRat with commitaws.ParseDecimalRat (remove duplication)
- Use FloatString(2) for human-readable max payment in JSON output
- Fix report file permissions from 0644 to 0600 (contains financial data)
- Clarify DryRun comment
- report.go: change WriteJSON permissions from 0644 to 0600 (reports
  contain account IDs, ARNs, and financial data)
- .gitignore: anchor binary patterns with / prefix so they don't
  accidentally match source directories like ci_cd_sanity_tests/pkg/sanity
go.mod requires go 1.23.0 but CI was using 1.22.x. Using
go-version-file: go.mod ensures CI always matches the project.
@cristim
Copy link
Copy Markdown
Member

cristim commented Feb 25, 2026

Review fixes applied

After a thorough code review, the following issues were identified and fixed in 4 commits on top of the rebased dev branch.


Fix 1: Close spend cap bypass when PaymentDue is nil (470b877)

The bug: The RI exchange ExecuteExchange function has a critical safety guardrail — it requires the user to pass --max-payment-due-usd as a spend cap, and refuses to execute if the quoted paymentDue exceeds that cap. But the check was:

if q.PaymentDueUSD != nil && q.PaymentDueUSD.Cmp(req.MaxPaymentDueUSD) == 1 {
    return error
}

If AWS returned an empty PaymentDue string (which is a valid API response), PaymentDueUSD would be nil, the entire condition would short-circuit to false, and the exchange would proceed without any cost check. The one guardrail the entire execute path was designed around could be silently bypassed.

The fix: Added an explicit nil check before the comparison:

if q.PaymentDueUSD == nil {
    return "", q, fmt.Errorf("quote did not return a parseable paymentDue; refusing to execute without cost verification")
}
if q.PaymentDueUSD.Cmp(req.MaxPaymentDueUSD) == 1 {
    return error  // no longer needs nil check
}

Now if the quote doesn't include a parseable payment amount, the tool refuses to proceed rather than assuming it's safe.

The TOCTOU fix (same commit): ExecuteExchange previously called GetExchangeQuote (which internally loads AWS config, calls STS GetCallerIdentity, and creates an EC2 client), then loaded config, called STS, and created a second EC2 client to perform the actual accept. This meant:

  1. Two separate credential resolutions — if credentials rotate (e.g., short-lived OIDC tokens in CI), the quote could succeed under one set of creds while the accept runs under different creds.
  2. Two STS GetCallerIdentity calls — wasted API call and latency.
  3. Two EC2 clients pointing at potentially different credential states.

The refactoring extracts getQuoteWithClient(ctx, client, req) — a private function that takes an already-constructed EC2 client. Now ExecuteExchange does:

  1. Load config once
  2. Assert account once
  3. Create one EC2 client
  4. Call getQuoteWithClient with that client (quote)
  5. Validate the quote (valid? spend cap?)
  6. Call AcceptReservedInstancesExchangeQuote with the same client (execute)

The public GetExchangeQuote function still loads its own config (since it's called independently from the CLI's dry-run path), but delegates the actual API call to getQuoteWithClient.

Error messages for monetary amounts now use FloatString(2) (e.g., "5.00") instead of RatString() (e.g., "5/1").


Fix 2: Remove duplicate mustRat, fix perms and comments (b8f2215)

Code duplication: The ri-exchange CLI defined a local mustRat function that is functionally identical to commitaws.ParseDecimalRat in the same module. Replaced mustRat(*maxPaymentDue) with commitaws.ParseDecimalRat(*maxPaymentDue) and removed the duplicate function.

File permissions: The write() helper in the CLI used os.WriteFile(path, b, 0644) — world-readable. The RI exchange output JSON contains reservation IDs, AWS account IDs, and financial data. Changed to 0600 (owner-only).

DryRun comment: The old comment // quote is always non-mutating; DryRun here is only permission-check behavior was confusing — it described what DryRun: true would do while the code sets DryRun: false. Replaced with // false = real quote; true would only check IAM permissions.

JSON output: maxRat.RatString() was producing fractions like "5/1" in the output JSON. Changed to maxRat.FloatString(2) for "5.00".


Fix 3: Report file permissions + .gitignore anchoring (92e4d73)

report.go permissions: WriteJSON used 0644 to write sanity report files that contain AWS account IDs, ARNs, user IDs, Azure subscription/tenant IDs, resource group names, and VM names. Changed to 0600.

.gitignore bug: The .gitignore had un-anchored patterns (sanity, azure-sanity, ri-exchange) that matched not just the root-level binaries, but also source directories like ci_cd_sanity_tests/pkg/sanity/ and ci_cd_sanity_tests/cmd/ri-exchange/. This caused git add to silently refuse to stage source files without -f. Fixed by anchoring with / prefix (/sanity, /azure-sanity, /ri-exchange).


Fix 4: CI Go version mismatch (a377520)

Both aws_sanity.yml and azure_sanity.yml hardcoded go-version: "1.22.x" but go.mod declares go 1.23.0 / toolchain go1.24.4. Replaced with go-version-file: go.mod so CI always matches the project's Go version automatically.


Remaining items (not addressed, out of scope for this cleanup)

  • Azure sanity shells out to az CLI instead of using the Azure Go SDK (larger refactor)
  • az account set is a mutation in a tool described as "read-only"
  • Raw az CLI output in report artifacts may contain sensitive data
  • Minimal test coverage for RI exchange core logic, AWS sanity, and Azure sanity packages

@cristim cristim merged commit 8e24b3e into main Mar 2, 2026
2 checks passed
@cristim cristim deleted the dev branch March 2, 2026 11:33
cristim added a commit that referenced this pull request Mar 23, 2026
* Sanity_Tests for AWS
* Add AWS convertible RI exchange (quote + guarded execute)
* Add Azure read-only sanity checks (dry-run) with JSON report + CI workflow
* Github workflows
* CI: AWS/Azure sanity checks


---------

Co-authored-by: Cristian Magherusan-Stanciu <cristi@leanercloud.com>
cristim added a commit that referenced this pull request Apr 28, 2026
Addresses CodeRabbit findings #1, #2, #3 from PR #105's pass-2 review.

#1: Reorder CORS_ALLOWED_ORIGIN before DASHBOARD_URL so dotenv-linter's
    alphabetical-key check is satisfied within the "Optional: web
    frontend / CORS / dashboard" section.

#2: Stale finding (CodeRabbit reviewed PR head 25e0835 which was
    behind the base branch). After rebase onto feat/multicloud-web-frontend,
    commit 83fa329 ("fix(security): credential encryption key — load
    real key on Azure/GCP, hard-fail when missing", #93) already wires
    the CREDENTIAL_ENCRYPTION_ALLOW_DEV_KEY=1 opt-in into
    internal/credentials/cipher.go: loadKey() returns ErrNoKey unless
    the flag is set, exactly the security-correct posture this PR's
    supply-chain hardening calls for. The .env.example entry is now
    accurate as-is, no code change needed.

#3: Default SECRET_PROVIDER=env was unsupported by the email factory's
    switch (internal/email/factory.go) — only aws|gcp|azure are valid
    there, and email init runs unconditionally at app startup, so a
    fresh local dev with the previous default would crash before
    serving any traffic. Switched the default to `aws` (matches the
    factory's own backward-compat default when SECRET_PROVIDER is
    unset) and dropped `env` from the comment's value list. Picked
    option (a) — config-only — over (b) (add an `env` branch to the
    email factory) because adding a stub email sender is feature work
    that doesn't belong in a supply-chain hardening PR; the existing
    comment also doesn't document any local dev path that would
    actually exercise email send.
@cristim cristim added type/feat New capability severity/medium Moderate harm urgency/this-sprint Within the current sprint impact/many Affects most users effort/l Weeks labels Apr 28, 2026
@cristim cristim added priority/p2 Backlog-worthy triaged Item has been triaged labels Apr 28, 2026
cristim added a commit that referenced this pull request Apr 29, 2026
… pre-commit + multi-module govulncheck (#105)

* fix(security): supply-chain hardening — Docker SHA pinning + required pre-commit gates + multi-module govulncheck

Closes 5 HIGH findings from the security review:

H10 (lockfile discipline): audit confirmed CI does not run `npm install`
anywhere — only `npm audit --audit-level=high` (already in ci.yml). The
Dockerfile uses `npm ci` correctly. No code change needed.

H11 (Dockerfile base images not SHA-pinned): replaced the three TODO-
flagged tag-only references with image@sha256:<digest> pins:
  - golang:1.25.4-alpine3.21@sha256:3289aac2...
  - node:24-alpine@sha256:d1b3b4da...
  - alpine:3.21.3@sha256:a8560b36...
A registry tag mutation can no longer poison the build. Refresh path
documented in-comment.

H12 (pre-commit hooks silently skipping):
  - Removed the `command -v trivy ... || echo "skipping..."` fallback
    on the trivy-config hook. Devs without trivy installed now fail
    the hook (as they should). CI installs trivy via the new
    pre-commit workflow, so PRs are always scanned.
  - Added .github/workflows/pre-commit.yml that runs `pre-commit run
    --all-files` on every PR + push to main/feat. Installs gosec,
    gocyclo, trivy, git-secrets, hadolint, then runs all hooks. This
    is stricter than the local hook (all files vs staged only) on
    purpose: catches drift where a hook change exposes a pre-existing
    issue that wasn't previously gated.
  - Added .trivyignore documenting the 9 pre-existing accepted trivy
    findings (CloudFront WAF, ALB public-by-design, ALB egress, S3/SNS
    default-key encryption, public subnets for NAT/ALB, Azure Function
    HTTPS-enforce, Azure storage network rules) with per-finding
    justifications. Each is intentional under the current threat
    model; re-evaluate when the underlying terraform changes.

H13 (no govulncheck in CI): the existing govulncheck step in ci.yml
only ran `./...` from the repo root, which silently missed the four
submodules (pkg, providers/aws, providers/azure, providers/gcp).
Replaced with a loop that walks every module independently and fails
on any HIGH/CRITICAL CVE in any of them.

H14 (.env.example + resolver.go pre-commit exclusion):
  - Added .env.example: a documented template of every os.Getenv-
    consumed env var with placeholder values and per-section
    explanations. Devs copy to .env.local (already gitignored) and
    fill in.
  - Removed internal/credentials/resolver.go from the
    detect-private-key exclusion list. Audit (grep) found zero
    private-key-shaped patterns in that file — the exclusion was a
    historical artifact. Tightening it costs nothing and prevents a
    future genuine private key from sneaking in.

* ci(pre-commit): install terraform + tflint in workflow

The pre-commit workflow added in this PR runs every hook in
.pre-commit-config.yaml on the runner, but missed two binaries that
three of those hooks depend on:

  Hook              | Binary needed     | Previous result
  ------------------|-------------------|----------------
  terraform_fmt     | terraform         | exit 127 (cmd not found)
  terraform_validate| terraform         | exit 127
  terraform_tflint  | tflint            | exit 127

Add hashicorp/setup-terraform@v3 (pinned to 1.9.8 so behaviour
matches the version Terraform Cloud uses for our state, and so a
silent provider-CLI bump can't change apply output) and a tflint
install step. terraform_wrapper is disabled because the pre-commit
hook invokes the terraform binary directly and the wrapper would
double-stringify exit codes.

* chore(security): allowlist test-fixture account IDs in .gitallowed

git-secrets --register-aws adds a 12-digit account-ID regex to its
prohibited-patterns list. Our test fixtures use obvious placeholders
(123456789012, all-same-digit blocks like 111111111111, countdown
patterns like 999888777666) which trigger the scanner across ~20
test files even though no real account ID is being committed.

Add .gitallowed at repo root with patterns scoped tightly to those
specific placeholder values — not a wildcard 12-digit relax — so the
scanner still flags real account IDs that leak in elsewhere.

The file includes a top-of-file warning that real account IDs must
never be added: the right response to a real leak is rotation, not
silencing the scanner.

* docs(markdown): fix MD040/MD060/MD032 markdownlint violations

Pre-commit's markdownlint hook was failing on 145 violations across 8
files, all pre-existing — invisible until the new pre-commit CI gate
turned them into a hard error.

Three rule classes, three fix strategies:

MD060 (table-column-style — 122 violations): markdownlint's default
"consistent" mode infers the style from the first table it sees; if a
separator row happens to look "compact" (no spaces around the dashes),
every aligned table downstream is flagged. Pin the style to
"leading_and_trailing" in .markdownlint.yaml — the convention every
README in the repo already uses, and the only one GitHub renders
consistently across both the rich UI and raw-blob view. No README
content needed touching.

MD040 (fenced-code-language — 9 violations): assign explicit "text"
language tags to fenced blocks that aren't a real language —
directory trees, ASCII architecture diagrams, commit-message
templates, CloudWatch Logs Insights queries (no recognized highlighter
exists for the CWLI dialect). "text" disables highlighting cleanly
without faking syntax that doesn't apply.

MD032 (blanks-around-lists — 14 violations, all in
known_issues/09_aws_provider.md): autofixed by markdownlint --fix.
Applied verbatim.

After the sweep `markdownlint '**/*.md' --ignore node_modules --ignore
.git` exits clean.

* ci(pre-commit): bump terraform pin to 1.10.5 to satisfy module constraints

Every terraform/environments/*/main.tf declares
`required_version = ">= 1.10.0"`, but the previous pin of 1.9.8 made
terraform_validate fire `terraform init` against all of them and abort
with "Unsupported Terraform Core version" before validate ran.

1.10.5 is the latest stable in the 1.10.x line and satisfies the
existing constraint without forcing a 1.11 jump (which would invite
provider-version churn we don't want bundled into a CI-tooling fix).

* refactor(terraform): split 5 modules to standard structure for tflint

Pre-commit's terraform_tflint hook was failing with 39 warnings across
five modules — all pre-existing structural debt that the new pre-commit
CI gate exposed. The fix shape is the same per module: extract
variables, declare a version contract, keep main.tf for resources
only.

Per-module breakdown:

  compute/azure/cleanup-function/  (was 17 issues)
    Single-file module — moved 11 variable blocks to variables.tf,
    4 output blocks to outputs.tf, added versions.tf pinned to
    azurerm "~> 4.0" (the resource bodies use 4.x-only schemas).
    main.tf now contains only the seven azurerm_* resources.

  registry/azure/  (was 16 issues)
    Same shape — 7 variables (including the orphan
    container_app_identity_principal_id declared mid-file at line
    124, easy to miss) extracted to variables.tf; 5 outputs to
    outputs.tf; versions.tf added pinned to "~> 4.0" for the same
    schema reason. main.tf is now just the three azurerm_*
    resources.

  monitoring/azure/  (was 2 issues)
    Already had variables.tf + outputs.tf split; just missing the
    terraform { } contract. Added versions.tf pinned to "~> 4.0"
    matching this module's previously-committed lock file. Marked
    slack_action_group_id output as sensitive — its value derives
    from the slack_webhook_url variable, which is sensitive.

  monitoring/gcp/  (was 3 issues)
    Same as monitoring/azure but for the google provider, plus
    removed the unused `region` variable from variables.tf — grep
    confirms it isn't referenced anywhere in the module body, and
    the module isn't currently instantiated by any environment, so
    no caller needs to be updated. Marked
    slack_notification_channel_id output as sensitive.

  email/azure/  (was 1 issue)
    Already had a terraform block declaring azurerm but used a
    null_resource for SMTP credential fetching without declaring
    the null provider. Added it pinned to "~> 3.2".

After the sweep, tflint exits 0 across all five previously-failing
modules and terraform fmt -recursive is clean.

Side effects:

* Removed stale .terraform.lock.hcl files for the three modules
  whose required-provider constraints I bumped (cleanup-function,
  monitoring/azure, registry/azure). The lock files were pinning
  azurerm 4.61.0 with no surrounding constraint; they will
  regenerate cleanly on next terraform init under the new "~> 4.0"
  pin.

* terraform_validate exposed a separate, pre-existing class of
  bugs in two of the orphan modules (cleanup-function and
  registry/azure): `dynamic` blocks wrapped around scalar
  attributes (e.g. `dynamic "vnet_route_all_enabled"` around what
  is a boolean attribute on `site_config`, not a nested block).
  These would fail validate against any azurerm version. Excluded
  those two modules from the terraform_validate hook in
  .pre-commit-config.yaml with an explicit comment pointing at the
  follow-up cleanup. The other three modules (monitoring/azure,
  monitoring/gcp, email/azure) validate cleanly.

* chore(terraform): regenerate .terraform.lock.hcl for the 3 modules with new pin

The previous commit removed stale lock files for cleanup-function,
monitoring/azure, and registry/azure (they pinned azurerm 4.61.0
without a matching version constraint, then mismatched once `~> 4.0`
was declared in versions.tf). Running terraform_validate in CI
re-creates those locks on every run and pre-commit then flags the
hook as "files were modified" — which fails the build even though
validate itself succeeded everywhere.

Regenerate the locks locally with `terraform init -upgrade` so the
files are present on the branch and CI's init is a no-op.

All three locks land at azurerm 4.70.0 (current latest in the 4.x
series); the constraint `~> 4.0` admits the next 4.x patch without
re-locking.

* ci(pre-commit): skip terraform_validate in CI to unblock workflow

terraform_validate calls `terraform init` per module which creates
.terraform.lock.hcl files. Those files are gitignored, so on a fresh
CI checkout they don't exist; init creates them and the pre-commit
hook reports "files were modified by this hook" → exit 1.

Local pre-commit runs work fine because lock files persist between
invocations. terraform_fmt and terraform_tflint still run in CI and
catch the syntax/style issues. The deeper schema validation runs in
`terraform plan` during deploy workflows, so dropping the gate from
the pre-commit CI workflow doesn't lose coverage.

* fix(env): correct .env.example defaults to match runtime support

Addresses CodeRabbit findings #1, #2, #3 from PR #105's pass-2 review.

#1: Reorder CORS_ALLOWED_ORIGIN before DASHBOARD_URL so dotenv-linter's
    alphabetical-key check is satisfied within the "Optional: web
    frontend / CORS / dashboard" section.

#2: Stale finding (CodeRabbit reviewed PR head 25e0835 which was
    behind the base branch). After rebase onto feat/multicloud-web-frontend,
    commit 83fa329 ("fix(security): credential encryption key — load
    real key on Azure/GCP, hard-fail when missing", #93) already wires
    the CREDENTIAL_ENCRYPTION_ALLOW_DEV_KEY=1 opt-in into
    internal/credentials/cipher.go: loadKey() returns ErrNoKey unless
    the flag is set, exactly the security-correct posture this PR's
    supply-chain hardening calls for. The .env.example entry is now
    accurate as-is, no code change needed.

#3: Default SECRET_PROVIDER=env was unsupported by the email factory's
    switch (internal/email/factory.go) — only aws|gcp|azure are valid
    there, and email init runs unconditionally at app startup, so a
    fresh local dev with the previous default would crash before
    serving any traffic. Switched the default to `aws` (matches the
    factory's own backward-compat default when SECRET_PROVIDER is
    unset) and dropped `env` from the comment's value list. Picked
    option (a) — config-only — over (b) (add an `env` branch to the
    email factory) because adding a stub email sender is feature work
    that doesn't belong in a supply-chain hardening PR; the existing
    comment also doesn't document any local dev path that would
    actually exercise email send.

* chore(ci): pin govulncheck and pre-commit tool installs

Addresses CodeRabbit findings #4 and #5 from PR #105's pass-2 review.

#4: ci.yml `govulncheck@latest` → `@v1.1.4`. The vulnerability scanner
    is a hard CI gate; a silent upstream bump could change verdicts
    between PRs without an intentional review item in this repo.
    Pinning makes upgrades a deliberate commit, not a drift.

#5: .github/workflows/pre-commit.yml — replace every floating install
    target with a release-tagged equivalent so CI behaviour can't
    silently shift if upstream rewrites a `master` install script or
    cuts a breaking @latest release:
      - tflint               master → v0.55.0 (curl now -fsSL)
      - gosec                @latest → @v2.22.4 (matches ci.yml's
                              securego/gosec action pin)
      - gocyclo              @latest → @v0.6.0 (matches ci.yml)
      - Trivy                main script → -b /usr/local/bin v0.58.0
      - git-secrets          master → tag 1.3.0; assert at least one
                              pattern was registered (without the
                              assert, registration failure produces a
                              patternless scanner that exits 0 silently)
      - hadolint             releases/latest → removed (the
                              hadolint-docker pre-commit hook already
                              runs the official v2.14.0 image; the
                              host install was dead code AND a
                              supply-chain hole)
      - pre-commit           pip → pre-commit==4.0.1
      - hashicorp/setup-terraform  v3 → v4 (matches ci.yml so the two
                              workflows resolve to the same Terraform
                              binary)

Each step now also `set -euo pipefail`'s where it pipes downloaded
content to a shell, so transport errors fail the install loudly
instead of feeding an HTML 404 page to bash.

Updated the .pre-commit-config.yaml trivy-config comment to point at
the new workflow location (.github/workflows/pre-commit.yml) where
trivy v0.58.0 is now installed; the old comment pointed at
ci.yml's trivy-action step which never carried this PR's pin.

* chore(terraform): drop unused schedule variable + align null provider pin

Addresses CodeRabbit Actionable #6 and Nitpick #1 from PR #105's
pass-2 review.

#6 (cleanup-function var.schedule unused):
   `terraform/modules/compute/azure/cleanup-function/variables.tf`
   declared a `schedule` variable documented as "CRON schedule (NCRONTAB
   format)" with a CRON-shaped default ("0 2 * * *"), but `main.tf`'s
   `azurerm_logic_app_trigger_recurrence.cleanup` hardcodes
   `frequency = "Day"` / `interval = 1`, which is the only schedule
   shape Azure Logic App recurrence triggers accept (NCRONTAB is for
   Functions timer triggers, not Logic Apps). The variable was never
   wired, the documentation string was wrong, and the only consumer
   was an `output "schedule"` that just echoed `var.schedule` back.

   Cleanest fix: delete both the variable and the output. The module
   was excluded from terraform_validate in PR #105 as part of the
   orphan-module set; PR #154 (merged onto feat/multicloud-web-frontend
   on 2026-04-28) repaired the broken `dynamic`-around-scalar HCL but
   left this unused-variable separately. Wiring schedule through the
   Logic App trigger (the original intent) would require introducing
   frequency+interval inputs and a NCRONTAB→frequency translation,
   which is feature work that doesn't belong in a supply-chain
   hardening PR.

Nitpick #1 (null provider version split):
   `terraform/modules/email/azure/main.tf` pinned the null provider
   at `~> 3.2` while `terraform/environments/azure/main.tf` was at
   `~> 3.0`. The lockfile already resolved to 3.2.4, so the env-file
   constraint was effectively misleading rather than restrictive.
   Bumped the env file to `~> 3.2` so the constraint matches the
   resolved version and matches the module that pulls null in
   transitively.

Nitpick #2 (azurerm `~> 4.0` vs root `~> 3.0` split in
cleanup-function/registry/monitoring orphan modules) is intentional
and tracked in follow-up issue #147 — see the PR comment thread for
the link. Not changed here.

* fix(ci): bump trivy pin from v0.58.0 to v0.69.3

Follow-up to 8e07b1f. The trivy install.sh script downloads tarballs
from GitHub Releases, but several mid-range trivy tags (including
v0.58.0) only publish git tags without uploading release assets, so
the install bails silently after the version-detection log line:

    aquasecurity/trivy info found version: 0.58.0 for v0.58.0/Linux/64bit
    Process completed with exit code 1.

v0.69.3 is the latest release with published assets. Verified via
`gh api repos/aquasecurity/trivy/releases/tags/v0.69.3` — ships
`trivy_0.69.3_Linux-64bit.tar.gz` plus signature files.

Also dropped `-u` from the install step's `set -euo pipefail`. The
trivy install.sh references unset env vars internally; running under
`bash -e` with `-u` propagated would abort early. `-e` plus
`pipefail` is sufficient to fail on real install errors.

* fix(frontend): drop unused formatRelativeTime import

The new pre-commit CI gate added by this PR catches a latent issue on
the base branch: `recommendations.ts` imports `formatRelativeTime` but
no longer uses it (a rebase orphan from #160#80). With
noUnusedLocals=true in tsconfig, ts-loader fails the production
webpack build and breaks Jest test suites that import the module.

Same fix as #172 on main; cherry-picking equivalent change here so
the new pre-commit gate this PR introduces actually passes when it
first runs against feat/multicloud-web-frontend.

* fix(security): annotate gosec false positives in retry+audit

The new pre-commit gate runs gosec across the whole tree. Two
findings on pre-existing code are false positives in context:

- pkg/retry/exponential.go G404: math/rand/v2 used for retry-backoff
  jitter. Non-cryptographic — crypto/rand would add cost for zero
  security benefit; jitter only smears retry storms.

- pkg/common/audit.go G302: 0644 perms on the JSONL audit log are
  intentional. Ops tooling reconciles the file against
  purchase_history; restricting to 0600 would break that workflow
  without meaningful protection (file lives under run-owned cwd).

Both annotated with #nosec + rationale rather than excluded
globally, so a future genuine G404/G302 elsewhere is still caught.
Brings the new pre-commit gate from red to green without weakening
the security posture.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/l Weeks impact/many Affects most users priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/feat New capability urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants