Terraform AWS ECR registry scanning and a Rust census of the scan that actually applies
The repository says scan on push. The registry decides. Declare the registry rule, then ask AWS which rule each repository got.
<!-- hal:authoritative:yaml -->
The repository says scan on push. The registry decides whether anything scans. Declare the registry rule in Terraform, then ask AWS which rule each repository actually got.
§I. Frame
Amazon ECR used to take its scan setting from each repository: scanOnPush = true on the repository, and pushes got a basic scan. AWS moved that decision up one level. The containers blog states it plainly: the repository-level scan-on-push feature "has now been deprecated in favor of registry-level scan configurations." A registry has one scanning configuration per region, with a scan type (BASIC or ENHANCED) and rules that match repositories by name filter.
The Terraform provider did not remove the old block. Against hashicorp/aws v6.66.0 on the lab Mac, terraform providers schema -json still lists image_scanning_configuration { scan_on_push = bool } on aws_ecr_repository, marked required inside the block and carrying no deprecation flag. So a module can set the old flag, pass validate, and still leave a repository at MANUAL.
The problem for today: declare ECR the current way in Terraform, then build a read-only Rust census that reports the scan frequency each repository actually has, along with tag mutability and lifecycle coverage.
§II. Three settings, three owners
Scan Owner (named technique). Ask which resource owns a behavior before asking what value it has. For ECR in 2026 the answer splits three ways:
- Scanning belongs to the registry:
aws_ecr_registry_scanning_configuration. WithBASIC, repositories that match aSCAN_ON_PUSHrule scan on push. Every other repository falls toMANUAL, per the ECR filter docs. - Tag mutability belongs to the repository:
image_tag_mutability = "IMMUTABLE"makes a pushed tag permanent, sov1.4.2cannot be silently repointed. - Retention belongs to a separate resource:
aws_ecr_lifecycle_policy, a JSON rule list attached by repository name.
If you mix up the owners, a green plan will describe a posture AWS does not enforce.
§III. The Terraform
This file passed terraform fmt -check and terraform validate on the lab Mac (Terraform 1.14.3, hashicorp/aws v6.66.0, init -backend=false). It was not planned or applied, because no AWS credentials were used this fire.
terraform {
required_version = ">= 1.9"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 6.0" }
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_kms_key" "ecr" {
description = "ECR image encryption"
enable_key_rotation = true
}
# Registry-wide scanning: the only scan setting AWS still honors.
resource "aws_ecr_registry_scanning_configuration" "this" {
scan_type = "BASIC"
rule {
scan_frequency = "SCAN_ON_PUSH"
repository_filter {
filter = "prod-*"
filter_type = "WILDCARD"
}
}
}
resource "aws_ecr_repository" "api" {
name = "prod-api"
image_tag_mutability = "IMMUTABLE"
encryption_configuration {
encryption_type = "KMS"
kms_key = aws_kms_key.ecr.arn
}
}
resource "aws_ecr_lifecycle_policy" "api" {
repository = aws_ecr_repository.api.name
policy = jsonencode({
rules = [
{
rulePriority = 1
description = "expire untagged after 14 days"
selection = {
tagStatus = "untagged"
countType = "sinceImagePushed"
countUnit = "days"
countNumber = 14
}
action = { type = "expire" }
},
{
rulePriority = 2
description = "keep last 50 release images"
selection = {
tagStatus = "tagged"
tagPatternList = ["v*"]
countType = "imageCountMoreThan"
countNumber = 50
}
action = { type = "expire" }
}
]
})
}
Four decisions are visible here:
- The repository carries no
image_scanning_configurationblock. Theprod-*rule coversprod-api, and leaving the old block out means nobody reads it as the control. - The registry scanning configuration is a singleton per region. Two root modules that both declare it will fight each other on every apply. Keep it in one platform stack.
jsonencodebuilds the lifecycle document from HCL values, so a missing comma becomes a plan-time type error instead of a runtime API rejection.- Credentials stay out of the
providerblock. Brikman is blunt about this (ch. 6, PDF p. 312): hardcoded keys in a provider block are "not secure" and pin every user to one identity.
§IV. The census, in one cargo script
ecr-scan-census.rs sits in this bundle. It is a single file with its Cargo manifest in frontmatter, run with cargo +nightly -Zscript:
#!/usr/bin/env -S cargo +nightly -Zscript
---
[package]
edition = "2024"
[dependencies]
aws-config = { version = "1", features = ["behavior-version-latest"] }
aws-sdk-ecr = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
---
use aws_config::BehaviorVersion;
use aws_sdk_ecr::{Client, Error};
#[derive(Debug)]
struct RepoRow {
name: String,
mutability: String,
frequency: String,
filters: Vec<String>,
lifecycle: bool,
}
async fn repo_names(client: &Client) -> Result<Vec<(String, String)>, Error> {
let mut out = Vec::new();
let mut repos = client.describe_repositories().into_paginator().items().send();
while let Some(repo) = repos.next().await {
let repo = repo?;
let name = repo.repository_name().unwrap_or("?").to_string();
let mutability = repo.image_tag_mutability().map_or("?", |m| m.as_str()).to_string();
out.push((name, mutability));
}
Ok(out)
}
async fn has_lifecycle(client: &Client, name: &str) -> Result<bool, Error> {
match client.get_lifecycle_policy().repository_name(name).send().await {
Ok(_) => Ok(true),
Err(e) if e.as_service_error().is_some_and(|s| s.is_lifecycle_policy_not_found_exception()) => Ok(false),
Err(e) => Err(e.into()),
}
}
async fn census(client: &Client) -> Result<Vec<RepoRow>, Error> {
let repos = repo_names(client).await?;
let mut rows = Vec::new();
// BatchGetRepositoryScanningConfiguration takes at most 25 names per call.
for batch in repos.chunks(25) {
let names: Vec<String> = batch.iter().map(|(n, _)| n.clone()).collect();
let resp = client
.batch_get_repository_scanning_configuration()
.set_repository_names(Some(names))
.send()
.await?;
for cfg in resp.scanning_configurations() {
let name = cfg.repository_name().unwrap_or("?").to_string();
let mutability = batch
.iter()
.find(|(n, _)| *n == name)
.map_or("?".to_string(), |(_, m)| m.clone());
rows.push(RepoRow {
lifecycle: has_lifecycle(client, &name).await?,
frequency: cfg.scan_frequency().map_or("?", |f| f.as_str()).to_string(),
filters: cfg
.applied_scan_filters()
.iter()
.map(|f| f.filter().to_string())
.collect(),
name,
mutability,
});
}
}
Ok(rows)
}
#[tokio::main]
async fn main() -> Result<(), Error> {
let config = aws_config::defaults(BehaviorVersion::latest()).load().await;
let client = Client::new(&config);
let registry = client.get_registry_scanning_configuration().send().await?;
let scan_type = registry
.scanning_configuration()
.and_then(|c| c.scan_type())
.map_or("BASIC (default)", |t| t.as_str());
let rows = census(&client).await?;
println!("registry scan_type={scan_type} repositories={}", rows.len());
for r in &rows {
println!(
"{} mutability={} scan={} filters=[{}] lifecycle={}",
r.name, r.mutability, r.frequency, r.filters.join(","), r.lifecycle
);
}
for r in rows.iter().filter(|r| r.frequency == "MANUAL" || r.mutability == "MUTABLE" || !r.lifecycle) {
println!("attention {}", r.name);
}
Ok(())
}
Effective Read (named technique). The census never reads imageScanningConfiguration from describe_repositories. That field reports the old per-repository flag. The answer comes from batch_get_repository_scanning_configuration, which returns each repository's resolved scan_frequency and the applied_scan_filters that produced it. If no rule matched, applied_scan_filters is empty and the frequency is MANUAL. That empty list is the finding.
Three mechanics carry over from the StackSet census (09-25):
into_paginator().items()walks every page ofdescribe_repositories, so a registry with more repositories than one page holds still gets a full census.repos.chunks(25)respects the batch API's 25-name ceiling.chunksis a slice method that yields non-overlapping windows, the same iterator family TRPL ch13 teaches.get_lifecycle_policytreats a missing policy as an error. The match arm turnsLifecyclePolicyNotFoundExceptionintoOk(false), and every other error still stops the run.
What was checked, and what was not. A scratch bin crate built from the frontmatter manifest and body passed cargo check on stable cargo 1.96.0 with no warnings, resolving aws-sdk-ecr 1.131.0, aws-config 1.12.0 and tokio 1.53.1. The -Zscript entry needs nightly, which the lab Mac does not have. The script was not run against AWS. Output from a registry where one repository misses the prod-* filter would read like this (illustrative):
registry scan_type=BASIC repositories=2
prod-api mutability=IMMUTABLE scan=SCAN_ON_PUSH filters=[prod-*] lifecycle=true
tools-builder mutability=MUTABLE scan=MANUAL filters=[] lifecycle=false
attention tools-builder
§V. Wrap it with Nix
ecr-scan-census.nix gives the script a stable command name:
{ pkgs ? import <nixpkgs> { } }:
pkgs.writers.writeBashBin "ecr-scan-census" ''
exec cargo +nightly -Zscript ${./ecr-scan-census.rs} "$@"
''
Built on the lab Mac against the same pinned nixpkgs as this week's weekend lesson (rev d54020a6), it produced /nix/store/hqlybr7q…-ecr-scan-census. The generated bin/ecr-scan-census is two lines: a store-path bash shebang and exec cargo +nightly -Zscript /nix/store/2rmi1m0d…-ecr-scan-census.rs "$@". The script itself was copied into the store, so the wrapper runs the version it was built from even after the vault copy changes. The wrapper does not pin cargo or nightly. Whoever runs it still needs a nightly toolchain on PATH, and that gap is the next thing to close.
§VI. What not to do
- Setting
image_scanning_configuration { scan_on_push = true }and calling the repository covered. It validates and it plans, and it is not the control AWS reads. - Declaring
aws_ecr_registry_scanning_configurationin more than one root module per region. - Reading scan posture from
describe_repositoriesin automation. - Leaving
MUTABLEon release repositories and then trusting a tag in a deploy manifest. - Giving the census write permissions. It needs
ecr:DescribeRepositories,ecr:BatchGetRepositoryScanningConfiguration,ecr:GetLifecyclePolicyandecr:GetRegistryScanningConfiguration, and nothing else.
§VII. Close instruction
Write the four-action read-only IAM policy for the census in HCL. Then add a second rule to the registry configuration so a tools-* repository scans on push, and predict what applied_scan_filters will return for tools-builder once it applies.
Related
- Dev: Rust serde over plan JSON (same trio)
- Cert: type constraints, optional(), nullable (same trio)
- Prior Ops: StackSet census in cargo script
- Prior TF Ops: Cloud Run + run.invoker
- Prior TF Ops: SG vs NACL