Every authored recipe for this class, in theme order. The screen index lives at /plan/b; each theme is also its own page at /plan/b/<theme>.
IAM — Identity and Access Management
18 recipes · 13 of 31 controls in scope reached
Account-wide credential report proving MFA is active per principal and that passwords/access keys are rotated within policy
partial · cli · every monthly · /collect/iam-credential-report
$ aws iam generate-credential-report
$ aws iam get-credential-report --query GeneratedTime --output text
$ aws iam get-credential-report --query Content --output text | base64 --decode
Proves: IA-02, IA-05
Expected output: CSV, one row per IAM principal with columns including password_enabled, mfa_active, password_last_changed, access_key_1_active, access_key_1_last_rotated, access_key_2_last_rotated
GovCloud: identical API and CSV schema; principal ARNs use partition arn:aws-us-gov
Assert mfa_active=true for every password_enabled=true principal, and (now - access_key_N_last_rotated) <= 90d for every active key. generate-credential-report is async; poll get-credential-report until State=COMPLETE (report is regenerated at most every 4 hours). mfa_active is TRUE for a virtual TOTP device as well as a FIDO key, and the CSV covers IAM users and the root user only — a human confirms from the identity provider's own report that console authentication is phishing-resistant (the IA-02 (01)/(02) guidance) and that federated Identity Center or external-IdP sign-ins are covered elsewhere. The 90-day threshold is CIS Benchmark 1.14's, not FedRAMP's. Filed under IA-02 and IA-05 base: key age tests IA-05 (g), not AC-02 (01)'s automated account management or IA-05 (01)'s password rules.
Full snapshot of every IAM user, group, role, and attached/inline policy with their relationships, used to review that granted permissions match least-privilege intent
partial · cli · every quarterly · /collect/iam-account-authorization-details
$ aws iam get-account-authorization-details --query 'UserDetailList[].{User:UserName,Groups:GroupList,Attached:AttachedManagedPolicies[].PolicyName,Inline:UserPolicyList[].PolicyName}'$ aws iam get-account-authorization-details --filter Role --query 'RoleDetailList[].{Role:RoleName,Trust:AssumeRolePolicyDocument,Attached:AttachedManagedPolicies[].PolicyName}'Proves: AC-02, AC-03
Expected output: JSON with UserDetailList, GroupDetailList, RoleDetailList, and Policies arrays; embedded policy documents are URL-encoded (RFC 3986) and must be decoded before diffing against approved baselines
GovCloud: identical API; policy ARNs and principal ARNs use partition arn:aws-us-gov
The API fully enumerates entitlements (telemetry), but deciding whether a given permission set is 'least privilege' requires human judgement against a documented access baseline. Reviewers should diff this snapshot against the last approved baseline and justify deltas.
Active unused-access findings identifying IAM roles, access keys, console passwords, and service/action-level permissions that have not been used within the configured age, driving right-sizing and removal
partial · cli · every continuous · /collect/iam-access-analyzer-unused-access
$ aws accessanalyzer list-analyzers --type ACCOUNT_UNUSED_ACCESS --query 'analyzers[].{arn:arn,status:status,age:configuration.unusedAccess.unusedAccessAge}'$ aws accessanalyzer list-analyzers --type ORGANIZATION_UNUSED_ACCESS --query 'analyzers[].{arn:arn,status:status,age:configuration.unusedAccess.unusedAccessAge}'$ aws accessanalyzer list-findings-v2 --analyzer-arn <UNUSED_ACCESS_ANALYZER_ARN> --filter '{"status":{"eq":["ACTIVE"]}}'Expected output: JSON findings array; each finding has a findingType of UnusedIAMRole, UnusedIAMUserAccessKey, UnusedIAMUserPassword, or UnusedPermission, with the affected resource ARN and status
GovCloud: IAM Access Analyzer is available in AWS GovCloud (US); analyzer and resource ARNs use partition arn:aws-us-gov
Use list-findings-v2 (list-findings is external-access only and does not return unused-access findings). Requires an existing ACCOUNT_UNUSED_ACCESS analyzer; create one with create-analyzer --type ACCOUNT_UNUSED_ACCESS --configuration '{"unusedAccess":{"unusedAccessAge":90}}'. Target zero ACTIVE unused-permission findings for privileged roles. An empty findings list is meaningful only if an analyzer exists, is ACTIVE, and its unusedAccessAge equals the review period the SSP declares; entities younger than the tracking period, and principals or accounts excluded by tag, never appear as findings. UnusedPermission findings are computed for roles, so IAM users' unused permissions are outside this output, and AC-06 (07)(a)'s periodic review is a human record — which is why this is partial.
AWS Config compliance result for the managed rule proving every IAM user with a console password has MFA enabled
partial · config-rule · every continuous · /collect/config-mfa-enabled-console-access
$ aws configservice get-compliance-details-by-config-rule --config-rule-name mfa-enabled-for-iam-console-access --compliance-types NON_COMPLIANT
$ aws configservice describe-configuration-recorder-status
$ aws configservice describe-config-rule-evaluation-status --config-rule-names mfa-enabled-for-iam-console-access
Proves: IA-02
Expected output: EvaluationResults array; empty NON_COMPLIANT set means all console-enabled IAM users have MFA. Managed rule identifier: MFA_ENABLED_FOR_IAM_CONSOLE_ACCESS Plus describe-configuration-recorder-status showing recording=true and, per rule, describe-config-rule-evaluation-status showing FirstEvaluationStarted=true with a LastSuccessfulEvaluationTime — the proof the empty set was produced by a check that ran.
GovCloud: AWS Config and this managed rule are available in AWS GovCloud (US); resource ARNs use partition arn:aws-us-gov
Substitute your deployed rule name if it differs from the default. An empty NON_COMPLIANT result is the pass condition. Pair with the IAM_USER_MFA_ENABLED rule to also catch programmatic users. MFA_ENABLED_FOR_IAM_CONSOLE_ACCESS is COMPLIANT for any enabled device, including a virtual TOTP app; whether the factor is phishing-resistant (the IA-02 (01)/(02) guidance), and whether workforce console access runs through IAM users at all rather than IAM Identity Center, are judgements outside this output — filed under IA-02 base for that reason, and nothing here partitions privileged from non-privileged accounts.
AWS Config compliance result proving long-lived IAM access keys (including those used by service/non-user identities) are rotated within the maximum age
partial · config-rule · every continuous · /collect/config-access-keys-rotated
$ aws configservice get-compliance-details-by-config-rule --config-rule-name access-keys-rotated --compliance-types NON_COMPLIANT
$ aws configservice describe-configuration-recorder-status
$ aws configservice describe-config-rule-evaluation-status --config-rule-names access-keys-rotated
Proves: IA-05
Expected output: EvaluationResults array; empty NON_COMPLIANT set means every active access key is within maxAccessKeyAge. Managed rule identifier: ACCESS_KEYS_ROTATED (maxAccessKeyAge parameter, e.g. 90) Plus describe-configuration-recorder-status showing recording=true and, per rule, describe-config-rule-evaluation-status showing FirstEvaluationStarted=true with a LastSuccessfulEvaluationTime — the proof the empty set was produced by a check that ran.
GovCloud: AWS Config and this managed rule are available in AWS GovCloud (US); resource ARNs use partition arn:aws-us-gov
Strongest evidence for non-user auth is the absence of long-lived keys entirely (prefer IAM roles / temporary credentials). Where keys must exist, this rule proves rotation. Set maxAccessKeyAge to your policy (<=90d). The rule's verdict is only as strong as the maxAccessKeyAge an operator set, and the output does not carry the parameter — a human confirms it matches the SSP's rotation period. Root-user access keys are outside the rule by AWS's documented limitation. Filed under IA-05 base: key age tests IA-05 (g), not AC-02 (01).
AWS Config compliance result proving no customer-managed IAM policy grants full administrative access (Allow Action:* on Resource:*)
partial · config-rule · every continuous · /collect/config-iam-policy-no-admin-access
$ aws configservice get-compliance-details-by-config-rule --config-rule-name iam-policy-no-statements-with-admin-access --compliance-types NON_COMPLIANT
$ aws configservice describe-configuration-recorder-status
$ aws configservice describe-config-rule-evaluation-status --config-rule-names iam-policy-no-statements-with-admin-access
Expected output: EvaluationResults array; empty NON_COMPLIANT set means no evaluated customer-managed policy allows Action:* over Resource:*. Managed rule identifier: IAM_POLICY_NO_STATEMENTS_WITH_ADMIN_ACCESS Plus describe-configuration-recorder-status showing recording=true and, per rule, describe-config-rule-evaluation-status showing FirstEvaluationStarted=true with a LastSuccessfulEvaluationTime — the proof the empty set was produced by a check that ran.
GovCloud: AWS Config and this managed rule are available in AWS GovCloud (US); policy ARNs use partition arn:aws-us-gov
Scope caveat: this rule evaluates only customer-managed policies, not inline or AWS-managed policies. Combine with iam-account-authorization-details review to cover inline policies and admin AWS-managed policy attachments (e.g. AdministratorAccess). A human closes that gap by reading iam-account-authorization-details, which is why this is partial; and nothing in the output records the explicit authorizations AC-06 (01) asks for or partitions privileged from non-privileged users as AC-06 (10) does, so neither enhancement is claimed.
Documented just-in-time / break-glass privilege-elevation process backed by IAM Identity Center permission sets and account assignments, showing privileged access is role/attribute-based, time-bound, and approval-gated rather than standing
narrative · cli · every quarterly · /collect/identity-center-jit-elevation-workflow
# aws sso-admin list-instances --query 'Instances[0].InstanceArn' --output text
# aws sso-admin list-permission-sets --instance-arn <INSTANCE_ARN>
# aws sso-admin describe-permission-set --instance-arn <INSTANCE_ARN> --permission-set-arn <PERMISSION_SET_ARN> --query 'PermissionSet.{Name:Name,SessionDuration:SessionDuration}'# aws sso-admin list-account-assignments --instance-arn <INSTANCE_ARN> --account-id <ACCOUNT_ID> --permission-set-arn <PERMISSION_SET_ARN>
Proves: AC-03
Expected output: Permission set names with bounded SessionDuration (e.g. PT1H) and current account assignments; combined with the written approval-workflow runbook and ticket/approval records for each elevation
GovCloud: IAM Identity Center is available in AWS GovCloud (US); instance and permission-set ARNs use partition arn:aws-us-gov
The API proves RBAC structure and bounded session duration, but it cannot by itself prove that each elevation was requested, approved, and time-limited just-in-time. The approval/break-glass workflow and its evidence (tickets, approvals, deprovisioning records) are a documented process; attach the runbook and sampled approval records. Do not present the permission-set listing alone as proof of JIT.
GuardDuty IAM/credential-abuse findings (detection) paired with CloudTrail records of the responsive action taken to disable or secure the affected privileged principal
partial · cli · every continuous · /collect/guardduty-suspicious-iam-activity-response
$ aws guardduty list-detectors --query 'DetectorIds[0]' --output text
$ aws guardduty list-findings --detector-id <DETECTOR_ID> --finding-criteria '{"Criterion":{"type":{"Eq":["UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWS"]}}}'$ aws guardduty get-findings --detector-id <DETECTOR_ID> --finding-ids <FINDING_ID>
$ aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=DeleteLoginProfile --max-results 10
Proves: AC-07
Expected output: GuardDuty finding detail (severity, affected IAM principal, timestamp) plus CloudTrail events (e.g. DeleteLoginProfile, DeleteAccessKey, DeactivateMFADevice, or PutUserPolicy attaching a deny) showing the account was disabled/secured after the finding
GovCloud: GuardDuty and CloudTrail are available in AWS GovCloud (US); principal ARNs use partition arn:aws-us-gov. Some IAM-focused GuardDuty finding types depend on CloudTrail management-event coverage
Detection is pure telemetry; the response (which key/user to disable, whether it was malicious vs. authorized) requires incident-responder judgement, so this is partial. Evidence = finding + a linked responsive CloudTrail event within the IR SLA. cloudtrail lookup-events covers the last 90 days; use the finding type relevant to your workload.
How operators actually reach the environment from outside it: the managed access paths that exist, the logging and encryption configured on them, the session-by-session record of who used them, and the negative check that no instance is directly reachable instead
partial · cli · every weekly · /collect/remote-access-authorization-and-monitoring
$ aws ssm get-document --name SSM-SessionManagerRunShell --document-version '$LATEST' --query Content --output text
$ aws ssm describe-sessions --state History --query 'Sessions[].{owner:Owner,target:Target,start:StartDate,end:EndDate,document:DocumentName,accessType:AccessType,maxDuration:MaxSessionDuration}'$ aws ec2 describe-client-vpn-endpoints --query 'ClientVpnEndpoints[].{id:ClientVpnEndpointId,transport:TransportProtocol,auth:AuthenticationOptions[].Type,connectionLog:ConnectionLogOptions,splitTunnel:SplitTunnel,sessionTimeoutHours:SessionTimeoutHours,serverCert:ServerCertificateArn,selfServicePortal:SelfServicePortalUrl}'$ aws ec2 describe-client-vpn-connections --client-vpn-endpoint-id <CLIENT_VPN_ENDPOINT_ID> --query 'Connections[].{user:Username,commonName:CommonName,clientIp:ClientIp,established:ConnectionEstablishedTime,ended:ConnectionEndTime,status:Status,posture:PostureComplianceStatuses}'$ aws configservice get-compliance-details-by-config-rule --config-rule-name ec2-instance-no-public-ip --compliance-types NON_COMPLIANT
Proves: AC-17
Expected output: The Session Manager preferences document as JSON — s3BucketName, s3KeyPrefix, s3EncryptionEnabled, cloudWatchLogGroupName, cloudWatchEncryptionEnabled, cloudWatchStreamingEnabled, kmsKeyId, runAsEnabled, idleSessionTimeout and maxSessionDuration — which is your Region's entire remote-access logging and encryption configuration in one object. Then one row per terminated session from the past 30 days carrying Owner, Target, StartDate, EndDate, DocumentName, MaxSessionDuration and AccessType of Standard or JustInTime. Then per Client VPN endpoint the transport protocol tcp or udp, the authentication types in use (certificate-authentication, directory-service-authentication or federated-authentication), ConnectionLogOptions with Enabled plus CloudwatchLogGroup and CloudwatchLogStream, SplitTunnel, SessionTimeoutHours of 8, 10, 12 or 24 (default 24) and the server certificate ARN. Then per connection Username (Active Directory authentication only), CommonName, ClientIp, ConnectionEstablishedTime, ConnectionEndTime, Status and any PostureComplianceStatuses — active connections plus only those terminated within the last 60 minutes. Finally the EC2 instances AWS Config evaluated NON_COMPLIANT because a publicIp field is present in their configuration item.
GovCloud: Both access paths exist in AWS GovCloud (US-East) and (US-West). Client VPN endpoints there operate using FIPS 140-3 validated cryptographic modules and a fixed cipher set — TLS 1.3 TLS_AES_256_GCM_SHA384 and TLS_AES_128_GCM_SHA256; TLS 1.2 TLS-ECDHE-RSA/ECDSA-WITH-AES-256-GCM-SHA384 and the AES-128-GCM-SHA256 variants; data channel AES-256-GCM — and AWS advises using the exported client configuration file unmodified rather than configuring other ciphers, which makes AC-17(2) there largely a matter of not breaking the default. Systems Manager runs in both Regions; Change Manager and Incident Manager do not, and State Manager association history cannot be viewed, none of which this recipe touches. ec2-instance-no-public-ip is documented for all supported AWS Regions. Calls to these services must use SSL (HTTPS), and ARNs use partition arn:aws-us-gov
The commands split cleanly across the control family, and the gap in the middle is the one to be honest about. AC-17(1) — automated monitoring and control of remote access — is what session history and connection logs deliver, with two documented blind spots. Session Manager does not log sessions that connect through port forwarding or SSH, because SSH encrypts the session data inside the TLS connection and Session Manager is only the tunnel; an operator who port-forwards leaves a session record with no command content behind it. And describe-sessions reaches back 30 days only, so anything longer is an S3 or CloudWatch Logs query against the destinations named in the preferences document, not an SSM call. Client VPN retention is shorter still — terminated connections drop out of the API after 60 minutes, which makes the log group named in ConnectionLogOptions the only durable record, and Username is populated only for Active Directory authentication, so certificate-authenticated users are identified by CommonName or not at all. AC-17(2) is the strongest link in GovCloud, where the endpoints are FIPS 140-3 modules by construction; the Session Manager equivalent is kmsKeyId in the preferences document, and it is empty unless you set it, so an empty kmsKeyId is a finding rather than a default. AC-17(3) — routing remote access through managed network access control points — is the one nothing here proves. Session Manager and a Client VPN endpoint are managed access points, and ec2-instance-no-public-ip is the closest negative check, but that rule applies only to IPv4 and only to AWS::EC2::Instance: an IPv6-reachable instance, a load balancer fronting SSH, or a third-party jump host is invisible to it. Read the result as 'no EC2 instance carries a public IPv4 address', which is a useful sentence and not the control. AC-17 itself — the documented usage restrictions, configuration requirements and per-type authorization — is a record you write, and this telemetry only shows whether the estate matches it. One warning to carry into the evidence package: Session Manager logs the commands entered and their output, so a credential typed into a session lands in the log group you are about to hand an assessor.
Every way a workforce user can authenticate into the account, counted and named in one pass — how many IAM users and federated trusts exist, which SAML and OIDC providers are registered, whether an IAM Identity Center instance is the workforce entry path — together with the state of the two credentials that belong to no person: the root user's access key and the account's X.509 signing certificate
partial · cli · every monthly · /collect/identity-sources-and-root-credential-lockdown
$ aws iam get-account-summary --query 'SummaryMap.{Users:Users,Providers:Providers,MFADevices:MFADevices,MFADevicesInUse:MFADevicesInUse,AccountAccessKeysPresent:AccountAccessKeysPresent,AccountSigningCertificatesPresent:AccountSigningCertificatesPresent}'$ aws iam list-saml-providers --query 'SAMLProviderList[].{Arn:Arn,ValidUntil:ValidUntil,CreateDate:CreateDate}'$ aws iam list-open-id-connect-providers
$ aws sso-admin list-instances --query 'Instances[].{InstanceArn:InstanceArn,IdentityStoreId:IdentityStoreId,Status:Status}'$ aws configservice get-compliance-details-by-config-rule --config-rule-name iam-user-mfa-enabled --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name iam-root-access-key-check --compliance-types NON_COMPLIANT
Proves: IA-02
Expected output: Under the projection root account-summary, a SummaryMap of integers: Users and Providers size the two populations that can authenticate, MFADevices and MFADevicesInUse size the authenticator estate, and AccountAccessKeysPresent and AccountSigningCertificatesPresent are 0/1 flags for account-level credentials. Under saml-providers and oidc-providers, one entry per registered federation trust — SAMLProviderList carries Arn, ValidUntil and CreateDate (tags are not returned; GetSAMLProvider is the call for those). Under identity-center, zero or one instance with InstanceArn, IdentityStoreId and Status (CREATE_IN_PROGRESS | CREATE_FAILED | DELETE_IN_PROGRESS | ACTIVE). From AWS Config, two EvaluationResults arrays; empty NON_COMPLIANT sets mean every IAM user has an MFA device and the root user holds no access key. Managed rule identifiers: IAM_USER_MFA_ENABLED (rule name iam-user-mfa-enabled) and IAM_ROOT_ACCESS_KEY_CHECK (rule name iam-root-access-key-check).
GovCloud: IAM, IAM Identity Center and AWS Config are all available in both GovCloud (US) Regions, and both managed rules used here are in their supported-Region lists; user, provider and instance ARNs use partition arn:aws-us-gov, and an Identity Center instance ARN takes the form arn:aws-us-gov:sso:::instance/<SSOInstanceId>. Two GovCloud facts to plan around: the Identity Center administrative console, SDK and CLI must be reached over FIPS endpoints, and multi-Region Identity Center support is not available there, so one instance is the whole answer rather than one per Region. Note also that ROOT_ACCOUNT_MFA_ENABLED — the obvious companion rule — is explicitly NOT available in AWS GovCloud (US-East) or (US-West), which is why root MFA is not asserted here from AWS Config; evidence it from the IAM credential report's <root_account> row instead.
This enumerates the authentication paths and closes the credentials that belong to nobody. What it cannot do is the word IA-02 turns on: unique. No API reports that an IAM user is one named human rather than a login three engineers share, or that a federated subject maps one-to-one onto a person on the roster — that binding lives in the personnel record and the joiner/mover/leaver process, and a reviewer establishes it by joining this output against the account inventory (see the AC-02 account-authorization-details recipe), not by reading this output alone. Rated partial for that reason: the authentication half is decided here outright, the identification half is not.
Providers counts SAML and OIDC providers together, so the two list calls are what tell you which is which. An account with Users at 0 and one Identity Center instance is the strong shape — no standing workforce credentials at all — and an account with both is the one worth explaining. Substitute nothing here: every command runs as written.
The two AWS Config rules are periodic and evaluate global IAM resource types, so deploy them in exactly one Region; adding them in several does not add coverage and does add duplicate evaluations.
For every Amazon Cognito directory that fronts non-organizational users: the user pool's multi-factor configuration, and every identity pool's guest-access flag together with the named external providers it will exchange a token for
partial · cli · every weekly · /collect/cognito-external-user-authentication
$ aws cognito-idp list-user-pools --max-results 60 --query 'UserPools[].{Id:Id,Name:Name,Status:Status}'$ aws cognito-idp get-user-pool-mfa-config --user-pool-id <USER_POOL_ID>
$ aws cognito-identity list-identity-pools --max-results 60 --query 'IdentityPools[].{Id:IdentityPoolId,Name:IdentityPoolName}'$ aws cognito-identity describe-identity-pool --identity-pool-id <IDENTITY_POOL_ID> --query '{Id:IdentityPoolId,AllowUnauthenticatedIdentities:AllowUnauthenticatedIdentities,AllowClassicFlow:AllowClassicFlow,Cognito:CognitoIdentityProviders,Login:SupportedLoginProviders,Saml:SamlProviderARNs,Oidc:OpenIdConnectProviderARNs}'Proves: IA-08
Expected output: One get-user-pool-mfa-config response per user pool under the projection root user-pool-mfa: MfaConfiguration (OFF | ON | OPTIONAL) plus whichever factor blocks are configured — SoftwareTokenMfaConfiguration.Enabled for TOTP, SmsMfaConfiguration for SMS, EmailMfaConfiguration for email OTP. One describe-identity-pool response per identity pool under the projection root identity-pool: AllowUnauthenticatedIdentities (TRUE if the pool supports unauthenticated logins), AllowClassicFlow, and the four trust fields — CognitoIdentityProviders, SupportedLoginProviders (provider name to app id), SamlProviderARNs and OpenIdConnectProviderARNs.
GovCloud: Amazon Cognito user pools and identity pools are available in both GovCloud (US) Regions, with differences that change what this collection sees and how it is wired: Cognito in GovCloud uses FIPS endpoints only (cognito-idp-fips.us-gov-west-1.amazonaws.com and cognito-idp-fips.us-gov-east-1.amazonaws.com), custom domains for user pools are not available, and Amazon Cognito Sync is absent. Identity-pool role trust policies must grant AssumeRoleWithWebIdentity to the cognito-identity-us-gov.amazonaws.com service principal in GovCloud (US-West) and to cognito-identity.us-gov-east-1.amazonaws.com in GovCloud (US-East) — a trust policy copied from a commercial account names the wrong principal and the pool silently issues nothing. In GovCloud (US-East), role name plus role session name longer than 24 characters can stop an identity pool assuming the role at all. Pool and provider ARNs use partition arn:aws-us-gov.
Drive both loops from the list calls, not from whatever the describe calls happen to return, so a pool nobody configured is a visible failure rather than an absent row.
AllowUnauthenticatedIdentities is the assertion that carries the control: an identity pool with guest access on hands AWS credentials to a caller who never authenticated, which is precisely the population IA-08 exists to exclude. It is the one setting here that can silently undo everything upstream, which is why the cadence is weekly rather than quarterly.
MfaConfiguration is asserted against ON rather than merely not-OFF on purpose. OPTIONAL does not require anything — it delegates the decision to the client application, so a pool set to OPTIONAL and an application that never prompts is indistinguishable from OFF in this output. Scope the loop to the pools that actually serve non-organizational users: a pool used only as a directory for an internal service has no external population and asserting ON over it is a finding about the wrong thing.
That scoping sentence is why this recipe is partial rather than full, and the reason is worth stating plainly. Both assertions range over lists, and no AWS call reports which directory serves the external population — or whether external users reach the system through Cognito at all rather than through an API Gateway authorizer or an ALB fronting an IdP. On an account with no user pools and no identity pools, both assertions are vacuously true and the control is unevidenced. The second gap is the control's first verb: MfaConfiguration decides how strongly an external identity authenticates, never that it is UNIQUE to one human, and a pool with MFA ON and self-service sign-up admits as many accounts per person as they care to open. Both gaps close with a human naming the external population and the registration path, which is the definition of partial.
What this does not reach is the external identity provider's own registration and proofing — whether the SAML or OIDC provider behind SamlProviderARNs vetted the human before issuing them a subject. That is IA-08(1), (2) and (4) territory and is evidenced by the provider's assurance-level attestation, not by any AWS call.
The configured ceiling on how long any credential stays valid before its holder must present an authenticator again — MaxSessionDuration on every IAM role, SessionDuration on every IAM Identity Center permission set, and the aws:MultiFactorAuthAge conditions in policy that expire an MFA-backed session independently of the session itself
partial · cli · every quarterly · /collect/session-lifetime-and-reauthentication
$ aws iam list-roles --query 'Roles[].{Role:RoleName,MaxSessionDuration:MaxSessionDuration}'$ aws sso-admin list-instances --query 'Instances[].InstanceArn'
$ aws sso-admin list-permission-sets --instance-arn <INSTANCE_ARN>
$ aws sso-admin describe-permission-set --instance-arn <INSTANCE_ARN> --permission-set-arn <PERMISSION_SET_ARN> --query 'PermissionSet.{Name:Name,SessionDuration:SessionDuration}'$ aws iam get-account-authorization-details --query '{Managed:Policies[].PolicyVersionList[?IsDefaultVersion].Document,UserInline:UserDetailList[].UserPolicyList[].PolicyDocument,RoleInline:RoleDetailList[].RolePolicyList[].PolicyDocument,GroupInline:GroupDetailList[].GroupPolicyList[].PolicyDocument}'Proves: IA-11
Expected output: Under the projection root roles, one row per IAM role with MaxSessionDuration in SECONDS — the API floor is 3600 and the ceiling 43200, and the value caps what DurationSeconds an AssumeRole call may ask for. Under permission-sets, one row per permission set with SessionDuration as an ISO-8601 duration string (PT1H, PT12H). From get-account-authorization-details, four blocks of policy documents — the default version of every managed policy under Managed, and every INLINE policy under UserInline, RoleInline and GroupInline — each URL-encoded per RFC 3986 and needing a decode before you can search it for aws:MultiFactorAuthAge, a numeric condition key measured in SECONDS since the principal was authorized using MFA. The inline blocks are the reason for the projection: an MFA-age condition written inline on one role is invisible to a query that reads managed policies alone, and reads as an absence rather than as a miss.
GovCloud: IAM, STS and IAM Identity Center are available in both GovCloud (US) Regions; role and permission-set ARNs use partition arn:aws-us-gov, with permission sets taking the form arn:aws-us-gov:sso:::permissionSet/<SSOInstanceID>/<PermissionSetID>. The Identity Center console, SDK and CLI must be reached over FIPS endpoints in GovCloud, and multi-Region Identity Center is not available there, so a single instance holds every permission set in scope.
IA-11 asks for re-authentication under organization-defined circumstances. Only one of those circumstances — elapsed time — is in any of these outputs, and even that arrives as a ceiling rather than as an event: MaxSessionDuration caps what a caller may request, it does not report that a session ended or that a human presented an authenticator again. The other circumstances FedRAMP expects an organization to name — a change of role, a change of authenticator, before executing a privileged function, after a defined idle period — have no field here at all. Deciding whether the configured ceilings match the documented circumstances is the human judgement, which is why this is partial and not full.
Two scope gaps worth writing into the assessment rather than discovering later. A console session reached through an external identity provider ends on that provider's session policy, and no AWS API reports it: the IdP's own configuration is the artifact. And aws:MultiFactorAuthAge is absent from the request context for federated identities and for requests signed with long-term access keys, so a policy that expires an MFA session governs exactly the principals using temporary MFA-backed credentials and silently governs nobody else — pair it with BoolIfExists on aws:MultiFactorAuthPresent so the not-present case denies rather than passes.
A role left at the 3600-second default is not evidence of a decision; a role at 43200 is a decision someone should have written down. Read the distribution, not the extremes. Substitute the instance and permission-set ARNs, which the two list calls supply.
The mechanism that ends a temporary or emergency account without anyone deciding to: the AWS Config rule that measures how long an IAM credential has gone unused, the period it is configured with, the remediation configuration proving the revocation fires automatically, and an empty non-compliant set showing nothing has outlived the period
partial · config-rule · every continuous · /collect/temporary-account-automatic-revocation
$ aws configservice describe-config-rules --config-rule-names iam-user-unused-credentials-check
$ aws configservice get-compliance-details-by-config-rule --config-rule-name iam-user-unused-credentials-check --compliance-types NON_COMPLIANT
$ aws configservice describe-remediation-configurations --config-rule-names iam-user-unused-credentials-check
$ aws iam get-account-authorization-details --filter User
Expected output: The four responses are collected unprojected, so every field below is the name AWS returns and the name the assertions address. From describe-config-rules, ConfigRules[] with one entry: Source.SourceIdentifier IAM_USER_UNUSED_CREDENTIALS_CHECK, ConfigRuleState ACTIVE, and InputParameters as a JSON-formatted STRING (not an object) that must be parsed before maxCredentialUsageAge can be read from it — an int, a number of DAYS, defaulting to 90. From get-compliance-details-by-config-rule, an EvaluationResults array whose every entry is one IAM user still holding a password or an active access key unused beyond that period; empty is the passing shape. From describe-remediation-configurations, RemediationConfigurations[] — a list whose minimum length is ZERO, so a rule with no remediation attached returns an empty array rather than an error, which is why one assertion tests that the first entry exists at all before the others read Automatic, TargetType SSM_DOCUMENT and TargetId. From get-account-authorization-details, UserDetailList[] with one entry per IAM user carrying UserName, CreateDate as an ISO-8601 timestamp, and Tags as a list of key/value pairs.
GovCloud: AWS Config and IAM_USER_UNUSED_CREDENTIALS_CHECK are available in both GovCloud (US) Regions — the rule's published exclusion list names no GovCloud Region — as is Systems Manager Automation. User and role ARNs, and the AutomationAssumeRole the runbook assumes, use partition arn:aws-us-gov. The rule reports on a global IAM resource type, so deploy it in exactly one Region: a periodic rule on a global type evaluates in every Region it is added to, and duplicating it duplicates the evaluations rather than the coverage.
AC-02 (02) asks for something narrower than it looks: not that temporary and emergency accounts are reviewed, but that they END on their own after a stated period. Most of that sentence is in this output. The period is the rule's maxCredentialUsageAge. The ending is the remediation configuration — Automatic true and TargetId AWSConfigRemediation-RevokeUnusedIAMUserCredentials, a runbook whose documented behaviour is to deactivate expired access keys and delete expired login profiles. That nothing has outlived the period is the empty NON_COMPLIANT set. A screenshot of a console page proves none of those; these four calls prove all of them.
What they do not prove is the control's subject. The Config rule measures every IAM user in the account; nothing in this output says WHICH users are the temporary and emergency ones, and the control is about those. A human names that population — which is exactly why this recipe is partial and not full, and why the CreateDate assertion is written as a filter over your own tagging rather than as a claim over the account.
Two scope edges to write into the assessment rather than discover during it. The rule sees IAM users, so an account whose privileges live in an assumed role or an IAM Identity Center permission set is out of its reach — those expire by session duration instead, which is a different artifact (see the IA-11 recipe). And the rule measures INACTIVITY, not age: a temporary account that is used every day is compliant no matter how long ago it should have been closed. The fourth call is what narrows that gap, by reading CreateDate against your own tagging of which accounts were meant to be temporary.
That last check is only as complete as the tagging behind it, and no AWS Config rule can make it complete: REQUIRED_TAGS does not support AWS::IAM::User, so tag coverage over IAM users cannot be asserted from Config at all. Treat the tagging standard as a written control with a manual sample. Read the CreateDate assertion for what it is — a check over exactly the accounts your tagging labels, which passes without saying anything on an estate that labels none.
The first remediation assertion is not redundant with the two that follow it. describe-remediation-configurations returns a list whose minimum length is zero, so a rule with NO remediation attached returns an empty array, and a clause of the form 'every entry is Automatic' is true over it. Testing that the first entry exists is what turns 'the revocation fires by itself' from a claim into a field.
The 90 in the assertions is the AWS default, not a FedRAMP number. Replace it in both places with the period your SSP commits to, and keep the runbook's own MaxCredentialUsageAge parameter equal to the period of the rule that triggers it — AWS documents that discipline for the access-keys-rotated pairing (match MaxCredentialUsageAge to that rule's maxAccessKeyAge) rather than for this rule, but the failure it prevents is the same one: a mismatch means the detection window and the revocation window disagree silently. Re-evaluating the rule within 4 hours of its last evaluation returns the previous result, so collect on the rule's own cadence rather than on demand.
The machine-generated inventory of every resource an external entity can reach — IAM Access Analyzer's active ExternalAccess findings — read against the declared zone of trust, so the terms-and-conditions review has a list to work from rather than a memory
partial · cli · every quarterly · /collect/external-access-inventory-and-trust-boundary
$ aws accessanalyzer list-analyzers --query 'analyzers[].{Name:name,Type:type,Status:status,Arn:arn,LastAnalyzedAt:lastResourceAnalyzedAt}'$ aws accessanalyzer list-findings-v2 --analyzer-arn <ANALYZER_ARN> --filter '{"status":{"eq":["ACTIVE"]},"findingType":{"eq":["ExternalAccess"]}}'$ aws organizations describe-organization --query 'Organization.{Id:Id,FeatureSet:FeatureSet,ManagementAccount:MasterAccountId}'$ aws configservice get-compliance-details-by-config-rule --config-rule-name iam-external-access-analyzer-enabled --compliance-types NON_COMPLIANT
Proves: AC-20
Expected output: From list-analyzers, one row per analyzer with type ACCOUNT or ORGANIZATION for external access (the ACCOUNT_UNUSED_ACCESS, ORGANIZATION_UNUSED_ACCESS, ACCOUNT_INTERNAL_ACCESS and ORGANIZATION_INTERNAL_ACCESS types answer different questions and do not produce ExternalAccess findings) and status one of ACTIVE, CREATING, DISABLED, FAILED. From list-findings-v2, one finding per resource shared outside the zone of trust, each with resource, resourceType, resourceOwnerAccount, findingType, status, createdAt and analyzedAt. From describe-organization, the organization id, FeatureSet ALL or CONSOLIDATED_BILLING, and the management account id — the boundary an ORGANIZATION analyzer treats as internal. From the Config rule, an empty NON_COMPLIANT set means an external-access analyzer is enabled and ACTIVE in that Region.
GovCloud: IAM Access Analyzer and Organizations operate in GovCloud (US); analyzer and resource ARNs use partition arn:aws-us-gov. The Config managed rule IAM_EXTERNAL_ACCESS_ANALYZER_ENABLED is NOT available in either GovCloud (US) Region — its published availability excludes GovCloud (US-East) and GovCloud (US-West) — so in GovCloud the fourth call has no rule to query and the analyzer's own status field from list-analyzers is the evidence that it is enabled.
AC-20 is a two-limb control and only one limb is in this output. The limb that is here: which external entities can reach organization-controlled information. Access Analyzer answers that by logic-based reasoning over resource-based policies, and every access by a principal INSIDE the zone of trust is trusted by definition, so a finding is exactly an access that crosses the boundary the organization declared. The limb that is not here: whether each of those crossings is covered by terms and conditions consistent with the trust relationship. That is an agreement — a contract, an interconnection security agreement, an authorization to connect — and no call returns it. Attach the agreement register and reconcile it finding by finding; the reconciliation, not the finding list, is the AC-20 artifact.
The inventory also has a direction. Access Analyzer sees resources you share OUT. It does not see an external system your people use to process organization information — a SaaS tool reached from a workstation leaves no resource policy in your account and produces no finding. That half of AC-20 has to come from your own third-party register, and inventing a join between the two would make the coverage claim wider than the evidence.
Three scope facts worth pinning down before the count is quoted. External-access analysis is REGIONAL: an analyzer evaluates only resources in the Region where it is enabled, so one analyzer per Region in use, or the inventory is silently partial. Fifteen resource types are analyzed for external access — S3 buckets and directory buckets, IAM roles, KMS keys, Lambda functions and layers, SQS queues, Secrets Manager secrets, SNS topics, EBS volume snapshots, RDS DB and DB cluster snapshots, ECR repositories, EFS file systems, DynamoDB streams and tables — and a resource type outside that list is not covered by the analyzer at all. And findings refresh within about 30 minutes of a policy change but can lag up to 24 hours when a change notification is missed, so a finding list is a recent state, not a live one.
The enforced half of who may change what: the service control policy type actually enabled in the organization root, the customer-authored SCPs and the roots, OUs and accounts each one is attached to, and the permissions boundary carried by every principal your own tagging marks as a change authority
partial · cli · every continuous · /collect/change-authority-restrictions-and-enforcement
$ aws organizations describe-organization
$ aws organizations list-roots
$ aws organizations list-policies --filter SERVICE_CONTROL_POLICY
$ aws organizations describe-policy --policy-id <CUSTOMER_AUTHORED_SCP_ID>
$ aws organizations list-targets-for-policy --policy-id <CUSTOMER_AUTHORED_SCP_ID>
$ aws iam get-account-authorization-details --filter User Role
Proves: CM-05
Expected output: All six responses are collected unprojected, so every field below is the name AWS returns and the name the assertions address. The first three calls take no argument; calls four and five take a policy id that MUST be one of the AwsManaged false ids returned by call three — a human substitution the assertion grammar cannot make, and the reason the placeholder is named CUSTOMER_AUTHORED_SCP_ID rather than POLICY_ID. Call six must be issued with MANAGEMENT-ACCOUNT credentials: Organizations operations may also be called from a member account designated as a delegated administrator, and a collection run there returns that member account's principals while reading exactly like the management account's. From describe-organization, an Organization object with Id, Arn, MasterAccountId, MasterAccountEmail and FeatureSet, which is ALL or CONSOLIDATED_BILLING; AvailablePolicyTypes is also returned and is DEPRECATED by AWS, which documents that it omits every policy type other than SCPs and directs you to ListRoots instead. From list-roots, Roots[] with Id, Arn, Name and PolicyTypes[], each entry carrying Type and a Status that is ENABLED, PENDING_ENABLE or PENDING_DISABLE — there is no DISABLED value, because a policy type that is off is ABSENT from the list rather than reported as off. From list-policies, Policies[] with Id, Arn, Name, Description, Type and AwsManaged, a boolean that is true for policies you cannot edit; AWS attaches the managed FullAWSAccess policy to every root, OU and account when it is created, so the list is never empty on an organization with SCPs enabled and its length says nothing about whether anyone has authored a restriction. From describe-policy, a Policy object with a PolicySummary carrying the same six fields as a list entry, and Content — the policy document itself, returned as a JSON-formatted STRING that must be parsed before any statement in it can be read. From list-targets-for-policy, Targets[] with TargetId, Arn, Name and a Type of ACCOUNT, ORGANIZATIONAL_UNIT or ROOT. From get-account-authorization-details, UserDetailList[] and RoleDetailList[] with UserName/RoleName, CreateDate, Tags, the attached and inline policy lists, and PermissionsBoundary — an optional AttachedPermissionsBoundary object carrying PermissionsBoundaryType and PermissionsBoundaryArn, absent entirely on a principal that has none.
GovCloud: AWS Organizations is available in both AWS GovCloud (US) Regions and SCPs are one of the policy types a GovCloud organization may use, alongside RCPs, tag policies and declarative policies for EC2 and S3; backup, chat application and AI services opt-out policies cannot be created there. Organization, root, OU, account, policy and IAM ARNs use partition arn:aws-us-gov. Three GovCloud facts change how this recipe is run rather than what it means. All features are MANDATORY — the consolidated billing feature set is not offered — so FeatureSet reads ALL in every GovCloud organization and the first assertion is satisfied by the Region rather than by a decision anyone made. The SECOND call is the one with a Region constraint: AWS restricts any operation that references the organization root, naming ListRoots as its example, to the AWS GovCloud (US-West) Region, so list-roots must be issued against us-gov-west-1 regardless of where the workload runs. The other five calls carry no such restriction. And a GovCloud organization is INDEPENDENT of the commercial organization its accounts are paired with: SCPs attached in the commercial organization do not restrict the GovCloud accounts, and an assessor handed a commercial organization's policy list has been handed evidence about a different boundary.
CM-5 asks for physical AND logical access restrictions associated with changes that are defined, documented, approved and enforced. This recipe reaches one adjective and one verb.
The adjective it does not reach is PHYSICAL. No AWS API returns anything about physical access to the hardware a change is made on; under the shared responsibility model that half belongs to the IaaS provider and is inherited — read it from the provider's own authorization package and the FedRAMP customer responsibility matrix, not from this output. A collection that presents these six calls as CM-5 evidence without saying so has answered half a control and labelled it whole.
The verb it does reach is ENFORCE, and it reaches it properly: an SCP is not a description of a restriction, it is the restriction, evaluated by AWS on every request from every member account, and list-targets-for-policy is the difference between a policy that exists and a policy that applies to something.
Two gaps keep this partial, not one. The first is approval — that the enforced set IS the documented and approved set is a comparison against a change-management record, and no call returns it. The second is subject matter, and it is the easier one to miss: the assertions below show THAT a customer-authored SCP is enforced, never WHAT it restricts. An SCP denying mechanicalturk:* satisfies every clause here exactly as well as one denying ec2:ModifyInstanceAttribute. The fourth call exists to close that by putting the policy document in the evidence — Content is returned as a JSON string and must be parsed — but no assertion can grade a policy document, so the phrase that makes this CM-5 rather than AC-3, 'associated with changes', stays a human read. Both gaps are reconciliations against your own records; the reconciliation is the CM-5 artifact and this output is the column it is reconciled against.
Four ways this evidence is vacuous if it is read naively. Three are behaviour AWS documents in as many words; the fourth is an inference from a field being a list, flagged as such rather than dressed up as a citation. A policy type that has never been enabled is ABSENT from Roots[].PolicyTypes rather than present with a Status of DISABLED — the enum has no such value — so a clause of the form 'every SCP policy type entry is ENABLED' is true over an organization where SCPs were never turned on at all, which is why one assertion tests that the entry exists before another reads it. AWS attaches the managed FullAWSAccess policy to EVERY root, OU and account when it is created, so both a non-empty Policies[] and a non-empty Targets[] are the default state of a working organization rather than a restriction anyone wrote — which is why one assertion counts only the AwsManaged false subset, and why calls four and five are bound to a customer-authored policy id instead of any policy id. FeatureSet CONSOLIDATED_BILLING makes SCPs unavailable outright, so on such an organization every policy in the list is inert. And an SCP attached to nothing returns an empty Targets[]: that one AWS does not state, it follows from Targets being a list, and the assertion that rests on it should be read as an inference.
The scope edge that matters most to an assessor is that SCPs do not restrict the management account. AWS states this three times on its own page and lists it first among the tasks SCPs cannot restrict: SCPs affect only member accounts, including member accounts designated as delegated administrators, and they have no effect on users or roles in the management account. The account with the broadest reach over the organization is the one account this evidence says nothing about, and its change restrictions have to come from identity-based policy and permission boundaries inside it — which is what the sixth call collects, and why the sixth call has to be run there. SCPs also do not affect service-linked roles at all, and they do not affect principals from accounts outside the organization even when a resource-based policy in your account grants those principals access.
Read an SCP for what it is: a ceiling, never a grant. AWS is explicit that no permissions are granted by an SCP and that effective permissions are the intersection of what the SCP allows with what identity-based and resource-based policies allow — a principal with no IAM permissions has no access under the most permissive SCP in the world. Where a permissions boundary is also present, AWS documents that the boundary, the SCP and the identity-based policy must ALL allow the action, which is why the sixth call is collected beside the first five rather than instead of them.
One failure mode worth writing into the assessment because it is silent and total: disabling the SCP policy type in a root automatically detaches every SCP from every OU, account and organization in that root, and re-enabling it does not restore the attachments — the root reverts to FullAWSAccess alone and the previous attachments are lost and not automatically recoverable. After such an event list-policies still returns every authored policy, unchanged, while nothing is enforced anywhere. The Roots[].PolicyTypes reading and the Targets[] reading are what separate those two worlds, and a collection that skips them cannot tell them apart.
The permissions-boundary assertion is scoped by your own tagging rather than written as a claim over the account, for the same reason as in the AC-02 (02) recipe: no AWS call knows which of your roles are supposed to be the change authorities. It passes without saying anything on an estate that tags none, so treat the tagging standard as a written control with a manual sample rather than as coverage.
What actually happened, as opposed to what was permitted, is a different recipe: CloudTrail's non-read-only event history, collected under CM-03 alongside the Config resource timeline and the Systems Manager change-request records. This one is about the restriction; that one is about the change.
Every identifier the account has issued, with the date it was assigned and the AWS-generated unique id behind it; the workforce identifiers issued through IAM Identity Center and the external issuer each one came from; and the CloudTrail record of identifiers being deleted, which is the only dated evidence of a name becoming free to reuse
partial · cli · every continuous · /collect/identifier-assignment-and-reuse-prevention
$ aws iam get-account-authorization-details --filter User Role Group
$ aws sso-admin list-instances
$ aws identitystore list-users --identity-store-id <IDENTITY_STORE_ID>
$ aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=DeleteUser --start-time <T0> --end-time <T1>
Proves: IA-04
Expected output: From get-account-authorization-details, UserDetailList[], RoleDetailList[] and GroupDetailList[] — a group name is an identifier IA-04 covers and GroupDetail carries GroupId, the stable and unique string identifying the group, though NOT Tags — with UserName/RoleName/GroupName, the AWS-generated UserId/RoleId, Arn, Path, CreateDate as an ISO-8601 timestamp, Tags, and the attached and inline policy lists. This call rather than iam list-users is deliberate: list-users returns UserId, UserName, Arn, Path and CreateDate but AWS documents that it does NOT return Tags or PermissionsBoundary, and the tag is where an authorization reference can live. From sso-admin list-instances, Instances[] — a list whose minimum length is ZERO — each with InstanceArn, IdentityStoreId, Name, OwnerAccountId, CreatedDate, PrimaryRegion, Regions and a Status of CREATE_IN_PROGRESS, CREATE_FAILED, DELETE_IN_PROGRESS or ACTIVE. IdentityStoreId from that response is the argument the third call needs. From identitystore list-users, Users[] with the required IdentityStoreId and UserId and optional UserName, ExternalIds (a list of issuer/id pairs), Name, Emails, UserStatus (ENABLED or DISABLED), CreatedAt/CreatedBy and UpdatedAt/UpdatedBy. From lookup-events, Events[] with EventName DeleteUser, EventTime, and Username — which AWS documents as the user or role name of the REQUESTER that called the API, meaning the administrator who ran the deletion and NOT the identifier that was deleted. The deleted name is inside the CloudTrailEvent JSON, at requestParameters.userName, and that is the field the reuse comparison reads — bounded, as every lookup-events call is, to management events within the last 90 days.
GovCloud: IAM is global and partition-scoped: identifiers created in GovCloud carry arn:aws-us-gov and are entirely separate from the commercial partition's, so an identifier inventory taken in one partition says nothing about the other. IAM Identity Center operates in both AWS GovCloud (US) Regions over FIPS endpoints with NO multi-Region support, and its ARNs take the form arn:aws-us-gov:sso:::instance/<id> — so list-instances must be run in the Region the instance was enabled in, and PrimaryRegion in the response is what says which that is. CloudTrail management events are available in both GovCloud Regions, but note that CloudFront, IAM and STS events are delivered to us-gov-west-1 specifically: IAM is a global service, so the DeleteUser events this recipe reads land in US-West and a lookup-events call issued against US-East returns nothing while succeeding.
IA-04 is four verbs — receive authorization for an identifier, select it, assign it to the intended party, and prevent its reuse for a defined period. AWS proves the third completely, gives real but bounded evidence for the fourth, and returns nothing at all for the first two.
Assignment is the easy half and it is genuinely complete: every identifier in the account is enumerable with the date it was assigned and the AWS-generated unique id behind it. Selection and authorization are records about a decision, not about a resource, and no call returns them.
Reuse is where this control is usually mis-evidenced, and AWS's own documentation is unusually direct about why. Within an account a friendly name for a user, group, role or policy must be unique — but only while it exists. AWS documents the exact failure IA-04's reuse clause exists to prevent: an employee named John leaves, the IAM user John is deleted, a new employee named John arrives, a new IAM user John is created, and a policy written against the friendly name grants the new John access to what the old John left behind. Nothing in AWS prevents that. There is no cooling-off period, no reserved-name list, no setting. What AWS guarantees instead is narrower and worth reading precisely: the unique id is never reused, so the old John's AIDA... and the new John's AIDA... differ, and a resource-based policy or an aws:userId condition written against the unique id cannot be inherited by a successor. The identifier that is protected from reuse is the one nobody uses in policy by default.
So the honest evidence for the reuse limb is a comparison, not a field: DeleteUser events dated against the CreateDate of a live identifier bearing the same name — UserDetailList[].CreateDate is collected here and is the right side of that comparison, which is why no CreateUser lookup is listed. Note what to read on the left side: Username on a lookup-events entry is the REQUESTER, the administrator who ran the deletion, so the deleted identifier has to come out of the CloudTrailEvent JSON at requestParameters.userName. Reading Username as the deleted name is the easy mistake and it produces a comparison against the wrong string entirely. Three limits on the comparison, all hard. lookup-events reads management events for the last 90 DAYS only, so any reuse period your SSP states beyond 90 days cannot be evidenced from this call at all — a CloudTrail Lake event data store or an S3 trail with a longer retention is what closes that, and it is a different collection. The call is also Region-bound in a way that has nothing to do with GovCloud: IAM is a global service, its events are recorded in one Region, and lookup-events shows them in the Region where they occurred — so the same command run anywhere else returns an empty Events[] while succeeding, which is indistinguishable from a window in which nobody was deleted. And the comparison itself is a join between two commands' outputs, which the assertion grammar cannot express: it compares a field to a constant, never one response to another. Both of those are why the reuse limb is written into the notes as a manual reconciliation rather than dressed up as a clause below.
The IAM users this recipe enumerates are also, on a well-run estate, the smallest part of the answer. Workforce identifiers should be issued in an external identity provider and reach AWS through IAM Identity Center, where the identifier's authority is the ExternalIds pair naming the issuer — which is what the third call reads. An account with an empty UserDetailList and a populated identity store is in better shape than the reverse, and an assertion that ranges over IAM users will say almost nothing about it. Read the two together or read neither.
The identity-store calls are collected as EVIDENCE and deliberately carry no assertion. An earlier draft asserted that an Identity Center instance exists and that every identity-store user carries ExternalIds; both were withdrawn, because they fail an account that federates through an IAM SAML provider instead — which is a different architecture, not an IA-04 defect — and because the second is vacuous anyway on an instance with zero users, so the first does not guard it. An assertion that encodes an architectural preference as a control failure is worse than a paragraph saying which architecture this evidence reads best.
Emptiness traps to state rather than assert. list-instances returns a list whose documented minimum length is zero and whose maximum is ten, so an account with no instance returns an empty Instances[] rather than an error, and reading only the first entry misses up to nine. A DeleteUser lookup over a window in which nobody was deleted returns an empty Events[], indistinguishable from a window in which the trail was not recording. And GroupDetail carries no Tags, so the authorization-tag clause below cannot be extended to group identifiers at all; device and service identifiers are outside every call here.
Roles and groups are collected alongside users because their names are identifiers the control covers, and because the same reuse behaviour applies: RoleId and GroupId are never reused, while RoleName and GroupName are free the moment the resource is deleted.
The state of every credential in the account as of a stated moment, the enabled-or-disabled status of every workforce identity in the identity store, and the CloudTrail record of the revocations themselves — the five API calls that actually revoke standing access, each with the time it happened and the administrator who did it
partial · cli · every continuous · /collect/personnel-separation-access-revocation
$ aws iam generate-credential-report
$ aws iam get-credential-report
$ aws identitystore list-users --identity-store-id <IDENTITY_STORE_ID>
$ aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=DeleteUser --start-time <T0> --end-time <T1>
$ aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=DeleteLoginProfile --start-time <T0> --end-time <T1>
$ aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=DeleteAccessKey --start-time <T0> --end-time <T1>
$ aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=UpdateAccessKey --start-time <T0> --end-time <T1>
$ aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=DeactivateMFADevice --start-time <T0> --end-time <T1>
Proves: PS-04
Expected output: From generate-credential-report, a State of STARTED, INPROGRESS or COMPLETE — AWS stores ONE report per account and regenerates it at most once every four hours, so a request made inside that window silently returns the existing report rather than a fresh one. From get-credential-report, Content (the report, base64-encoded CSV), ReportFormat, and GeneratedTime — the timestamp that says what moment the evidence describes, and the field the freshness assertion reads. The CSV columns are: user, arn, user_creation_time, password_enabled, password_last_used, password_last_changed, password_next_rotation, mfa_active, access_key_1_active, access_key_1_last_rotated, access_key_1_last_used_date, access_key_1_last_used_region, access_key_1_last_used_service, the same five for access_key_2, cert_1_active, cert_1_last_rotated, cert_2_active, cert_2_last_rotated, and additional_credentials_info. Note what the report does NOT cover, because it bounds the whole recipe: AWS documents it as including only passwords, THE FIRST TWO access keys per user, MFA devices and X.509 signing certificates — service-specific credentials such as CodeCommit passwords are absent, as is any access key beyond the second, and additional_credentials_info is the only hint that more exist. From identitystore list-users, Users[] with UserId, UserName, ExternalIds and UserStatus, which is ENABLED or DISABLED. There are five lookup calls rather than one because lookup-events accepts exactly ONE lookup attribute per request, and revocation is five different API calls: deleting the user, deleting the console login profile, deleting an access key, setting an access key Inactive, and deactivating the MFA device. From each lookup-events call, Events[] with EventName, EventTime, Username — the REQUESTER who performed the revocation, not the identity revoked — and the CloudTrailEvent JSON, where requestParameters names the user or device that was acted on.
GovCloud: IAM, the credential report and CloudTrail all operate in both AWS GovCloud (US) Regions and every call here is available; ARNs use partition arn:aws-us-gov. IAM Identity Center is available in both Regions but has NO multi-Region support, so list-users must be run against the Region its instance was enabled in. The CloudTrail calls carry the one real trap: IAM is a global service and its events are recorded in AWS GovCloud (US-West), us-gov-west-1, while Event history and lookup-events show these events in the Region where they occurred — so a DeleteUser lookup issued against us-gov-east-1 returns an empty Events[] and exits zero, which reads exactly like a period in which nobody was separated. The same shape applies in the commercial partition, where those events land in us-east-1.
PS-04 is a list of things that happen when someone leaves: disable system access within a defined period, revoke the authenticators, conduct an exit interview, retrieve organizational property, retain access to the information the person worked on, and notify named personnel within a period. AWS holds evidence for the first two and nothing whatever for the other four.
And it holds those two only as state and history, never as subject. The credential report says every credential in the account and what shape it is in; CloudTrail says which revocation happened, when, and by which administrator. Five lookups rather than two, because lookup-events takes one lookup attribute per call and revocation is not one API: an estate that follows this recipe's own advice and DISABLES rather than deletes never emits DeleteUser at all, so a collection that watched only for deletions would return an empty Events[] on a correctly executed separation. UpdateAccessKey is in the list for the same reason — setting a key Inactive is a revocation that deletes nothing. What no call knows is that a PERSON was terminated. There is no roster in AWS, no employment status, no separation date — so the join between 'this human left on the 4th' and 'this identity was revoked on the 6th' is made by a human against an HR record, and the period the control defines is checked on that join and not here. This recipe produces the AWS-side column. It is partial for that reason and no assertion below pretends otherwise.
That is also why there is exactly one assertion. Any clause of the form 'no user has an active credential' is false on every working account and would be answering about the whole population when the control is about a named few; a clause over 'users your tagging marks as separated' would invent a third tagging convention to answer a question the HR record already answers better. So the single assertion is about the EVIDENCE rather than the estate: that the report describes a recent moment. A credential report is a snapshot with a timestamp, and a stale one is a true statement about a past that has already been superseded.
Read the four-hour rule as a collection constraint, not a detail. AWS stores one credential report per account and regenerates it at most once every four hours; a generate call inside that window returns the report you already had. So generate-then-get does not guarantee freshness, GeneratedTime is what does, and a collector that runs the two calls back to back and assumes the second reflects the first is reporting on a state up to four hours old.
The report's coverage is narrower than its name suggests, and the gap is exactly where a separation goes wrong. AWS documents it as covering passwords, the first two access keys per user, MFA devices and X.509 signing certificates. A third access key is not in it. Service-specific credentials are not in it. A separated user whose remaining access is a CodeCommit password or a long-term service credential appears in this report as fully revoked. additional_credentials_info is the only signal that anything else exists, and closing that gap needs ListAccessKeys and ListServiceSpecificCredentials per user, which this recipe does not collect and an assessment should.
On a well-run estate the IAM half is the small half. Workforce identities live in the identity store and a separation there is UserStatus DISABLED rather than a deletion, which is why the third call is collected: an account whose credential report lists no IAM user for a departed employee, while the identity store still lists them as ENABLED, has not revoked anything — and the credential report, which sees only IAM, cannot show it. Disabling rather than deleting is also the better practice for this control, because it preserves the identifier — see the IA-04 recipe on why a freed name is a hazard.
One KSI was deliberately not claimed. KSI-IAM-SUS asks that privileged accounts be disabled or secured IN RESPONSE TO SUSPICIOUS ACTIVITY, and a routine separation is not suspicious activity; the output is identical in both cases and cannot tell an assessor which one it is looking at. KSI-IAM-JIT was dropped for a quieter reason. Its statement asks for a least-privileged, role and attribute-based, and JUST-IN-TIME authorization model, persistently reviewed. Nothing in five commands over STANDING credentials evaluates a just-in-time or attribute-based model; only the 'persistently reviewed' tail is touched, and that tail is already what KSI-IAM-ELP carries here. KSI-IAM-ELP is earned outright: an identity that outlives its holder is precisely a failure of the rule that each user can access only what they need.
What a transferred individual can still reach, and what they have actually used: the reassignment events themselves from CloudTrail (group membership, attached policy, and Identity Center account-assignment changes, each with its time and the administrator who made it), the current Identity Center assignments per permission set, and IAM's service-last-accessed report for the identities involved — a per-principal view of which services the identity is permitted to reach and which of those it has never authenticated to. `iam-access-analyzer-unused-access` answers the same question estate-wide from findings; this answers it for the named principal a transfer is about.
partial · cli · every continuous · /collect/personnel-transfer-access-reassignment
$ aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=AddUserToGroup --start-time <T0> --end-time <T1>
$ aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=RemoveUserFromGroup --start-time <T0> --end-time <T1>
$ aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=AttachUserPolicy --start-time <T0> --end-time <T1>
$ aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=DetachUserPolicy --start-time <T0> --end-time <T1>
$ aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=CreateAccountAssignment --start-time <T0> --end-time <T1>
$ aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=DeleteAccountAssignment --start-time <T0> --end-time <T1>
$ aws sso-admin list-permission-sets --instance-arn <INSTANCE_ARN>
$ aws sso-admin list-account-assignments --instance-arn <INSTANCE_ARN> --account-id <ACCOUNT_ID> --permission-set-arn <PERMISSION_SET_ARN>
$ aws iam generate-service-last-accessed-details --arn <PRINCIPAL_ARN> --granularity ACTION_LEVEL
$ aws iam get-service-last-accessed-details --job-id <JOB_ID>
Proves: PS-05
Expected output: From each lookup-events call, Events[] with EventName, EventTime, Username — the REQUESTER who made the change, never the identity changed — and CloudTrailEvent, whose requestParameters names the user, group, policy or account assignment acted on. Six calls rather than one because lookup-events accepts exactly ONE lookup attribute per request and a reassignment is six different APIs across two services; the window is bounded at 90 days by the API. From list-permission-sets, PermissionSets[] of ARNs. From list-account-assignments, AccountAssignments[] with AccountId, PermissionSetArn, PrincipalId and PrincipalType (USER or GROUP) — for ONE permission set in ONE account, which is why list-permission-sets precedes it: the assignment set of an account is the union over its permission sets and no single call returns it. From generate-service-last-accessed-details, a JobId (36 characters); from get-service-last-accessed-details, JobStatus (IN_PROGRESS | COMPLETED | FAILED) and ServicesLastAccessed[] with ServiceName, ServiceNamespace, LastAuthenticated, LastAuthenticatedEntity and TotalAuthenticatedEntities, plus TrackedActionsLastAccessed[] when the request asked for ACTION_LEVEL. A service the principal is permitted to call and has never called comes back with the entry PRESENT and LastAuthenticated ABSENT, which is the shape that makes unused-but-granted access visible at all.
GovCloud: IAM, CloudTrail and IAM Identity Center all operate in both AWS GovCloud (US) Regions; ARNs use partition arn:aws-us-gov, and the sso-admin ARN patterns accept it. Two partition traps, both real here. IAM is global and its events are recorded in AWS GovCloud (US-West), us-gov-west-1, while lookup-events shows events in the Region where they occurred — so the four IAM lookups issued against us-gov-east-1 return an empty Events[] and exit zero, which reads exactly like a period in which nobody transferred. The equivalent commercial Region is us-east-1. Second, IAM Identity Center has no multi-Region support in GovCloud, so the sso-admin calls must be issued against the Region its instance was enabled in. What AWS documents is the absence of multi-Region support; what a call issued against the other Region actually does is not documented on that page and this recipe does not guess. Establish which Region holds the instance before collecting, rather than inferring it from a response.
PS-05 is about a person moving inside the organization: the access that fitted the old role is reviewed, what is no longer needed is removed, and the transfer is completed within a period the provider defines. AWS holds two-thirds of that and cannot hold the rest.
What it holds is the CHANGE and the STATE. CloudTrail records the six APIs a reassignment actually travels through — two for group membership, two for attached user policies, two for Identity Center account assignments — each with a timestamp and the administrator who made it. Identity Center holds the assignments as they stand today. What no call knows is that a person MOVED. There is no roster in AWS, no job title, no transfer date, so the join between 'this human changed teams on the 4th' and 'this principal lost the finance permission set on the 9th' is made by a human against an HR record, and the period the control defines is checked on that join. This recipe produces the AWS-side column and the rating says so.
The third call set is the one that earns its place. A separation is visible as an absence; a transfer is visible as a RESIDUE — the old permission that nobody removed because nothing failed when it stayed. Service-last-accessed is the signal that names it per principal: for a principal it lists every service the identity could reach under its permissions policies, with the last time it authenticated, and an entry with no LastAuthenticated is a permission granted and never exercised. Run against the transferred principal it turns 'review the access that no longer fits' from an interview into a list. It is not the only route to that question — `iam-access-analyzer-unused-access` answers it estate-wide from findings — but it is the one that answers it about a NAMED identity, which is the unit a transfer is measured in.
Read its four documented limits before quoting it. It reports for at least the last 400 days, and less in a Region that began supporting the feature within the last year — so a young Region reports a shorter history than the reviewer assumes. Recent activity usually appears within four hours, so a report pulled immediately after a transfer describes the state before it. It records ATTEMPTS and not successes: a denied call still marks a service as accessed, and AWS names CloudTrail as the authoritative source for whether a call succeeded. And it applies permissions-policy logic ONLY — resource-based policies, ACLs, Organizations policies, permissions boundaries and STS assume-role trust are all excluded, so an identity that reaches a bucket purely through a bucket policy is invisible to it. That last one bounds the whole claim: 'no unused permissions' from this report is a statement about identity policies, not about reach.
list-account-assignments takes an account id AND a permission set ARN, and returns the assignees of that one pair. There is no call that returns an account's assignments whole, which is why list-permission-sets runs first and the collection is a loop. A single unlooped call would be a slice of one permission set presented as an account's access, and an assertion over it would be green on an estate where every other permission set is wrong.
The authentication indicators are untouched here — nothing collected reads a factor. KSI-IAM-ELP is earned outright: a permission that fits a role the holder has left is exactly the failure of 'each user can access only what they need'. KSI-IAM-JIT is deliberately NOT claimed, on this overlay's own precedent: the PS-04 recipe dropped it because nothing evaluated over STANDING credentials examines a just-in-time or attribute-based model, and only the 'persistently reviewed' tail is touched — a tail KSI-IAM-ELP already carries. Everything collected here is standing too: standing credentials, standing group membership, standing account assignments. The just-in-time half is the Identity Center elevation recipe's to answer, and claiming it here would have had two sibling recipes taking opposite positions on one indicator over one class of telemetry.
SVC — Service Configuration
17 recipes · 13 of 21 controls in scope reached
AWS Config compliance results across the storage services proving customer data is encrypted at rest — S3 buckets with default server-side encryption, EBS volumes encrypted, and RDS storage encrypted, all backed by KMS
partial · config-rule · every continuous · /collect/config-encryption-at-rest
$ aws configservice get-compliance-details-by-config-rule --config-rule-name encrypted-volumes --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name rds-storage-encrypted --compliance-types NON_COMPLIANT
$ aws configservice describe-configuration-recorder-status
$ aws configservice describe-config-rule-evaluation-status --config-rule-names encrypted-volumes
$ aws configservice describe-config-rule-evaluation-status --config-rule-names rds-storage-encrypted
$ aws ec2 describe-regions --query 'Regions[].RegionName' --output text
$ aws organizations list-accounts --query 'Accounts[].Id' --output text
Proves: SC-28
Expected output: Two EvaluationResults arrays; an empty NON_COMPLIANT set from each rule means every evaluated S3 bucket, attached EBS volume, and RDS instance is encrypted at rest. Managed rule identifiers: ENCRYPTED_VOLUMES, RDS_STORAGE_ENCRYPTED Plus describe-configuration-recorder-status showing recording=true and, per rule, describe-config-rule-evaluation-status showing FirstEvaluationStarted=true with a LastSuccessfulEvaluationTime — the proof the empty set was produced by a check that ran.
GovCloud: AWS Config and all three managed rules are available in AWS GovCloud (US); bucket, volume, DB, and KMS key ARNs use partition arn:aws-us-gov
These rules prove encryption is present, not which key backs it. Pass ENCRYPTED_VOLUMES and RDS_STORAGE_ENCRYPTED a kmsId/kmsKeyId parameter to additionally assert a specific CMK rather than any key. These three cover the dominant data stores; extend with the analogous rules for DynamoDB (dynamodb-table-encrypted-kms), EFS (efs-encrypted-check), SNS (sns-encrypted-kms), and other services you actually run — the recipe is the pattern, not the exhaustive list. The S3 rule was removed from this recipe: S3 has applied SSE-S3 to every bucket since 5 January 2023 and it cannot be disabled, so s3-bucket-server-side-encryption-enabled cannot fail and proved nothing. ENCRYPTED_VOLUMES sees attached volumes only, leaving detached volumes and every snapshot unproven, and no CMK is asserted — a human names the data stores in scope, confirms each has a rule, and reads key ownership for SC-28 (01), which is not claimed.
AWS Config compliance results proving data in transit is protected by TLS — S3 bucket policies denying non-TLS requests, load-balancer listeners restricted to SSL/HTTPS, and Redshift clusters requiring SSL
partial · config-rule · every continuous · /collect/config-encryption-in-transit
$ aws configservice get-compliance-details-by-config-rule --config-rule-name s3-bucket-ssl-requests-only --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name elb-tls-https-listeners-only --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name redshift-require-tls-ssl --compliance-types NON_COMPLIANT
$ aws configservice describe-configuration-recorder-status
$ aws configservice describe-config-rule-evaluation-status --config-rule-names s3-bucket-ssl-requests-only
$ aws configservice describe-config-rule-evaluation-status --config-rule-names elb-tls-https-listeners-only
$ aws configservice describe-config-rule-evaluation-status --config-rule-names redshift-require-tls-ssl
$ aws ec2 describe-regions --query 'Regions[].RegionName' --output text
$ aws organizations list-accounts --query 'Accounts[].Id' --output text
Proves: SC-08
Expected output: Three EvaluationResults arrays; an empty NON_COMPLIANT set from each rule means every evaluated S3 bucket enforces aws:SecureTransport, every Classic Load Balancer listener is SSL/HTTPS, and every Redshift cluster sets require_SSL=true. Managed rule identifiers: S3_BUCKET_SSL_REQUESTS_ONLY, ELB_TLS_HTTPS_LISTENERS_ONLY, REDSHIFT_REQUIRE_TLS_SSL Plus describe-configuration-recorder-status showing recording=true and, per rule, describe-config-rule-evaluation-status showing FirstEvaluationStarted=true with a LastSuccessfulEvaluationTime — the proof the empty set was produced by a check that ran.
GovCloud: AWS Config and all three managed rules are available in AWS GovCloud (US); bucket, load-balancer, and cluster ARNs use partition arn:aws-us-gov
The SC-08.01 enhancement (cryptographic protection in transit) is the TLS requirement these rules assert. ELB_TLS_HTTPS_LISTENERS_ONLY covers Classic Load Balancers only — for Application/Network Load Balancers add elbv2-acm-certificate-required and alb-http-to-https-redirection-check, and pair with acm-certificate-expiration-check so the certs terminating TLS are valid. These three cover the dominant public data paths; extend to the services you actually run (e.g. api-gw-ssl-enabled, elasticsearch-node-to-node-encryption-check). ELB_TLS_HTTPS_LISTENERS_ONLY evaluates Classic Load Balancers only and returns NOT_APPLICABLE where there is no listener, so on an ALB/NLB estate that assertion passes having examined nothing; whether the TLS that terminates is FIPS-validated is carried by the listener's SslPolicy, which no command here reads, and a human confirms it — SC-08 (01) is not claimed.
AWS Config compliance results proving KMS customer-managed keys are lifecycle-managed — automatic annual rotation enabled and no active key scheduled for deletion — the key-hygiene half of the cryptographic-protection control
partial · config-rule · every continuous · /collect/config-kms-key-management
$ aws configservice get-compliance-details-by-config-rule --config-rule-name cmk-backing-key-rotation-enabled --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name kms-cmk-not-scheduled-for-deletion --compliance-types NON_COMPLIANT
Proves: SC-13
Expected output: Two EvaluationResults arrays; empty NON_COMPLIANT sets mean automatic rotation is enabled on every eligible customer-managed CMK and no CMK protecting live data is pending deletion. Managed rule identifiers: CMK_BACKING_KEY_ROTATION_ENABLED, KMS_CMK_NOT_SCHEDULED_FOR_DELETION
GovCloud: AWS Config and both managed rules are available in AWS GovCloud (US); KMS key ARNs use partition arn:aws-us-gov. GovCloud is served by FIPS 140-validated KMS endpoints by default
Rated partial: these rules prove key management (rotation, retention) as telemetry, but SC-13's core requirement — that the cryptographic module itself is FIPS 140-2/3 validated — is satisfied by using AWS KMS FIPS endpoints (kms-fips.<region>.amazonaws.com) and validated modules, which is a documented configuration/architecture assertion the compliance result does not itself carry. Attach the KMS FIPS endpoint usage evidence and the CMP module certificate reference alongside these results. Rotation is not supported for asymmetric/HMAC/imported-material/custom-key-store keys — scope the rotation rule accordingly.
AWS Config compliance results proving the network boundary is controlled — no security group exposes SSH to the internet, groups open to 0.0.0.0/0 only allow authorized ports, and every VPC's default security group denies all traffic
partial · config-rule · every continuous · /collect/config-network-boundary-protection
$ aws configservice get-compliance-details-by-config-rule --config-rule-name restricted-ssh --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name vpc-sg-open-only-to-authorized-ports --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name vpc-default-security-group-closed --compliance-types NON_COMPLIANT
$ aws configservice describe-configuration-recorder-status
$ aws configservice describe-config-rule-evaluation-status --config-rule-names restricted-ssh
$ aws configservice describe-config-rule-evaluation-status --config-rule-names vpc-sg-open-only-to-authorized-ports
$ aws configservice describe-config-rule-evaluation-status --config-rule-names vpc-default-security-group-closed
$ aws ec2 describe-regions --query 'Regions[].RegionName' --output text
$ aws organizations list-accounts --query 'Accounts[].Id' --output text
Proves: SC-07
Expected output: Three EvaluationResults arrays; empty NON_COMPLIANT sets mean no security group leaves SSH (port 22) open to 0.0.0.0/0 or ::/0, any internet-open group is limited to the authorizedTcpPorts/authorizedUdpPorts you set, and every default security group is closed. Managed rule identifiers: INCOMING_SSH_DISABLED (rule name restricted-ssh), VPC_SG_OPEN_ONLY_TO_AUTHORIZED_PORTS, VPC_DEFAULT_SECURITY_GROUP_CLOSED Plus describe-configuration-recorder-status showing recording=true and, per rule, describe-config-rule-evaluation-status showing FirstEvaluationStarted=true with a LastSuccessfulEvaluationTime — the proof the empty set was produced by a check that ran.
GovCloud: AWS Config and all three managed rules are available in AWS GovCloud (US); VPC and security-group ARNs use partition arn:aws-us-gov
Set VPC_SG_OPEN_ONLY_TO_AUTHORIZED_PORTS parameters (authorizedTcpPorts/authorizedUdpPorts) to your documented ingress allow-list, otherwise any 0.0.0.0/0 rule is NON_COMPLIANT. These three cover security-group ingress; for full SC-07 boundary evidence also collect NACL and subnet routing posture and, where used, restricted-common-ports and vpc-flow-logs-enabled. Note VPC_DEFAULT_SECURITY_GROUP_CLOSED may lag on deleted VPCs until the next baselining pass. These three rules read security-group ingress and nothing else; SC-07(b) — the logical separation FedRAMP's own guidance singles out — plus NACLs, route tables, gateways and VPC endpoints are a human's architecture assertion against this output. MAS-CSO-FLO is not claimed: its artifact is the enumeration of permitted connections, and a NON_COMPLIANT filter is empty on a compliant estate.
AWS Config compliance results proving continuous security monitoring is switched on account-wide — GuardDuty threat detection enabled (optionally centralized to a delegated admin) and Security Hub aggregating control findings
partial · config-rule · every continuous · /collect/config-threat-monitoring-enabled
$ aws configservice get-compliance-details-by-config-rule --config-rule-name guardduty-enabled-centralized --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name securityhub-enabled --compliance-types NON_COMPLIANT
$ aws configservice describe-configuration-recorder-status
$ aws configservice describe-config-rule-evaluation-status --config-rule-names guardduty-enabled-centralized
$ aws configservice describe-config-rule-evaluation-status --config-rule-names securityhub-enabled
$ aws ec2 describe-regions --query 'Regions[].RegionName' --output text
$ aws organizations list-accounts --query 'Accounts[].Id' --output text
Proves: SI-04
Expected output: Two EvaluationResults arrays; empty NON_COMPLIANT sets mean GuardDuty is enabled in the account/Region (and results land in the CentralMonitoringAccount if you set one) and Security Hub is enabled. Managed rule identifiers: GUARDDUTY_ENABLED_CENTRALIZED, SECURITYHUB_ENABLED Plus describe-configuration-recorder-status showing recording=true and, per rule, describe-config-rule-evaluation-status showing FirstEvaluationStarted=true with a LastSuccessfulEvaluationTime — the proof the empty set was produced by a check that ran.
GovCloud: AWS Config, GuardDuty, and Security Hub are available in AWS GovCloud (US); detector and hub ARNs use partition arn:aws-us-gov
This proves the monitoring capability is ON, which is the automatable half of SI-04. Whether findings are triaged and acted on within your SLA is the review workflow — surface that with the GuardDuty finding-plus-response recipe (see guardduty-suspicious-iam-activity-response) and AU-06 log review. Set CentralMonitoringAccount to your delegated-administrator account id in a multi-account org so member accounts are evaluated against the aggregation point. Both rules are evaluated per Region, so GuardDuty disabled in any Region other than the one queried is invisible; and whether findings are analyzed and acted on — SI-04(a), (b), (d) — is a human workflow. Partial for both reasons.
AWS Config compliance results plus the State Manager association list proving a defined configuration is actually applied and re-applied to every managed node — instances are under SSM management, and the associations that carry your baseline report COMPLIANT on a schedule rather than drifting
partial · cli · every continuous · /collect/ssm-configuration-baseline-enforced
$ aws configservice get-compliance-details-by-config-rule --config-rule-name ec2-instance-managed-by-systems-manager --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name ec2-managedinstance-association-compliance-status-check --compliance-types NON_COMPLIANT
$ aws ssm list-associations --query 'Associations[].{Name:Name,AssociationId:AssociationId,Schedule:ScheduleExpression,Targets:Targets,Status:Overview.Status,LastRun:LastExecutionDate}'Proves: CM-02, CM-06
Expected output: Two EvaluationResults arrays plus an Associations list. Empty NON_COMPLIANT sets mean every running EC2 instance has a running SSM Agent and every SSM association compliance record reads COMPLIANT after execution; the Associations list names the documents, schedules, and targets that carry the baseline, with Overview.Status and LastExecutionDate showing it ran. Managed rule identifiers: EC2_INSTANCE_MANAGED_BY_SSM (rule name ec2-instance-managed-by-systems-manager), EC2_MANAGEDINSTANCE_ASSOCIATION_COMPLIANCE_STATUS_CHECK
GovCloud: AWS Config and Systems Manager are available in AWS GovCloud (US); instance, document, and association ARNs use partition arn:aws-us-gov
The telemetry proves a configuration is being enforced and drift corrected — it does not prove the enforced content IS your approved baseline. That the association's SSM document encodes the hardened settings you baselined (CIS/STIG content, approved through your change process) is the human judgement half; keep the document version and its approval record alongside this output. Two limits to state plainly: EC2_INSTANCE_MANAGED_BY_SSM does not flag a stopped instance whose agent is running, and this whole recipe is EC2-only — container images, Lambda, and managed-service settings need their own baseline evidence. CM-08 inventory is a separate recipe, not this one.
The machine-maintained component inventory — Config's recorder status and discovered-resource counts proving supported resources are tracked continuously and the list stays current without anyone editing a spreadsheet, plus Systems Manager Inventory's node and installed-application metadata for what runs inside them
partial · cli · every daily · /collect/config-asset-inventory
$ aws configservice describe-configuration-recorder-status --query 'ConfigurationRecordersStatus[].{Name:name,Recording:recording,LastStatus:lastStatus,LastStart:lastStartTime,Error:lastErrorMessage}'$ aws configservice get-discovered-resource-counts
$ aws configservice select-resource-config --expression "SELECT resourceId, resourceType, awsRegion WHERE resourceType = 'AWS::EC2::Instance'"
$ aws ssm get-inventory --aggregators Expression=AWS:InstanceInformation.PlatformType
$ aws ssm list-inventory-entries --instance-id i-0123456789abcdef0 --type-name AWS:Application
Proves: CM-08
Expected output: A recorder status with recording true and lastStatus SUCCESS — read this first, because a stopped or failing recorder makes everything below stale — then a resourceCounts array giving a count per resourceType alongside totalDiscoveredResources, a Results list naming each recorded resource of the type you queried, an aggregation of managed nodes grouped by platform, and an Entries list of installed applications stamped with the CaptureTime they were collected.
GovCloud: AWS Config and Systems Manager Inventory are available in AWS GovCloud (US-East) and (US-West); resource and node ARNs use partition arn:aws-us-gov
This proves the inventory is machine-maintained and current (CM-08.01, and the automated-currency half of CM-02.02) — it does not prove the inventory is complete. Config sees only supported resource types, only in the regions and accounts where a recorder runs, and only within the recording group you configured; unsupported types, an un-recorded region, on-premises hosts, SaaS components and in-container software are invisible here and need their own source. SSM Inventory covers only managed nodes with a running agent and an inventory association, collects no more often than every 30 minutes, and the console's Inventory cards hide stopped and terminated nodes even though the API still returns them. The accountability attributes CM-08 asks for — system owner, function, criticality — live in your tags or CMDB, not in a resource count, so join them before calling this an inventory. Substitute your real instance id. Detecting unauthorized components (CM-08.03) is a different question; the prohibited-software half is in the least-functionality recipe.
Config compliance results proving the ports you declared unnecessary are not reachable from the internet and the software you declared prohibited is not installed, plus the actual installed-application set a periodic review has to read
partial · cli · every monthly · /collect/config-least-functionality
$ aws configservice get-compliance-details-by-config-rule --config-rule-name restricted-common-ports --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name ec2-managedinstance-applications-blacklisted --compliance-types NON_COMPLIANT
$ aws ssm list-inventory-entries --instance-id i-0123456789abcdef0 --type-name AWS:Application
Proves: CM-07
Expected output: Two EvaluationResults arrays plus an inventory listing. Empty NON_COMPLIANT sets mean no security group opens a blocked TCP port to 0.0.0.0/0 or ::/0 and none of the denylisted applications is installed on any evaluated managed node; the Entries list, stamped with its CaptureTime, is the installed-software set your periodic review actually reads. Managed rule identifiers: RESTRICTED_INCOMING_TRAFFIC (rule name restricted-common-ports), EC2_MANAGEDINSTANCE_APPLICATIONS_BLACKLISTED
GovCloud: AWS Config, both managed rules, and Systems Manager Inventory are available in AWS GovCloud (US); security-group and node ARNs use partition arn:aws-us-gov
These prove the negatives you asserted and hand the reviewer the real installed-application set — they do not prove least functionality. That the functions, ports, protocols and services still enabled are the minimum necessary is a judgement against your documented essential-capability list, and CM-07.01's periodic review is a decision someone makes and records, not an API result; keep the review record next to this output. Both rules are only as strong as their parameters. RESTRICTED_INCOMING_TRAFFIC defaults to blocking TCP 20, 21, 3389, 3306 and 4333 — set blockedPorts to your real denylist or you are testing AWS's defaults, not your policy. EC2_MANAGEDINSTANCE_APPLICATIONS_BLACKLISTED needs exact application names (no wildcards, and the name differs per distro) and evaluates AWS::SSM::ManagedInstanceInventory, so a node with no running agent or inventory association is simply not evaluated rather than flagged — pair it with the inventory recipe's coverage check. Security-group ingress deliberately overlaps the SC-07 boundary recipe: there it proves boundary protection, here it proves unnecessary ports are closed.
Cryptographic proof that the audit trail CloudTrail delivered has not been altered or deleted, plus the compliance state of the write-once controls that make stored records and container images tamper-evident
partial · cli · every weekly · /collect/integrity-verification-and-immutability
$ aws configservice get-compliance-details-by-config-rule --config-rule-name cloud-trail-log-file-validation-enabled --compliance-types NON_COMPLIANT
$ aws cloudtrail validate-logs --trail-arn <TRAIL_ARN> --start-time <START_TIME> --verbose
$ aws configservice get-compliance-details-by-config-rule --config-rule-name s3-bucket-default-lock-enabled --compliance-types NON_COMPLIANT
$ aws backup describe-backup-vault --backup-vault-name evidence-vault --query '{Locked:Locked,LockDate:LockDate,MinRetentionDays:MinRetentionDays,MaxRetentionDays:MaxRetentionDays}'$ aws configservice get-compliance-details-by-config-rule --config-rule-name ecr-private-tag-immutability-enabled --compliance-types NON_COMPLIANT
Expected output: Three EvaluationResults arrays, a validation run and a vault description. Empty NON_COMPLIANT sets mean every trail signs digest files, every evaluated bucket has Object Lock on by default and every private ECR repository refuses to move a tag. validate-logs prints the window it actually found and two ratios — for example '3/3 digest files valid' and '15/15 log files valid'; any shortfall names the file. describe-backup-vault returns Locked true with a LockDate, the UTC instant the compliance-mode grace time ends. Managed rule identifiers: CLOUD_TRAIL_LOG_FILE_VALIDATION_ENABLED, S3_BUCKET_DEFAULT_LOCK_ENABLED, ECR_PRIVATE_TAG_IMMUTABILITY_ENABLED
GovCloud: All five calls work in AWS GovCloud (US): none of the three managed rules names a GovCloud Region in its exclusion list, and AWS Backup Vault Lock is documented among the features offered for all supported resources with no Region carve-out — unlike restore testing and logically air-gapped vaults, which are blank for both GovCloud rows in the feature-availability table. Trail, bucket, vault and repository ARNs use partition arn:aws-us-gov, and because CloudTrail uses a different key pair per Region, validate the logs in the Region that produced them
The strong claim here is narrow and worth stating precisely. validate-logs is real cryptography — SHA-256 hashing with SHA-256/RSA signing, an hourly digest file that references the last hour's log files and carries the signature of the previous digest — so a clean run positively asserts that the delivered log files were not modified or deleted, and can even assert that no log files were delivered in a window you believed was empty. What it will not do: validate files you moved, since they must stay where CloudTrail put them; and it cannot report tampering across a gap — disable validation for an hour and no digest exists for that hour, so the chain simply breaks. Enabling the feature is not the same as checking it, which is why both the Config rule and the CLI run belong here: the rule proves digests are being produced, the run is the only thing that verifies them. Object Lock and Vault Lock are prevention, not detection — they make a deletion fail rather than proving none happened, and Object Lock only counts if the mode and period match your policy: the rule's optional mode parameter is what pins GOVERNANCE versus COMPLIANCE, and unset it passes either. Vault Lock in governance mode can be removed by anyone holding the IAM permission, so read Locked together with LockDate — before that date even a compliance-mode lock is still removable. The honest gap is the host: SI-7 asks for integrity verification of software, firmware and information, and nothing above watches a filesystem. ECR tag immutability stops a tag being repointed at a different image but says nothing about drift inside a running instance; file integrity monitoring is third-party or self-built on AWS, and SI-7(1)'s ‘defined frequency’ is a policy number you compare against, not an API result. Finally, a NON_COMPLIANT-only query returns an empty array on success and says nothing about resources Config never evaluated — join it against recorder coverage before reading emptiness as compliance.
Every running instance built from an image you never approved and every node carrying denylisted software, together with proof that an automated action was configured for those findings and a record of what it did when one fired
partial · cli · every continuous · /collect/unauthorized-component-detection-and-response
$ aws configservice get-compliance-details-by-config-rule --config-rule-name approved-amis-by-tag --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name ec2-managedinstance-applications-blacklisted --compliance-types NON_COMPLIANT
$ aws configservice describe-remediation-configurations --config-rule-names approved-amis-by-tag ec2-managedinstance-applications-blacklisted
$ aws configservice describe-remediation-execution-status --config-rule-name approved-amis-by-tag
Expected output: Two EvaluationResults arrays naming each unauthorized component by resource id, then the response half: RemediationConfigurations showing TargetType SSM_DOCUMENT, the TargetId document and version, Automatic true or false, MaximumAutomaticAttempts and RetryAttemptSeconds; and RemediationExecutionStatuses with State QUEUED, IN_PROGRESS, SUCCEEDED, FAILED or UNKNOWN plus per-step StepDetails, InvocationTime and LastUpdatedTime. An empty RemediationConfigurations list is the finding: detection with no configured action does not satisfy CM-8(3)b. Managed rule identifiers: APPROVED_AMIS_BY_TAG, EC2_MANAGEDINSTANCE_APPLICATIONS_BLACKLISTED
GovCloud: AWS Config and both managed rules are available in AWS GovCloud (US) — neither rule's Region list excludes a GovCloud Region — and config.us-gov-east-1 and config.us-gov-west-1 are both listed in the Region-support table for Config remediation actions. But the GovCloud user guide states flatly that AWS Systems Manager documents (SSM documents) for AWS Config remediation actions are not available, so expect to author your own SSM Automation document rather than attach an AWS-managed one, and confirm the document resolves in your Region before you claim the automated half. Instance and document ARNs use partition arn:aws-us-gov
CM-8(3) has two halves and only one comes free. Detection is genuine: APPROVED_AMIS_BY_TAG is configuration-change triggered, so an instance launched from an unapproved image is flagged as it appears rather than at the next sweep, and the remediation records are real evidence of action — TargetId names the document that ran, Automatic separates auto-remediation from a button a human pressed, and StepDetails timestamps each step and quotes the error when one fails. What telemetry cannot supply is the definition of ‘unauthorized’. APPROVED_AMIS_BY_TAG matches on up to ten AMI tag keys or key:value pairs that you assert mean approved, so it tests your tagging discipline as much as your fleet — tag an unvetted image and it becomes compliant. The applications denylist needs exact application names with no wildcards, and the name differs per distribution; it evaluates AWS::SSM::ManagedInstanceInventory, so a node with no agent or no inventory association is simply not evaluated rather than flagged — read it beside the inventory-coverage recipe or you will mistake blindness for cleanliness. Neither rule sees firmware, and neither sees what a container image runs. The response side also carries a judgement someone must record: CM-8(3)b wants a chosen action — disable network access, isolate the component, notify defined personnel — and choosing to isolate a production instance automatically is a risk decision, not a default. Deliberate overlap: EC2_MANAGEDINSTANCE_APPLICATIONS_BLACKLISTED also carries CM-7 and CM-7.01 in the least-functionality recipe, where it proves prohibited software is absent; here the same signal is read as detection of an unauthorized component, with the remediation record attached.
A Region-by-Region inventory of the resources that can hold information, the classification tags you asserted on them, and — where Macie exists — a sampled machine judgement about which S3 buckets actually contain sensitive data
partial · cli · every monthly · /collect/information-location-and-classification
$ aws configservice select-aggregate-resource-config --configuration-aggregator-name <ORG_AGGREGATOR> --expression "SELECT awsRegion, resourceType, COUNT(*) WHERE resourceType IN ('AWS::S3::Bucket', 'AWS::RDS::DBInstance', 'AWS::DynamoDB::Table', 'AWS::EFS::FileSystem') GROUP BY awsRegion, resourceType"$ aws resourcegroupstaggingapi get-resources --tag-filters Key=DataClassification --region us-gov-west-1
$ aws macie2 get-automated-discovery-configuration
$ aws macie2 describe-buckets --query 'buckets[].{bucket:bucketName,region:region,score:sensitivityScore,monitored:automatedDiscoveryMonitoringStatus,lastAnalyzed:lastAutomatedDiscoveryTime,unclassifiable:unclassifiableObjectCount.total}'Expected output: A per-Region, per-type count of information-bearing resources across every account in the aggregator — aggregation queries page at 500 rows by default and plain SELECTs at 25, so page or raise --max-results before treating a result set as the whole estate. Then a ResourceTagMappingList of ARNs carrying your DataClassification key, one Region per call. Then Macie's status ENABLED or DISABLED with firstEnabledAt, lastUpdatedAt, classificationScopeId and sensitivityInspectionTemplateId; and per bucket a sensitivityScore — documented as -1 for a classification error, 1 for an empty bucket, 50 for a bucket excluded from recent analyses, up to 100 for sensitive — alongside automatedDiscoveryMonitoringStatus MONITORED or NOT_MONITORED, lastAutomatedDiscoveryTime and the unclassifiable object count
GovCloud: Amazon Macie is not available in AWS GovCloud (US): the AWS General Reference lists no macie2 endpoint for us-gov-east-1 or us-gov-west-1, and the macie-status-check Config rule is excluded from both GovCloud Regions. In GovCloud the last two commands have nothing to call and CM-12(1)'s automated identification by information type needs another tool. The first two do work — AWS Config in both Regions, and the Resource Groups Tagging API at tagging.us-gov-east-1.amazonaws.com and tagging.us-gov-west-1.amazonaws.com — but Config in GovCloud does not record third-party or custom resource types, so anything you model that way is invisible to the aggregate query. ARNs use partition arn:aws-us-gov
Three different qualities of evidence are stacked here, and conflating them is the trap. The Config aggregate query is solid on where storage lives — resource type by Region, across accounts — and that is the part of CM-12 most often undocumented. The tag query is only as true as your tagging: GetResources by design never returns untagged resources, so an unclassified bucket is absent from the answer rather than flagged, which is precisely backwards for an inventory control; run it beside the aggregate count and treat the difference as your unclassified population. Macie is the only machine-derived opinion about information type, and it is a sample rather than a census — automated sensitive data discovery continually selects representative objects from your buckets and scores each bucket from those, so a MONITORED bucket with a low score means ‘nothing sensitive in what was sampled’, never ‘no sensitive data here’. The unclassifiable object count is the population Macie could not read at all because of storage class or file format, and per-file size quotas mean a large archive can be skipped entirely, so read coverage before reading scores. Macie also only looks at S3: nothing above inspects an RDS table, an EFS volume, a DynamoDB item or a Parameter Store value, and the Region field tells you where a bucket is, which is the CM-12 question, not what is in it. What no command produces is CM-12 itself — the documented location of each information type, the users authorized to access it and the purpose it is held for. That is a record you write and then check against this telemetry, not one you derive from it.
How operators actually reach the environment from outside it: the managed access paths that exist, the logging and encryption configured on them, the session-by-session record of who used them, and the negative check that no instance is directly reachable instead
partial · cli · every weekly · /collect/remote-access-authorization-and-monitoring
$ aws ssm get-document --name SSM-SessionManagerRunShell --document-version '$LATEST' --query Content --output text
$ aws ssm describe-sessions --state History --query 'Sessions[].{owner:Owner,target:Target,start:StartDate,end:EndDate,document:DocumentName,accessType:AccessType,maxDuration:MaxSessionDuration}'$ aws ec2 describe-client-vpn-endpoints --query 'ClientVpnEndpoints[].{id:ClientVpnEndpointId,transport:TransportProtocol,auth:AuthenticationOptions[].Type,connectionLog:ConnectionLogOptions,splitTunnel:SplitTunnel,sessionTimeoutHours:SessionTimeoutHours,serverCert:ServerCertificateArn,selfServicePortal:SelfServicePortalUrl}'$ aws ec2 describe-client-vpn-connections --client-vpn-endpoint-id <CLIENT_VPN_ENDPOINT_ID> --query 'Connections[].{user:Username,commonName:CommonName,clientIp:ClientIp,established:ConnectionEstablishedTime,ended:ConnectionEndTime,status:Status,posture:PostureComplianceStatuses}'$ aws configservice get-compliance-details-by-config-rule --config-rule-name ec2-instance-no-public-ip --compliance-types NON_COMPLIANT
Proves: AC-17
Expected output: The Session Manager preferences document as JSON — s3BucketName, s3KeyPrefix, s3EncryptionEnabled, cloudWatchLogGroupName, cloudWatchEncryptionEnabled, cloudWatchStreamingEnabled, kmsKeyId, runAsEnabled, idleSessionTimeout and maxSessionDuration — which is your Region's entire remote-access logging and encryption configuration in one object. Then one row per terminated session from the past 30 days carrying Owner, Target, StartDate, EndDate, DocumentName, MaxSessionDuration and AccessType of Standard or JustInTime. Then per Client VPN endpoint the transport protocol tcp or udp, the authentication types in use (certificate-authentication, directory-service-authentication or federated-authentication), ConnectionLogOptions with Enabled plus CloudwatchLogGroup and CloudwatchLogStream, SplitTunnel, SessionTimeoutHours of 8, 10, 12 or 24 (default 24) and the server certificate ARN. Then per connection Username (Active Directory authentication only), CommonName, ClientIp, ConnectionEstablishedTime, ConnectionEndTime, Status and any PostureComplianceStatuses — active connections plus only those terminated within the last 60 minutes. Finally the EC2 instances AWS Config evaluated NON_COMPLIANT because a publicIp field is present in their configuration item.
GovCloud: Both access paths exist in AWS GovCloud (US-East) and (US-West). Client VPN endpoints there operate using FIPS 140-3 validated cryptographic modules and a fixed cipher set — TLS 1.3 TLS_AES_256_GCM_SHA384 and TLS_AES_128_GCM_SHA256; TLS 1.2 TLS-ECDHE-RSA/ECDSA-WITH-AES-256-GCM-SHA384 and the AES-128-GCM-SHA256 variants; data channel AES-256-GCM — and AWS advises using the exported client configuration file unmodified rather than configuring other ciphers, which makes AC-17(2) there largely a matter of not breaking the default. Systems Manager runs in both Regions; Change Manager and Incident Manager do not, and State Manager association history cannot be viewed, none of which this recipe touches. ec2-instance-no-public-ip is documented for all supported AWS Regions. Calls to these services must use SSL (HTTPS), and ARNs use partition arn:aws-us-gov
The commands split cleanly across the control family, and the gap in the middle is the one to be honest about. AC-17(1) — automated monitoring and control of remote access — is what session history and connection logs deliver, with two documented blind spots. Session Manager does not log sessions that connect through port forwarding or SSH, because SSH encrypts the session data inside the TLS connection and Session Manager is only the tunnel; an operator who port-forwards leaves a session record with no command content behind it. And describe-sessions reaches back 30 days only, so anything longer is an S3 or CloudWatch Logs query against the destinations named in the preferences document, not an SSM call. Client VPN retention is shorter still — terminated connections drop out of the API after 60 minutes, which makes the log group named in ConnectionLogOptions the only durable record, and Username is populated only for Active Directory authentication, so certificate-authenticated users are identified by CommonName or not at all. AC-17(2) is the strongest link in GovCloud, where the endpoints are FIPS 140-3 modules by construction; the Session Manager equivalent is kmsKeyId in the preferences document, and it is empty unless you set it, so an empty kmsKeyId is a finding rather than a default. AC-17(3) — routing remote access through managed network access control points — is the one nothing here proves. Session Manager and a Client VPN endpoint are managed access points, and ec2-instance-no-public-ip is the closest negative check, but that rule applies only to IPv4 and only to AWS::EC2::Instance: an IPv6-reachable instance, a load balancer fronting SSH, or a third-party jump host is invisible to it. Read the result as 'no EC2 instance carries a public IPv4 address', which is a useful sentence and not the control. AC-17 itself — the documented usage restrictions, configuration requirements and per-type authorization — is a record you write, and this telemetry only shows whether the estate matches it. One warning to carry into the evidence package: Session Manager logs the commands entered and their output, so a credential typed into a session lands in the log group you are about to hand an assessor.
DNSSEC signing status of every public hosted zone, with the key-signing key state and the DS record that carries the chain of trust to the parent
partial · cli · every weekly · /collect/route53-dnssec-signing
$ aws route53 list-hosted-zones --query 'HostedZones[?Config.PrivateZone==`false`].{Id:Id,Name:Name}'$ aws route53 get-dnssec --hosted-zone-id <PUBLIC_ZONE_ID>
$ aws route53domains get-domain-detail --region us-east-1 --domain-name <DOMAIN> --query 'DnssecKeys'
Proves: SC-20
Expected output: One get-dnssec response per public hosted zone under the projection root public-zone-dnssec: Status.ServeSignature (SIGNING | NOT_SIGNING | DELETING | ACTION_NEEDED | INTERNAL_FAILURE) and KeySigningKeys[] with Status (ACTIVE | INACTIVE | DELETING | ACTION_NEEDED | INTERNAL_FAILURE), DSRecord, DNSKEYRecord, KeyTag and KmsArn. get-domain-detail returns DnssecKeys[] with the Digest, KeyTag, Algorithm and Flags actually lodged at the registry.
GovCloud: Route 53 public and private DNS are available in both GovCloud (US) Regions and DNSSEC signing is supported, with two placement constraints AWS documents: the customer managed key used for signing must be in GovCloud (US-West), and the Route 53 control plane for GovCloud is in GovCloud (US-West). Zone ARNs use partition arn:aws-us-gov. route53domains is a commercial-partition registrar service, so the third command applies only where the domain is registered with Route 53 Domains.
Private hosted zones cannot be DNSSEC-signed, which is why the first command narrows to Config.PrivateZone==false before the per-zone loop; asserting SIGNING over every hosted zone would fail on private zones for a reason that is not a finding. Drive the loop from list-hosted-zones rather than from whatever get-dnssec happens to return, so a zone that was never configured is a visible failure instead of an absent row.
SC-20's second limb — the means to indicate the security status of child zones and enable verification of a chain of trust — is carried by KeySigningKeys[].DSRecord, which get-dnssec returns and the assertions cover. Whether that DS record has actually been published by the parent is a fact about the registrar, not about the zone: where the domain is registered with Route 53 Domains, get-domain-detail's DnssecKeys evidences it directly, and where it is registered elsewhere the registrar's own DS confirmation is the artifact to attach. No assertion is written against the third command for that reason — it is decisive for some tenants and inapplicable to others, and an assertion that silently does not apply is worse than a named gap.
ACTION_NEEDED on either the zone status or a KSK is an outage risk, not a paperwork state; AWS recommends a CloudWatch alarm on DNSSECInternalFailure and DNSSECKeySigningKeysNeedingAction, and a collector that only samples weekly should not be the first thing to notice it. list-hosted-zones returning no public zones passes all three assertions vacuously — a human confirms this account is the authoritative DNS for the offering's names, and confirms registrar-side DS publication where the registrar is not Route 53 Domains. Partial for the second limb of SC-20.
Per-VPC DNSSEC validation status of the Route 53 Resolver, joined against the full VPC inventory so that a VPC which never enabled validation is visible rather than absent
partial · cli · every weekly · /collect/route53-resolver-dnssec-validation
$ aws ec2 describe-vpcs --query 'Vpcs[].VpcId'
$ aws route53resolver list-resolver-dnssec-configs
Proves: SC-21
Expected output: describe-vpcs yields the VpcId inventory for the Region. list-resolver-dnssec-configs yields ResolverDnssecConfigs[] with Id, OwnerId, ResourceId and ValidationStatus (ENABLING | ENABLED | DISABLING | DISABLED | UPDATING_TO_USE_LOCAL_RESOURCE_SETTING | USE_LOCAL_RESOURCE_SETTING); the array is paginated and carries NextToken.
GovCloud: Route 53 is available in both GovCloud (US) Regions and the GovCloud difference page documents no carve-out for Resolver DNSSEC validation; it does record that Route 53 Resolver delegation is unavailable for private hosted zones, which is a different feature. The Route 53 control plane for GovCloud is in GovCloud (US-West). VPC ARNs use partition arn:aws-us-gov.
Rated partial for a reason that is in AWS's own words rather than in our judgement of the control: ListResolverDnssecConfigs returns one element per DNSSEC validation configuration associated with the account and "doesn't contain disabled DNSSEC configurations for the resource". A VPC with validation off is therefore ABSENT from the array, not reported as DISABLED — so a check of the form "every returned entry is ENABLED" is vacuously true on an account that has enabled validation nowhere. That is the exact shape of a check that passes while proving nothing, so it is not written as an assertion.
The decidable question is a set comparison: every VpcId from describe-vpcs must appear as a ResourceId in ResolverDnssecConfigs with ValidationStatus ENABLED. That spans two collections, and the assertion vocabulary compares a field against a constant rather than one command's output against another's, so the completeness half is a human read. The telemetry is real and specific; what it cannot do by itself is prove coverage.
Two further limits worth attaching to the evidence. Validation is applied by the VPC Resolver when it performs recursive resolution, so if the VPC forwards to another resolver, that resolver is the one doing recursion and must validate — the AWS status says nothing about it. And the VPC Resolver ignores the DO and CD bits and does not set AD or return DNSSEC records, so a workload cannot perform its own validation downstream of it; where a system owes that, the artifact is the resolver it runs instead.
Every place a client session terminates, with the protocol and negotiated policy it terminates under: listener protocol and SslPolicy on each load balancer, HTTP listeners that redirect rather than serve, an ACM certificate behind each one, and CloudFront viewer policies that refuse plain HTTP
partial · cli · every quarterly · /collect/session-authenticity-tls-termination
$ aws elbv2 describe-load-balancers --query 'LoadBalancers[].{LB:LoadBalancerName,Arn:LoadBalancerArn,Type:Type,Scheme:Scheme}'$ aws elbv2 describe-listeners --load-balancer-arn <LOAD_BALANCER_ARN> --query 'Listeners[].{Port:Port,Protocol:Protocol,SslPolicy:SslPolicy,Certificates:Certificates[].CertificateArn,Action:DefaultActions[0].Type,Redirect:DefaultActions[0].RedirectConfig}'$ aws elbv2 describe-ssl-policies --query "SslPolicies[?contains(Name,'FIPS')].Name"
$ aws configservice get-compliance-details-by-config-rule --config-rule-name alb-http-to-https-redirection-check --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name cloudfront-viewer-policy-https --compliance-types NON_COMPLIANT
Expected output: From describe-listeners, one row per listener: Protocol from HTTP, HTTPS, TCP, TLS, UDP, TCP_UDP, GENEVE, QUIC or TCP_QUIC; SslPolicy present only on HTTPS and TLS listeners and naming a policy such as ELBSecurityPolicy-TLS13-1-2-2021-06 (TLS 1.3 and 1.2 only) or ELBSecurityPolicy-TLS13-1-2-FIPS-2023-04; Certificates carrying the default certificate ARN; and for a plain HTTP listener a DefaultActions entry of Type redirect whose RedirectConfig names Protocol HTTPS with StatusCode HTTP_301. Note the CLI default: a listener created by CLI, CloudFormation or CDK without an explicit policy gets ELBSecurityPolicy-2016-08, which still negotiates TLS 1.0. From the two Config rules, empty NON_COMPLIANT sets mean no Application Load Balancer serves an HTTP listener without redirection and no CloudFront distribution leaves ViewerProtocolPolicy at allow-all. Run the CloudFront call in US East (N. Virginia): CLOUDFRONT_VIEWER_POLICY_HTTPS evaluates only there, matching CloudFront's global scope, so the same command in any other Region returns nothing at all — a silence that reads exactly like a pass and is not one.
GovCloud: Elastic Load Balancing, the security policies including the FIPS families, and AWS Certificate Manager are available in GovCloud (US); load-balancer and certificate ARNs use partition arn:aws-us-gov. ALB_HTTP_TO_HTTPS_REDIRECTION_CHECK is available in all supported Regions. Two rules in this family are not usable there: CLOUDFRONT_VIEWER_POLICY_HTTPS evaluates only in US East (N. Virginia), matching CloudFront's global scope and leaving GovCloud with no CloudFront distributions to evaluate, and ELBV2_ACM_CERTIFICATE_REQUIRED is excluded from both GovCloud (US) Regions — so in GovCloud the certificate check comes from the Certificates field of describe-listeners directly, not from Config.
SC-23 says protect the authenticity of communications sessions, and authenticity has a floor and a ceiling. The floor is transport: a session that can be read or injected into on the wire is not authentic in any sense, and the floor is entirely in this output — which listeners terminate TLS, under which negotiated policy, with which certificate, and whether the HTTP door redirects instead of answering. The ceiling is session identity above the transport: regenerating a session identifier on privilege change, invalidating it at logout, binding it to the principal it was issued to, refusing one that was replayed. That lives in application code and emits no AWS telemetry, which is what keeps this partial. Say which half you are evidencing, or a reader will assume the larger one.
Read SslPolicy as a version floor, not a checkmark. The policy name encodes the oldest protocol it will negotiate: ELBSecurityPolicy-TLS13-1-2-2021-06 admits TLS 1.3 and 1.2 only, while ELBSecurityPolicy-TLS13-1-0-2021-06 and the 2016-08 default still admit TLS 1.0. The FIPS families — listed by the describe-ssl-policies query above — use the AWS-LC FIPS validated module, and AWS marks ELBSecurityPolicy-TLS13-1-1-FIPS-2023-04 and ELBSecurityPolicy-TLS13-1-0-FIPS-2023-04 as legacy compatibility only, FIPS cryptography that, in AWS's own words, may not conform to the latest NIST guidance for TLS configuration. FIPS in the name is therefore not by itself the answer to a FIPS question.
The redirection rule is narrower than it sounds: ALB_HTTP_TO_HTTPS_REDIRECTION_CHECK is NON_COMPLIANT both when an HTTP listener has no redirect and when it forwards to another HTTP listener instead of redirecting — but it covers Application Load Balancers only. Network Load Balancer TLS listeners, API Gateway, and anything terminating TLS on an instance are outside it and have to be enumerated by hand.
The hosted zones that serve name resolution, each marked private or public so internal and external resolution can be shown to be served by separate zones, and the Resolver endpoints that carry queries across the VPC boundary with their direction and operational status
partial · cli · every continuous · /collect/name-resolution-role-separation
$ aws route53 list-hosted-zones
$ aws route53resolver list-resolver-endpoints
Proves: SC-22
Expected output: From list-hosted-zones, HostedZones[] with Id, Name, CallerReference, ResourceRecordSetCount, an optional LinkedService naming the AWS service that created the zone, and Config carrying Comment and PrivateZone — a boolean, and the ONLY field that separates an internal zone from an external one. Read its absence as public: AWS documents PrivateZone true as private and false-or-absent as public, so a clause testing for equality with false misses the zones where Config never appears. From list-resolver-endpoints, ResolverEndpoints[] with Id, Arn, Name, CreatorRequestId, HostVPCId, SecurityGroupIds, IpAddressCount, a ResolverEndpointType of IPV4, IPV6 or DUALSTACK, a Direction of INBOUND, OUTBOUND or INBOUND_DELEGATION, and a Status of CREATING, OPERATIONAL, UPDATING, AUTO_RECOVERING, ACTION_NEEDED or DELETING.
GovCloud: Route 53 is available in both AWS GovCloud (US) Regions with public and private DNS and health checking, and AWS states that public-zone DNS queries are answered from within the FedRAMP boundary — which is itself worth quoting in an assessment. The control plane for Route 53 in GovCloud is in AWS GovCloud (US-West), so these calls are issued there. Four documented differences bear on this recipe. Route 53 RESOLVER DELEGATION is not available for private hosted zones, so an architecture that separates roles by delegating a private subtree cannot be built there and the separation has to be zone-level. Alias targets may point at GovCloud Regions only, never at global AWS Regions. IP-based routing, the console DNS query checking tool and the TestDNSAnswer API are all unavailable, so there is no in-account way to verify what an external resolver actually receives. And the customer managed key for DNSSEC signing and the CloudWatch Logs group for query logging must both be in AWS GovCloud (US-West), where CloudWatch metrics such as DNSQueries can also be found. All Route 53 API actions there share a token bucket of capacity 40 refilling at 5 per second, which matters for a collector that walks many zones.
SC-22 asks two things of the systems that collectively provide name and address resolution: that they be FAULT-TOLERANT, and that they implement INTERNAL AND EXTERNAL ROLE SEPARATION. This output answers the second and cannot answer the first.
Role separation is a field. Config.PrivateZone marks each hosted zone as serving internal or external resolution, and an architecture where internal names are served from private zones associated with VPCs while public names are served from public zones is exactly the separation the control describes — visible, enumerable, and reviewable on the cadence the indicator asks for. Resolver endpoints are collected beside the zones because they are where the two roles actually meet: an INBOUND endpoint lets on-premises resolvers query into the VPC, an OUTBOUND endpoint forwards VPC queries out, and each one is a crossing point that the zone list alone does not show.
Fault tolerance is not a field and is not measurable from your account. Route 53's resilience is a property of AWS's anycast name server fleet, not of your configuration, and no call against your account returns it. It is inherited under the shared responsibility model and evidenced from AWS's own authorization package — say so in the assessment rather than presenting a zone list as though it spoke to availability. The one adjacent thing this output does show is whether YOUR crossing points are healthy, which is what the Status clause below asserts, and that is endpoint health rather than service fault tolerance. Conflating them would be the whole control's failure in one line.
A scope edge that decides whether this evidence means anything: name resolution is only separated if the two zone sets actually differ. Two hosted zones for the same domain, one private and one public, is split-horizon DNS and is a legitimate and common pattern — but so is a single public zone answering internal names, which satisfies neither role separation nor the control, and the assertions below cannot tell those apart because the grammar compares a field to a constant rather than one zone's Name to another's. Reconcile the two zone sets by name as part of the review.
A private hosted zone also cannot be DNSSEC-signed, which is why signing is a separate recipe and not folded in here. And LinkedService is worth reading before anything is concluded from a zone's presence: a zone created by another AWS service on your behalf is inventory rather than architecture.
One KSI only. KSI-SVC-SIN — information secured from unwanted access — is what a private zone does, by keeping internal names off the public resolvers. The recipe does not claim the identity indicator that also reaches this control in the dataset: nothing in these two responses evaluates a permission.
Whether secret scanning, push protection and validity checking are switched on in this organization and which repositories those settings actually reach, together with the alerts themselves — each carrying the kind of credential found, whether the credential was confirmed to still work, how it was closed and by whom, and whether a push containing it was blocked or waved through. The settings are the population; the alerts are what was found in it, and an alert list read without the settings beside it cannot be told apart from a list produced by scanning nothing.
partial · api · every continuous · /collect/secret-exposure-detection-and-push-protection
$ gh api --paginate "/orgs/<ORG>/code-security/configurations"
$ gh api --paginate "/orgs/<ORG>/code-security/configurations/<CONFIGURATION_ID>/repositories?per_page=100"
$ gh api --paginate "/orgs/<ORG>/secret-scanning/alerts?state=open&per_page=100"
$ gh api --paginate "/orgs/<ORG>/secret-scanning/alerts?state=resolved&per_page=100"
Expected output: Report names used below, and the command each comes from: `configurations` is the first command, `configuration-repositories` the second, `alerts-open` the third, `alerts-resolved` the fourth.
RUN THE SECOND COMMAND ONCE PER CONFIGURATION returned by the first, not once for the organization. The first returns every configuration and the second takes a single `<CONFIGURATION_ID>`; the clause on `configuration-repositories` speaks only for the configurations actually walked.
From the configurations call, an array of code security configurations, each with an id, a name, a `target_type` of `global`, `organization` or `enterprise`, and the secret-scanning feature fields as THREE-STATE strings rather than booleans — `secret_scanning`, `secret_scanning_push_protection`, `secret_scanning_validity_checks`, `secret_scanning_non_provider_patterns`, `secret_scanning_delegated_bypass` and `secret_scanning_delegated_alert_dismissal` each taking `enabled`, `disabled` or `not_set` — plus an `enforcement` of `enforced` or `unenforced`. `not_set` is the value that defeats a careless clause: it is neither on nor off, it is the absence of a decision, and a comparison against `disabled` alone walks straight past it. From the configuration-repositories call, rows carrying a `status` of `attached`, `attaching`, `detached`, `removed`, `enforced`, `failed`, `updating` or `removed_by_enterprise`.
From the alerts calls, alerts with `number`, `created_at`, `updated_at`, `url`, `html_url`, a `state` of `open` or `resolved`, a `resolution` drawn from `false_positive`, `wont_fix`, `revoked`, `used_in_tests`, `pattern_edited` and `pattern_deleted`, `resolved_at` and `resolved_by`, a `secret_type` and its `secret_type_display_name`, a `validity` of `active`, `inactive` or `unknown`, and the push-protection quartet `push_protection_bypassed`, `push_protection_bypassed_by`, `push_protection_bypassed_at` and — for the alert raised by the bypass — the repository it landed in, plus `publicly_leaked` and `multi_repo`. Note that `resolution` and `validity` are documented as comma-separated FILTERS accepting those values as well as fields carrying them, and that the endpoint takes `secret_type`, `exclude_secret_types`, `providers`, `sort`, `direction`, `before`, `after`, `is_publicly_leaked` and `is_multi_repo` besides. The organization endpoint requires an administrator or a security manager, and a token carrying the `repo` or `security_events` scope.
IA-05 (06) asks the provider to protect authenticators commensurate with the security category of the information to which use of the authenticator permits access. Two words in that sentence are the reason this is `partial` and would stay `partial` under any amount of extra collection. PROTECT is broader than DETECT: this evidence is a detection-and-response record for one storage location, and the control asks about the protection of authenticators wherever they live. COMMENSURATE is a judgement that requires knowing what the credential opens, and no field in any response here says that — `secret_type` names the kind of credential, never the security category of what it unlocks. A `high` alert on a test-fixture token and a `low` alert on a production database password are the same shape in this output. The disposition this recipe spends said exactly that, and writing the commands down has not changed it.
The population problem, stated once and load-bearing everywhere below. Authenticators live in secret stores, CI variable sets, container images, build arguments, configuration management and on operator workstations. This recipe reads git repositories on one platform. The fraction of the provider's authenticator population that is in scope here is not a number this recipe can compute, and `scan_scope` names the external list against which somebody has to compute it.
WHY THE VALIDITY CLAUSE IS INTERLOCKED WITH A SETTINGS CLAUSE, AND WHY THE INTERLOCK IS NECESSARY WITHOUT BEING SUFFICIENT. The fourth assertion asserts that no open alert has a `validity` of `active` — no leaked credential is confirmed to still work. Read alone it is one of the most vacuous clauses on this plane, because `validity` is `unknown` for every alert in an organization that never switched validity checks on, and `unknown` is not `active`, so the clause passes perfectly on an organization that has never checked a single credential. The third assertion exists to close that: it fails an organization whose configurations leave `secret_scanning_validity_checks` at anything other than `enabled`. What it does NOT do is make the fourth clause safe, and an earlier draft of this paragraph claimed it did. Three residues survive it. First, GitHub documents that validity checks are "available for secrets from many service providers, and support continues to expand" and that "Some secrets require more than the token itself to confirm whether they are active" — so a leaked credential of an unsupported type reports `unknown` with validity checking fully enabled, and the fourth clause passes over it. Second, GitHub "will periodically check the validity of a detected credential", which is a schedule rather than a trigger: enabling the feature does not retro-populate the alerts already open, so there is a window after enablement in which every existing alert is still `unknown`. Third, the third clause is not even the only prerequisite — the FIRST assertion is equally load-bearing, because with `secret_scanning` off there are no alerts of any validity at all and the fourth clause is vacuous for a reason the third never touches. And the third clause reads configurations rather than repositories, so it can be red while validity checking is genuinely on through repository-level settings, and green while the repositories that matter are attached to no configuration at all; it does not bind the population the alerts came from. Read the fourth clause as meaningful only when the first and third are green AND the reconciliation in `scan_scope` has been done, and read a green fourth clause on its own as saying nothing.
THE PARTNER PROGRAMME IS AN INVISIBLE SUCCESS PATH AND IT MAKES AN EMPTY ALERT LIST AMBIGUOUS IN A NEW DIRECTION. GitHub states that "Partner secrets are reported directly to the provider and aren't displayed in your repository alerts". So for a leaked credential issued by a participating vendor, the platform's response is to tell the vendor, and the provider's own alert list stays empty. An empty `alerts-open` is therefore equally an organization that has leaked nothing, an organization that is not scanning, and an organization whose leaks were all handled by a party that never told it. The first and third of those are indistinguishable in this output and only one of them is a pass. This is not the usual empty-list caveat imported from the plane card; it is a documented behaviour of this specific feature, and an assessment reading a clean alert list as evidence of a clean estate has made the exact error the platform's own documentation predicts.
What the bypass fields are worth, and where they stop. Push protection blocks a push containing a detected credential, and GitHub documents that anyone with write access can bypass it by choosing a reason from "It's used in tests", "It's a false positive" and "I'll fix it later", after which the platform "Creates an alert", "Adds the bypass event to the audit log", and emails owners, security managers and repository administrators. So a bypass is a recorded decision rather than a silent one, and `push_protection_bypassed`, `push_protection_bypassed_by` and `push_protection_bypassed_at` on the resulting alert are that decision as fields. No clause below asserts a count of zero over them, deliberately: a bypass is a documented, authorized action with a named actor and a stated reason, and a provider with a legitimate test fixture would fail such a clause while behaving correctly. The bypass records are collected as a record for a human to read against the provider's own procedure — the third of the three documented reasons is an admission that a credential was pushed knowingly, and how long "later" ran is a question for the assessment rather than for a threshold. Delegated bypass, whose enablement is visible as `secret_scanning_delegated_bypass`, moves the decision to a reviewer; it is collected, not asserted, for the same reason.
A code security configuration is not the only way any of this gets switched on, so the first three clauses are wrong in one direction. The features can be enabled on a repository directly in its own settings, with no configuration involved, and an organization that never adopted configurations returns an empty list from the first command and fails all three while scanning everything it owns. Read a failure as "no organization-level configuration governs this" — which is literally true and is itself a finding — and NOT as "nothing is scanned". The reconciliation named in `scan_scope` is what separates the two readings, and nothing in this output does.
The `target_type` narrowing carries the same cost it carries on the dependency recipe: the enum admits `global` and `enterprise`, an administrator can apply a configuration owned elsewhere, and applying it is a decision this clause will not count. The trade is made the same way and for the same reason.
KSI-SVC-ASM is claimed on the PROTECTION limb only. The indicator asks for management, protection and regular ROTATION of keys, certificates and other secrets, automated and persistently reviewed. Detection of an exposed secret, and the record of what was decided about it, is protection and it is review. Rotation is not a scanning result: `resolution` admits `revoked`, which is the closest this output comes, and it is a human's assertion typed into a dropdown rather than an observation that a credential was replaced. The rotation limb of this indicator is answered on the AWS plane, by the KMS and ACM recipes, and an indicator page carrying this recipe should be read as carrying evidence for a part. KSI-IAM-APM and KSI-IAM-ELP also reach this control in the dataset and are deliberately not claimed: one is about passwordless and phishing-resistant authentication methods and the other about least privilege, and a leaked-credential detector speaks to neither.
MLA — Monitoring, Logging, and Auditing
9 recipes · 12 of 13 controls in scope reached
AWS Config compliance results proving the audit trail exists and is protected — CloudTrail enabled and multi-region so management events are captured account-wide, log-file validation on so records are tamper-evident, and SSE-KMS encryption on so the logs themselves are protected at rest
partial · config-rule · every continuous · /collect/config-cloudtrail-audit-logging
$ aws configservice get-compliance-details-by-config-rule --config-rule-name cloudtrail-enabled --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name multi-region-cloudtrail-enabled --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name cloud-trail-log-file-validation-enabled --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name cloud-trail-encryption-enabled --compliance-types NON_COMPLIANT
$ aws configservice describe-configuration-recorder-status
$ aws configservice describe-config-rule-evaluation-status --config-rule-names cloudtrail-enabled
$ aws configservice describe-config-rule-evaluation-status --config-rule-names multi-region-cloudtrail-enabled
$ aws configservice describe-config-rule-evaluation-status --config-rule-names cloud-trail-log-file-validation-enabled
$ aws configservice describe-config-rule-evaluation-status --config-rule-names cloud-trail-encryption-enabled
$ aws ec2 describe-regions --query 'Regions[].RegionName' --output text
$ aws organizations list-accounts --query 'Accounts[].Id' --output text
Proves: AU-02, AU-09, AU-12
Expected output: Four EvaluationResults arrays; empty NON_COMPLIANT sets mean a trail is enabled, at least one trail is multi-region, log-file validation (SHA-256 signed digest) is on, and the trail delivers SSE-KMS-encrypted logs. Managed rule identifiers: CLOUD_TRAIL_ENABLED (rule name cloudtrail-enabled), MULTI_REGION_CLOUD_TRAIL_ENABLED (rule name multi-region-cloudtrail-enabled), CLOUD_TRAIL_LOG_FILE_VALIDATION_ENABLED, CLOUD_TRAIL_ENCRYPTION_ENABLED Plus describe-configuration-recorder-status showing recording=true and, per rule, describe-config-rule-evaluation-status showing FirstEvaluationStarted=true with a LastSuccessfulEvaluationTime — the proof the empty set was produced by a check that ran.
GovCloud: AWS Config, CloudTrail, and all four managed rules are available in AWS GovCloud (US); trail, bucket, and KMS key ARNs use partition arn:aws-us-gov
Together these prove audit-record generation (AU-02/AU-12) and protection of the audit information — integrity via log-file validation and confidentiality via SSE-KMS (AU-09). This is the trail's existence and protection, not review of its contents: pair with the log-review/alerting recipe (MLA) for AU-06. Pass the S3/CloudWatch parameters to CLOUD_TRAIL_ENABLED to assert delivery to your specific log destination. A trail existing proves generation, not content — AU-03 is not claimed, because no Config rule reads record content. AU-02's organization-defined event types and AU-12's component list (OS, container and application audit are components) are SSP parameters a human compares this output against, and AU-09's protection from unauthorized access and deletion needs the log bucket's policy, Object Lock and MFA-delete posture, which is not collected here.
AWS Config compliance results proving continuous security monitoring is switched on account-wide — GuardDuty threat detection enabled (optionally centralized to a delegated admin) and Security Hub aggregating control findings
partial · config-rule · every continuous · /collect/config-threat-monitoring-enabled
$ aws configservice get-compliance-details-by-config-rule --config-rule-name guardduty-enabled-centralized --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name securityhub-enabled --compliance-types NON_COMPLIANT
$ aws configservice describe-configuration-recorder-status
$ aws configservice describe-config-rule-evaluation-status --config-rule-names guardduty-enabled-centralized
$ aws configservice describe-config-rule-evaluation-status --config-rule-names securityhub-enabled
$ aws ec2 describe-regions --query 'Regions[].RegionName' --output text
$ aws organizations list-accounts --query 'Accounts[].Id' --output text
Proves: SI-04
Expected output: Two EvaluationResults arrays; empty NON_COMPLIANT sets mean GuardDuty is enabled in the account/Region (and results land in the CentralMonitoringAccount if you set one) and Security Hub is enabled. Managed rule identifiers: GUARDDUTY_ENABLED_CENTRALIZED, SECURITYHUB_ENABLED Plus describe-configuration-recorder-status showing recording=true and, per rule, describe-config-rule-evaluation-status showing FirstEvaluationStarted=true with a LastSuccessfulEvaluationTime — the proof the empty set was produced by a check that ran.
GovCloud: AWS Config, GuardDuty, and Security Hub are available in AWS GovCloud (US); detector and hub ARNs use partition arn:aws-us-gov
This proves the monitoring capability is ON, which is the automatable half of SI-04. Whether findings are triaged and acted on within your SLA is the review workflow — surface that with the GuardDuty finding-plus-response recipe (see guardduty-suspicious-iam-activity-response) and AU-06 log review. Set CentralMonitoringAccount to your delegated-administrator account id in a multi-account org so member accounts are evaluated against the aggregation point. Both rules are evaluated per Region, so GuardDuty disabled in any Region other than the one queried is invisible; and whether findings are analyzed and acted on — SI-04(a), (b), (d) — is a human workflow. Partial for both reasons.
AWS Config compliance results plus the State Manager association list proving a defined configuration is actually applied and re-applied to every managed node — instances are under SSM management, and the associations that carry your baseline report COMPLIANT on a schedule rather than drifting
partial · cli · every continuous · /collect/ssm-configuration-baseline-enforced
$ aws configservice get-compliance-details-by-config-rule --config-rule-name ec2-instance-managed-by-systems-manager --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name ec2-managedinstance-association-compliance-status-check --compliance-types NON_COMPLIANT
$ aws ssm list-associations --query 'Associations[].{Name:Name,AssociationId:AssociationId,Schedule:ScheduleExpression,Targets:Targets,Status:Overview.Status,LastRun:LastExecutionDate}'Proves: CM-02, CM-06
Expected output: Two EvaluationResults arrays plus an Associations list. Empty NON_COMPLIANT sets mean every running EC2 instance has a running SSM Agent and every SSM association compliance record reads COMPLIANT after execution; the Associations list names the documents, schedules, and targets that carry the baseline, with Overview.Status and LastExecutionDate showing it ran. Managed rule identifiers: EC2_INSTANCE_MANAGED_BY_SSM (rule name ec2-instance-managed-by-systems-manager), EC2_MANAGEDINSTANCE_ASSOCIATION_COMPLIANCE_STATUS_CHECK
GovCloud: AWS Config and Systems Manager are available in AWS GovCloud (US); instance, document, and association ARNs use partition arn:aws-us-gov
The telemetry proves a configuration is being enforced and drift corrected — it does not prove the enforced content IS your approved baseline. That the association's SSM document encodes the hardened settings you baselined (CIS/STIG content, approved through your change process) is the human judgement half; keep the document version and its approval record alongside this output. Two limits to state plainly: EC2_INSTANCE_MANAGED_BY_SSM does not flag a stopped instance whose agent is running, and this whole recipe is EC2-only — container images, Lambda, and managed-service settings need their own baseline evidence. CM-08 inventory is a separate recipe, not this one.
The metric filters that turn audit log events into metrics, the alarms built on them, and Config's confirmation that those alarms actually notify someone — the automated-mechanism half of audit review
partial · cli · every weekly · /collect/cloudwatch-log-review-alerting
$ aws logs describe-metric-filters --query 'metricFilters[].{Filter:filterName,LogGroup:logGroupName,Pattern:filterPattern,Metric:metricTransformations[0].metricName}'$ aws cloudwatch describe-alarms --alarm-types MetricAlarm CompositeAlarm --query 'MetricAlarms[].{Name:AlarmName,Metric:MetricName,State:StateValue,Actions:AlarmActions}'$ aws configservice get-compliance-details-by-config-rule --config-rule-name cloudwatch-alarm-action-check --compliance-types NON_COMPLIANT
Proves: AU-06
Expected output: A metricFilters list showing each audit pattern you watch for and the metric it publishes, a matching alarm for each with a non-empty AlarmActions (the SNS topic or response action it fires), and an empty NON_COMPLIANT EvaluationResults set meaning no alarm is configured without an action. Managed rule identifier: CLOUDWATCH_ALARM_ACTION_CHECK
GovCloud: CloudWatch, CloudWatch Logs, and AWS Config are available in AWS GovCloud (US); log-group, alarm, and SNS topic ARNs use partition arn:aws-us-gov
AU-06 asks that audit records be reviewed and analysed and findings reported; AU-06.01 asks that the review use automated mechanisms. What these calls prove is the automated half — the pipeline exists, it fires, and it reaches a human. No API proves a person read the alert, judged it, and reported the finding, so pair this with your ticket or case record; do not present an alarm list as a completed review. Metric filters only publish for events after the filter was created and are supported only on Standard-class log groups, so a filter created yesterday says nothing about last month. cloudwatch-alarm-action-check defaults to requiring an ALARM and INSUFFICIENT_DATA action; pass action1..action5 to assert alarms route to a specific SNS topic. Trail existence and protection is the separate cloudtrail recipe (AU-02/03/09/12).
How long audit records are kept, how much space they occupy, and whether the pipeline that delivers them is currently failing — retention settings on every log group and log bucket, the storage they consume, and the trail's own delivery-error fields
partial · cli · every daily · /collect/audit-log-retention-and-delivery-failure
$ aws logs describe-log-groups --query 'logGroups[].{Group:logGroupName,RetentionDays:retentionInDays,StoredBytes:storedBytes,Class:logGroupClass}'$ aws configservice get-compliance-details-by-config-rule --config-rule-name cw-loggroup-retention-period-check --compliance-types NON_COMPLIANT
$ aws s3api get-bucket-lifecycle-configuration --bucket <LOG_BUCKET>
$ aws configservice get-compliance-details-by-config-rule --config-rule-name s3-lifecycle-policy-check --compliance-types NON_COMPLIANT
$ aws cloudwatch get-metric-statistics --namespace AWS/S3 --metric-name BucketSizeBytes --dimensions Name=BucketName,Value=<LOG_BUCKET> Name=StorageType,Value=StandardStorage --start-time <START_TIME> --end-time <END_TIME> --period 86400 --statistics Average
$ aws cloudtrail get-trail-status --name <TRAIL_NAME> --query '{Logging:IsLogging,LastDelivery:LatestDeliveryTime,DeliveryError:LatestDeliveryError,DigestDelivery:LatestDigestDeliveryTime,DigestError:LatestDigestDeliveryError,NotificationError:LatestNotificationError}'$ aws configservice get-compliance-details-by-config-rule --config-rule-name cloud-trail-cloud-watch-logs-enabled --compliance-types NON_COMPLIANT
Proves: AU-04, AU-05, AU-11
Expected output: A log-group list in which every group carries the retentionInDays your policy requires and a storedBytes you can trend — a group with no retentionInDays at all never expires; an empty NON_COMPLIANT set from the retention rule, meaning no group falls below MinRetentionTime (default 365 days); a Rules array on the log bucket with an Enabled rule whose Transitions and Expiration match your archive and deletion policy, or a NoSuchLifecycleConfiguration error if none is set; a daily BucketSizeBytes series showing the log store's growth curve against whatever headroom you provisioned; and a trail status with IsLogging true, a LatestDeliveryTime within the last few minutes, and LatestDeliveryError, LatestDigestDeliveryError and LatestNotificationError all absent — a populated error field is the audit-logging-process failure AU-05 is about. Managed rule identifiers: CW_LOGGROUP_RETENTION_PERIOD_CHECK, S3_LIFECYCLE_POLICY_CHECK, CLOUD_TRAIL_CLOUD_WATCH_LOGS_ENABLED
GovCloud: CloudWatch Logs, Amazon S3, CloudTrail, AWS Config and CloudWatch metrics are all available in AWS GovCloud (US); log-group, bucket and trail ARNs use partition arn:aws-us-gov
This is the capacity and durability of the audit pipeline, not proof that it is adequate. AU-04 asks that storage capacity be sufficient for your defined requirement — S3 has no fixed ceiling to report, so the honest evidence is the consumption trend plus the retention and lifecycle settings that bound it, and the judgement that the headroom is enough stays yours. AU-05 is answered here only in its detection half: get-trail-status surfaces the delivery failure, and the alerting and the real-time response — notify these people, shut down or overwrite oldest records — is process, with the alarm-side telemetry living in the AU-06 log-review recipe (CLOUDWATCH_ALARM_ACTION_CHECK). AU-11 is the retention setting, which these commands read directly, but conformance to your record-retention period is a comparison against policy: note that cw-loggroup-retention-period-check marks a Never-expire group COMPLIANT, so an unbounded group passes the rule while potentially violating a maximum-retention or data-disposal requirement — read the raw retentionInDays, not just the rule verdict. Substitute your real bucket, trail and window; run get-trail-status per trail (it is a single-trail call) and against the trail's home Region. BucketSizeBytes is a once-daily storage metric and CloudWatch metric delivery is best-effort, so a missing data point is not by itself an incident. Pass expectedDeliveryWindowAge to cloud-trail-cloud-watch-logs-enabled if you want the rule to fail on stale delivery rather than only on an unconfigured CloudWatch Logs destination; the trail's existence, integrity and encryption are the AU-02/03/09/12 recipe's job.
What each instance's clock is actually locked to and how far off it is right now — the chrony daemon's reference source, offset and leap status collected fleet-wide through Run Command — together with the configured time source in chrony.conf and the UTC time stamps CloudTrail already writes on every audit record
partial · cli · every daily · /collect/clock-synchronization-and-timestamps
$ aws ssm send-command --document-name AWS-RunShellScript --targets Key=tag:Environment,Values=<ENVIRONMENT_TAG> --parameters 'commands=["chronyc tracking","chronyc sources -v | grep -F ^*","grep -E \"^(server|pool|refclock)\" /etc/chrony.conf"]' --output-s3-bucket-name <EVIDENCE_BUCKET> --output-s3-key-prefix au-08
$ aws ssm list-command-invocations --command-id 11111111-2222-3333-4444-555555555555 --details --query 'CommandInvocations[].{Instance:InstanceId,Status:Status,Output:CommandPlugins[].Output}'$ aws cloudtrail lookup-events --max-results 5 --query 'Events[].{Name:EventName,Time:EventTime,Source:EventSource}'Proves: AU-08
Expected output: For every managed instance, a chronyc tracking block whose Reference ID is A9FEA97B (169.254.169.123) — the local Amazon Time Sync Service — with System time within your documented granularity of NTP time (microseconds on a healthy instance), a small RMS offset, and Leap status Normal; a chronyc sources line beginning ^*, which marks the preferred source, pointing at 169.254.169.123 (or fd00:ec2::123 on a Nitro instance using the IPv6 endpoint); and a chrony.conf that names that endpoint and no unapproved public NTP pool. Every invocation should come back Status Success — an instance that returns nothing is an instance whose clock you have not evidenced. From CloudTrail, EventTime values in UTC, which is what the record format guarantees: eventTime is documented as 'the date and time the request was completed, in coordinated universal time (UTC)'.
GovCloud: The local Amazon Time Sync Service is reachable from any AWS Region, GovCloud included, at the link-local addresses 169.254.169.123 (IPv4) and fd00:ec2::123 (IPv6, Nitro instances only), and Systems Manager and CloudTrail are both available in AWS GovCloud (US); instance, document and trail ARNs use partition arn:aws-us-gov. One difference to plan around: precision time placement groups — the route to the enhanced Amazon Time Sync Service and the PTP hardware clock — are documented as available in all AWS Commercial Regions, so expect NTP-level accuracy rather than microsecond PHC accuracy in GovCloud
The clock is telemetry; the mapping and the granularity are not. AU-8 wants time stamps for audit records that use an internal clock mapped to UTC and meet a granularity you defined — chrony proves the host clock is disciplined to an authoritative UTC source and by how much it is off, and CloudTrail's eventTime is UTC by contract, so the AWS-generated half of your audit trail satisfies the mapping without any work of yours. What no command proves is that your own application writes its records from that clock in UTC: chrony can be flawless while code stamps local time or truncates to the second, and comparing the measured offset against your defined granularity is a judgement against a policy number. Scope honesty matters here too — this is a point-in-time sample of the instances Run Command could reach, so an unmanaged, stopped or unreachable instance is silently absent; join the results against your managed-instance inventory before calling the fleet covered, and remember containers and serverless compute have no chrony to query. If you claim sub-millisecond granularity, note that a PTP hardware clock passes no error bound to chrony (chrony then assumes an error bound of 0): read /sys/bus/pci/devices/<pci-slot>/phc_error_bound and add it, or run ClockBound. Leap seconds are smeared on the NTP endpoints but not on the PHC, so do not configure both smeared and non-smeared sources. Substitute your real targets, bucket and command id; send-command returns the CommandId that the second call consumes, and plugin Output is truncated at 2500 characters, which is why the S3 output bucket is worth setting.
How operators actually reach the environment from outside it: the managed access paths that exist, the logging and encryption configured on them, the session-by-session record of who used them, and the negative check that no instance is directly reachable instead
partial · cli · every weekly · /collect/remote-access-authorization-and-monitoring
$ aws ssm get-document --name SSM-SessionManagerRunShell --document-version '$LATEST' --query Content --output text
$ aws ssm describe-sessions --state History --query 'Sessions[].{owner:Owner,target:Target,start:StartDate,end:EndDate,document:DocumentName,accessType:AccessType,maxDuration:MaxSessionDuration}'$ aws ec2 describe-client-vpn-endpoints --query 'ClientVpnEndpoints[].{id:ClientVpnEndpointId,transport:TransportProtocol,auth:AuthenticationOptions[].Type,connectionLog:ConnectionLogOptions,splitTunnel:SplitTunnel,sessionTimeoutHours:SessionTimeoutHours,serverCert:ServerCertificateArn,selfServicePortal:SelfServicePortalUrl}'$ aws ec2 describe-client-vpn-connections --client-vpn-endpoint-id <CLIENT_VPN_ENDPOINT_ID> --query 'Connections[].{user:Username,commonName:CommonName,clientIp:ClientIp,established:ConnectionEstablishedTime,ended:ConnectionEndTime,status:Status,posture:PostureComplianceStatuses}'$ aws configservice get-compliance-details-by-config-rule --config-rule-name ec2-instance-no-public-ip --compliance-types NON_COMPLIANT
Proves: AC-17
Expected output: The Session Manager preferences document as JSON — s3BucketName, s3KeyPrefix, s3EncryptionEnabled, cloudWatchLogGroupName, cloudWatchEncryptionEnabled, cloudWatchStreamingEnabled, kmsKeyId, runAsEnabled, idleSessionTimeout and maxSessionDuration — which is your Region's entire remote-access logging and encryption configuration in one object. Then one row per terminated session from the past 30 days carrying Owner, Target, StartDate, EndDate, DocumentName, MaxSessionDuration and AccessType of Standard or JustInTime. Then per Client VPN endpoint the transport protocol tcp or udp, the authentication types in use (certificate-authentication, directory-service-authentication or federated-authentication), ConnectionLogOptions with Enabled plus CloudwatchLogGroup and CloudwatchLogStream, SplitTunnel, SessionTimeoutHours of 8, 10, 12 or 24 (default 24) and the server certificate ARN. Then per connection Username (Active Directory authentication only), CommonName, ClientIp, ConnectionEstablishedTime, ConnectionEndTime, Status and any PostureComplianceStatuses — active connections plus only those terminated within the last 60 minutes. Finally the EC2 instances AWS Config evaluated NON_COMPLIANT because a publicIp field is present in their configuration item.
GovCloud: Both access paths exist in AWS GovCloud (US-East) and (US-West). Client VPN endpoints there operate using FIPS 140-3 validated cryptographic modules and a fixed cipher set — TLS 1.3 TLS_AES_256_GCM_SHA384 and TLS_AES_128_GCM_SHA256; TLS 1.2 TLS-ECDHE-RSA/ECDSA-WITH-AES-256-GCM-SHA384 and the AES-128-GCM-SHA256 variants; data channel AES-256-GCM — and AWS advises using the exported client configuration file unmodified rather than configuring other ciphers, which makes AC-17(2) there largely a matter of not breaking the default. Systems Manager runs in both Regions; Change Manager and Incident Manager do not, and State Manager association history cannot be viewed, none of which this recipe touches. ec2-instance-no-public-ip is documented for all supported AWS Regions. Calls to these services must use SSL (HTTPS), and ARNs use partition arn:aws-us-gov
The commands split cleanly across the control family, and the gap in the middle is the one to be honest about. AC-17(1) — automated monitoring and control of remote access — is what session history and connection logs deliver, with two documented blind spots. Session Manager does not log sessions that connect through port forwarding or SSH, because SSH encrypts the session data inside the TLS connection and Session Manager is only the tunnel; an operator who port-forwards leaves a session record with no command content behind it. And describe-sessions reaches back 30 days only, so anything longer is an S3 or CloudWatch Logs query against the destinations named in the preferences document, not an SSM call. Client VPN retention is shorter still — terminated connections drop out of the API after 60 minutes, which makes the log group named in ConnectionLogOptions the only durable record, and Username is populated only for Active Directory authentication, so certificate-authenticated users are identified by CommonName or not at all. AC-17(2) is the strongest link in GovCloud, where the endpoints are FIPS 140-3 modules by construction; the Session Manager equivalent is kmsKeyId in the preferences document, and it is empty unless you set it, so an empty kmsKeyId is a finding rather than a default. AC-17(3) — routing remote access through managed network access control points — is the one nothing here proves. Session Manager and a Client VPN endpoint are managed access points, and ec2-instance-no-public-ip is the closest negative check, but that rule applies only to IPv4 and only to AWS::EC2::Instance: an IPv6-reachable instance, a load balancer fronting SSH, or a third-party jump host is invisible to it. Read the result as 'no EC2 instance carries a public IPv4 address', which is a useful sentence and not the control. AC-17 itself — the documented usage restrictions, configuration requirements and per-type authorization — is a record you write, and this telemetry only shows whether the estate matches it. One warning to carry into the evidence package: Session Manager logs the commands entered and their output, so a credential typed into a session lands in the log group you are about to hand an assessor.
A demonstration run against the live log estate that audit records can be reduced, sorted and searched on demand by event criteria — the standing saved queries, the query that ran, and the report it returned
partial · cli · every quarterly · /collect/audit-reduction-and-report-generation
$ aws logs describe-query-definitions --query 'queryDefinitions[].{id:queryDefinitionId,name:name,language:queryLanguage,logGroups:logGroupNames}'$ aws logs start-query --log-group-names <LOG_GROUP_NAME> --start-time <START_EPOCH> --end-time <END_EPOCH> --query-string 'fields @timestamp, userIdentity.arn, eventName, sourceIPAddress | filter eventName = "ConsoleLogin" | sort @timestamp desc | limit 1000'
$ aws logs get-query-results --query-id <QUERY_ID>
$ aws cloudtrail list-event-data-stores --query 'EventDataStores[].{name:Name,arn:EventDataStoreArn,status:Status,retentionDays:RetentionPeriod,multiRegion:MultiRegionEnabled,organization:OrganizationEnabled,terminationProtection:TerminationProtectionEnabled}'$ aws cloudtrail start-query --query-statement "SELECT eventTime, eventName, userIdentity.arn, sourceIPAddress FROM <EVENT_DATA_STORE_ID> WHERE eventName = 'ConsoleLogin' ORDER BY eventTime DESC LIMIT 1000" --delivery-s3-uri s3://<EVIDENCE_BUCKET>
$ aws athena start-query-execution --work-group primary --query-execution-context Database=cloudtrail_logs --result-configuration OutputLocation=s3://audit-reports-bucket/athena/ --query-string "SELECT eventtime, useridentity.arn, eventname, sourceipaddress FROM cloudtrail_logs WHERE eventname = 'ConsoleLogin' ORDER BY eventtime DESC LIMIT 1000"
Expected output: First the saved CloudWatch Logs Insights query definitions — queryDefinitionId, name, queryLanguage of CWLI, SQL or PPL, the queryString itself and the log groups each is scoped to — which is the standing reduction-and-reporting capability as configured rather than as claimed. Then a queryId, and against it the matching records: at most 100,000 log events per query and 10,000 returned per get-query-results call, one query spanning at most 50 log groups, a 60-minute runtime ceiling and a Region-wide limit of 100 concurrent Insights queries. Then the event data stores with Status (CREATED, ENABLED, PENDING_DELETION, STARTING_INGESTION, STOPPING_INGESTION or STOPPED_INGESTION), RetentionPeriod in days from 7 to 3,653, MultiRegionEnabled, OrganizationEnabled and TerminationProtectionEnabled. Then a QueryId from CloudTrail Lake whose results are delivered to the S3 URI you named, and a QueryExecutionId from Athena whose results land in the workgroup's OutputLocation. Keep the query text beside its output — the pair is the evidence and neither half is evidence alone.
GovCloud: All three query paths run in both AWS GovCloud (US) Regions. Athena's only documented difference is that granting AWS Lake Formation permissions to Athena users who authenticate through the JDBC or ODBC driver with a SAML identity provider is unavailable. CloudWatch Logs is available with Live Tail missing and the logGroupNamePattern parameter unsupported on DescribeLogGroups — neither affects an Insights query. CloudTrail Lake is available, but Lake integrations, query generation, query results summarization, event data stores for AWS Config configuration items, AWS Audit Manager evidence and events from outside AWS, and the Activity summary widget are not: you write the SQL yourself, and non-AWS audit records cannot be pulled into the same store for reduction. One scoping trap: since 22 November 2021 CloudFront, IAM and AWS STS events are recorded in AWS GovCloud (US-West), so a single-Region search from US-East silently misses every global-service event unless the trail is multi-Region. Calls must use SSL (HTTPS) and ARNs use partition arn:aws-us-gov
AU-7 asks for a capability, so the honest evidence is a query that ran rather than a configuration that exists — run one and keep the query text beside its output. AU-7(1), processing and sorting records by event criteria, is exactly what the filter and sort clauses in the Insights query and the WHERE and ORDER BY in the Lake and Athena statements demonstrate; pick criteria an assessor cares about — a named principal, a source IP, an event name, a bounded window — rather than a bare SELECT *. The half of AU-7 that no output proves is the requirement that reduction not alter the original content or time ordering of the records: these are read APIs, and Athena queries the CloudTrail objects in place in S3 rather than rewriting them, but that is an argument from the API contract, not a line in the result set — the property is actually carried by log file validation and object immutability, which belong to the integrity recipe, not this one. Two limits deserve to be read before the query power is. A query can only reduce records that reached the log group or the event data store, so a 90-day retention makes an annual report impossible no matter how good the SQL, which is why the retention fields are pulled alongside. And the Insights ceilings — 100,000 events per query, 10,000 per page, 50 log groups, 60 minutes — mean a broad search across a year of CloudTrail truncates silently rather than failing, so a result set sitting exactly at the limit is a truncated report and must not be filed as a complete one; Athena and CloudTrail Lake have no such row ceiling and are the right tools for a long look-back. Whether the resulting report actually supports after-the-fact investigation is a human judgement about the query, which is what keeps this partial rather than full.
For each artifact actually running in the boundary, the cryptographic answer to whether it came from the build this provider claims built it: the signed provenance statement, the certificate identifying the workflow that produced it, and the transparency-log timestamps that make the signature checkable later. Collected alongside the two things that decide whether that answer can be trusted at all — the version of the verifying client, and the record of which deployed artifacts were submitted for verification in the first place.
partial · cli · every on-change · /collect/build-provenance-attestation-verification
$ gh --version
$ aws ecr describe-repositories --query "repositories[].repositoryName" --output json
$ aws ecr describe-images --repository-name <ECR_REPO> --query "imageDetails[].{digest:imageDigest,pushedAt:imagePushedAt,tags:imageTags}" --output json$ gh attestation verify oci://<REGISTRY>/<IMAGE>@<DIGEST> --repo <ORG>/<REPO> --predicate-type https://slsa.dev/provenance/v1 --format json
$ gh api "/repos/<ORG>/<REPO>/attestations/sha256:<DIGEST>?per_page=100"
$ gh attestation trusted-root > trusted_root.jsonl
Expected output: Report names used below, and the command each comes from: `gh-version` is the first command, `ecr-repositories` the second, `deployed-images` the third, `verify-results` the fourth, `attestations` the fifth.
THIS RECIPE IS TWO NESTED LOOPS AND BOTH ARE LOAD-BEARING. Run the third command ONCE PER REPOSITORY NAME returned by the second — `describe-images` takes a single `--repository-name` and there is no organization-wide form of it — and run the fourth and fifth ONCE PER DIGEST returned by the third. Walking one repository and reading its images as the registry's is the same arithmetic error the sibling recipes on this plane call out by name, and here it lands on the command that IS the scope claim, so it under-states the population rather than merely under-reporting a setting.
From `gh --version`, the client version string. It is collected rather than decorative — see the CVE paragraph in the notes.
From the registry calls, the repository names in the account and, per repository, one row per image with its `digest`, `pushedAt` and `tags`. This is the widest population the commands can establish, it comes from the estate rather than from the platform under assessment, and `scan_scope` says what it is still not.
From the verify call with `--format json`, GitHub documents "a JSON array containing one entry per verified attestation", each entry carrying an `attestation` (the verified bundle) and a `verificationResult` whose parsed contents include `signature.certificate` — the parsed X.509 certificate identifying the signer — `verifiedTimestamps`, an array of transparency-log and timestamp-authority records, and `statement`, carrying `subject`, `predicateType` and the `predicate` object. The command's documented purpose is to "Verify the integrity and provenance of an artifact using its associated cryptographically signed attestations", validating "the identity of the actor that produced the attestation" and "the expected attestation predicate type (the nature of the claim)". `--predicate-type` defaults to `https://slsa.dev/provenance/v1` and is written out explicitly in the command above rather than left implicit, for the reason the notes give. Other flags worth knowing exist rather than being used blind: `--cert-identity` and `--cert-identity-regex`, `--cert-oidc-issuer` (default `https://token.actions.githubusercontent.com`), `--signer-repo`, `--signer-workflow`, `--source-ref`, `--source-digest`, `--deny-self-hosted-runners`, `--digest-alg` (default `sha256`), `--limit` (default 30), `--bundle` and `--custom-trusted-root`.
From the attestations REST call, a JSON OBJECT — not an array, and it is the only command in this file whose stdout is not one. The body carries an `attestations` array whose entries have `repository_id`, `bundle_url`, `initiator` and a `bundle` object with `mediaType`, `verificationMaterial` and `dsseEnvelope`. Because the report name and the body key are the same word, the clause below reads `attestations.attestations` and that repetition is correct rather than a typo: the first is this recipe's name for the command's stdout, the second is the key inside it. The path parameter is documented as `sha256:HEX_DIGEST`, `predicate_type` filters to "provenance, sbom, release, or freeform text for custom predicate types", `per_page` maxes at 100, and a fine-grained token needs `attestations:read`.
The sixth command writes the trusted root out for an air-gapped assessment: GitHub documents that "Artifact attestations can be verified without an internet connection" given the bundle, the trusted root file and the CLI imported in advance, and advises generating "a new `trusted_root.jsonl` file any time you are importing new signed material into your offline environment".
SI-07 (07) asks the provider to incorporate the detection of organization-defined security-relevant unauthorized changes INTO THE ORGANIZATIONAL INCIDENT RESPONSE CAPABILITY. The detection half of that sentence is the strongest thing this plane has: verification of a signed provenance statement over a named artifact is cryptography, not inference, and it either checks out or it does not. The other half is not in this output anywhere. Nothing here shows that a verification failure raises an incident, that an incident is triaged, that it reaches a human, or that a deployment is stopped. The gap is the integration and not the detection, and the disposition this recipe spends said so in advance precisely so that the authoring batch would not read the strength of the command as covering the clause it does not touch. It does not.
THE EXIT STATUS IS NOT THE EVIDENCE, AND THERE IS A CVE THAT PROVES IT. The obvious way to write this recipe is to run the command in CI and trust its exit code — which is what makes CVE-2025-25204 the single most important fact in this file. GitHub's own advisory for the GitHub CLI states that `gh attestation verify` "may return an incorrect zero exit status when no matching attestations are found for the specified `--predicate-type <value>`", that this happens when "an artifact has an attestation with a predicate type different from the one provided in the command", that the cause was "a re-used uninitialized error variable" returning `nil` "when no matching attestations are found", and that "Users who rely exclusively on the exit status code of `gh attestation verify` may incorrectly verify an attestation when the attestation's predicate type does not match the specified predicate type in the command". Versions from v2.49.0 are affected; v2.67.0 is the fix. Read what that says about this plane, because it is the plane card's vacuity trap arriving in the one place the card said `full` was plausible: the ABSENCE of a matching attestation was reported as success. The clauses below are therefore written against the PARSED JSON rather than against the exit code, `--predicate-type` is written out explicitly in the command rather than relied on as a default, and `gh --version` is collected so that an assessment can state which client produced the result. A recipe on this plane that reduces to `run the command, check $?` is unsound on a client older than v2.67.0 and is unverifiable on any client whose version was never recorded.
TWO ASSERTIONS WERE WRITTEN, AUDITED AND DELETED BEFORE THIS RECIPE SHIPPED. Both are recorded here so that a later batch reading the thin assertion list does not helpfully restore them.
The first asserted `verify-results[?verificationResult.verifiedTimestamps==null] count_eq 0` — that no returned attestation lacked the transparency-log records that make its signature checkable later. It was unfalsifiable, and the way it failed is the exact shape this plane's card warns about. Verification requires at least one observer timestamp to succeed, so an attestation with none never appears in the output at all: it is ABSENT from the response, not false in it, and the clause therefore returned zero on every possible output of the command, including on an estate where nothing verifies. The literal was wrong twice over besides — a bare `null` is not a JMESPath literal in the form this file uses elsewhere, and the field is an empty array rather than null in any case — but the fatal defect is the one no rewrite of the expression could repair. It read as the recipe's most confident clause and decided nothing. That it appeared in the one recipe whose whole argument is that this plane must not trust a signal reporting success on absence is the reason it is written up here at length rather than quietly dropped.
The second asserted `verify-results[?verificationResult.statement.predicateType!='https://slsa.dev/provenance/v1'] count_eq 0` — that every returned entry carried the predicate type asked for. It cannot fail either, for a duller reason: the command is invoked WITH `--predicate-type https://slsa.dev/provenance/v1`, so on a patched client the returned set is already filtered to it, and on an affected client the array is empty and the surviving existence clause is what catches that. A clause restating the flag it was invoked with is an invariant, not a check. What it was really trying to say belongs where it now is — in the command, written out explicitly rather than left to a default.
What remains is two clauses that can both go red, which is a smaller number than this recipe started with and a more honest one.
Why this is `partial` and not `full`, stated against the schema's own two tests rather than by assertion. The writable check exists — the clauses below are it. The freshness clause does not: a `full` rating on this plane additionally needs a `max_age_days` assertion showing the scan RAN, RECENTLY, over a named set, and there is nothing in this output to hang one on. `verifiedTimestamps` carries transparency-log and timestamp-authority records, but those timestamp the SIGNING of the attestation, not the running of the verification, and a freshness clause over them would assert that the artifact was built recently — a different claim, and one that is false for a correctly pinned long-lived dependency. The verification event itself leaves no timestamp in this output at all. So the honest reading is that the population cannot be shown to have been re-verified on any cadence from the evidence the recipe collects, which is the second reason the rating is `partial` and the reason it would remain `partial` even if the incident-response limb were somehow answered.
What an attestation proves, in GitHub's own words, and what it does not. The concept page states that "Artifact attestations enable you to create unfalsifiable provenance and integrity guarantees for the software you build" and that "Artifact attestations by itself provides SLSA v1.0 Build Level 2". It also carries the sentence that belongs in every assessment reading this evidence: "It is important to remember that artifact attestations are *not* a guarantee that an artifact is secure. Instead, artifact attestations link you to the source code and the build instructions that produced them." A verified artifact is an artifact whose origin is known. SI-07 (07) is about unauthorized CHANGE, and provenance answers that limb well; it says nothing about whether the authorized build was itself sound, which is SA-11's and SI-07's own territory.
The signer identity is where this recipe is weakest as written, and it is deliberate. The command validates "the identity of the actor that produced the attestation", but the clauses below do not pin WHICH actor: `--cert-identity`, `--cert-identity-regex`, `--signer-repo`, `--signer-workflow` and `--source-ref` are the flags that would, and the right values for them are provider-specific — the workflow file and ref that legitimately builds this artifact. A verification that passes without them says the artifact was built by SOME workflow in the named repository, which is a real claim and a weaker one than most readers will assume. `--deny-self-hosted-runners` is the same shape: a provider whose threat model distinguishes hosted from self-hosted runners has a flag for it and this recipe does not set it. Pinning these is the single highest-value change an implementer can make to this recipe, and it is left to the implementer because a wrong value here fails closed and noisily rather than silently.
The registry commands are AWS calls inside a pipeline recipe, and that is on purpose rather than an oversight about which file this belongs in. `scan_scope` requires an inventory held OUTSIDE the scanner, and a scanner that cannot enumerate anything makes the requirement bite harder here than anywhere else on this plane — so the enumeration has to come from the estate. They are written against ECR because that is the estate this project's other plane describes; a provider deploying Lambda packages, AMIs or plain binaries substitutes the equivalent enumeration and the recipe is unchanged in every other respect. Nothing about the verification is AWS-specific. What these commands are NOT is the control's population, and `scan_scope` now says so at length rather than implying otherwise: a registry listing is what was pushed, the deployment record is what is running, and an earlier draft of that field described the second while enumerating the first.
KSI-MLA-EVC is claimed and the other two indicators reaching this control are not, and the claim is the weakest judgement in this recipe rather than a settled one. EVC asks that the configuration of machine-based information resources, especially infrastructure as code, is persistently evaluated and tested. Two of its words strain here. `Persistently` is the one the missing freshness clause fails to evidence, and it is named elsewhere in these notes. `Configuration` is the harder one and is recorded rather than argued away: an artifact's provenance is not its configuration, EVC's other controls are `ca-7`, `cm-2` and `cm-6` — configuration-baseline controls, which is the company the indicator keeps — and the honest statement is that verifying what a deployment was built from is a test applied to a machine-based information resource before it becomes one, which is adjacent to the indicator's subject rather than inside it. EVC is claimed because it is the least-bad of the three indicators that reach this control and because the evidence is real; a batch that later finds a better home for this recipe should move it without treating this paragraph as a defence. KSI-MLA-LET asks for a maintained and reviewed list of information resources and event types that will be logged, monitored and audited; a verification result is not that list. KSI-MLA-OSM asks for a SIEM or similar system used for centralized, tamper-resistant logging; the Sigstore transparency log is genuinely tamper-resistant logging, but it is the signing infrastructure's log rather than the provider's monitoring system, and claiming OSM from it would be reading someone else's control as evidence of yours. Both omissions are the same judgement the incident-response gap makes: the route from this output into the provider's own response and monitoring capability is exactly what is missing, and claiming the indicators that describe that route would be claiming it.
SCR — Supply Chain Risk
11 recipes · 7 of 11 controls in scope reached
Patch Manager compliance state plus Amazon Inspector scan status and coverage — proving flaws are being found continuously (Inspector enabled and actually covering your resources) and that the fixes landed (per-node missing/failed patch counts and the time of the last scan or install)
partial · cli · every daily · /collect/patch-and-vulnerability-remediation
$ aws configservice get-compliance-details-by-config-rule --config-rule-name ec2-managedinstance-patch-compliance-status-check --compliance-types NON_COMPLIANT
$ aws ssm describe-instance-patch-states --instance-ids i-0123456789abcdef0 --query 'InstancePatchStates[].{Node:InstanceId,Baseline:BaselineId,Missing:MissingCount,Failed:FailedCount,CriticalNonCompliant:CriticalNonCompliantCount,SecurityNonCompliant:SecurityNonCompliantCount,Operation:Operation,EndTime:OperationEndTime}'$ aws inspector2 batch-get-account-status --account-ids <ACCOUNT_ID>
$ aws inspector2 list-coverage --filter-criteria '{"resourceType":[{"comparison":"EQUALS","value":"AWS_EC2_INSTANCE"}]}'Proves: SI-02, RA-05
Expected output: An EvaluationResults array with an empty NON_COMPLIANT set (every SSM patch-compliance record reads COMPLIANT), InstancePatchStates showing MissingCount/FailedCount/CriticalNonCompliantCount/SecurityNonCompliantCount at zero with a recent OperationEndTime, an account status whose resourceState.ec2/ecr/lambda read ENABLED, and coveredResources whose scanStatus.statusCode is ACTIVE with a recent lastScannedAt. Managed rule identifier: EC2_MANAGEDINSTANCE_PATCH_COMPLIANCE_STATUS_CHECK
GovCloud: AWS Config, Systems Manager, and Amazon Inspector are available in AWS GovCloud (US-East) and (US-West); instance and finding ARNs use partition arn:aws-us-gov. Two GovCloud differences to record: Lambda code scanning is not available, and the Inspector plugin for Linux deep inspection is not FIPS compliant.
This proves flaws are detected and shows exactly what is still missing and when patching last ran — it does not prove the remediation clock was met. Whether an open finding sits inside your SI-02 timeframe, or carries an approved deviation or POA&M entry, is a judgement joined against your risk-acceptance record, not an API result. Substitute your real instance ids and account id; describe-instance-patch-states requires --instance-ids (use describe-instance-patch-states-for-patch-group to sweep a patch group). Note also that patch compliance data is a point-in-time snapshot and each successful scan overwrites the previous one, so capture the output at collection time rather than reconstructing history later.
Cryptographic proof that the audit trail CloudTrail delivered has not been altered or deleted, plus the compliance state of the write-once controls that make stored records and container images tamper-evident
partial · cli · every weekly · /collect/integrity-verification-and-immutability
$ aws configservice get-compliance-details-by-config-rule --config-rule-name cloud-trail-log-file-validation-enabled --compliance-types NON_COMPLIANT
$ aws cloudtrail validate-logs --trail-arn <TRAIL_ARN> --start-time <START_TIME> --verbose
$ aws configservice get-compliance-details-by-config-rule --config-rule-name s3-bucket-default-lock-enabled --compliance-types NON_COMPLIANT
$ aws backup describe-backup-vault --backup-vault-name evidence-vault --query '{Locked:Locked,LockDate:LockDate,MinRetentionDays:MinRetentionDays,MaxRetentionDays:MaxRetentionDays}'$ aws configservice get-compliance-details-by-config-rule --config-rule-name ecr-private-tag-immutability-enabled --compliance-types NON_COMPLIANT
Expected output: Three EvaluationResults arrays, a validation run and a vault description. Empty NON_COMPLIANT sets mean every trail signs digest files, every evaluated bucket has Object Lock on by default and every private ECR repository refuses to move a tag. validate-logs prints the window it actually found and two ratios — for example '3/3 digest files valid' and '15/15 log files valid'; any shortfall names the file. describe-backup-vault returns Locked true with a LockDate, the UTC instant the compliance-mode grace time ends. Managed rule identifiers: CLOUD_TRAIL_LOG_FILE_VALIDATION_ENABLED, S3_BUCKET_DEFAULT_LOCK_ENABLED, ECR_PRIVATE_TAG_IMMUTABILITY_ENABLED
GovCloud: All five calls work in AWS GovCloud (US): none of the three managed rules names a GovCloud Region in its exclusion list, and AWS Backup Vault Lock is documented among the features offered for all supported resources with no Region carve-out — unlike restore testing and logically air-gapped vaults, which are blank for both GovCloud rows in the feature-availability table. Trail, bucket, vault and repository ARNs use partition arn:aws-us-gov, and because CloudTrail uses a different key pair per Region, validate the logs in the Region that produced them
The strong claim here is narrow and worth stating precisely. validate-logs is real cryptography — SHA-256 hashing with SHA-256/RSA signing, an hourly digest file that references the last hour's log files and carries the signature of the previous digest — so a clean run positively asserts that the delivered log files were not modified or deleted, and can even assert that no log files were delivered in a window you believed was empty. What it will not do: validate files you moved, since they must stay where CloudTrail put them; and it cannot report tampering across a gap — disable validation for an hour and no digest exists for that hour, so the chain simply breaks. Enabling the feature is not the same as checking it, which is why both the Config rule and the CLI run belong here: the rule proves digests are being produced, the run is the only thing that verifies them. Object Lock and Vault Lock are prevention, not detection — they make a deletion fail rather than proving none happened, and Object Lock only counts if the mode and period match your policy: the rule's optional mode parameter is what pins GOVERNANCE versus COMPLIANCE, and unset it passes either. Vault Lock in governance mode can be removed by anyone holding the IAM permission, so read Locked together with LockDate — before that date even a compliance-mode lock is still removable. The honest gap is the host: SI-7 asks for integrity verification of software, firmware and information, and nothing above watches a filesystem. ECR tag immutability stops a tag being repointed at a different image but says nothing about drift inside a running instance; file integrity monitoring is third-party or self-built on AWS, and SI-7(1)'s ‘defined frequency’ is a policy number you compare against, not an API result. Finally, a NON_COMPLIANT-only query returns an empty array on success and says nothing about resources Config never evaluated — join it against recorder coverage before reading emptiness as compliance.
The machine-generated inventory of every resource an external entity can reach — IAM Access Analyzer's active ExternalAccess findings — read against the declared zone of trust, so the terms-and-conditions review has a list to work from rather than a memory
partial · cli · every quarterly · /collect/external-access-inventory-and-trust-boundary
$ aws accessanalyzer list-analyzers --query 'analyzers[].{Name:name,Type:type,Status:status,Arn:arn,LastAnalyzedAt:lastResourceAnalyzedAt}'$ aws accessanalyzer list-findings-v2 --analyzer-arn <ANALYZER_ARN> --filter '{"status":{"eq":["ACTIVE"]},"findingType":{"eq":["ExternalAccess"]}}'$ aws organizations describe-organization --query 'Organization.{Id:Id,FeatureSet:FeatureSet,ManagementAccount:MasterAccountId}'$ aws configservice get-compliance-details-by-config-rule --config-rule-name iam-external-access-analyzer-enabled --compliance-types NON_COMPLIANT
Proves: AC-20
Expected output: From list-analyzers, one row per analyzer with type ACCOUNT or ORGANIZATION for external access (the ACCOUNT_UNUSED_ACCESS, ORGANIZATION_UNUSED_ACCESS, ACCOUNT_INTERNAL_ACCESS and ORGANIZATION_INTERNAL_ACCESS types answer different questions and do not produce ExternalAccess findings) and status one of ACTIVE, CREATING, DISABLED, FAILED. From list-findings-v2, one finding per resource shared outside the zone of trust, each with resource, resourceType, resourceOwnerAccount, findingType, status, createdAt and analyzedAt. From describe-organization, the organization id, FeatureSet ALL or CONSOLIDATED_BILLING, and the management account id — the boundary an ORGANIZATION analyzer treats as internal. From the Config rule, an empty NON_COMPLIANT set means an external-access analyzer is enabled and ACTIVE in that Region.
GovCloud: IAM Access Analyzer and Organizations operate in GovCloud (US); analyzer and resource ARNs use partition arn:aws-us-gov. The Config managed rule IAM_EXTERNAL_ACCESS_ANALYZER_ENABLED is NOT available in either GovCloud (US) Region — its published availability excludes GovCloud (US-East) and GovCloud (US-West) — so in GovCloud the fourth call has no rule to query and the analyzer's own status field from list-analyzers is the evidence that it is enabled.
AC-20 is a two-limb control and only one limb is in this output. The limb that is here: which external entities can reach organization-controlled information. Access Analyzer answers that by logic-based reasoning over resource-based policies, and every access by a principal INSIDE the zone of trust is trusted by definition, so a finding is exactly an access that crosses the boundary the organization declared. The limb that is not here: whether each of those crossings is covered by terms and conditions consistent with the trust relationship. That is an agreement — a contract, an interconnection security agreement, an authorization to connect — and no call returns it. Attach the agreement register and reconcile it finding by finding; the reconciliation, not the finding list, is the AC-20 artifact.
The inventory also has a direction. Access Analyzer sees resources you share OUT. It does not see an external system your people use to process organization information — a SaaS tool reached from a workstation leaves no resource policy in your account and produces no finding. That half of AC-20 has to come from your own third-party register, and inventing a join between the two would make the coverage claim wider than the evidence.
Three scope facts worth pinning down before the count is quoted. External-access analysis is REGIONAL: an analyzer evaluates only resources in the Region where it is enabled, so one analyzer per Region in use, or the inventory is silently partial. Fifteen resource types are analyzed for external access — S3 buckets and directory buckets, IAM roles, KMS keys, Lambda functions and layers, SQS queues, Secrets Manager secrets, SNS topics, EBS volume snapshots, RDS DB and DB cluster snapshots, ECR repositories, EFS file systems, DynamoDB streams and tables — and a resource type outside that list is not covered by the analyzer at all. And findings refresh within about 30 minutes of a policy change but can lag up to 24 hours when a change notification is missed, so a finding list is a recent state, not a live one.
The advisories AWS itself has issued against this account — operational issues, scheduled changes and account notifications, each dated and scoped — together with the subscribers on the topic those alerts are published to and the confirmation state of each subscription, which is the difference between an address that was entered and an address that receives
partial · cli · every continuous · /collect/security-advisories-receipt-and-dissemination
$ aws health describe-events --filter eventTypeCategories=issue,accountNotification,scheduledChange
$ aws sns list-subscriptions-by-topic --topic-arn <SECURITY_ALERT_TOPIC_ARN>
$ aws sns get-subscription-attributes --subscription-arn <SUBSCRIPTION_ARN>
Proves: SI-05
Expected output: From describe-events, events[] with arn, service, eventTypeCode, an eventTypeCategory of issue, accountNotification, scheduledChange or investigation, region, availabilityZone, startTime, endTime, lastUpdatedTime, a statusCode of open, closed or upcoming, and an eventScopeCode of PUBLIC, ACCOUNT_SPECIFIC or NONE — the field that says whether an advisory is a general service event or one raised against this account in particular. The API is served from a global endpoint, global.health.amazonaws.com, which resolves by CNAME to whichever of the us-east-1 (active) and us-east-2 (passive) endpoints is current, and AWS notes that only the active endpoint carries the latest data. From list-subscriptions-by-topic, Subscriptions[] with SubscriptionArn, Owner, Protocol, Endpoint and TopicArn, up to 100 per call with a NextToken beyond that. Read SubscriptionArn carefully: for a subscription that has not been confirmed it is not an ARN at all but the LITERAL STRING PendingConfirmation, which AWS's own published response for an unconfirmed email subscriber shows. That is the discriminator, and it is on this list. From get-subscription-attributes, an Attributes MAP whose values are all strings — including PendingConfirmation, documented as "true if the subscription hasn't been confirmed", and ConfirmationWasAuthenticated, "true if the subscription confirmation request was authenticated" — which is why any clause on them would compare against the string "false" and not a boolean. This call is collected as supplementary evidence — ConfirmationWasAuthenticated is not derivable from the list — and carries no assertion, because it takes --subscription-arn and the unconfirmed case has no ARN to pass it.
GovCloud: Amazon SNS operates in both AWS GovCloud (US) Regions and topic and subscription ARNs use partition arn:aws-us-gov. The AWS Health call is the one to check before this recipe is planned rather than after, and its constraint is commercial as much as GovCloud: AWS states that a Business, Business+, Enterprise On-Ramp, Enterprise or Unified Operations plan is REQUIRED to use the AWS Health API, and that an account not enrolled in one receives a SubscriptionRequiredException. A provider on Basic or Developer support cannot run the first command at all, and for them the advisory-receipt half of this recipe is a Health Dashboard record — which every customer can see — rather than an API result. In GovCloud the endpoint is different in kind, not just in name: AWS documents the Health API there as a single regional endpoint in us-gov-west-1, as opposed to the commercial global endpoint with failover-capable Regions behind it, so the global.health.amazonaws.com DNS-lookup pattern does not apply and us-gov-west-1 is the target. Two further GovCloud behaviours worth writing into the assessment: some Health events are global rather than Regional — IAM's among them — and receiving those requires a rule in AWS GovCloud (US-West), the twin of the CloudTrail Region rule the PS-04 recipe names; and the EventBridge channel there does not deliver public Service Health View events at all, so an alerting pipeline built on EventBridge sees account-specific events only and the API or the RSS feed is what carries the rest.
SI-05 has four movements: receive security alerts, advisories and directives from external organizations on an ongoing basis; generate internal security alerts; disseminate them to the personnel your policy names; and implement directives or notify the issuing organization of the degree of nonconformance. This output touches the first and the third, and only for one external organization.
That organization is AWS. Health events are the vendor's own notifications to this account — operational issues, scheduled changes, account notifications — dated, scoped by eventScopeCode to this account or to the service generally, and enumerable. That is a real answer to 'are you receiving advisories from your suppliers, on an ongoing basis, and can you show it'. The supply-chain indicator is claimed on one limb of its statement and not the whole: KSI-SCR-MON allows for mechanisms that 'may include contractual notification requirements', and a vendor health feed is exactly such a mechanism. It is NOT the other limb — none of Health's four event categories is a vulnerability category, and this evidence monitors AWS's service health rather than the provider's third-party software inventory. Read the claim as the notification limb only.
What it is not is the control. CISA emergency directives, US-CERT advisories, vendor bulletins for every other component in the stack, and the entire question of implementing a directive or reporting nonconformance to the issuer are correspondence and decisions. No API returns them, and a collection that presents Health events as SI-05 evidence without saying which limb they serve has answered a quarter of a control.
The dissemination half is where the honest telemetry is, and it is smaller than it looks. A topic with subscribers proves an alert has somewhere to go. It does not prove the subscribers are the personnel the policy names — that is a roster comparison — and it does not prove they receive anything unless the subscription is CONFIRMED. AWS requires confirmation for HTTP(S) endpoints, email addresses and cross-account resources.
The unconfirmed case is visible, and it is visible in the place you would not look for it. An unconfirmed subscription appears in the topic's subscription list with SubscriptionArn set to the literal string PendingConfirmation instead of an ARN — not a missing field, not a flag, a sentinel value in the identifier column. So the completeness clause below is written against the LIST. An earlier draft asserted the PendingConfirmation attribute from get-subscription-attributes instead, which cannot work: that call takes --subscription-arn, the unconfirmed subscription has no ARN to pass, and the failing case therefore never reaches the assertion at all. The third call stays as supplementary evidence for ConfirmationWasAuthenticated, which the list genuinely does not carry, and it carries no clause.
If a clause is ever written on those attributes, note that they come back as a map of STRINGS — PendingConfirmation is "true" or "false", not a boolean, and a comparison against a boolean matches nothing while returning green.
One gap the assertions cannot see, named here because a partial rating owes it: nothing collected joins the Health feed to the topic. SECURITY_ALERT_TOPIC_ARN is supplied by the operator, and no output here shows an EventBridge rule on the aws.health source targeting it. The two halves of this recipe are adjacent in the assessment's head and unjoined in the data; collect the EventBridge rule and its targets if that join needs to be evidence rather than assertion.
Two emptiness traps. describe-events as written sets no time bound — the filter selects categories, not a window — so what comes back is what AWS Health currently holds, and an empty events[] means the same thing on an account receiving advisories perfectly as on one whose Health access was never wired up. Add startTimes to the filter if the assessment needs the evidence pinned to a period. And the third call must be repeated per subscription: one confirmed subscription proves nothing about the other nine, and the assertion below is written against a single subscription ARN because that is what the API takes — walk the list from the second call and collect one response each, or the evidence covers one address.
KSI-SVC-ACM was deliberately not claimed. It asks that configuration be managed by automation and reviewed for drift; an advisory feed and a subscriber list say nothing about configuration, and claiming it would put this recipe on an indicator page as evidence for a question it never asks.
Whether the tooling that examines acquired software is switched on and covering the estate, and what it found: Inspector's per-account enablement state for each scanned resource type, the registry-wide ECR scanning configuration (scan type and frequency, and the repository filters that decide which repositories it applies to), Inspector's own coverage statistics, and a CycloneDX 1.4 or SPDX 2.3 SBOM exported per monitored resource — the component-level inventory of what was actually acquired.
partial · cli · every continuous · /collect/acquisition-scanning-and-sbom-inventory
$ aws inspector2 batch-get-account-status
$ aws ecr get-registry-scanning-configuration
$ aws inspector2 list-coverage-statistics --group-by SCAN_STATUS_REASON
$ aws inspector2 create-sbom-export --report-format CYCLONEDX_1_4 --s3-destination bucketName=<SBOM_BUCKET>,keyPrefix=<SBOM_PREFIX>,kmsKeyArn=<KMS_KEY_ARN>
$ aws inspector2 get-sbom-export --report-id <REPORT_ID>
Proves: SR-05
Expected output: From batch-get-account-status, accounts[] with accountId, state.status, and resourceState broken out per scanned resource type — ec2, ecr, lambda, lambdaCode and codeRepository — each with its own status and an errorCode when the service could not enable it. From get-registry-scanning-configuration, registryId and scanningConfiguration with scanType (BASIC or ENHANCED) and rules[], each rule carrying scanFrequency (SCAN_ON_PUSH, CONTINUOUS_SCAN or MANUAL) and repositoryFilters[] of {filter, filterType}; ENHANCED supports CONTINUOUS_SCAN and SCAN_ON_PUSH, BASIC supports SCAN_ON_PUSH only, and where scan-on-push is not specified the frequency defaults to MANUAL. From list-coverage-statistics, countsByGroup[] and totalCounts — the number of resources Inspector is actually covering, which is the number that decides whether an empty findings list means clean or means unscanned. From create-sbom-export, a reportId; from get-sbom-export, status (IN_PROGRESS | SUCCEEDED | FAILED | CANCELLED) with the s3Destination the JSON documents were written to. Each exported document is one resource's component inventory: CycloneDX 1.4 with components[] carrying purl and bom-ref, or SPDX 2.3 with packages[] carrying versionInfo and externalRefs. Unresolved hashes — components whose package manager used a version range or dynamic reference and which therefore cannot be scanned for vulnerabilities — are INCLUDED in the export as hashes, and are the part of the inventory a vulnerability count silently omits.
GovCloud: Both services are available in AWS GovCloud (US-West) and AWS GovCloud (US-East). Two documented differences bear on this recipe and one bears on the control it proves. Inspector: Lambda CODE scanning is not available in the partition, so resourceState.lambdaCode reports its absence rather than a misconfiguration, and the Linux deep-inspection plugin is documented as not FIPS compliant — which matters to a provider whose SSP claims FIPS-validated modules end to end. ECR: pull-through cache rules work only WITHIN the same partition, ECR public registries are not available, and the ECR Public Gallery is not hosted in GovCloud though it may be reachable if the estate has external internet access. Those three are acquisition facts, not availability trivia: they change where a GovCloud estate's images can legitimately come from, which is the strategy half of SR-05. S3 and KMS ARNs in the export destination use partition arn:aws-us-gov.
SR-05 asks for acquisition strategies, contract tools and procurement methods that reduce supply-chain risk. Two halves, and AWS holds one of them completely and the other not at all.
The half it holds is the TOOLING at the moment of acquisition. ECR's registry scanning configuration is the acquisition gate: scan-on-push examines an image as it enters the registry, and the rules[] with their repository filters say which repositories that applies to. Inspector monitors what has already landed and exports an SBOM per resource, which is the component-level inventory of what was actually acquired rather than what a supplier said they shipped. Both are continuous, both are machine-readable, and together they answer 'what did we take in, and what was wrong with it'.
The half it does not hold is the STRATEGY the control names — the contract clauses, the approved-supplier list, the delivery method, the decision to buy this component rather than that one. No scanner stands in for procurement, and none of these five commands reads a contract. That is the whole reason for the partial rating, and the assessment reads it out of the acquisition documentation the control asks for.
The enablement and coverage calls exist because of the empty-list trap, and here it is unusually sharp. A findings query against an estate with Inspector switched off returns an empty array and exits zero, and so does a clean estate. batch-get-account-status is what separates them: it reports status per resource type, so ECR ENABLED with EC2 DISABLED is visible as the partial coverage it is rather than as silence. Read BOTH of its arrays. An account that could not be enabled — access denied, or blocked by an Organizations policy — is reported in failedAccounts[] and does not appear in accounts[] at all, so a clause over accounts[] alone is green on the one account whose scanning never began. The enumeration has an edge worth stating too: accounts[] is capped at one hundred entries with no continuation token, and what the call returns when no account ids are passed is undocumented — so run it as the delegated administrator with the ids you mean, and treat the response as evidence about the accounts it names rather than about the organization. list-coverage-statistics closes the second half of the same gap — Inspector can be enabled and still not be covering a resource, and a count of covered resources is the denominator every finding count needs. Neither is decoration; without them the SBOM evidence is a claim about an unknown population.
The registry configuration has its own version of the trap, and it is the reason no assertion here asserts on rules[] alone. Scan frequency defaults to MANUAL where scan-on-push was not configured, and a registry with NO rules is a registry that scans nothing on push — but it answers with a well-formed scanningConfiguration all the same, so a clause reading 'every rule has SCAN_ON_PUSH or CONTINUOUS_SCAN' is vacuously true on exactly the estate that has configured nothing. The offender form is the population of repositories no rule's filter matches, and that needs a join against describe-repositories this assertion grammar cannot express — which is why this is partial and not full even though the enablement half could be written down.
On the SBOM export: it needs an S3 bucket whose policy allows Inspector to write and a KMS key whose policy allows Inspector to encrypt, both configured before the call, and it is asynchronous — create returns a reportId and get reports status. Read the unresolved-hash note in the output as evidence rather than noise. A component whose package manager used a version range cannot be mapped to a name and version, and therefore cannot be scanned for vulnerabilities at all; Inspector now includes those hashes in the export. They are the components a vulnerability count does not cover, and an SBOM read without them looks cleaner than the estate is.
KSI-SCR-MON is earned directly: third-party software resources ARE automatically monitored for upstream vulnerabilities, and these commands prove the mechanism is on and covering. KSI-SCR-MIT is claimed for identify-and-review and not for mitigate — nothing here shows a risk being closed, only found.
Whether Dependabot alerting is configured in this organization and which repositories it actually reaches, together with the alerts themselves — each carrying the advisory that raised it, the package, ecosystem and manifest path it was found in, the reason a human gave for closing it, and, for a remediated one, the date it was fixed. The first half is the population; the second half is what was found in it, and the second half means nothing without the first.
partial · api · every continuous · /collect/dependency-vulnerability-monitoring
$ gh api --paginate "/orgs/<ORG>/code-security/configurations"
$ gh api --paginate "/orgs/<ORG>/code-security/configurations/<CONFIGURATION_ID>/repositories?per_page=100"
$ gh api --paginate "/orgs/<ORG>/dependabot/alerts?state=open&per_page=100"
$ gh api --paginate "/orgs/<ORG>/dependabot/alerts?state=dismissed&per_page=100"
$ gh api --paginate "/orgs/<ORG>/dependabot/alerts?state=auto_dismissed&per_page=100"
$ gh api --paginate "/orgs/<ORG>/dependabot/alerts?state=fixed&per_page=100"
Expected output: Report names used below, and the command each comes from: `configurations` is the first command, `configuration-repositories` the second, `alerts-open` the third, `alerts-dismissed` the fourth, `alerts-auto-dismissed` the fifth, `alerts-fixed` the sixth.
RUN THE SECOND COMMAND ONCE PER CONFIGURATION returned by the first, not once for the organization. The first command returns every configuration and the second takes a single `<CONFIGURATION_ID>`; collecting one response and reading it as the organization's is the arithmetic error this recipe is most likely to be assessed with, and the clause on `configuration-repositories` speaks only for the configurations actually walked.
From the configurations call, an array of code security configurations, each with an id, a name, a `target_type` of `global`, `organization` or `enterprise`, and the feature fields as THREE-STATE strings rather than booleans — `dependabot_alerts`, `dependabot_security_updates`, `dependency_graph`, `secret_scanning`, `secret_scanning_push_protection`, `code_scanning_default_setup` and `private_vulnerability_reporting` each taking `enabled`, `disabled` or `not_set` — plus an `enforcement` of `enforced` or `unenforced`. `not_set` is the value to read carefully: it is neither on nor off, it is the absence of a decision, and a clause comparing against `disabled` alone does not see it. From the configuration-repositories call, rows carrying a `status` of `attached`, `attaching`, `detached`, `removed`, `enforced`, `failed`, `updating` or `removed_by_enterprise` and the repository object each refers to; the call passes no status filter, so a failing row is present-and-false rather than absent. From the alerts calls, alerts with `number`, a `state` of `open`, `dismissed`, `fixed` or `auto_dismissed`, a `severity` of `low`, `medium`, `high` or `critical`, a `dependency` object naming `package.ecosystem`, `package.name`, `manifest_path`, a `scope` of `development` or `runtime` and a `relationship` of `direct`, `transitive`, `inconclusive` or `unknown`, a `security_advisory` object carrying `ghsa_id`, a nullable `cve_id`, `cvss_severities` with `cvss_v3` and `cvss_v4` scores, `cwes`, a `classification` of `general` or `malware` and `epss` percentage and percentile, a `security_vulnerability` with the affected version range, `created_at`, `updated_at`, `dismissed_at`, `fixed_at` and `auto_dismissed_at` timestamps, a `dismissed_reason` from the enum `fix_started`, `inaccurate`, `no_bandwidth`, `not_used` and `tolerable_risk`, and the `repository` the alert belongs to. Note the calls that look like one: `dismissed`, `auto_dismissed` and `fixed` are SEPARATE values of `state`, so a query for any one returns none of the others. The organization endpoint requires an organization owner or a security manager, and a token carrying the `security_events` scope.
SR-06 asks the provider to ASSESS AND REVIEW the supply chain risk associated with suppliers and contractors. This output assesses one population — the third-party software components resolved into the build — continuously, and it produces a reviewable record for each finding. That is a real answer to one limb and it is not the control. Provenance, contract terms, geographic and financial exposure, and the frequency at which a supplier is re-reviewed are a human record, and a manifest entry names a package, not a supplier's risk posture. The disposition this recipe spends said exactly that, and the rating is unchanged by having written the commands down.
What the population is, and what it is not. GitHub documents that when Dependabot alerts are enabled it "immediately generates the dependency graph and creates alerts for any vulnerable dependencies it identifies", so the graph is not a separate switch a clause below needs to police — which is why the first assertion reads `dependabot_alerts` and not both fields. What the same page says under its own heading of limitations, and what bounds this recipe more than anything else in it, is that "only advisories reviewed by GitHub trigger alerts". The detection surface is the GitHub Advisory Database's reviewed set. A vulnerability that is real, published, and not in that set produces no alert, and an empty list is silent about it. A second, sharper limit sits inside the first: alerting on MALICIOUS packages, as opposed to vulnerable ones, is a separate opt-in that has to be enabled after Dependabot alerts are, and no command in this recipe can see whether it is on — the code security configuration response carries no property for it. So an alert list with no malware finding in it is equally an organization with no malicious dependency and an organization that never switched the detection on, and a clause asserting the absence of malware would be vacuous for exactly that reason. Both limits are ceilings on the evidence rather than defects in the collection, and they belong in the assessment rather than in a footnote.
A code security configuration is not the only way Dependabot gets switched on, and the first clause below is wrong in one direction because of it. Alerts can be enabled on a repository directly, in that repository's own settings, with no configuration involved. An organization that never adopted configurations therefore returns an empty list from the first command and FAILS the clause while scanning every repository it owns. Read a failure there as "no organization-level configuration governs this", which is a real and useful finding, and NOT as "nothing is scanned" — the second reading is unsupported and the reconciliation named in `scan_scope` is what distinguishes them.
The multi-configuration case, named because the first assertion is weaker than it reads in the other direction too. The clause is satisfied by ANY qualifying configuration, including one applied to no repository at all.
The `target_type` narrowing on both configuration clauses is a deliberate trade and it costs something. The enum admits `global` and `enterprise` alongside `organization`, and GitHub ships a recommended configuration that an administrator does not create but DOES apply from the organization's own configurations table — applying it is a decision, and a decision this clause will not count. So the narrowing buys a false FAIL on GitHub's recommended path and on an enterprise-applied one, in exchange for not passing on a row the organization never chose. That is the right way round only because of how a failure is to be read, below: not as "nothing is scanned" but as "no configuration this organization owns governs this", which is literally true in the global and enterprise cases too. What documentation does NOT settle is whether the org endpoint returns those rows at all — its description says configurations "available in" an organization rather than owned by one — and the live risk is not that the narrowing is unnecessary but that the rows it excludes are the organization's real answer. One call against a live organization settles it, and an assessment leaning on either configuration clause should make that call first.
Why there is no clause on `dismissed_reason`. An earlier draft asserted that no dismissed alert had a null reason, and it was decorative: GitHub requires a `dismissed_reason` when an alert's state is set to `dismissed`, so on a `state=dismissed` list there is no documented path that yields null and the clause could not fail. It is recorded here so it is not re-added. The review limb of the claim rests on the fourth command's output being collected and read, not on a check that cannot go red.
`state=dismissed`, `state=auto_dismissed` and `state=fixed` are three separate queries, and the last two carry no assertion deliberately. The auto-dismissed list exists because an organization whose alerts close mostly by rule has a short `dismissed` list, and any review clause would be near-vacuously true while very little was reviewed by anyone; an auto-dismissal is a rule firing, not a person deciding, and a clause about its count would assert that a policy is correct rather than that a review happened. The fixed list exists because it is the mitigation record, and it is the reason KSI-SCR-MIT can be claimed at all — but a count of remediated alerts is a measure of how much was broken, not of how well it was handled, and no threshold over it would mean anything without the response clock the plan surfaces separately. It carries the emptiness problem the open list carries, and it carries it while bearing the whole weight of the mitigation claim: an empty fixed list is equally an organization that has never had a vulnerable dependency, one that has never remediated one, and one whose scanning was switched on last week. Nothing in this recipe distinguishes those, which is why the claim on that indicator is a collected record for a human to read and not a check that can go red.
MAS-CSO-TPR was deliberately not claimed, and the reasoning is recorded so the next batch does not re-derive it. Its artifact is a machine-readable output of the third-party information resources of the offering, and a dependency IS a third-party information resource on the dataset's own definition — an information resource not entirely inside the Minimum Assessment Scope, where information resources expressly include software and code. But the requirement's statement names the data that output must carry: general usage and configuration, an explanation or justification for use, mitigation measures, and compensating controls. An alert stream carries none of the four, and the enumeration it implies is a by-product rather than the artifact. A recipe that exported the dependency graph SBOM would have a genuine partial claim on that requirement; this one does not, and adjacency is not a join.
KSI-SCR-MIT is claimed on the software-component limb only. Identify is the alert, review is `dismissed_reason`, mitigate is the transition to `fixed` — those three are the indicator's three verbs, over one supplier population, and all three are now collected rather than two of them being collected and the third described. Hardware, managed services, and every supplier relationship that never resolves into a manifest are outside it, and an indicator page carrying this recipe should be read as carrying evidence for a part.
The evidence platform is itself an external system; see `external_system`. It is not a footnote on this control in particular — SR-06 is a supply chain control, and answering it by adopting a supplier is a move that has to be visible in the assessment rather than only in the recipe.
The organization's webhook configuration — which endpoints are subscribed to the supply-chain alert event, whether each is switched on, and where it points — together with the platform's own record of what it actually delivered to them and with what response code. Configuration says a path exists; the delivery log says the path carried something.
partial · api · every continuous · /collect/supply-chain-alert-notification-routing
$ gh api --paginate "/orgs/<ORG>/hooks?per_page=100"
$ gh api "/orgs/<ORG>/hooks/<HOOK_ID>"
$ gh api --paginate "/orgs/<ORG>/hooks/<HOOK_ID>/deliveries?status=failure&per_page=100"
$ gh api --paginate "/orgs/<ORG>/hooks/<HOOK_ID>/deliveries?per_page=100"
Proves: SR-08
Expected output: Report names used below, and the command each comes from: `hooks` is the first command, `deliveries-failed` the third, `deliveries` the fourth.
RUN COMMANDS 2, 3 AND 4 ONCE PER HOOK returned by the first, not once for the organization. The first command returns every organization webhook and the rest take a single `<HOOK_ID>`; the delivery clause below speaks only for the hooks actually walked, and collecting one hook's deliveries and reading them as the organization's is the arithmetic error this recipe is most likely to be assessed with.
From the hooks calls, webhook objects with `id`, `name`, `active`, an `events` array, a `config` object carrying `url`, `content_type` and `insecure_ssl`, plus `created_at`, `updated_at`, `url`, `ping_url`, `deliveries_url` and `type`. Read `events` knowing that GitHub documents the wildcard on the create body: the parameter "determines what events the hook is triggered for. Set to [\"*\"] to receive all possible events", and it defaults to `push`. Whether a GET returns the literal `"*"` or an expanded list is NOT documented and was not verified, which is why both clauses below carry the wildcard as a disjunct rather than testing for it — the disjunction is correct under either behaviour. From the deliveries calls, delivery records with `id`, `guid`, `delivered_at`, `redelivery`, `duration`, `status`, `status_code`, `event`, `action`, `installation_id`, `repository_id` and `throttled_at`. The third command uses GitHub's own `status` filter, documented on the organization webhooks reference as `success` for a response code of 200–399 and `failure` for 400–599, so it returns a list built to hold only offenders. The relevant `event` value is `dependabot_alert`, whose actions are `assignees_changed`, `auto_dismissed`, `auto_reopened`, `created`, `dismissed`, `fixed`, `reintroduced` and `reopened`, and which GitHub makes available on repository, organization and app webhooks.
SR-08 establishes agreements and procedures with entities in the supply chain for notification of compromise or of a vulnerability. It has two limbs and this recipe answers one of them. The agreements limb is a contract with a supplier and nothing here touches it.
What this recipe deliberately does NOT read is the alert list, and that is the whole design. A public advisory feed is not an entity in your supply chain, so an alert stream — however rich — cannot evidence SR-08 on its own; the feed itself is SI-05's artifact and is already collected there. What is evidence is the procedure: that an upstream notification, once it arrives, is delivered to a named destination rather than into nothing. That is configuration plus a delivery record, which is what these commands collect. A future batch tempted to strengthen this recipe by adding `dependabot/alerts` to it should read this paragraph first.
The emptiness trap here is worse than usual and it is worth being blunt about. GitHub's deliveries reference documents `per_page`, `cursor` and a `status` filter, and states NO retention period for delivery records. So the window these commands read is of unknown length, and an empty deliveries list means one of: nothing was ever delivered, nothing has been delivered recently, or nothing is retained that far back. None of the three is distinguishable from the others in the output, and none of them is a pass. The failure clause below is therefore written over the `status=failure` list — a list whose emptiness is meaningful in a way the full list's is not, because it is built to contain offenders — and the fourth command is collected as the corroborating record a human reads for volume and recency. No clause asserts the full list is non-empty, because on a quiet organization it legitimately is.
The wildcard is in BOTH hook clauses rather than in a note, and it has to be in both. A hook created with `events: [\"*\"]` receives every event including this one and may contain the string `dependabot_alert` nowhere, so a clause matching the literal alone reports the most completely subscribed configuration on the platform as unsubscribed. An earlier draft carried the disjunct on the first clause and not the second, which was worse than omitting it from both: the second clause exists to catch a subscribed-but-disabled hook, and without the disjunct the hook it could not see was exactly the wildcard one that had been switched off. Note also that GitHub documents the wildcard on the create request body; nothing states what a GET returns for such a hook, so the disjunction is written to be correct whether the literal survives the round trip or is expanded.
The deprecated event, which no clause tests and every assessment should look for. GitHub carries a closing-down notice on `repository_vulnerability_alert` — "this event is closing down, use the `dependabot_alert` event instead" — with actions `create`, `dismiss`, `reopen` and `resolve`. An organization whose only subscription is that event is being delivered to today and is on a path that ends. It fails the first clause below, and that failure is correct rather than a false positive: the finding is real, its severity is scheduled rather than immediate, and reading the failure as a configuration error would be reading it right for the wrong reason.
Organization hooks are not the only hooks. `dependabot_alert` is available on repository, organization and app webhooks, and `/orgs/{org}/hooks` returns only the middle one. A provider routing alerts per repository, or through a GitHub App, has a correct configuration that is entirely absent from these commands. That is a scope limit rather than a finding, and an assessment that reads an empty org hooks list as a failure has misread it — walk the repositories in the boundary, or the app installation, and collect the same two calls there.
What a delivery proves and where it stops. A `status_code` in the 200–399 band says the platform reached an endpoint and the endpoint accepted the payload. It does not say the payload was parsed, was routed onward, reached a person, or was acted on within any clock. Every one of those is the thing SR-08's procedures limb ultimately cares about, and every one of them lives in the receiving system rather than in this one — which is the honest reason this recipe is `partial` and would still be `partial` with a perfect delivery log.
KSI-SCR-MIT was deliberately not claimed. The dataset reaches SR-08 from KSI-SCR-MON alone, and a routing path evidences monitoring reaching someone rather than a risk being identified, reviewed and mitigated.
Whether static analysis is configured in this organization and which repositories it actually reaches; for each of those repositories, which query suite ran over which languages, whether the recurring schedule is still alive, when the analysis last ran and with how many rules in the run; and what the analysis found, split into what is still open and what a person closed by hand — each closure carrying who closed it, which of four fixed reasons they chose, and whatever they wrote down. The first two halves are the population and the proof that testing happened; the third is the half an assessment asks for, and read without the other two it cannot be told apart from the output of a scanner that never ran.
partial · api · every continuous · /collect/static-analysis-coverage-and-flaw-disposition
$ gh api --paginate "/orgs/<ORG>/code-security/configurations"
$ gh api --paginate "/orgs/<ORG>/code-security/configurations/<CONFIGURATION_ID>/repositories?per_page=100"
$ gh api "/repos/<ORG>/<REPO>/code-scanning/default-setup"
$ gh api --paginate "/repos/<ORG>/<REPO>/code-scanning/analyses?tool_name=CodeQL&ref=refs/heads/<DEFAULT_BRANCH>&per_page=100"
$ gh api --paginate "/orgs/<ORG>/code-scanning/alerts?state=open&tool_name=CodeQL&per_page=100"
$ gh api --paginate "/orgs/<ORG>/code-scanning/alerts?state=dismissed&tool_name=CodeQL&per_page=100"
Expected output: Report names used below, and the command each comes from: `configurations` is the first command, `configuration-repositories` the second, `default-setup` the third, `analyses` the fourth, `alerts-open` the fifth and `alerts-dismissed` the sixth.
RUN COMMANDS 3 AND 4 ONCE PER REPOSITORY, not once for the organization. There is no organization-scoped analyses endpoint and no organization-scoped default-setup endpoint — GitHub documents both only under /repos/{owner}/{repo} — while the alert endpoints exist at both scopes. So the population half of this recipe is assembled one repository at a time and the findings half arrives in one list, and collecting one repository's analyses and reading them as the organization's is the arithmetic error this recipe is most likely to be assessed with.
From `configurations`, configuration objects whose code-scanning fields are `code_scanning_default_setup` and `code_scanning_delegated_alert_dismissal`, each documented as `enabled | disabled | not_set`, plus `code_scanning_default_setup_options` carrying `runner_type` (`standard | labeled | not_set`) and `runner_label`. Each row also carries `target_type`, which is `global | organization | enterprise` — and the list endpoint's own `target_type` parameter is documented `Default: all`, so THIS RESPONSE IS NOT A LIST OF CONFIGURATIONS THE ORGANIZATION AUTHORED. It can contain a GitHub-provided global configuration and an enterprise-owned one, neither of which the organization can edit, and both of which can be the thing that switched code scanning on. The two clauses below split on exactly that distinction; read their descriptions together rather than separately. From `configuration-repositories`, association rows whose `status` is one of `attached | attaching | detached | removed | enforced | failed | updating | removed_by_enterprise`; the call passes no `status` filter, whose documented default is `all`, which is deliberate here because the `failed` row is what one clause below exists to catch.
From `default-setup`, `state` (`configured | not-configured`), `languages` drawn from `actions, c-cpp, csharp, go, java-kotlin, javascript-typescript, python, ruby, swift`, `query_suite` (`default | extended`), `threat_model` (`remote | remote_and_local`), `runner_type`, `runner_label`, `updated_at` and `schedule` (`weekly | null`).
From `analyses`, analysis objects with `id`, `ref`, `commit_sha`, `analysis_key`, `environment`, `category`, `error`, `warning`, `created_at`, `results_count`, `rules_count`, `sarif_id`, `deletable` and a `tool` object of `name`, `version`, `guid`. `sort` accepts only `created` and `direction` defaults to `desc`, which is what makes `analyses[0]` the most recent run and is the documented behaviour the freshness clause depends on — a collector that overrides either has changed what that clause means. The command narrows on `ref` as well as `tool_name`, and both narrowings are load-bearing: without `ref` the newest row can be a pull-request analysis (`refs/pull/N/merge`), and GitHub documents `category` as what distinguishes "multiple analyses for the same tool and commit, but performed on different languages or different parts of the code", so on a multi-language repository `analyses[0]` is whichever LANGUAGE finished last. There is no server-side `category` filter, so a repository with more than one analysed language needs the freshness clause read per category — see the note.
From both alert lists, alerts with `number`, `created_at`, `updated_at`, `state` (`open | dismissed | fixed | null`), `fixed_at`, `dismissed_by`, `dismissed_at`, `dismissed_reason` (`false positive | won't fix | used in tests | mitigated | null`), `dismissed_comment` (nullable, max 280 characters), a `rule` object (`id`, `name`, `severity` `none | note | warning | error`, `security_severity_level` `low | medium | high | critical`, `description`, `full_description`, `tags`, `help`, `help_uri`), a `tool` object, `most_recent_instance` (with `ref`, `analysis_key`, `category`, `commit_sha`, `location`, `classifications`) and, at organization scope, the `repository` the alert belongs to. Read the alert lists knowing two documented things about them: the organization endpoint takes no `ref` filter, and GitHub states that "The status and details on the alert page only reflect the state of the alert on the default branch of the repository, even if the alert exists in other branches" — so this is a default-branch picture, not a repository-wide one. Both alert commands pass `tool_name=CodeQL`, because code scanning stores third-party SARIF in the same store and this recipe's claims are about the CodeQL analyses the fourth command measures.
SA-11 has five limbs and this recipe reaches parts of three. The developer's security and privacy assessment plan and the depth-and-coverage determination that plan is required to state are documents; a 3PAO reads them there. What the platform contributes is that testing RAN, with a named ruleset and a live schedule, over a named set of repositories and languages, and what it found — the evidence-of-execution limb — plus the disposition of each finding, which is the flaw-correction limb seen from one side, plus one testable property of the remediation record itself: that each closure carries a written justification, which is the verifiability limb the dismissal clause is grounded in. The judgement that a flaw was corrected rather than argued away is not in this output and no amount of extra collection puts it there. That is what the disposition this recipe spends already said, and writing the commands down has not changed it.
WHAT AN EMPTY ALERT LIST IS CONSISTENT WITH, ON THIS CONTROL SPECIFICALLY. Five worlds, and only one is a pass: nothing was found; nothing was scanned; scanning is off; scanning was on and the schedule has since been disabled for inactivity; or one repository out of forty was scanned. The first six assertions exist to separate them and they are ordered deliberately — configuration intent, ownership, attachment success, repository state, schedule liveness, then rule count and freshness — because each is necessary for the next to mean anything. There is a sixth world hidden by a present, plausible-looking field: default setup analyses a SELECTED LIST of languages, and a repository can be `configured`, scheduled, fresh, rule-carrying, and completely silent about the service that carries the risk because that language was never selected. `languages` is returned by the third command, so the fact is collected; it is not asserted, because the list it must be compared against — the languages the boundary's code is written in — is not in any response here. `scan_scope` names that comparison.
WHY THERE IS NO SEVERITY THRESHOLD CLAUSE. It would be easy to add "no open alert at critical severity" and it would be the wrong control. SA-11 is about the developer performing testing and producing evidence of it, not about the estate being free of findings. The clock on how fast a detected finding must be dealt with is the dataset's own VER-TFR-EVU requirement — evaluate ALL vulnerabilities within a stated number of days of detection — which is a different measurement with a different per-class value, and a recipe that quietly turned SA-11 into a zero-criticals gate would report an authorization failure for a provider testing exactly as this control requires and holding a legitimately open medium.
A CADENCE GAP THAT BELONGS TO THE PLATFORM RATHER THAN TO THE PROVIDER, AND WHICH THE SEVEN-DAY CLAUSE DOES NOT CLOSE. The freshness clause enforces seven days because that is what `schedule: weekly` means. KSI-SCR-MIT's own class-c floor is the VDR-TFR-MVX MUST — verify and validate the status of machine-based information resources at least once every three days — which is tighter than the schedule this platform runs on default setup, and SA-11 is a class c and class d control, so this affects every reader of it. On an actively developed repository the gap closes by itself, because default setup also scans on pushes and pull requests; on a boundary repository that is quiet, the weekly schedule is the only thing running and it does not meet the indicator's clock. For class d the binding MUST is looser rather than tighter — read it off `classClocks[].tightestMust` rather than off the tightest clock, which is a SHOULD — so the gap is a class-c fact, not a monotonic one. A provider needing to close it moves to advanced setup and its own schedule, which is a different recipe against a different endpoint.
HOW SEVERITY IS DERIVED, SINCE ASSESSMENTS TREAT IT AS AN OBSERVATION. GitHub documents that every code scanning alert carries a level of Error, Warning or Note, that CodeQL security alerts additionally carry Critical, High, Medium or Low, and that those levels "follow the industry-standard Common Vulnerability Scoring System (CVSS)" — derived by taking the 75th-percentile CVSS score of CVEs whose CWE tags relate to the query. So `security_severity_level` is a statistical property of a class of vulnerability, not a measurement of this instance in this codebase. It is a good prioritisation signal and it is not a risk determination for the system under assessment.
WHAT `dismissed_reason` ADMITS, AND THE ONE VALUE THAT IS A CLAIM RATHER THAN AN OBSERVATION. The documented values are `false positive`, `won't fix`, `used in tests`, `mitigated` and `null`. Three of the four strings describe the finding; `mitigated` describes the code, and nothing in the platform verifies it — a person selected it. `won't fix` is the value an assessment should read against the provider's own risk-acceptance procedure rather than against this recipe. Where `code_scanning_delegated_alert_dismissal` is `enabled` on the governing configuration, dismissals go behind a reviewer instead of being unilateral; that field is collected by the first command and is deliberately not asserted, because the control does not require delegated dismissal and a provider without it can still hold a verifiable remediation record.
THE DEFAULT-BRANCH PICTURE. GitHub states that an alert's status "only reflect[s] the state of the alert on the default branch of the repository, even if the alert exists in other branches", and the organization-scoped alerts endpoint accepts no `ref` filter at all. So this evidence is a statement about the default branch of each repository, which is also why the analyses command pins `ref` to it. For a provider that releases from the default branch that is the right population; for one that maintains long-lived release branches, the branch that is actually deployed may be carrying findings this recipe cannot see, and the repository-scoped alerts endpoint with `ref` is where that gap is closed.
ONE THING THE DOCUMENTATION DOES NOT SAY, RECORDED AS A GAP RATHER THAN FILLED IN. The reference page for alerts does not define what makes an alert `fixed` rather than `dismissed`, or what happens to an open alert when the code stops being scanned, and this recipe makes no claim about either. An alert disappearing from `alerts-open` is therefore not by itself evidence that a flaw was corrected — the same limb the dismissal clause is careful about, arriving from the other direction.
KSI-SCR-MIT is claimed because it is the ONLY in-scope indicator the dataset maps onto SA-11 — the claim is forced by an upstream mapping rather than chosen, and it is worth saying so plainly, because the fit is imperfect. The indicator asks that supply chain RISKS be persistently identified, reviewed and mitigated, and CodeQL default setup analyses the provider's own source: a first-party injection sink is a software weakness rather than a supply chain risk. What transfers cleanly are the verbs — a recurring analysis is identification and the dismissal record is review — and the noun does not. Mitigation is outside this plane in any case: whether the code changed is a fact about a commit, not about an alert list, and the `fixed` state is not documented well enough here to carry it.
The rules that made every change to the mainline arrive through a reviewed pull request, as a signed commit, onto a history that cannot be rewritten — together with the record of when those rules themselves last changed and who changed them, and the platform's own per-push record of any change that got past them.
partial · api · every weekly · /collect/developer-change-control-and-integrity
$ gh api --paginate "/orgs/<ORG>/repos?per_page=100"
$ gh api "/repos/<ORG>/<REPO>/rules/branches/<DEFAULT_BRANCH>"
$ gh api --paginate "/repos/<ORG>/<REPO>/rulesets?includes_parents=true&per_page=100"
$ gh api --paginate "/repos/<ORG>/<REPO>/rulesets/<RULESET_ID>/history?per_page=100"
$ gh api "/repos/<ORG>/<REPO>/rulesets/<RULESET_ID>/history/<VERSION_ID>"
$ gh api --paginate "/repos/<ORG>/<REPO>/rulesets/rule-suites?ref=refs/heads/<DEFAULT_BRANCH>&time_period=month&evaluate_status=active&rule_suite_result=bypass&per_page=100"
Expected output: Report names used below, and the command each comes from: `repos` is the first command, `branch-rules` the second, `rulesets` the third, `ruleset-history` the fourth, `ruleset-version` the fifth, `rule-suites-bypassed` the sixth.
RUN COMMANDS 2, 3 AND 6 ONCE PER REPOSITORY AND ASSESSED BRANCH; run command 4 ONCE PER RULESET returned by command 3, and command 5 once per version of a ruleset you actually want to read. Every clause below speaks only for the repository and branch walked.
`branch-rules` and `rulesets` return the shapes described in the sibling recipe `change-verification-status-checks-and-run-records`, and the rule types this recipe filters on are `pull_request`, `required_signatures` and `non_fast_forward`. The `pull_request` rule's parameters are `required_approving_review_count`, `dismiss_stale_reviews_on_push`, `require_code_owner_review`, `require_last_push_approval`, `required_review_thread_resolution` and `allowed_merge_methods`. `non_fast_forward` is the rule GitHub describes in its interface as blocking force pushes, and `required_signatures` the one under which "contributors and bots can only push commits that have been signed and verified to the branch".
From `ruleset-history`, one entry per version of the ruleset: a `version_id`, an `actor` with an `id` and a `type`, and an `updated_at`. It says WHEN the change-control rules changed and WHO changed them, and it does not say what they changed to. From `ruleset-version`, the same three fields plus a `state` object, which is the ruleset as it stood at that version — this is the command to run when a history entry needs to become an answer.
From `rule-suites-bypassed`, the push-evaluation records filtered by GitHub's own `rule_suite_result` parameter, documented as accepting `pass`, `fail`, `bypass` and `all`. The command passes `bypass`, so the response is built to contain only the pushes that got past the rules, each with `actor_name`, `before_sha`, `after_sha`, `ref` and `pushed_at`. `time_period` tops out at `month` and no retention period is documented for these records. The command also passes `evaluate_status=active` — documented as returning "only rule suites resulting from rulesets in active (non-evaluate) mode" — so the bypasses counted are bypasses of rules that were actually enforcing.
SA-10 requires the provider to make the developer perform configuration management during development, implementation and operation; to document, manage and control the integrity of changes; to implement ONLY ORGANIZATION-APPROVED changes; to document approved changes and their potential security impacts; and to track flaws. This output answers the integrity-and-control limb with mechanism rather than with assertion: a rule that every change arrives through a pull request, a rule that its commits are signed, a rule that the history behind it cannot be rewritten, and the platform's own record of anything that got past all three. The gap the disposition named is the word ORGANIZATION-APPROVED. The configuration management plan naming the approving authority and the classes of change it covers is a document, and a merge record cannot show that the person who approved held that authority. Nothing here closes that, and the rating is unchanged by having written the commands down.
A PULL-REQUEST RULE THAT REQUIRES NO APPROVAL. `required_approving_review_count` is a documented, selectable parameter and zero is a legal value for it. A `pull_request` rule with zero required approvals routes every change through a pull request and lets its author merge it unread — it satisfies the first clause, appears in every listing, and is a process with nobody in it. The second clause counts it, and it carries `!parameters` as a disjunct because the zero test alone cannot see a rule object with no parameters at all: JMESPath treats the number zero as truthy, so `!parameters.required_approving_review_count` would NOT catch a count of zero and `parameters.required_approving_review_count == 0` would not catch an absent object. The two together catch both, and a rule that legitimately carries no parameters fails loudly rather than passing silently.
THE DRY-RUN TRAP is closed by the endpoint the first four clauses read, not by the fifth, and the first draft of this batch had that backwards in both siblings. GitHub documents `/rules/branches/{branch}` as returning "all active rules that apply to the specified branch" and says that "Rules in rulesets with \"evaluate\" or \"disabled\" enforcement statuses are not returned" — so a `pull_request`, `required_signatures` or `non_fast_forward` rule found there is enforcing, and a ruleset in dry-run is invisible to those clauses rather than satisfying them. That matters more here than in the sibling recipe, because four of this recipe's five clauses read that endpoint. The fifth is kept as an independent finding — a branch ruleset believed to be in force that blocks nothing — and not as the guard the draft called it.
SIGNED COMMITS PROVE ATTRIBUTION, NOT AUTHORITY. Under `required_signatures`, "contributors and bots can only push commits that have been signed and verified to the branch", which is the integrity limb of SA-10 in the most literal available sense: every change carries a cryptographic claim about who made it, checkable long after the fact. What a signature does not carry is that the signer was entitled to make that change, which is the organization-approved gap again one layer down. Note also what the clause asserts and does not: that the RULE is in effect, not that any particular commit on the branch is signed. A history that predates the rule is unaffected by it, and nothing in this recipe reads the commits themselves.
BLOCKING FORCE PUSHES IS WHAT MAKES THE REST OF THE RECORD EVIDENCE. `non_fast_forward` is the rule GitHub describes as preventing force pushes, and it is asserted here rather than treated as hygiene because every other artifact this recipe collects is a claim about a history: a merge record, a signature, an approval. All of them are statements about commits that a force push can replace. The fourth clause is therefore not a fourth control property but the precondition of the other three being worth reading.
THE HISTORY ENDPOINT IS THE ONE PIECE OF EVIDENCE ABOUT THE CONTROL ITSELF, AND IT CARRIES NO CLAUSE ON PURPOSE. `/rulesets/{ruleset_id}/history` returns a `version_id`, an `actor` and an `updated_at` per version — who changed the change-control rules, and when. That is as close as this plane comes to configuration management OF the configuration management, and no threshold over it would mean anything: one entry is a ruleset created once and never touched, which is a good state, and forty entries is a ruleset under active maintenance, which is also a good state. It is collected because an assessment reading a green ruleset today needs to know whether it was green last quarter, and the version list is the only thing here that answers. Turning an entry into an answer takes the per-version command, which returns the ruleset's `state` as it stood.
THE BYPASS LIST IS FILTERED BY THE VENDOR, AND THAT IS WHY ITS EMPTINESS MEANS SOMETHING. The sixth command passes `rule_suite_result=bypass`, so the response is constructed to hold only the pushes that got past the rules; an empty response is the meaningful kind of empty rather than the ambiguous kind, in the way a `status=failure` list is and a full list is not. Its bounds are the same as the sibling recipe's — at most a month, and only as much of that month as the platform retains — which is why the cadence here is weekly rather than monthly despite everything else in the recipe being configuration that changes rarely.
KSI-SCR-MIT asks the provider to persistently identify, review and mitigate potential supply chain risks, and this recipe is claimed on the MITIGATE limb alone. The developer is a supply chain participant, and constraining what any developer — or any credential belonging to one — can put into the mainline is a mitigation of that participant's risk. It is not an identification of any risk and not a review of one: nothing here surfaces a finding, ranks it, or records a decision about it. The identify and review limbs of this indicator are carried on this plane by `dependency-vulnerability-monitoring`, and an indicator page carrying this recipe should be read as carrying evidence for a part.
The evidence platform is itself an external system; see `external_system`. Two sibling recipes in the same batch read overlapping commands for different controls, and are separate recipes for the reason given in `change-verification-status-checks-and-run-records`.
ONE LIMB OF THE DISPOSITION IS NOT CARRIED FORWARD, and it is recorded here because deleting the disposition deletes the only other place it was written down. The rationale this recipe spends argued from "branch protection, required reviews, signed commits AND MERGE RECORDS", and there is no merge record in this recipe: no `/pulls`, no `/commits`, no review endpoint. What replaced that limb is the rule that makes a pull request compulsory and the bypass list that says nothing got around it, which is the mechanism rather than the instances. The instances are collected on this plane by `security-representative-change-approval`, under a different control, and an assessment wanting per-change evidence for SA-10 should read that recipe's fifth and sixth commands beside this one. The rating is unaffected — `partial` was right on either reading — but the covered ground is narrower than the register said it would be.
The type-string-to-interface-name join is unsourced and is stated here rather than implied. `required_signatures` and `non_fast_forward` are the API `type` values, taken from the organization-rules reference; "Require signed commits" and "Block force pushes" are the names the ruleset documentation uses for the same two rules. No page cited here maps one spelling to the other. The mapping is not in doubt, and it is not cited either, which is the honest way to leave it.
The exact component-and-version inventory the boundary's repositories build from — every package the dependency graph resolved, carrying the version string that is the only thing SA-22's question can be asked about — together with whether that graph is switched on across the boundary at all, and then the open advisories for which the ecosystem offers no patched version, which is the closest thing a pipeline emits to a component nobody maintains any more. The inventory is the load-bearing half and it is why this recipe exists separately from the vulnerability one: "is this component past end of support" is a question about a name and a version, and a check that cannot produce the version has not asked it.
partial · api · every monthly · /collect/unsupported-component-inventory-and-lifecycle-review
$ gh api --paginate "/orgs/<ORG>/code-security/configurations"
$ gh api --paginate "/orgs/<ORG>/code-security/configurations/<CONFIGURATION_ID>/repositories?status=attached,enforced&per_page=100"
$ gh api --paginate "/orgs/<ORG>/code-security/configurations/<CONFIGURATION_ID>/repositories?per_page=100"
$ gh api "/repos/<ORG>/<REPO>/dependency-graph/sbom"
$ gh api --paginate "/orgs/<ORG>/dependabot/alerts?state=open&per_page=100"
$ gh api --paginate "/orgs/<ORG>/dependabot/alerts?state=dismissed&per_page=100"
Proves: SA-22
Expected output: Report names used below, and the command each comes from: `configurations` is the first, `configuration-repositories` the second, `configuration-repositories-all` the third, `sbom` the fourth, `alerts-open` the fifth and `alerts-dismissed` the sixth. The second and third call the SAME endpoint and the difference between them is the point rather than a duplication: the second narrows `status` to `attached,enforced` so the enumeration holds only repositories the configuration actually reached, and the third leaves `status` at its documented default of `all` so the `failed` rows are visible at all. A recipe carrying only the narrowed call cannot fail a clause about `failed`, because the row it is looking for was filtered out before the report was written.
RUN COMMAND 4 ONCE PER REPOSITORY. The SBOM export is documented at `GET /repos/{owner}/{repo}/dependency-graph/sbom` and has no organization-scoped form, so the component inventory is assembled one repository at a time from the list command 2 returns; the two alert commands are organization-scoped and each alert carries its owning `repository`, so the advisory half arrives in one walk.
From `configurations`, the fields this recipe reads and their documented defaults, which are not the same for the two settings it depends on: `dependency_graph` is `enabled | disabled | not_set` and defaults to `enabled`, while `dependabot_alerts` is the same enum and defaults to `disabled`. `target_type` is `global | organization | enterprise` and `enforcement` is `enforced | unenforced`. From `configuration-repositories`, the association `status`, whose documented values are `all, attached, attaching, detached, removed, enforced, failed, updating, removed_by_enterprise` and whose documented DEFAULT is `all` — the second command narrows it to `attached,enforced` so the enumeration holds only repositories the configuration actually reached, and the third leaves it unnarrowed so the `failed` rows a clause of its own reads below are present in a report at all.
From `sbom`, an SPDX document under a required `sbom` key carrying `SPDXID`, `spdxVersion`, `creationInfo`, `name`, `dataLicense`, `documentNamespace`, a required `packages` array and a `relationships` array. Each package entry is documented with `SPDXID`, `name`, `versionInfo`, `downloadLocation`, `filesAnalyzed`, `licenseConcluded`, `licenseDeclared`, `supplier`, `copyrightText` and `externalRefs`, the last an array of `{referenceCategory, referenceLocator, referenceType}` — the purl that identifies the component precisely enough to look its support dates up. `versionInfo` is the field this recipe turns on, and the documented behaviour behind it is the opposite of the one an earlier draft of this recipe assumed. It is REQUIRED on every package entry — the schema's required list is `SPDXID, name, versionInfo, downloadLocation, filesAnalyzed` — so a component never arrives without the field, and a clause counting the entries that lack one could never fire. What the page documents instead is the real failing case: "The version of the package. If the package does not have an exact version specified, a version range is given." An unpinned component is present, carries the field, and carries a RANGE, which is the shape the clause below enumerates.
From `alerts-open` and `alerts-dismissed`, `dependency` (`package.ecosystem`, `package.name`, `manifest_path`, `scope`, `relationship`), `state` (`open | dismissed | fixed | auto_dismissed`), `dismissed_reason` (`fix_started | inaccurate | no_bandwidth | not_used | tolerable_risk`), `dismissed_comment` (documented required, string or null, capped at 280 characters), `auto_dismissed_at`, and `security_advisory` with `ghsa_id`, `cve_id`, `severity`, `classification` (`general | malware`), `cwes`, `published_at`, `updated_at`, `withdrawn_at` and a `vulnerabilities` array whose entries carry `vulnerable_version_range` and a `first_patched_version` documented "object or null". Beside `security_advisory`, and not to be confused with it, each alert carries a required `security_vulnerability` object of its own — `package`, `severity`, `vulnerable_version_range` and its own `first_patched_version`, same "object or null" — and THAT is the entry pertaining to the alerted component, where `security_advisory.vulnerabilities[]` enumerates every affected range across every ecosystem the advisory covers. The no-patch clause below reads the former for that reason. The `has` filter is documented as supporting only `patch` on the REPOSITORY form and is left unenumerated on the organization form this recipe calls, so on neither form is there a documented way to select the alerts that lack a fix; the no-patch narrowing is therefore a client-side filter over a full paginated walk, and a collector that reads one page of thirty has produced a smaller answer rather than a filtered one.
SA-22 asks two things: that unsupported components are replaced, and that alternative sources of continued support are provided for the ones that are not — in-house support, or an external provider the organization names. This plane can see the population and cannot see either answer, which is what `partial` records. An earlier draft of this note said the second limb was documented justification and approval; that is the shape of legacy baseline supplemental guidance rather than of the control, this repo's own dataset carries no guidance for SA-22 at all, and the difference matters here because the platform DOES hold a justification artifact and holds nothing whatsoever about an alternative support arrangement. Getting that wrong would have made the dismissal clause below look like it closed a limb it does not touch.
WHY THIS IS A DIFFERENT RECIPE FROM THE DEPENDENCY-VULNERABILITY ONE AND NOT A SECOND VIEW OF IT. `dependency-vulnerability-monitoring` asks whether known-vulnerable components are found and fixed; this asks whether the provider knows what it ships and whether any of it has been abandoned. The two questions overlap in their telemetry and diverge in their failure cases, and the divergence is the point: a component with no advisories at all is clean by the first question and is precisely the profile of an unmaintained package by the second, because an advisory exists when somebody researches a package and nobody researches a dead one. That is why the inventory clause rather than the alert clauses is the centre of this recipe.
THE NO-PATCH CLAUSE IS A HINT AND IS DELIBERATELY NOT WRITTEN AS A VERDICT. An open alert whose advisory carries no `first_patched_version` on any entry is the strongest end-of-support signal the documented API emits — nobody shipped a fix — and it is still not the control's finding. It is equally the shape of an advisory published hours ago, of a dispute upstream, and of a maintainer who fixed the issue without cutting a release. It is asserted because a growing set of them is a real and readable finding about the boundary, and it is described here as a hint because reading it as "these components are past end of support" would be authoring the reconciliation `scan_scope` says nobody has done.
WHAT THE DISMISSAL CLAUSE DOES AND DOES NOT ESTABLISH. `dismissed_reason` of `no_bandwidth` or `tolerable_risk` on an alert nobody can patch is the platform's record that a component was knowingly KEPT, and that is its whole value: it enumerates the components SA-22's second limb is about, which is a real service and is not the limb itself. Nothing in this output says whether in-house support was arranged for any of them or whether an external provider was named, and no field on this endpoint could. The clause is worth asserting because a dismissal carrying an empty comment is unambiguously worse than one carrying a reason, and it is worth saying plainly that passing it establishes neither replacement nor alternative support.
THE `withdrawn_at` FIELD IS COLLECTED AND NOT ASSERTED, AND THE REASON IS THE SAME ONE THAT KEEPS THE EXTERNAL-SYSTEM NOTE SHARP. An advisory can be withdrawn after this evidence was collected, which retroactively changes what a dated report meant. Nothing in the provider's system moved. It is read so a human comparing two months of this report can tell a fixed component from a withdrawn advisory, and it is not asserted because a recipe that failed on withdrawn advisories would be failing the provider for the database's corrections.
CADENCE IS `monthly` RATHER THAN `continuous`, AND THAT IS A CLAIM ABOUT THE HUMAN STEP RATHER THAN ABOUT THE PLATFORM. The alert stream underneath is continuous and the sibling recipe on SR-06 collects it that way. What SA-22 asks for is a review of an inventory against published lifecycle dates, and that reconciliation is an act somebody performs on a schedule; exporting the SBOM is the same kind of act. A provider running it continuously has not done anything wrong, and one collecting the alerts continuously while never exporting the inventory has met the sibling recipe and not this one.
KSI-SCR-MIT is the only indicator that reaches this control, and it is claimed for the identification limb of its own statement — persistently identify, review and mitigate potential supply chain risks — with the review and mitigation limbs left to the human step `scan_scope` names.
For a service that ships code to a browser, the two things a pipeline can say about the mobile code it delivers: what was allowed INTO it, and whether what shipped is what this pipeline built. The first is the dependency diff for the change — every component added, its ecosystem, its version, its licence and any advisory against it, separated by whether it reaches the runtime or stops at the build — and the gate that makes the check mandatory rather than advisory. The second is a provenance attestation over the built bundle, verified against the repository and workflow that are supposed to have produced it. Neither is a statement about which mobile code technologies the organization decided to permit, and that is the control's first limb.
partial · api · every on-change · /collect/mobile-code-admission-and-bundle-provenance
$ gh api --paginate "/orgs/<ORG>/repos?per_page=100"
$ gh api "/repos/<ORG>/<REPO>/rules/branches/<DEFAULT_BRANCH>"
$ gh api "/repos/<ORG>/<REPO>/dependency-graph/compare/<BASE_SHA>...<HEAD_SHA>"
$ gh attestation verify <BUNDLE_PATH> --repo <ORG>/<REPO> --signer-workflow <ORG>/<REPO>/.github/workflows/<BUILD_WORKFLOW> --format json
Expected output: Report names used below, and the command each comes from: `repos` is the first, `branch-rules` the second, `dependency-review` the third and `verify-results` the fourth.
From `branch-rules`, GitHub documents the endpoint as returning "all active rules that apply to the specified branch" — the effective set, which is what this recipe needs, because a rule can arrive from a repository ruleset or from an organization one and a reader asking whether the branch is gated does not care which. For the `required_status_checks` rule type the documented parameters are a required `required_status_checks` array of "Status checks that are required", each entry carrying a required `context` — "The status check context name that must be present on the commit" — and an optional `integration_id`, "The optional integration ID that this status check must originate from"; a required boolean `strict_required_status_checks_policy`, "Whether pull requests targeting a matching branch must be tested with the latest code" — whose documentation continues "This setting will not take effect unless at least one status check is enabled", which is why the clause asserting it is not the first clause in this recipe and why the existence clause that precedes it is not redundant with it; and an optional `do_not_enforce_on_create`.
From `dependency-review`, the endpoint is `GET /repos/{owner}/{repo}/dependency-graph/compare/{basehead}` and `basehead` is documented as expecting the form `{base}...{head}`. Each returned change carries `change_type`, documented `added | removed`; required `manifest`, `ecosystem`, `name` and `version`; `package_url`, `license` and `source_repository_url`, each documented string or null; a required `vulnerabilities` array whose entries carry `severity`, `advisory_ghsa_id`, `advisory_summary` and `advisory_url`; and `scope`, documented `unknown | runtime | development`.
From `verify-results`, the CLI documents `gh attestation verify [<file-path> | oci://<image-uri>] [--owner | --repo]`, so the same command verifies a built file and not only a registry image — which is the form this recipe needs, because a browser bundle is a file. With `--format json` it emits an array with one entry per verified attestation, each carrying `attestation` and a `verificationResult` holding the parsed bundle: `signature.certificate`, `verifiedTimestamps` and `statement`. `--signer-workflow` is documented as enforcing that the signing workflow matches `[host/]<owner>/<repo>/<path>/<to>/<workflow>`, and it is the flag that carries this clause's weight; `--predicate-type` defaults to `https://slsa.dev/provenance/v1`.
WHY PARTIAL, AGAINST THE CONTROL'S TWO LIMBS. SC-18 has a definition limb — establishing which mobile code technologies are acceptable — and an enforcement limb: authorizing, monitoring and controlling their use, with the discussion contemplating mobile code digitally signed by a trusted source. This plane reaches the second limb and cannot reach the first. A gate enforces an acceptability list and does not establish one, and no output collected here names a single decision about a technology. That is the same shape the sibling recipes on CM-03 (04) and SA-22 carry, and it is why this is `partial` rather than `full` even though two of its clauses are as mechanical as any in this overlay.
DEPENDENCY REVIEW ONLY SEES PULL REQUESTS THAT TOUCH A MANIFEST. GitHub documents the feature for "pull requests that contain changes to package manifests or lock files". A change that alters the delivered mobile code without touching one — a vendored script edited in place, an inline handler added to a template, a CDN URL swapped in a page, a build configuration change that pulls a different chunk — produces a diff with no entries, and both dependency clauses below are then vacuously green over a change that did exactly what this control is about. The clauses are written in offender form so an empty result is empty rather than false, and this note is the honest reading of what an empty result means.
WHAT `scope=='runtime'` DOES AND DOES NOT DELIMIT. The scope value is the ecosystem's own classification of a dependency as production or development, and for a browser bundle it is the closest available proxy for "ends up in the code the user's browser executes". It is a proxy: the mapping from a runtime-scoped package to bytes actually emitted belongs to the bundler, not the platform, and tree-shaking, lazy chunks and server-only imports all break it. Where the scope IS populated it breaks in the safe direction — more components are claimed as delivered than are delivered. Where it is not, it broke the other way and this recipe shipped the clause wrong until the audit: a component the ecosystem cannot classify resolves to `unknown`, falls outside a clause narrowed to `runtime`, and takes its advisories out of the count with it. That is now asserted separately rather than described, because a residue that hides findings is not a residue, and the sentence you are reading replaced one that claimed the failure direction was safe in both cases.
WHY THE ATTESTATION CLAUSE IS THIN AND THE COMMAND IS NOT. The clause below is an existence check over a parsed statement, and nearly all of this evidence's weight is carried by the flags on the command that produced it: `--repo` scopes the attestation lookup and `--signer-workflow` is documented as enforcing that the signing workflow matches a named path. A reader who drops those flags gets a verification that proves an attestation exists somewhere for the bytes, which is a much weaker claim than the one this recipe describes. The sibling recipe `build-provenance-attestation-verification` reads the same machinery at depth on SI-07 (07), including the version-specific reason a clause has to read the parsed statement rather than trust the exit code, and this recipe deliberately does not repeat it.
WHAT AN ATTESTATION CANNOT SAY, IN THE PLATFORM'S OWN WORDS. GitHub's page states that "artifact attestations are not a guarantee that an artifact is secure. Instead, artifact attestations link you to the source code and the build instructions that produced them", and that defining and evaluating the policy is the consumer's job. For SC-18 that is the right size of claim and is worth stating as a limit rather than as a caveat: the enforcement limb asks that mobile code be signed by a trusted source, and this establishes that the delivered bundle came from a named repository and workflow. It says nothing whatsoever about what the code in it does.
TWO INDICATORS REACH THIS CONTROL AND THEY ARE CLAIMED FOR DIFFERENT HALVES. KSI-SCR-MIT — persistently identify, review and mitigate supply chain risks — is claimed for the admission half, where a component with a known advisory or an undeterminable licence is identified before it enters the delivered artifact. KSI-PIY-RSD, whose statement is about the effectiveness of building security into the SDLC being persistently reviewed, is claimed for the gate itself: a required check and a signed build are the SDLC's shape, and their persistent review is the human act neither clause performs.
CMT — Change Management
10 recipes · 8 of 8 controls in scope reached
AWS Config compliance results plus the State Manager association list proving a defined configuration is actually applied and re-applied to every managed node — instances are under SSM management, and the associations that carry your baseline report COMPLIANT on a schedule rather than drifting
partial · cli · every continuous · /collect/ssm-configuration-baseline-enforced
$ aws configservice get-compliance-details-by-config-rule --config-rule-name ec2-instance-managed-by-systems-manager --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name ec2-managedinstance-association-compliance-status-check --compliance-types NON_COMPLIANT
$ aws ssm list-associations --query 'Associations[].{Name:Name,AssociationId:AssociationId,Schedule:ScheduleExpression,Targets:Targets,Status:Overview.Status,LastRun:LastExecutionDate}'Proves: CM-02, CM-06
Expected output: Two EvaluationResults arrays plus an Associations list. Empty NON_COMPLIANT sets mean every running EC2 instance has a running SSM Agent and every SSM association compliance record reads COMPLIANT after execution; the Associations list names the documents, schedules, and targets that carry the baseline, with Overview.Status and LastExecutionDate showing it ran. Managed rule identifiers: EC2_INSTANCE_MANAGED_BY_SSM (rule name ec2-instance-managed-by-systems-manager), EC2_MANAGEDINSTANCE_ASSOCIATION_COMPLIANCE_STATUS_CHECK
GovCloud: AWS Config and Systems Manager are available in AWS GovCloud (US); instance, document, and association ARNs use partition arn:aws-us-gov
The telemetry proves a configuration is being enforced and drift corrected — it does not prove the enforced content IS your approved baseline. That the association's SSM document encodes the hardened settings you baselined (CIS/STIG content, approved through your change process) is the human judgement half; keep the document version and its approval record alongside this output. Two limits to state plainly: EC2_INSTANCE_MANAGED_BY_SSM does not flag a stopped instance whose agent is running, and this whole recipe is EC2-only — container images, Lambda, and managed-service settings need their own baseline evidence. CM-08 inventory is a separate recipe, not this one.
Patch Manager compliance state plus Amazon Inspector scan status and coverage — proving flaws are being found continuously (Inspector enabled and actually covering your resources) and that the fixes landed (per-node missing/failed patch counts and the time of the last scan or install)
partial · cli · every daily · /collect/patch-and-vulnerability-remediation
$ aws configservice get-compliance-details-by-config-rule --config-rule-name ec2-managedinstance-patch-compliance-status-check --compliance-types NON_COMPLIANT
$ aws ssm describe-instance-patch-states --instance-ids i-0123456789abcdef0 --query 'InstancePatchStates[].{Node:InstanceId,Baseline:BaselineId,Missing:MissingCount,Failed:FailedCount,CriticalNonCompliant:CriticalNonCompliantCount,SecurityNonCompliant:SecurityNonCompliantCount,Operation:Operation,EndTime:OperationEndTime}'$ aws inspector2 batch-get-account-status --account-ids <ACCOUNT_ID>
$ aws inspector2 list-coverage --filter-criteria '{"resourceType":[{"comparison":"EQUALS","value":"AWS_EC2_INSTANCE"}]}'Proves: SI-02, RA-05
Expected output: An EvaluationResults array with an empty NON_COMPLIANT set (every SSM patch-compliance record reads COMPLIANT), InstancePatchStates showing MissingCount/FailedCount/CriticalNonCompliantCount/SecurityNonCompliantCount at zero with a recent OperationEndTime, an account status whose resourceState.ec2/ecr/lambda read ENABLED, and coveredResources whose scanStatus.statusCode is ACTIVE with a recent lastScannedAt. Managed rule identifier: EC2_MANAGEDINSTANCE_PATCH_COMPLIANCE_STATUS_CHECK
GovCloud: AWS Config, Systems Manager, and Amazon Inspector are available in AWS GovCloud (US-East) and (US-West); instance and finding ARNs use partition arn:aws-us-gov. Two GovCloud differences to record: Lambda code scanning is not available, and the Inspector plugin for Linux deep inspection is not FIPS compliant.
This proves flaws are detected and shows exactly what is still missing and when patching last ran — it does not prove the remediation clock was met. Whether an open finding sits inside your SI-02 timeframe, or carries an approved deviation or POA&M entry, is a judgement joined against your risk-acceptance record, not an API result. Substitute your real instance ids and account id; describe-instance-patch-states requires --instance-ids (use describe-instance-patch-states-for-patch-group to sweep a patch group). Note also that patch compliance data is a point-in-time snapshot and each successful scan overwrites the previous one, so capture the output at collection time rather than reconstructing history later.
The machine-maintained component inventory — Config's recorder status and discovered-resource counts proving supported resources are tracked continuously and the list stays current without anyone editing a spreadsheet, plus Systems Manager Inventory's node and installed-application metadata for what runs inside them
partial · cli · every daily · /collect/config-asset-inventory
$ aws configservice describe-configuration-recorder-status --query 'ConfigurationRecordersStatus[].{Name:name,Recording:recording,LastStatus:lastStatus,LastStart:lastStartTime,Error:lastErrorMessage}'$ aws configservice get-discovered-resource-counts
$ aws configservice select-resource-config --expression "SELECT resourceId, resourceType, awsRegion WHERE resourceType = 'AWS::EC2::Instance'"
$ aws ssm get-inventory --aggregators Expression=AWS:InstanceInformation.PlatformType
$ aws ssm list-inventory-entries --instance-id i-0123456789abcdef0 --type-name AWS:Application
Proves: CM-08
Expected output: A recorder status with recording true and lastStatus SUCCESS — read this first, because a stopped or failing recorder makes everything below stale — then a resourceCounts array giving a count per resourceType alongside totalDiscoveredResources, a Results list naming each recorded resource of the type you queried, an aggregation of managed nodes grouped by platform, and an Entries list of installed applications stamped with the CaptureTime they were collected.
GovCloud: AWS Config and Systems Manager Inventory are available in AWS GovCloud (US-East) and (US-West); resource and node ARNs use partition arn:aws-us-gov
This proves the inventory is machine-maintained and current (CM-08.01, and the automated-currency half of CM-02.02) — it does not prove the inventory is complete. Config sees only supported resource types, only in the regions and accounts where a recorder runs, and only within the recording group you configured; unsupported types, an un-recorded region, on-premises hosts, SaaS components and in-container software are invisible here and need their own source. SSM Inventory covers only managed nodes with a running agent and an inventory association, collects no more often than every 30 minutes, and the console's Inventory cards hide stopped and terminated nodes even though the API still returns them. The accountability attributes CM-08 asks for — system owner, function, criticality — live in your tags or CMDB, not in a resource count, so join them before calling this an inventory. Substitute your real instance id. Detecting unauthorized components (CM-08.03) is a different question; the prohibited-software half is in the least-functionality recipe.
Who changed what, when, and from which state to which — CloudTrail's record of every mutating API call and Config's per-resource configuration history, plus, where Change Manager is in use, the change-request executions that carry the approval
partial · cli · every weekly · /collect/cloudtrail-config-change-history
$ aws cloudtrail lookup-events --lookup-attributes AttributeKey=ReadOnly,AttributeValue=false --start-time <START_TIME> --end-time 2026-07-31T23:59:59Z --query 'Events[].{Event:EventName,Time:EventTime,User:Username,Source:EventSource,Resources:Resources}'$ aws configservice get-resource-config-history --resource-type AWS::EC2::SecurityGroup --resource-id sg-0123456789abcdef0 --chronological-order Reverse
$ aws ssm describe-automation-executions --filters Key=AutomationSubtype,Values=ChangeRequest --query 'AutomationExecutionMetadataList[].{Id:AutomationExecutionId,Document:DocumentName,Status:AutomationExecutionStatus,Mode:Mode,By:ExecutedBy,Start:ExecutionStartTime,End:ExecutionEndTime}'Expected output: An Events list in which each entry names the EventName, EventTime, Username, EventSource and the resources touched, with CloudTrailEvent carrying the full request parameters; a configurationItems list ordered newest-first whose configurationItemCaptureTime and configurationStateId let you diff the resource across the change and whose relationships show what else it touched; and, where Change Manager applies, an AutomationExecutionMetadataList of AutomationSubtype ChangeRequest showing the runbook, Mode, ExecutedBy and the start and end of the workflow.
GovCloud: CloudTrail and AWS Config are available in AWS GovCloud (US); trail, resource, and automation ARNs use partition arn:aws-us-gov. Systems Manager Change Manager is NOT available in the AWS GovCloud (US) Regions — drop the third command there and evidence approvals from your own change-management system instead.
CloudTrail and Config prove a change was recorded and is reconstructable — actor, time, parameters, and the before/after configuration state — which is CM-03's record-retention and monitoring half. They do not prove the change was proposed, reviewed and approved before it happened; that decision lives in your CCB or ticketing system and has to be joined by ticket or change id. State the retention limits plainly rather than implying full history: lookup-events reaches back only 90 days and returns management (and Insights) events only, so anything older must be read from the trail's S3 objects or a CloudTrail Lake event data store, and get-resource-config-history honours your Config retention period (30 days minimum, up to 7 years) but each call spans at most 7 days, so a long window needs paging by time. Substitute your real resource type, resource id and time window. Change Manager has also been closed to new customers since 2025-11-07, so treat that command as available only if you were already signed up. Whether the post-change configuration is still compliant is a different question answered by the Config rule recipes.
Config compliance results proving the ports you declared unnecessary are not reachable from the internet and the software you declared prohibited is not installed, plus the actual installed-application set a periodic review has to read
partial · cli · every monthly · /collect/config-least-functionality
$ aws configservice get-compliance-details-by-config-rule --config-rule-name restricted-common-ports --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name ec2-managedinstance-applications-blacklisted --compliance-types NON_COMPLIANT
$ aws ssm list-inventory-entries --instance-id i-0123456789abcdef0 --type-name AWS:Application
Proves: CM-07
Expected output: Two EvaluationResults arrays plus an inventory listing. Empty NON_COMPLIANT sets mean no security group opens a blocked TCP port to 0.0.0.0/0 or ::/0 and none of the denylisted applications is installed on any evaluated managed node; the Entries list, stamped with its CaptureTime, is the installed-software set your periodic review actually reads. Managed rule identifiers: RESTRICTED_INCOMING_TRAFFIC (rule name restricted-common-ports), EC2_MANAGEDINSTANCE_APPLICATIONS_BLACKLISTED
GovCloud: AWS Config, both managed rules, and Systems Manager Inventory are available in AWS GovCloud (US); security-group and node ARNs use partition arn:aws-us-gov
These prove the negatives you asserted and hand the reviewer the real installed-application set — they do not prove least functionality. That the functions, ports, protocols and services still enabled are the minimum necessary is a judgement against your documented essential-capability list, and CM-07.01's periodic review is a decision someone makes and records, not an API result; keep the review record next to this output. Both rules are only as strong as their parameters. RESTRICTED_INCOMING_TRAFFIC defaults to blocking TCP 20, 21, 3389, 3306 and 4333 — set blockedPorts to your real denylist or you are testing AWS's defaults, not your policy. EC2_MANAGEDINSTANCE_APPLICATIONS_BLACKLISTED needs exact application names (no wildcards, and the name differs per distro) and evaluates AWS::SSM::ManagedInstanceInventory, so a node with no running agent or inventory association is simply not evaluated rather than flagged — pair it with the inventory recipe's coverage check. Security-group ingress deliberately overlaps the SC-07 boundary recipe: there it proves boundary protection, here it proves unnecessary ports are closed.
Every running instance built from an image you never approved and every node carrying denylisted software, together with proof that an automated action was configured for those findings and a record of what it did when one fired
partial · cli · every continuous · /collect/unauthorized-component-detection-and-response
$ aws configservice get-compliance-details-by-config-rule --config-rule-name approved-amis-by-tag --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name ec2-managedinstance-applications-blacklisted --compliance-types NON_COMPLIANT
$ aws configservice describe-remediation-configurations --config-rule-names approved-amis-by-tag ec2-managedinstance-applications-blacklisted
$ aws configservice describe-remediation-execution-status --config-rule-name approved-amis-by-tag
Expected output: Two EvaluationResults arrays naming each unauthorized component by resource id, then the response half: RemediationConfigurations showing TargetType SSM_DOCUMENT, the TargetId document and version, Automatic true or false, MaximumAutomaticAttempts and RetryAttemptSeconds; and RemediationExecutionStatuses with State QUEUED, IN_PROGRESS, SUCCEEDED, FAILED or UNKNOWN plus per-step StepDetails, InvocationTime and LastUpdatedTime. An empty RemediationConfigurations list is the finding: detection with no configured action does not satisfy CM-8(3)b. Managed rule identifiers: APPROVED_AMIS_BY_TAG, EC2_MANAGEDINSTANCE_APPLICATIONS_BLACKLISTED
GovCloud: AWS Config and both managed rules are available in AWS GovCloud (US) — neither rule's Region list excludes a GovCloud Region — and config.us-gov-east-1 and config.us-gov-west-1 are both listed in the Region-support table for Config remediation actions. But the GovCloud user guide states flatly that AWS Systems Manager documents (SSM documents) for AWS Config remediation actions are not available, so expect to author your own SSM Automation document rather than attach an AWS-managed one, and confirm the document resolves in your Region before you claim the automated half. Instance and document ARNs use partition arn:aws-us-gov
CM-8(3) has two halves and only one comes free. Detection is genuine: APPROVED_AMIS_BY_TAG is configuration-change triggered, so an instance launched from an unapproved image is flagged as it appears rather than at the next sweep, and the remediation records are real evidence of action — TargetId names the document that ran, Automatic separates auto-remediation from a button a human pressed, and StepDetails timestamps each step and quotes the error when one fails. What telemetry cannot supply is the definition of ‘unauthorized’. APPROVED_AMIS_BY_TAG matches on up to ten AMI tag keys or key:value pairs that you assert mean approved, so it tests your tagging discipline as much as your fleet — tag an unvetted image and it becomes compliant. The applications denylist needs exact application names with no wildcards, and the name differs per distribution; it evaluates AWS::SSM::ManagedInstanceInventory, so a node with no agent or no inventory association is simply not evaluated rather than flagged — read it beside the inventory-coverage recipe or you will mistake blindness for cleanliness. Neither rule sees firmware, and neither sees what a container image runs. The response side also carries a judgement someone must record: CM-8(3)b wants a chosen action — disable network access, isolate the component, notify defined personnel — and choosing to isolate a production instance automatically is a risk decision, not a default. Deliberate overlap: EC2_MANAGEDINSTANCE_APPLICATIONS_BLACKLISTED also carries CM-7 and CM-7.01 in the least-functionality recipe, where it proves prohibited software is absent; here the same signal is read as detection of an unauthorized component, with the remediation record attached.
Whether malware scanning is switched on for compute and for the buckets that accept uploads, plus the scan-by-scan record of what was actually examined and what came back INFECTED
partial · cli · every weekly · /collect/malicious-code-protection
$ aws guardduty list-detectors
$ aws guardduty get-detector --detector-id <DETECTOR_ID> --query '{status:Status,publishingFrequency:FindingPublishingFrequency,features:Features[].{name:Name,status:Status,additional:AdditionalConfiguration}}'$ aws guardduty describe-malware-scans --detector-id <DETECTOR_ID> --query 'Scans[].{id:ScanId,type:ScanType,status:ScanStatus,result:ScanResultDetails,started:ScanStartTime,ended:ScanEndTime,files:FileCount,bytes:TotalBytes,resource:ResourceDetails,trigger:TriggerDetails,failure:FailureReason}'$ aws guardduty list-malware-protection-plans
$ aws guardduty get-malware-protection-plan --malware-protection-plan-id <PLAN_ID> --query '{protected:ProtectedResource,status:Status,statusReasons:StatusReasons,actions:Actions,role:Role,created:CreatedAt}'$ aws configservice get-compliance-details-by-config-rule --config-rule-name guardduty-malware-protection-enabled --compliance-types NON_COMPLIANT
Proves: SI-03
Expected output: A detector id per Region — an empty list means GuardDuty was never enabled there, which is itself the finding. Then the detector's Status ENABLED or DISABLED, its FindingPublishingFrequency of FIFTEEN_MINUTES, ONE_HOUR or SIX_HOURS, and the Features list in which EBS_MALWARE_PROTECTION is the entry that matters, alongside FLOW_LOGS, CLOUD_TRAIL, DNS_LOGS, S3_DATA_EVENTS, EKS_AUDIT_LOGS, RDS_LOGIN_EVENTS, LAMBDA_NETWORK_LOGS, EKS_RUNTIME_MONITORING and RUNTIME_MONITORING with its EC2_AGENT_MANAGEMENT, EKS_ADDON_MANAGEMENT and ECS_FARGATE_AGENT_MANAGEMENT sub-configuration. Then one row per malware scan: ScanId, ScanType GUARDDUTY_INITIATED or ON_DEMAND, ScanStatus RUNNING, COMPLETED, FAILED or SKIPPED, a FailureReason when it failed, ScanStartTime and ScanEndTime, the scanned InstanceArn and its attached volumes, FileCount and TotalBytes actually examined, TriggerDetails carrying the GuardDutyFindingId and a TriggerType of GUARDDUTY or BACKUP, and a ScanResultDetails of CLEAN or INFECTED. Then the Malware Protection plan ids, and per plan the protected S3 bucket with its object prefixes, the scanning role, whether result tagging is on, and a Status of ACTIVE, WARNING or ERROR with StatusReasons naming the problem. Finally, outside GovCloud, the detectors AWS Config marks NON_COMPLIANT for GUARDDUTY_MALWARE_PROTECTION_ENABLED.
GovCloud: GuardDuty runs in both AWS GovCloud (US) Regions and Malware Protection for EC2 works there with one documented gap: instances whose productCode is marketplace are not scanned — GuardDuty skips them and logs the skip reason UNSUPPORTED_PRODUCT_CODE_TYPE, so a SKIPPED scan in GovCloud may be that rather than a misconfiguration. Malware Protection for Backup cannot scan EC2 or EBS recovery points there. The GovCloud differences page records no carve-out for Malware Protection for S3. The last command has nothing to call: GUARDDUTY_MALWARE_PROTECTION_ENABLED is excluded from both AWS GovCloud (US-East) and (US-West) — as it is from the China Regions, Mexico (Central), Asia Pacific (Thailand), (Malaysia) and (Taipei) — so drop it and take the enablement fact from the EBS_MALWARE_PROTECTION feature status in get-detector instead. Also unavailable in GovCloud: the entity lists customisation (IP address lists still work) and the GuardDuty Investigation preview. ARNs use partition arn:aws-us-gov
The trap in SI-3 is reading GuardDuty Malware Protection as antivirus. It is not a scheduled sweep of your file systems. A GuardDuty-initiated scan fires only after GuardDuty has already produced a finding indicative of malware on that resource, at most once every 24 hours per resource, and it works agentlessly against snapshots of the attached EBS volumes — so an empty describe-malware-scans list is the expected steady state of a healthy estate and proves nothing about coverage. The two enablement reads prove capability; the scan list proves exercise; neither proves protection. Coverage has a second silent hole: the global GuardDutyExcluded:true tag and your own inclusion or exclusion scan-option tags make GuardDuty initiate a scan and then skip it, so read the scan options next to the tag inventory or a deliberately excluded estate looks like a clean one, and Fargate workloads under EKS or ECS are not scanned at all. Malware Protection for S3 is the closest thing here to SI-3's entry-point requirement — it scans each newly uploaded object and each new version in a configured bucket — but it covers only buckets with an active plan, in the same Region as the plan, in your own account (a delegated GuardDuty administrator cannot enable it on a member account's bucket), and when run independently of GuardDuty there is no detector, so malware produces an EventBridge event, a CloudWatch metric and the optional object tag rather than a GuardDuty finding. What no command here produces is the rest of SI-3: signature or engine currency, since AWS operates the scan engines and exposes no version for you to attest to; periodic full scans; false-positive handling; and the documented response when malicious code is found. Rate those from the plan and the incident record, and keep these reads as the machine half of the answer.
The enforced half of who may change what: the service control policy type actually enabled in the organization root, the customer-authored SCPs and the roots, OUs and accounts each one is attached to, and the permissions boundary carried by every principal your own tagging marks as a change authority
partial · cli · every continuous · /collect/change-authority-restrictions-and-enforcement
$ aws organizations describe-organization
$ aws organizations list-roots
$ aws organizations list-policies --filter SERVICE_CONTROL_POLICY
$ aws organizations describe-policy --policy-id <CUSTOMER_AUTHORED_SCP_ID>
$ aws organizations list-targets-for-policy --policy-id <CUSTOMER_AUTHORED_SCP_ID>
$ aws iam get-account-authorization-details --filter User Role
Proves: CM-05
Expected output: All six responses are collected unprojected, so every field below is the name AWS returns and the name the assertions address. The first three calls take no argument; calls four and five take a policy id that MUST be one of the AwsManaged false ids returned by call three — a human substitution the assertion grammar cannot make, and the reason the placeholder is named CUSTOMER_AUTHORED_SCP_ID rather than POLICY_ID. Call six must be issued with MANAGEMENT-ACCOUNT credentials: Organizations operations may also be called from a member account designated as a delegated administrator, and a collection run there returns that member account's principals while reading exactly like the management account's. From describe-organization, an Organization object with Id, Arn, MasterAccountId, MasterAccountEmail and FeatureSet, which is ALL or CONSOLIDATED_BILLING; AvailablePolicyTypes is also returned and is DEPRECATED by AWS, which documents that it omits every policy type other than SCPs and directs you to ListRoots instead. From list-roots, Roots[] with Id, Arn, Name and PolicyTypes[], each entry carrying Type and a Status that is ENABLED, PENDING_ENABLE or PENDING_DISABLE — there is no DISABLED value, because a policy type that is off is ABSENT from the list rather than reported as off. From list-policies, Policies[] with Id, Arn, Name, Description, Type and AwsManaged, a boolean that is true for policies you cannot edit; AWS attaches the managed FullAWSAccess policy to every root, OU and account when it is created, so the list is never empty on an organization with SCPs enabled and its length says nothing about whether anyone has authored a restriction. From describe-policy, a Policy object with a PolicySummary carrying the same six fields as a list entry, and Content — the policy document itself, returned as a JSON-formatted STRING that must be parsed before any statement in it can be read. From list-targets-for-policy, Targets[] with TargetId, Arn, Name and a Type of ACCOUNT, ORGANIZATIONAL_UNIT or ROOT. From get-account-authorization-details, UserDetailList[] and RoleDetailList[] with UserName/RoleName, CreateDate, Tags, the attached and inline policy lists, and PermissionsBoundary — an optional AttachedPermissionsBoundary object carrying PermissionsBoundaryType and PermissionsBoundaryArn, absent entirely on a principal that has none.
GovCloud: AWS Organizations is available in both AWS GovCloud (US) Regions and SCPs are one of the policy types a GovCloud organization may use, alongside RCPs, tag policies and declarative policies for EC2 and S3; backup, chat application and AI services opt-out policies cannot be created there. Organization, root, OU, account, policy and IAM ARNs use partition arn:aws-us-gov. Three GovCloud facts change how this recipe is run rather than what it means. All features are MANDATORY — the consolidated billing feature set is not offered — so FeatureSet reads ALL in every GovCloud organization and the first assertion is satisfied by the Region rather than by a decision anyone made. The SECOND call is the one with a Region constraint: AWS restricts any operation that references the organization root, naming ListRoots as its example, to the AWS GovCloud (US-West) Region, so list-roots must be issued against us-gov-west-1 regardless of where the workload runs. The other five calls carry no such restriction. And a GovCloud organization is INDEPENDENT of the commercial organization its accounts are paired with: SCPs attached in the commercial organization do not restrict the GovCloud accounts, and an assessor handed a commercial organization's policy list has been handed evidence about a different boundary.
CM-5 asks for physical AND logical access restrictions associated with changes that are defined, documented, approved and enforced. This recipe reaches one adjective and one verb.
The adjective it does not reach is PHYSICAL. No AWS API returns anything about physical access to the hardware a change is made on; under the shared responsibility model that half belongs to the IaaS provider and is inherited — read it from the provider's own authorization package and the FedRAMP customer responsibility matrix, not from this output. A collection that presents these six calls as CM-5 evidence without saying so has answered half a control and labelled it whole.
The verb it does reach is ENFORCE, and it reaches it properly: an SCP is not a description of a restriction, it is the restriction, evaluated by AWS on every request from every member account, and list-targets-for-policy is the difference between a policy that exists and a policy that applies to something.
Two gaps keep this partial, not one. The first is approval — that the enforced set IS the documented and approved set is a comparison against a change-management record, and no call returns it. The second is subject matter, and it is the easier one to miss: the assertions below show THAT a customer-authored SCP is enforced, never WHAT it restricts. An SCP denying mechanicalturk:* satisfies every clause here exactly as well as one denying ec2:ModifyInstanceAttribute. The fourth call exists to close that by putting the policy document in the evidence — Content is returned as a JSON string and must be parsed — but no assertion can grade a policy document, so the phrase that makes this CM-5 rather than AC-3, 'associated with changes', stays a human read. Both gaps are reconciliations against your own records; the reconciliation is the CM-5 artifact and this output is the column it is reconciled against.
Four ways this evidence is vacuous if it is read naively. Three are behaviour AWS documents in as many words; the fourth is an inference from a field being a list, flagged as such rather than dressed up as a citation. A policy type that has never been enabled is ABSENT from Roots[].PolicyTypes rather than present with a Status of DISABLED — the enum has no such value — so a clause of the form 'every SCP policy type entry is ENABLED' is true over an organization where SCPs were never turned on at all, which is why one assertion tests that the entry exists before another reads it. AWS attaches the managed FullAWSAccess policy to EVERY root, OU and account when it is created, so both a non-empty Policies[] and a non-empty Targets[] are the default state of a working organization rather than a restriction anyone wrote — which is why one assertion counts only the AwsManaged false subset, and why calls four and five are bound to a customer-authored policy id instead of any policy id. FeatureSet CONSOLIDATED_BILLING makes SCPs unavailable outright, so on such an organization every policy in the list is inert. And an SCP attached to nothing returns an empty Targets[]: that one AWS does not state, it follows from Targets being a list, and the assertion that rests on it should be read as an inference.
The scope edge that matters most to an assessor is that SCPs do not restrict the management account. AWS states this three times on its own page and lists it first among the tasks SCPs cannot restrict: SCPs affect only member accounts, including member accounts designated as delegated administrators, and they have no effect on users or roles in the management account. The account with the broadest reach over the organization is the one account this evidence says nothing about, and its change restrictions have to come from identity-based policy and permission boundaries inside it — which is what the sixth call collects, and why the sixth call has to be run there. SCPs also do not affect service-linked roles at all, and they do not affect principals from accounts outside the organization even when a resource-based policy in your account grants those principals access.
Read an SCP for what it is: a ceiling, never a grant. AWS is explicit that no permissions are granted by an SCP and that effective permissions are the intersection of what the SCP allows with what identity-based and resource-based policies allow — a principal with no IAM permissions has no access under the most permissive SCP in the world. Where a permissions boundary is also present, AWS documents that the boundary, the SCP and the identity-based policy must ALL allow the action, which is why the sixth call is collected beside the first five rather than instead of them.
One failure mode worth writing into the assessment because it is silent and total: disabling the SCP policy type in a root automatically detaches every SCP from every OU, account and organization in that root, and re-enabling it does not restore the attachments — the root reverts to FullAWSAccess alone and the previous attachments are lost and not automatically recoverable. After such an event list-policies still returns every authored policy, unchanged, while nothing is enforced anywhere. The Roots[].PolicyTypes reading and the Targets[] reading are what separate those two worlds, and a collection that skips them cannot tell them apart.
The permissions-boundary assertion is scoped by your own tagging rather than written as a claim over the account, for the same reason as in the AC-02 (02) recipe: no AWS call knows which of your roles are supposed to be the change authorities. It passes without saying anything on an estate that tags none, so treat the tagging standard as a written control with a manual sample rather than as coverage.
What actually happened, as opposed to what was permitted, is a different recipe: CloudTrail's non-read-only event history, collected under CM-03 alongside the Config resource timeline and the Systems Manager change-request records. This one is about the restriction; that one is about the change.
For every change that reached the assessed branch, the automated verification that ran against it — which workflows ran, on which commit, and what each concluded — together with the two things that decide whether those runs were a condition of the change or merely adjacent to it: the rule that made the checks required, and the platform's own per-push record of whether that rule held, failed, or was bypassed. The runs alone are activity; the rule and the per-push record are what make them a gate.
partial · api · every weekly · /collect/change-verification-status-checks-and-run-records
$ gh api --paginate "/orgs/<ORG>/repos?per_page=100"
$ gh api "/repos/<ORG>/<REPO>/rules/branches/<DEFAULT_BRANCH>"
$ gh api --paginate "/repos/<ORG>/<REPO>/rulesets?includes_parents=true&per_page=100"
$ gh api --paginate "/repos/<ORG>/<REPO>/rulesets/rule-suites?ref=refs/heads/<DEFAULT_BRANCH>&time_period=month&evaluate_status=active&per_page=100"
$ gh api --paginate "/repos/<ORG>/<REPO>/actions/runs?branch=<DEFAULT_BRANCH>&status=completed&per_page=100"
$ gh api --paginate "/repos/<ORG>/<REPO>/actions/runs?branch=<DEFAULT_BRANCH>&status=failure&per_page=100"
Expected output: Report names used below, and the command each comes from: `repos` is the first command, `branch-rules` the second, `rulesets` the third, `rule-suites` the fourth, `runs-completed` the fifth, `runs-failure` the sixth.
RUN COMMANDS 2 THROUGH 6 ONCE PER REPOSITORY AND ONCE PER ASSESSED BRANCH, not once for the organization. The first command returns the repository list and the rest take a single `<REPO>` and a single `<DEFAULT_BRANCH>`; every clause below speaks only for the repository and branch actually walked, and collecting one repository's rules and reading them as the organization's is the arithmetic error this recipe is most likely to be assessed with.
From `branch-rules`, an array of the repository rule objects IN EFFECT on that branch, each carrying a `type` drawn from `creation`, `update`, `deletion`, `required_linear_history`, `required_deployments`, `required_signatures`, `pull_request`, `required_status_checks`, `non_fast_forward`, `commit_message_pattern`, `commit_author_email_pattern`, `committer_email_pattern`, `branch_name_pattern`, `tag_name_pattern`, `file_path_restriction`, `max_file_path_length`, `file_extension_restriction`, `max_file_size`, `workflows`, `code_scanning` and `copilot_code_review`, and a `parameters` object whose shape depends on the type. For `required_status_checks` the parameters are a `required_status_checks` array of `{context, integration_id}` entries, `strict_required_status_checks_policy` and `do_not_enforce_on_create`.
From `rulesets`, the rulesets that apply to the repository — `includes_parents` defaults to TRUE, so organization and enterprise rulesets are included and `source_type` (`Repository`, `Organization`, `Enterprise`) says which is which. Each carries `id`, `name`, a `target` of `branch`, `tag`, `push` or `repository`, an `enforcement` of `disabled`, `active` or `evaluate`, `bypass_actors` entries with an `actor_type` of `Integration`, `OrganizationAdmin`, `RepositoryRole`, `Team`, `DeployKey` or `User` and a `bypass_mode` of `always`, `pull_request` or `exempt`, plus `current_user_can_bypass`, `created_at` and `updated_at`.
From `rule-suites`, one record per push evaluated against the rules: `id`, `actor_id`, `actor_name`, `before_sha`, `after_sha`, `ref`, `repository_id`, `repository_name`, `pushed_at`, a `result` of `pass`, `fail` or `bypass`, and an `evaluation_result`. The per-suite endpoint adds `rule_evaluations`, each with a `rule_source`, a `rule_type`, a `result` and an `enforcement` of `active`, `evaluate` or `deleted ruleset` — worth fetching for any suite this recipe's clauses flag, because it is the only place that says WHICH rule decided. `time_period` accepts `hour`, `day`, `week` and `month` and defaults to `day`; the command above passes `month`, which is the documented ceiling, and `evaluate_status=active`, documented as returning "only rule suites resulting from rulesets in active (non-evaluate) mode" — without it the response mixes pushes measured against enforcing rulesets with pushes measured against dry-run ones, and nothing on the page describes what `result` holds for the second kind.
From `runs-completed` and `runs-failure`, workflow runs with `id`, `name`, `head_branch`, `head_sha`, `path`, `run_number`, `run_attempt`, `event`, `status`, a `conclusion` — documented on the workflow-runs page as a string or null and NOT as an enum, so treat `success`, `failure`, `neutral`, `cancelled`, `skipped`, `timed_out` and `action_required` as the values to expect rather than as a closed set this citation establishes — `workflow_id`, `created_at`, `updated_at`, `run_started_at`, `triggering_actor`, `head_commit` and `referenced_workflows`. `failure` is a value of the `status` FILTER as well as of `conclusion`, so the sixth command returns a list built to hold only failing runs.
CM-04 (02) requires the provider to verify that the IMPACTED CONTROLS are implemented correctly, operating as intended, and producing the desired outcome. This output shows that automated checks ran against each change, that passing them was a condition of the change, and what each concluded. Which controls a change impacts, and whether a passing test verifies one of them, is a mapping from test to control that a human writes; a pipeline that publishes such a mapping is publishing an authored artifact rather than a measurement. The disposition this recipe spends said exactly that, and writing the commands down has not changed the rating.
THE DRY-RUN TRAP, AND THE ENDPOINT CHOICE THAT ALREADY CLOSED IT. A ruleset in `evaluate` mode is a complete, well-formed set of rules that enforces nothing, and it is this plane's empty list wearing a different coat: it appears in `/rulesets` intact and generates rule suite records. The first draft of this batch hedged about whether `/rules/branches/{branch}` returns such rules, called it a live risk, and told the reader to make one call against a live repository to settle it. That was a manufactured uncertainty, and the page the recipe already cites settles it in one sentence: the endpoint "Returns all active rules that apply to the specified branch", and "Rules in rulesets with \"evaluate\" or \"disabled\" enforcement statuses are not returned." So reading the rules IN EFFECT rather than the rulesets that contain them is what closes the trap here, and it was closed before any clause was written. The consequence runs both ways and both are worth stating: the first two clauses are stronger than the draft claimed, because a rule they find is enforcing by construction; and the evaluate clause is weaker, because it guards nothing above it. It stays as a finding in its own right — a branch ruleset somebody believes is in force and that blocks nothing — narrowed to `target=='branch'`, since the same response carries tag, push and repository rulesets whose dry-run state says nothing about this change path.
A REQUIRED-CHECKS RULE THAT REQUIRES NOTHING. The `required_status_checks` rule takes a `required_status_checks` array of contexts, and an empty one is a rule that is present in every listing, satisfies the first clause, and gates on no check whatsoever. The second clause is spelled `!parameters.required_status_checks` rather than with `length()` deliberately: an empty array and an absent key are both falsy under that spelling and both are genuine offenders, while `length()` raises on the row where `parameters` is missing entirely — which is the row most worth seeing. A rule object that legitimately carries no `parameters` would fail this clause; that is a false red that makes somebody look, which is the direction this repo has chosen every time it has had the choice.
WHY THE BYPASS CLAUSE IS ASSERTED HERE AND THE PUSH-PROTECTION ONE WAS NOT. `secret-exposure-detection-and-push-protection` deliberately does not count push-protection bypasses to zero, because a bypass there is a documented decision with a named actor and one of three stated reasons, and a provider with a legitimate test fixture would fail such a clause while behaving correctly. A rule suite bypass has no reason field in anything fetched: the record carries `actor_name`, `result` and the rule evaluations, and nothing that says why. It therefore cannot be read as an authorized exception — all it says is that a change reached the assessed branch without the verification the rule required, which for CM-04 (02) is the finding rather than a footnote. What it CAN be read against, and the first draft of this batch missed this, is `bypass_actors` — returned in the same `rulesets` response this recipe already collects, with a `bypass_mode` of `always`, `pull_request` or `exempt`. That is a standing authorization to bypass, configured and visible, so a provider with a designated break-glass integration fails this clause while operating exactly as configured. The per-event reason is absent; the standing permission is not, and a failure of this clause is read against that list before it is read as a finding. Note one collection caveat that would make any future clause over `bypass_actors` vacuous: GitHub documents that "To prevent leaking sensitive information, the bypass_actors property is only returned if the user making the API request has write access to the ruleset", so a read-only assessment token sees an empty bypass list rather than an error.
THE WINDOW, AND WHY THE CADENCE IS WEEKLY. `time_period` accepts `hour`, `day`, `week` and `month`, defaults to `day`, and `month` is the ceiling — and no retention period is documented for rule suite records at all. The bypass clause is therefore a statement about at most a month, and about however much of that month the platform still holds. Collecting monthly against a monthly ceiling leaves no margin: a collection that slips by a day loses records silently rather than erroring, and the records it loses are exactly the ones nobody saw. Weekly is four chances at the same evidence.
WHY NO CLAUSE COUNTS THE FAILED RUNS. The sixth command returns a list built to contain offenders, and it carries no assertion. A workflow run that failed on the protected branch is the verification apparatus working — the desired state is that failures happen and are addressed, not that none occur — and a scheduled nightly that failed once says nothing about whether any change was verified. Both run lists are corroboration for a human: that runs exist at all, that they run on the branch the rules protect, and that their names correspond to the contexts the required-checks rule names. Nothing here checks that last correspondence, and it is the join an assessment most wants — a required context is a string, a workflow run has a name, and no response in this recipe says the string was produced by the run.
NO CLAUSE READS A POSITION, deliberately. Nothing fetched documents the sort order of `/actions/runs`, so nothing here reads `[0]` as "the most recent". The immediately preceding batch on this plane put a freshness clause on `analyses[0]` and had to be corrected, because position zero was whichever language finished last. The same shape is available here and is not taken; a freshness claim over these runs would need a `created` range in the query rather than an index into the response.
KSI-CMT-VTD is the direct claim: persistent testing and validation of changes throughout deployment, automated. KSI-CMT-LMC — modifications to the offering are logged and monitored — is claimed on the LOGGED limb only. The rule suite record is a per-push log of every change measured against the rules, and the workflow runs are a per-change record of what ran against it, which is logging in the most literal sense. Monitored is weaker and is not claimed: nothing in this recipe routes any of it to a person or to an alert, and no threshold in it fires.
The evidence platform is itself an external system; see `external_system`. Two sibling recipes in the same batch — `developer-change-control-and-integrity` and `security-representative-change-approval` — read overlapping commands for different controls. They are separate recipes rather than one, because a single recipe carrying all three would put one `automatable` rating and one assertion set over three separate arguments, and the argument is the thing being assessed.
The rule requiring the designated owners of the changed code to approve before it merges, the file that names who those owners are, the platform's own report of whether that file actually parses — and, per change, who approved, on which commit, and when.
partial · api · every continuous · /collect/security-representative-change-approval
$ gh api --paginate "/orgs/<ORG>/repos?per_page=100"
$ gh api "/repos/<ORG>/<REPO>/rules/branches/<DEFAULT_BRANCH>"
$ gh api "/repos/<ORG>/<REPO>/codeowners/errors?ref=<DEFAULT_BRANCH>"
$ gh api -H "Accept: application/vnd.github.raw" "/repos/<ORG>/<REPO>/contents/.github/CODEOWNERS?ref=<DEFAULT_BRANCH>"
$ gh api --paginate "/repos/<ORG>/<REPO>/pulls?state=closed&base=<DEFAULT_BRANCH>&per_page=100"
$ gh api --paginate "/repos/<ORG>/<REPO>/pulls/<PULL_NUMBER>/reviews?per_page=100"
Expected output: Report names used below, and the command each comes from: `repos` is the first command, `branch-rules` the second, `codeowners-errors` the third, `codeowners-file` the fourth, `pulls-closed` the fifth, `reviews` the sixth.
RUN COMMANDS 2 THROUGH 5 ONCE PER REPOSITORY AND ASSESSED BRANCH, and command 6 once per pull request selected from the fifth. The fourth and fifth commands are a SAMPLE AND WALK for a human reader, not a population any clause below reads — the closed pull request list is unbounded and no assertion here is written over it.
From `branch-rules`, the rule objects in effect on the branch, of which this recipe reads the `pull_request` type and its parameters `required_approving_review_count`, `dismiss_stale_reviews_on_push`, `require_code_owner_review`, `require_last_push_approval`, `required_review_thread_resolution` and `allowed_merge_methods`.
From `codeowners-errors`, an object with an `errors` array, each error carrying `line`, `column`, `kind`, `source`, `suggestion`, `message` and `path`. The `ref` parameter is "A branch, tag or commit name used to determine which version of the CODEOWNERS file to use" and DEFAULTS TO THE REPOSITORY'S DEFAULT BRANCH — the command passes it explicitly, because a recipe assessing any other branch would otherwise be checking a different file from the one in force there.
From `codeowners-file`, the file itself. GitHub documents three legal locations — "the .github/, root, or docs/ directory of the repository" — so a 404 from the path above is not an absence, it is the wrong one of the three; try `CODEOWNERS` and `docs/CODEOWNERS` before concluding anything.
From `pulls-closed`, the closed pull requests targeting the branch, and from `reviews`, the review records: `id`, `user`, `body`, a `state` — the page documents it as a required string and enumerates nothing, so `APPROVED`, `COMMENTED`, `DISMISSED` and `PENDING` are the values to expect rather than a closed set, and no clause here depends on the enum being complete, `html_url`, `submitted_at`, `commit_id` and an `author_association` of `COLLABORATOR`, `CONTRIBUTOR`, `FIRST_TIMER`, `FIRST_TIME_CONTRIBUTOR`, `MANNEQUIN`, `MEMBER`, `NONE` or `OWNER`. `commit_id` is the field that makes a review an answer rather than a timestamp: it says WHICH revision was approved.
CM-03 (04) requires an information security representative to be a MEMBER of the configuration change control element. This is the closest this plane comes to a membership claim it can check: a rule requiring the designated owners of the changed code to approve, a file naming who those owners are, and per-change approval records carrying identities, revisions and timestamps. What none of it establishes is that those identities ARE the security and privacy representatives the control names. That mapping lives in the change control charter, and a repository team called `security` is an assertion about a name. The gap is an identity mapping rather than a missing signal, which is exactly what the disposition said, and no amount of further collection on this platform closes it.
THE SKIPPED LINE — this recipe's vacuity trap, and unusually it is documented by the vendor rather than inferred from an API shape. GitHub states plainly that "If any line in your CODEOWNERS file contains invalid syntax, that line will be skipped." So a `pull_request` rule with `require_code_owner_review` set to true, sitting over a CODEOWNERS file whose one line covering the infrastructure directory has a typo, requires review from the code owners of that directory — of which there are now none. The rule is green, the platform requests no reviewer, and the change merges on whatever approvals its author could find. Nothing in the rules API can see this, and nothing in a screenshot of the branch protection settings can either. `GET /repos/{owner}/{repo}/codeowners/errors` is the only endpoint in this recipe that sees it, and it is the entire reason the second clause exists.
A MISSING FILE AND A CLEAN FILE ARE NOT DISTINGUISHED BY THE CLAUSE, AND THE THIRD COMMAND IS WHY. Nothing fetched documents what the errors endpoint returns when there is no CODEOWNERS file at all, so an empty `errors` array is the passing shape for a valid file and the plausible shape for an absent one — the plane's standing emptiness problem, arriving through a door that looks like a validator. The third command reads the file itself for exactly this reason: it is collected so a human can confirm there IS a file and see who it names. No clause is written over its contents, because who ought to own which path is the identity mapping this recipe cannot make, and a clause counting lines would be a clause asserting that a file is long.
WHY THE STALE-APPROVAL CLAUSE IS HERE AND A LAST-PUSH ONE IS NOT. `dismiss_stale_reviews_on_push` is what stops an approval from outliving the change it approved: without it, a code owner approves, the author pushes again, and the approval carries over to code no owner has seen — a membership failure wearing the appearance of a membership success, and the one failure mode in this recipe that leaves a complete and convincing paper trail. `require_last_push_approval`, GitHub's option to "require an approval from someone other than the last person to push to a branch", is deliberately NOT asserted: it is a separation-of-duty property rather than a membership one, a small team can be operating correctly without it, and CM-03 (04) asks who sits on the element rather than whether two distinct people acted.
WHAT A REVIEW RECORD PROVES AND WHERE IT STOPS. A review carries `state`, `user`, `submitted_at`, `commit_id` and `author_association`, so the record says a named platform identity approved a named revision at a named time — and `commit_id` is what makes it an approval of something rather than a timestamp. It does not say that the identity belongs to a person, that the person holds the role, or that the approval was informed. `author_association` is the closest thing to a role anywhere in the response and it is a repository-relationship enum — `MEMBER`, `COLLABORATOR`, `OWNER` — describing a relationship to the repository rather than a position in the organization, so no clause is written over it. The fourth and fifth commands are collected as a sample for a human to read against the charter, and no assertion reads them; a clause over an unbounded list of closed pull requests would be a clause about how many were sampled.
NO `enforcement` CLAUSE APPEARS IN THIS RECIPE, and after the audit of this batch that is the correct answer for a better reason than the one first written. Every clause here except the second reads `/rules/branches/{branch}`, which GitHub documents as returning "all active rules that apply to the specified branch" and from which "Rules in rulesets with \"evaluate\" or \"disabled\" enforcement statuses are not returned" — so a dry-run ruleset cannot satisfy the first or third clause, and no guard against it is needed. The second clause reads a file parser that no ruleset governs at all: a CODEOWNERS file with a syntax error is broken whether the ruleset requiring code-owner review is active, evaluating or absent. An assessment wanting the dry-run finding itself gets it from `developer-change-control-and-integrity` on the same repository, where it belongs, being a property of the repository rather than of this control.
KSI-CMT-RVP — the effectiveness of documented change management procedures is persistently reviewed — is claimed on the MECHANISM limb. A required approval per change, with the approver and the revision recorded, is the material a review of procedure effectiveness reads, and the rule is the documented procedure in executable form. Whether the procedure is EFFECTIVE is a judgement, and no API in this recipe makes it. KSI-PIY-RSD — the effectiveness of building security and privacy considerations into the Software Development Lifecycle and aligning with CISA Secure By Design principles is persistently reviewed — is claimed narrowly and should be read narrowly: requiring a security owner's approval at merge is one security consideration built into one point of the lifecycle. The indicator is about the whole lifecycle and about alignment with an external body of principles that nothing here reads.
The evidence platform is itself an external system, and on this control the dependency runs through the identity provider as well; see `external_system`.
CNA — Cloud Native Architecture
10 recipes · 8 of 8 controls in scope reached
AWS Config compliance results proving the network boundary is controlled — no security group exposes SSH to the internet, groups open to 0.0.0.0/0 only allow authorized ports, and every VPC's default security group denies all traffic
partial · config-rule · every continuous · /collect/config-network-boundary-protection
$ aws configservice get-compliance-details-by-config-rule --config-rule-name restricted-ssh --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name vpc-sg-open-only-to-authorized-ports --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name vpc-default-security-group-closed --compliance-types NON_COMPLIANT
$ aws configservice describe-configuration-recorder-status
$ aws configservice describe-config-rule-evaluation-status --config-rule-names restricted-ssh
$ aws configservice describe-config-rule-evaluation-status --config-rule-names vpc-sg-open-only-to-authorized-ports
$ aws configservice describe-config-rule-evaluation-status --config-rule-names vpc-default-security-group-closed
$ aws ec2 describe-regions --query 'Regions[].RegionName' --output text
$ aws organizations list-accounts --query 'Accounts[].Id' --output text
Proves: SC-07
Expected output: Three EvaluationResults arrays; empty NON_COMPLIANT sets mean no security group leaves SSH (port 22) open to 0.0.0.0/0 or ::/0, any internet-open group is limited to the authorizedTcpPorts/authorizedUdpPorts you set, and every default security group is closed. Managed rule identifiers: INCOMING_SSH_DISABLED (rule name restricted-ssh), VPC_SG_OPEN_ONLY_TO_AUTHORIZED_PORTS, VPC_DEFAULT_SECURITY_GROUP_CLOSED Plus describe-configuration-recorder-status showing recording=true and, per rule, describe-config-rule-evaluation-status showing FirstEvaluationStarted=true with a LastSuccessfulEvaluationTime — the proof the empty set was produced by a check that ran.
GovCloud: AWS Config and all three managed rules are available in AWS GovCloud (US); VPC and security-group ARNs use partition arn:aws-us-gov
Set VPC_SG_OPEN_ONLY_TO_AUTHORIZED_PORTS parameters (authorizedTcpPorts/authorizedUdpPorts) to your documented ingress allow-list, otherwise any 0.0.0.0/0 rule is NON_COMPLIANT. These three cover security-group ingress; for full SC-07 boundary evidence also collect NACL and subnet routing posture and, where used, restricted-common-ports and vpc-flow-logs-enabled. Note VPC_DEFAULT_SECURITY_GROUP_CLOSED may lag on deleted VPCs until the next baselining pass. These three rules read security-group ingress and nothing else; SC-07(b) — the logical separation FedRAMP's own guidance singles out — plus NACLs, route tables, gateways and VPC endpoints are a human's architecture assertion against this output. MAS-CSO-FLO is not claimed: its artifact is the enumeration of permitted connections, and a NON_COMPLIANT filter is empty on a compliant estate.
AWS Config compliance results plus the State Manager association list proving a defined configuration is actually applied and re-applied to every managed node — instances are under SSM management, and the associations that carry your baseline report COMPLIANT on a schedule rather than drifting
partial · cli · every continuous · /collect/ssm-configuration-baseline-enforced
$ aws configservice get-compliance-details-by-config-rule --config-rule-name ec2-instance-managed-by-systems-manager --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name ec2-managedinstance-association-compliance-status-check --compliance-types NON_COMPLIANT
$ aws ssm list-associations --query 'Associations[].{Name:Name,AssociationId:AssociationId,Schedule:ScheduleExpression,Targets:Targets,Status:Overview.Status,LastRun:LastExecutionDate}'Proves: CM-02, CM-06
Expected output: Two EvaluationResults arrays plus an Associations list. Empty NON_COMPLIANT sets mean every running EC2 instance has a running SSM Agent and every SSM association compliance record reads COMPLIANT after execution; the Associations list names the documents, schedules, and targets that carry the baseline, with Overview.Status and LastExecutionDate showing it ran. Managed rule identifiers: EC2_INSTANCE_MANAGED_BY_SSM (rule name ec2-instance-managed-by-systems-manager), EC2_MANAGEDINSTANCE_ASSOCIATION_COMPLIANCE_STATUS_CHECK
GovCloud: AWS Config and Systems Manager are available in AWS GovCloud (US); instance, document, and association ARNs use partition arn:aws-us-gov
The telemetry proves a configuration is being enforced and drift corrected — it does not prove the enforced content IS your approved baseline. That the association's SSM document encodes the hardened settings you baselined (CIS/STIG content, approved through your change process) is the human judgement half; keep the document version and its approval record alongside this output. Two limits to state plainly: EC2_INSTANCE_MANAGED_BY_SSM does not flag a stopped instance whose agent is running, and this whole recipe is EC2-only — container images, Lambda, and managed-service settings need their own baseline evidence. CM-08 inventory is a separate recipe, not this one.
Config compliance results proving the ports you declared unnecessary are not reachable from the internet and the software you declared prohibited is not installed, plus the actual installed-application set a periodic review has to read
partial · cli · every monthly · /collect/config-least-functionality
$ aws configservice get-compliance-details-by-config-rule --config-rule-name restricted-common-ports --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name ec2-managedinstance-applications-blacklisted --compliance-types NON_COMPLIANT
$ aws ssm list-inventory-entries --instance-id i-0123456789abcdef0 --type-name AWS:Application
Proves: CM-07
Expected output: Two EvaluationResults arrays plus an inventory listing. Empty NON_COMPLIANT sets mean no security group opens a blocked TCP port to 0.0.0.0/0 or ::/0 and none of the denylisted applications is installed on any evaluated managed node; the Entries list, stamped with its CaptureTime, is the installed-software set your periodic review actually reads. Managed rule identifiers: RESTRICTED_INCOMING_TRAFFIC (rule name restricted-common-ports), EC2_MANAGEDINSTANCE_APPLICATIONS_BLACKLISTED
GovCloud: AWS Config, both managed rules, and Systems Manager Inventory are available in AWS GovCloud (US); security-group and node ARNs use partition arn:aws-us-gov
These prove the negatives you asserted and hand the reviewer the real installed-application set — they do not prove least functionality. That the functions, ports, protocols and services still enabled are the minimum necessary is a judgement against your documented essential-capability list, and CM-07.01's periodic review is a decision someone makes and records, not an API result; keep the review record next to this output. Both rules are only as strong as their parameters. RESTRICTED_INCOMING_TRAFFIC defaults to blocking TCP 20, 21, 3389, 3306 and 4333 — set blockedPorts to your real denylist or you are testing AWS's defaults, not your policy. EC2_MANAGEDINSTANCE_APPLICATIONS_BLACKLISTED needs exact application names (no wildcards, and the name differs per distro) and evaluates AWS::SSM::ManagedInstanceInventory, so a node with no running agent or inventory association is simply not evaluated rather than flagged — pair it with the inventory recipe's coverage check. Security-group ingress deliberately overlaps the SC-07 boundary recipe: there it proves boundary protection, here it proves unnecessary ports are closed.
Every route in or out of the boundary, named and counted — internet gateways, NAT gateways, VPC endpoints and Site-to-Site VPN tunnels — alongside what each boundary device does with traffic that matched no rule: the network ACL entries, the closed default security group, subnets that hand out public IPs, and the firewall policy's stateless and stateful default actions
partial · cli · every weekly · /collect/boundary-access-points-and-default-deny
$ aws ec2 describe-internet-gateways --query 'InternetGateways[].{Igw:InternetGatewayId,Attachments:Attachments[].{Vpc:VpcId,State:State}}'$ aws ec2 describe-nat-gateways --query 'NatGateways[].{Nat:NatGatewayId,State:State,Vpc:VpcId,Subnet:SubnetId,Connectivity:ConnectivityType}'$ aws ec2 describe-vpc-endpoints --query 'VpcEndpoints[].{Endpoint:VpcEndpointId,Type:VpcEndpointType,Service:ServiceName,Vpc:VpcId,State:State}'$ aws ec2 describe-vpn-connections --query 'VpnConnections[].{Vpn:VpnConnectionId,State:State,Category:Category,Tunnels:VgwTelemetry[].{Ip:OutsideIpAddress,Status:Status,Changed:LastStatusChange}}'$ aws ec2 describe-network-acls --query 'NetworkAcls[].{Acl:NetworkAclId,Default:IsDefault,Vpc:VpcId,Entries:Entries[].{Rule:RuleNumber,Action:RuleAction,Egress:Egress,Cidr:CidrBlock,Protocol:Protocol}}'$ aws network-firewall describe-firewall-policy --firewall-policy-name <FIREWALL_POLICY> --query 'FirewallPolicy.{Stateless:StatelessDefaultActions,Fragments:StatelessFragmentDefaultActions,Stateful:StatefulDefaultActions}'$ aws configservice get-compliance-details-by-config-rule --config-rule-name vpc-default-security-group-closed --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name subnet-auto-assign-public-ip-disabled --compliance-types NON_COMPLIANT
$ aws configservice get-compliance-details-by-config-rule --config-rule-name netfw-policy-default-action-full-packets --compliance-types NON_COMPLIANT
Expected output: A short, closed list of access points you can put next to your documented boundary: one internet gateway per VPC that is meant to have one and none attached to a VPC that is not, NAT gateways whose ConnectivityType matches whether that subnet is supposed to reach the internet at all, VPC endpoints that keep service traffic off the internet, and VPN connections whose VgwTelemetry shows both tunnels UP with a LastStatusChange that is not flapping. On the deny side: network ACL Entries in which no allow rule opens a CIDR wider than your documented exceptions, an empty NON_COMPLIANT set from vpc-default-security-group-closed meaning every VPC's default security group carries no inbound or outbound rule at all, an empty set from subnet-auto-assign-public-ip-disabled meaning no subnet hands instances a public address on launch, and a firewall policy whose StatelessDefaultActions and StatelessFragmentDefaultActions are aws:drop (or aws:forward_to_sfe into a stateful engine whose StatefulDefaultActions is aws:drop_strict) rather than aws:pass. Managed rule identifiers: VPC_DEFAULT_SECURITY_GROUP_CLOSED, SUBNET_AUTO_ASSIGN_PUBLIC_IP_DISABLED, NETFW_POLICY_DEFAULT_ACTION_FULL_PACKETS, NETFW_POLICY_DEFAULT_ACTION_FRAGMENT_PACKETS
GovCloud: Amazon VPC, Amazon EC2, AWS Config and AWS Network Firewall are all available in AWS GovCloud (US) — the GovCloud user guide records no differences for Network Firewall — but the two Network Firewall managed rules (NETFW_POLICY_DEFAULT_ACTION_FULL_PACKETS and NETFW_POLICY_DEFAULT_ACTION_FRAGMENT_PACKETS) are explicitly unavailable in AWS GovCloud (US-East) and AWS GovCloud (US-West), so read the default actions straight from describe-firewall-policy there and drop the rule call; gateway, endpoint and firewall ARNs use partition arn:aws-us-gov
The inventory is telemetry; the limit is policy. SC-07.03 asks that external connections be held to the minimum needed and each one routed through a managed interface — these calls enumerate every gateway, endpoint and tunnel exactly, but whether that count is the minimum is a comparison against your documented architecture, and nothing in the API tells you a gateway is unnecessary. SC-07.04 is the weakest half here: describe-vpn-connections proves the tunnels exist and are up, but the control also wants each external telecommunications interface documented with its business need and traffic-flow policy, exceptions reviewed and removed — that record is a document, not a call. This recipe also does not cover carrier links terminated outside these APIs (AWS Direct Connect connections, Transit Gateway peering to another network); enumerate those separately if you use them. SC-07.05 is the closest to full: the default-deny posture of security groups, network ACLs and the firewall policy is directly readable, and vpc-default-security-group-closed is a clean pass/fail. Read the ACL Entries yourself rather than trusting a rule verdict — an allow entry with a low rule number can shadow everything below it, and no managed rule scores ordering. Substitute your real firewall policy name; describe-firewall-policy is one policy per call. Security-group ingress is covered by the SC-07 recipe (restricted-ssh, vpc-sg-open-only-to-authorized-ports) and not repeated here.
The denial-of-service defences that are actually attached to the internet-facing resources — the Shield Advanced subscription and the list of resources it protects, the web ACL's rate-based rules and their limits, whether web ACL logging is on — plus what those defences observed: the attacks Shield recorded over the period and the CloudWatch detection and block counts underneath them
partial · cli · every daily · /collect/ddos-protection-and-rate-limiting
$ aws shield describe-subscription --region us-east-1 --query 'Subscription.{Start:StartTime,End:EndTime,AutoRenew:AutoRenew,ProactiveEngagement:ProactiveEngagementStatus}'$ aws shield list-protections --region us-east-1 --query 'Protections[].{Name:Name,Resource:ResourceArn,AutoAppLayerResponse:ApplicationLayerAutomaticResponseConfiguration.Status}'$ aws shield list-attacks --region us-east-1 --start-time FromInclusive=2026-04-27T00:00:00Z,ToExclusive=2026-07-27T00:00:00Z --query 'AttackSummaries[].{Attack:AttackId,Resource:ResourceArn,Start:StartTime,End:EndTime,Vectors:AttackVectors}'$ aws wafv2 list-web-acls --scope REGIONAL --query 'WebACLs[].{Name:Name,Id:Id,Arn:ARN}'$ aws wafv2 get-web-acl --name <WEB_ACL_NAME> --scope REGIONAL --id <WEB_ACL_ID> --query 'WebACL.{Default:DefaultAction,Rules:Rules[].{Name:Name,Priority:Priority,Action:Action,RateBased:Statement.RateBasedStatement}}'$ aws configservice get-compliance-details-by-config-rule --config-rule-name wafv2-logging-enabled --compliance-types NON_COMPLIANT
$ aws cloudwatch get-metric-statistics --namespace AWS/DDoSProtection --metric-name DDoSDetected --dimensions Name=ResourceArn,Value=<PROTECTED_RESOURCE_ARN> --start-time <START_TIME> --end-time <END_TIME> --period 3600 --statistics Maximum
$ aws cloudwatch get-metric-statistics --namespace AWS/WAFV2 --metric-name BlockedRequests --dimensions Name=WebACL,Value=<WEB_ACL_NAME> Name=Rule,Value=<RATE_LIMIT_RULE_NAME> Name=Region,Value=<REGION> --start-time <START_TIME> --end-time <END_TIME> --period 3600 --statistics Sum
Proves: SC-05
Expected output: A Shield Advanced subscription whose EndTime is in the future and AutoRenew is ENABLED, and a Protections list that names every internet-facing resource in your boundary — a resource missing from that list has Shield Standard only and no protection record to show an assessor. A web ACL whose Rules include at least one RateBasedStatement carrying the Limit, EvaluationWindowSec and aggregation you documented, with a Block action rather than Count, and a DefaultAction you meant. An empty NON_COMPLIANT set from wafv2-logging-enabled, meaning every web ACL is logging (and, if you passed KinesisFirehoseDeliveryStreamArns, to the destination you named). Then the observation half: an AttackSummaries array — often empty, which is itself the finding for a quiet quarter — and CloudWatch series in which DDoSDetected stays at 0 except during events, and BlockedRequests shows the rate-based rule doing work. Managed rule identifiers: WAFV2_LOGGING_ENABLED, SHIELD_ADVANCED_ENABLED_AUTORENEW. Namespaces: AWS/DDoSProtection (DDoSDetected, DDoSAttackBitsPerSecond, DDoSAttackPacketsPerSecond, DDoSAttackRequestsPerSecond), AWS/WAFV2 (AllowedRequests, BlockedRequests, CountedRequests)
GovCloud: AWS WAF is available in both AWS GovCloud (US) Regions, with one documented difference: only AWS-provided managed rule groups are usable — AWS Marketplace third-party rule groups are not. Shield is the awkward one: the Shield Response Team documentation states the SRT serves customers in AWS GovCloud (US-East) and (US-West), but the AWS General Reference lists a single Shield endpoint, shield.us-east-1.amazonaws.com, with no GovCloud entry, and the shield-advanced-enabled-autorenew managed rule is documented as available only in US East (N. Virginia) — so run the aws shield calls and that Config rule against us-east-1 and confirm your own account's GovCloud Shield coverage before you claim it. Web ACL and load-balancer ARNs use partition arn:aws-us-gov
This proves the mechanism, not the outcome. SC-5 asks that denial-of-service attacks be protected against or their effects limited: the configuration calls prove rate limiting and Shield are attached to the right resources, and list-attacks plus the DDoSProtection metrics prove detection and mitigation fired — but 'the effect on availability was limited' is a judgement you make against your own application health, error rates and capacity headroom, which are not in this output. Two honest gaps. Shield Standard, which protects every AWS customer automatically, has no API and no record to collect; if you are not subscribed to Shield Advanced there is nothing here to show beyond the WAF half, and describe-subscription simply errors. And the rate limit itself is a number you chose: a RateBasedStatement set far above real traffic passes every check while limiting nothing, so put the documented threshold next to the configured Limit. Note also that Shield Advanced reports metrics once a minute during an event but only once a day when nothing is happening, so a sparse series is normal and a missing data point is not an outage; and that engaging the SRT requires a Business or Enterprise Support plan. list-attacks covers the window you pass — keep the collected output, since the API's own history is not your retention policy.
How operators actually reach the environment from outside it: the managed access paths that exist, the logging and encryption configured on them, the session-by-session record of who used them, and the negative check that no instance is directly reachable instead
partial · cli · every weekly · /collect/remote-access-authorization-and-monitoring
$ aws ssm get-document --name SSM-SessionManagerRunShell --document-version '$LATEST' --query Content --output text
$ aws ssm describe-sessions --state History --query 'Sessions[].{owner:Owner,target:Target,start:StartDate,end:EndDate,document:DocumentName,accessType:AccessType,maxDuration:MaxSessionDuration}'$ aws ec2 describe-client-vpn-endpoints --query 'ClientVpnEndpoints[].{id:ClientVpnEndpointId,transport:TransportProtocol,auth:AuthenticationOptions[].Type,connectionLog:ConnectionLogOptions,splitTunnel:SplitTunnel,sessionTimeoutHours:SessionTimeoutHours,serverCert:ServerCertificateArn,selfServicePortal:SelfServicePortalUrl}'$ aws ec2 describe-client-vpn-connections --client-vpn-endpoint-id <CLIENT_VPN_ENDPOINT_ID> --query 'Connections[].{user:Username,commonName:CommonName,clientIp:ClientIp,established:ConnectionEstablishedTime,ended:ConnectionEndTime,status:Status,posture:PostureComplianceStatuses}'$ aws configservice get-compliance-details-by-config-rule --config-rule-name ec2-instance-no-public-ip --compliance-types NON_COMPLIANT
Proves: AC-17
Expected output: The Session Manager preferences document as JSON — s3BucketName, s3KeyPrefix, s3EncryptionEnabled, cloudWatchLogGroupName, cloudWatchEncryptionEnabled, cloudWatchStreamingEnabled, kmsKeyId, runAsEnabled, idleSessionTimeout and maxSessionDuration — which is your Region's entire remote-access logging and encryption configuration in one object. Then one row per terminated session from the past 30 days carrying Owner, Target, StartDate, EndDate, DocumentName, MaxSessionDuration and AccessType of Standard or JustInTime. Then per Client VPN endpoint the transport protocol tcp or udp, the authentication types in use (certificate-authentication, directory-service-authentication or federated-authentication), ConnectionLogOptions with Enabled plus CloudwatchLogGroup and CloudwatchLogStream, SplitTunnel, SessionTimeoutHours of 8, 10, 12 or 24 (default 24) and the server certificate ARN. Then per connection Username (Active Directory authentication only), CommonName, ClientIp, ConnectionEstablishedTime, ConnectionEndTime, Status and any PostureComplianceStatuses — active connections plus only those terminated within the last 60 minutes. Finally the EC2 instances AWS Config evaluated NON_COMPLIANT because a publicIp field is present in their configuration item.
GovCloud: Both access paths exist in AWS GovCloud (US-East) and (US-West). Client VPN endpoints there operate using FIPS 140-3 validated cryptographic modules and a fixed cipher set — TLS 1.3 TLS_AES_256_GCM_SHA384 and TLS_AES_128_GCM_SHA256; TLS 1.2 TLS-ECDHE-RSA/ECDSA-WITH-AES-256-GCM-SHA384 and the AES-128-GCM-SHA256 variants; data channel AES-256-GCM — and AWS advises using the exported client configuration file unmodified rather than configuring other ciphers, which makes AC-17(2) there largely a matter of not breaking the default. Systems Manager runs in both Regions; Change Manager and Incident Manager do not, and State Manager association history cannot be viewed, none of which this recipe touches. ec2-instance-no-public-ip is documented for all supported AWS Regions. Calls to these services must use SSL (HTTPS), and ARNs use partition arn:aws-us-gov
The commands split cleanly across the control family, and the gap in the middle is the one to be honest about. AC-17(1) — automated monitoring and control of remote access — is what session history and connection logs deliver, with two documented blind spots. Session Manager does not log sessions that connect through port forwarding or SSH, because SSH encrypts the session data inside the TLS connection and Session Manager is only the tunnel; an operator who port-forwards leaves a session record with no command content behind it. And describe-sessions reaches back 30 days only, so anything longer is an S3 or CloudWatch Logs query against the destinations named in the preferences document, not an SSM call. Client VPN retention is shorter still — terminated connections drop out of the API after 60 minutes, which makes the log group named in ConnectionLogOptions the only durable record, and Username is populated only for Active Directory authentication, so certificate-authenticated users are identified by CommonName or not at all. AC-17(2) is the strongest link in GovCloud, where the endpoints are FIPS 140-3 modules by construction; the Session Manager equivalent is kmsKeyId in the preferences document, and it is empty unless you set it, so an empty kmsKeyId is a finding rather than a default. AC-17(3) — routing remote access through managed network access control points — is the one nothing here proves. Session Manager and a Client VPN endpoint are managed access points, and ec2-instance-no-public-ip is the closest negative check, but that rule applies only to IPv4 and only to AWS::EC2::Instance: an IPv6-reachable instance, a load balancer fronting SSH, or a third-party jump host is invisible to it. Read the result as 'no EC2 instance carries a public IPv4 address', which is a useful sentence and not the control. AC-17 itself — the documented usage restrictions, configuration requirements and per-type authorization — is a record you write, and this telemetry only shows whether the estate matches it. One warning to carry into the evidence package: Session Manager logs the commands entered and their output, so a credential typed into a session lands in the log group you are about to hand an assessor.
Whether malware scanning is switched on for compute and for the buckets that accept uploads, plus the scan-by-scan record of what was actually examined and what came back INFECTED
partial · cli · every weekly · /collect/malicious-code-protection
$ aws guardduty list-detectors
$ aws guardduty get-detector --detector-id <DETECTOR_ID> --query '{status:Status,publishingFrequency:FindingPublishingFrequency,features:Features[].{name:Name,status:Status,additional:AdditionalConfiguration}}'$ aws guardduty describe-malware-scans --detector-id <DETECTOR_ID> --query 'Scans[].{id:ScanId,type:ScanType,status:ScanStatus,result:ScanResultDetails,started:ScanStartTime,ended:ScanEndTime,files:FileCount,bytes:TotalBytes,resource:ResourceDetails,trigger:TriggerDetails,failure:FailureReason}'$ aws guardduty list-malware-protection-plans
$ aws guardduty get-malware-protection-plan --malware-protection-plan-id <PLAN_ID> --query '{protected:ProtectedResource,status:Status,statusReasons:StatusReasons,actions:Actions,role:Role,created:CreatedAt}'$ aws configservice get-compliance-details-by-config-rule --config-rule-name guardduty-malware-protection-enabled --compliance-types NON_COMPLIANT
Proves: SI-03
Expected output: A detector id per Region — an empty list means GuardDuty was never enabled there, which is itself the finding. Then the detector's Status ENABLED or DISABLED, its FindingPublishingFrequency of FIFTEEN_MINUTES, ONE_HOUR or SIX_HOURS, and the Features list in which EBS_MALWARE_PROTECTION is the entry that matters, alongside FLOW_LOGS, CLOUD_TRAIL, DNS_LOGS, S3_DATA_EVENTS, EKS_AUDIT_LOGS, RDS_LOGIN_EVENTS, LAMBDA_NETWORK_LOGS, EKS_RUNTIME_MONITORING and RUNTIME_MONITORING with its EC2_AGENT_MANAGEMENT, EKS_ADDON_MANAGEMENT and ECS_FARGATE_AGENT_MANAGEMENT sub-configuration. Then one row per malware scan: ScanId, ScanType GUARDDUTY_INITIATED or ON_DEMAND, ScanStatus RUNNING, COMPLETED, FAILED or SKIPPED, a FailureReason when it failed, ScanStartTime and ScanEndTime, the scanned InstanceArn and its attached volumes, FileCount and TotalBytes actually examined, TriggerDetails carrying the GuardDutyFindingId and a TriggerType of GUARDDUTY or BACKUP, and a ScanResultDetails of CLEAN or INFECTED. Then the Malware Protection plan ids, and per plan the protected S3 bucket with its object prefixes, the scanning role, whether result tagging is on, and a Status of ACTIVE, WARNING or ERROR with StatusReasons naming the problem. Finally, outside GovCloud, the detectors AWS Config marks NON_COMPLIANT for GUARDDUTY_MALWARE_PROTECTION_ENABLED.
GovCloud: GuardDuty runs in both AWS GovCloud (US) Regions and Malware Protection for EC2 works there with one documented gap: instances whose productCode is marketplace are not scanned — GuardDuty skips them and logs the skip reason UNSUPPORTED_PRODUCT_CODE_TYPE, so a SKIPPED scan in GovCloud may be that rather than a misconfiguration. Malware Protection for Backup cannot scan EC2 or EBS recovery points there. The GovCloud differences page records no carve-out for Malware Protection for S3. The last command has nothing to call: GUARDDUTY_MALWARE_PROTECTION_ENABLED is excluded from both AWS GovCloud (US-East) and (US-West) — as it is from the China Regions, Mexico (Central), Asia Pacific (Thailand), (Malaysia) and (Taipei) — so drop it and take the enablement fact from the EBS_MALWARE_PROTECTION feature status in get-detector instead. Also unavailable in GovCloud: the entity lists customisation (IP address lists still work) and the GuardDuty Investigation preview. ARNs use partition arn:aws-us-gov
The trap in SI-3 is reading GuardDuty Malware Protection as antivirus. It is not a scheduled sweep of your file systems. A GuardDuty-initiated scan fires only after GuardDuty has already produced a finding indicative of malware on that resource, at most once every 24 hours per resource, and it works agentlessly against snapshots of the attached EBS volumes — so an empty describe-malware-scans list is the expected steady state of a healthy estate and proves nothing about coverage. The two enablement reads prove capability; the scan list proves exercise; neither proves protection. Coverage has a second silent hole: the global GuardDutyExcluded:true tag and your own inclusion or exclusion scan-option tags make GuardDuty initiate a scan and then skip it, so read the scan options next to the tag inventory or a deliberately excluded estate looks like a clean one, and Fargate workloads under EKS or ECS are not scanned at all. Malware Protection for S3 is the closest thing here to SI-3's entry-point requirement — it scans each newly uploaded object and each new version in a configured bucket — but it covers only buckets with an active plan, in the same Region as the plan, in your own account (a delegated GuardDuty administrator cannot enable it on a member account's bucket), and when run independently of GuardDuty there is no detector, so malware produces an EventBridge event, a CloudWatch metric and the optional object tag rather than a GuardDuty finding. What no command here produces is the rest of SI-3: signature or engine currency, since AWS operates the scan engines and exposes no version for you to attest to; periodic full scans; false-positive handling; and the documented response when malicious code is found. Rate those from the plan and the incident record, and keep these reads as the machine half of the answer.
The machine-generated inventory of every plane on which one part of the system reaches another — VPC peering connections, Transit Gateway attachments, the interface and gateway endpoints this account consumes, the endpoint connections other accounts have made INTO your endpoint service, and the security-group rules that name another group rather than a CIDR — each narrowed to the states that are actually live
partial · cli · every continuous · /collect/internal-connection-inventory-and-authorization
$ aws ec2 describe-vpc-peering-connections
$ aws ec2 describe-transit-gateway-attachments
$ aws ec2 describe-vpc-endpoints
$ aws ec2 describe-vpc-endpoint-connections
$ aws ec2 describe-security-group-rules
Proves: CA-09
Expected output: All five responses are collected unprojected and per-Region — every one of these is a Regional call, so an inventory taken in one Region is an inventory of one Region. From describe-vpc-peering-connections, VpcPeeringConnections[] with VpcPeeringConnectionId, Tags, ExpirationTime, and RequesterVpcInfo and AccepterVpcInfo each carrying OwnerId, VpcId, Region, CidrBlock and CidrBlockSet — the account on the far side is OwnerId, and it is the field that says whether a connection is internal at all. Status.Code is one of initiating-request, pending-acceptance, active, deleted, rejected, failed, expired, provisioning, deleting: the list is a history, not a live inventory, and only `active` is a connection. From describe-transit-gateway-attachments, TransitGatewayAttachments[] with TransitGatewayAttachmentId, TransitGatewayId, TransitGatewayOwnerId, ResourceOwnerId, ResourceId, CreationTime, Tags, an Association carrying TransitGatewayRouteTableId and its own State, a ResourceType of vpc, vpn, vpn-concentrator, direct-connect-gateway, connect, peering, tgw-peering (deprecated) or client-vpn, and a State among initiating (deprecated), initiatingRequest, pendingAcceptance, rollingBack, pending, available, modifying, deleting, deleted, failed, rejected, rejecting and failing — `available` is the live one, and `pendingAcceptance` is a cross-account attachment nobody has adjudicated. From describe-vpc-endpoints, VpcEndpoints[] with VpcEndpointId, VpcEndpointType (Interface, Gateway, GatewayLoadBalancer, Resource, ServiceNetwork), VpcId, ServiceName, OwnerId, CreationTimestamp, PolicyDocument, PrivateDnsEnabled, RouteTableIds for gateway endpoints, SubnetIds and Groups for interface endpoints, Tags, and a State among PendingAcceptance, Pending, Available, Deleting, Deleted, Rejected, Failed, Expired and Partial. From describe-vpc-endpoint-connections — the provider side, and the only one of the five that shows connections INTO you — VpcEndpointConnections[] with ServiceId, VpcEndpointId, VpcEndpointOwner (the consumer's account id), CreationTimestamp, DnsEntries, IpAddressType and a VpcEndpointState of PendingAcceptance, Pending, Available, Deleting, Deleted, Rejected, Failed, Expired or Partial — PascalCase, unlike the Transit Gateway attachment state beside it, and unlike the lowercase forms the CLI page lists as --filters ARGUMENT values. From describe-security-group-rules, SecurityGroupRules[] with SecurityGroupRuleId, SecurityGroupRuleArn, GroupId, IsEgress, IpProtocol, FromPort, ToPort, Description, Tags, and a peer field naming what the rule permits traffic to or from — CidrIpv4, CidrIpv6, PrefixListId or ReferencedGroupInfo, and IsEgress is what says which direction that peer sits in; a rule carrying ReferencedGroupInfo is one component reaching another by identity rather than by address, which is the internal connection CA-09 is about.
GovCloud: Amazon VPC, Transit Gateway and PrivateLink all operate in both AWS GovCloud (US) Regions and every call here is available; VPC, endpoint, attachment and security-group ARNs use partition arn:aws-us-gov. Two documented GovCloud differences touch this recipe and neither blocks it. Security group rule IDs are not shown in the Amazon VPC CONSOLE in GovCloud — the API returns SecurityGroupRuleId normally, so collect this evidence through the CLI and do not expect a reviewer to reconcile it against a console screenshot. And not all VPC endpoints in GovCloud support VPC endpoint policies, so PolicyDocument may be absent on an endpoint where the commercial Regions would carry one; read its absence as an unsupported endpoint type rather than as a removed control, and say which it was. Note also that VPC metadata in GovCloud is not permitted to contain export-controlled data, and AWS names security group rule descriptions, tag keys and values, and VPC endpoint service names among the free-text fields this applies to — which is a constraint on the very fields this recipe asks you to write an authorization reference into.
CA-09 has four elements and this output serves parts of two of them. The part it holds: which internal connections exist. Every plane on which one component of the system reaches another leaves a resource behind — a peering connection, a Transit Gateway attachment, an endpoint, an endpoint connection, or a security-group rule that names another group instead of a CIDR — and five calls enumerate them completely for a Region. That enumeration is worth having on its own: it is the list the control's documentation requirement is written against, and most providers do not have it.
What it does not hold is everything CA-09 asks you to record about each connection. The interface characteristics, the security AND PRIVACY requirements agreed for the connection, the nature of the information communicated, and the authorization to connect at all — none of them is a field. Two further elements of the control are not addressed here at all and should not be read as covered: terminating internal connections when the stated conditions are met, and reviewing the continued need for each connection on a stated frequency. The second of those is the one this inventory most nearly serves — a continuously collected enumeration is the substrate a periodic review runs on — but the review itself is a decision with a date and a reviewer, and none of that is in the response. They live in an interconnection agreement or an internal approval record, and the reconciliation of that record against this inventory is the CA-09 artifact. This recipe produces the column you reconcile against, which is why it is partial and why no assertion below claims more than attachment and state.
Read every one of these lists as a history rather than an inventory. Peering connections persist in the response with Status.Code deleted, rejected, expired or failed; Transit Gateway attachments persist as deleted, failed or rejected; endpoints persist as Deleted or Rejected. A count taken over the raw response counts connections that do not exist, and — the direction that actually matters — a clause of the form 'every connection carries an authorization tag' evaluated over the raw response is answering about the dead ones too. Every assertion here is therefore narrowed by state, and the narrowing is the assertion.
The completeness clauses are written as OFFENDER LISTS — a filter that selects the rows which fail, asserted to be empty — and not as 'every connection carries a tag'. This is not a style preference. A field projection over a list drops the rows where the field is absent instead of reporting them as false, so an every-row clause of the obvious form evaluates only the rows that already comply and is green on an estate where half the connections are untagged. The offender form was verified against synthetic responses before it was written down.
The vacuity to state plainly: all five lists are empty on an account that has none of these, and an every-entry clause over an empty list is true. This evidence cannot distinguish 'no internal connections' from 'no connections in THIS Region' from 'the collection ran with a role that cannot see them'. Record the Region set the collection covered beside the output, because every one of these calls is Regional and nothing in the response says which Region produced it.
Two directions, and only one of them is intuitive. Four of the five calls show what this account reaches OUT to. describe-vpc-endpoint-connections is the other direction — it is the provider-side view of who has connected IN to an endpoint service you publish — and a pendingAcceptance entry there is a connection request nobody has adjudicated. The same is true of a peering connection in pending-acceptance: an unaccepted request is not yet a connection, but it is a decision someone owes, and it belongs in the review the KSIs describe as persistent rather than in a quarterly surprise.
OwnerId and ResourceOwnerId are what make 'internal' checkable at all, and they are also the join this grammar cannot do: deciding whether a peer account is inside the authorization boundary means comparing OwnerId against your own account list, and an assertion compares a field to a constant. Write the boundary's account set into the assessment and reconcile by hand; a tag naming the authorization record is the closest a field gets, which is what the assertions ask for.
Security-group rules are included for a reason worth stating: they are the only one of the five that shows a connection with no resource of its own. A rule whose ReferencedGroupInfo names another security group is one component permitted to reach another by identity, and it is invisible to any inventory that looks only for peering connections and attachments. Description is free text and no AWS call validates it, so treat the description assertion as a documentation-completeness check rather than as evidence about the connection itself.
Which taint-tracking ruleset actually ran over this repository — the query suite, the languages selected, and the threat model that decides what counts as an untrusted source — together with whether the recurring scan is still scheduled, when it last ran per analysed language, and how many rules were in the run; and then the open findings in the injection families SI-10 is about, identified by the CWE tags the queries carry. The ruleset and the freshness are the load-bearing half: a finding names a sink that exists, but only the run record says the absence of findings means anything at all.
partial · api · every continuous · /collect/input-validation-taint-analysis-coverage
$ gh api --paginate "/orgs/<ORG>/code-security/configurations"
$ gh api --paginate "/orgs/<ORG>/code-security/configurations/<CONFIGURATION_ID>/repositories?status=attached,enforced&per_page=100"
$ gh api "/repos/<ORG>/<REPO>/code-scanning/default-setup"
$ gh api --paginate "/repos/<ORG>/<REPO>/code-scanning/analyses?tool_name=CodeQL&ref=refs/heads/<DEFAULT_BRANCH>&per_page=100"
$ gh api --paginate "/orgs/<ORG>/code-scanning/alerts?state=open&tool_name=CodeQL&per_page=100"
$ gh api -H "Accept: application/sarif+json" "/repos/<ORG>/<REPO>/code-scanning/analyses/<ANALYSIS_ID>"
Expected output: Report names used below, and the command each comes from: `configurations` is the first, `configuration-repositories` the second, `default-setup` the third, `analyses` the fourth, `alerts-open` the fifth, and the sixth returns SARIF rather than a report this recipe asserts on.
RUN COMMANDS 3, 4 AND 6 ONCE PER REPOSITORY. Neither default setup nor analyses has an organization-scoped endpoint; the alerts endpoint does, and it returns the owning `repository` on each alert, so the findings arrive in one list while the run record is assembled one repository at a time.
AND READ THE TWO RUN-RECORD CLAUSES ONCE PER ANALYSED LANGUAGE, not once per repository. GitHub documents `category` as what distinguishes "multiple analyses for the same tool and commit, but performed on different languages or different parts of the code", and the endpoint has NO `category` filter — its filters are `tool_name`, `tool_guid`, `pr`, `ref` and `sarif_id`. So on a Java-plus-JavaScript repository the newest row is whichever language finished last, and a clause reading position zero of the unfiltered list can be satisfied by a run that says nothing about the language carrying the risk. The command pins `ref` to the default branch for the same class of reason — without it a `refs/pull/N/merge` analysis can be the newest row — and `<CATEGORY>` in the two clauses below is a placeholder the collector fills once per entry in `default-setup.languages`. The exact category string default setup emits per language is NOT documented and was not verified; read it off the `category` field of the response rather than constructing it.
From `default-setup`, the fields this recipe exists to read — `query_suite` (`default | extended`), `threat_model` (`remote | remote_and_local`) and `languages` (from `actions, c-cpp, csharp, go, java-kotlin, javascript-typescript, python, ruby, swift`) — plus `state` (`configured | not-configured`), `updated_at` and `schedule` (`weekly | null`). From `analyses`, `created_at`, `rules_count`, `results_count`, `category`, `commit_sha`, `ref`, `error`, `warning` and the `tool` object; `sort` accepts only `created` and `direction` defaults to `desc`, so the first row of any category-filtered projection is that category's newest run.
From `alerts-open`, alerts whose `rule` object carries `tags` — the field the CWE clause below reads. CodeQL query metadata puts CWE classification in that array in the form `external/cwe/cwe-089`, verified on three query help pages: `js/sql-injection` carries `security`, `external/cwe/cwe-089`, `external/cwe/cwe-090` and `external/cwe/cwe-943`; `js/command-line-injection` carries `correctness`, `security`, `external/cwe/cwe-078` and `external/cwe/cwe-088`; `js/reflected-xss` carries `security`, `external/cwe/cwe-079` and `external/cwe/cwe-116`. Two things about that array decide how the clause below is written. `tags` is documented as "array of string or null", and a null array is the case a `contains()` filter cannot state an opinion about — so a separate clause counts the rows that have no array at all, rather than letting them pass silently. And the alerts endpoint has NO server-side tag filter — its filters are `tool_name`, `tool_guid`, `state`, `severity`, `sort`, `direction` and `assignees` — so the CWE narrowing is a client-side filter over a FULL page walk, and a collector that fetches one page of thirty and filters it has produced a smaller answer rather than a filtered one. The command passes `tool_name=CodeQL` so the list holds only rows from the analyses the run-record clauses measure.
The sixth command returns the run's SARIF, whose `runs[].tool.driver.rules[]` is the closest thing available to a list of the queries that actually executed. GitHub documents the response as "a subset of the analysis data that was uploaded", which is why nothing below asserts on it: it is collected so a human can read which rules were present, and a subset is not a population.
SI-10 asks the provider to check the validity of organization-defined information inputs, and the defining is the half no scanner does. Taint-tracking analysis is genuine telemetry about the other half — a path from a source the ruleset models to a sink it knows is exactly what an input-validation failure looks like in code — but a clean result says "no sink the ruleset knows about, reachable from a source it models" and the control says "the inputs we defined are checked". Those are different sentences, and `scan_scope` names the second list because nothing in this output contains it.
THE THREAT MODEL IS THE SHARPEST TRAP ON THIS CONTROL AND IT LOOKS LIKE A SOLVED PROBLEM. `threat_model` is a field in the response with a value of `remote` or `remote_and_local`, so it reads like a setting a provider can simply turn up. GitHub documents that "The default threat model includes remote sources of untrusted data" and that extending it to local sources — "command-line arguments, environment variables, file systems, and databases" — is "currently in public preview and subject to change" and "supported only by analysis for Java/Kotlin and C#". Two consequences follow, and both are findings rather than caveats. On a codebase in any other language, there is no local-source configuration to make: a CLI argument, an environment variable or a row read back out of the database is NOT in the model the scan used, and SI-10's information inputs frequently include exactly those. And where the language does support it, the capability is in public preview, which is a poor foundation for a control statement in a Moderate or High system. Nothing below asserts `remote_and_local`, deliberately — asserting it would fail every provider whose language cannot express it, while implying the ones that can have covered local inputs.
WHY THE RULESET-COVERAGE LIMB IS COLLECTED AND NOT ASSERTED, AND WHY THAT IS WHAT KEEPS THIS RECIPE `partial`. The clause an assessment actually wants is "a query for this CWE family was in the run", because that is what makes an empty finding list meaningful. It is not writable from the documented API. `rule.tags` exists only on alerts that exist, so the CWE tags are visible precisely when something was found and invisible in the case where the claim matters. `rules_count` counts rules without naming them. The SARIF response is the closest available and GitHub documents it as "a subset of the analysis data that was uploaded" — a subset cannot establish the absence of a rule, and a recipe that asserted over it would be reading an unspecified sample as a population. What IS writable, and is now asserted, is the coarser half of the same question: that the language was selected at all. The residue — which QUERIES ran within that language — is what keeps this `partial`, and the residue is a property of the documented API rather than of this batch's effort. Two things could close it and neither is in scope here: a documented endpoint listing a run's rules, or the published per-language suite membership read against `query_suite`, which is an inference the cited pages do not license.
WHAT THE QUERY SUITE CHANGES ABOUT THE WORD "CLEAN". GitHub documents the `default` suite as "highly precise" with "few false positive" results, and `security-extended` as "all the queries in the default query suite, plus additional queries with slightly lower precision and severity" that "may return a greater number of false positive code scanning results". So a clean result under `default` is a weaker statement than a clean result under `extended` — fewer queries asked fewer questions — and a provider that switched to `extended` and now has open alerts has not regressed. The value is collected and not asserted because SI-10 names no suite; treating `extended` as a requirement would be authoring a preference as a control.
A CADENCE GAP WORTH STATING PLAINLY, BECAUSE IT IS THE PLATFORM FAILING THE INDICATOR RATHER THAN THE PROVIDER. The freshness clause enforces seven days because that is what `schedule: weekly` means. KSI-CNA-MAT's own class-c floor is the VDR-TFR-MVX MUST — verify and validate the status of machine-based information resources at least once every three days — which is tighter, and SI-10 is a class c and class d control, so this reaches every reader. On an actively developed repository the gap closes by itself, because default setup also scans on pushes and pull requests; on a boundary repository that is quiet, the weekly schedule is the only thing running and the schedule clause is the only reason its silence is visible at all. For class d the binding MUST is LOOSER rather than tighter — read it off `classClocks[].tightestMust`, because the tightest class-d clock is a SHOULD — so this is a class-c fact and not a monotonic one. A provider needing to close it moves to advanced setup and its own schedule, which is a different recipe against a different endpoint.
The default-branch limit applies here as it does to the sibling recipe on this platform: alert status reflects the default branch, the organization-scoped alerts endpoint takes no `ref` filter, and the analyses command pins `ref` to the default branch for the same reason. This is a default-branch statement about a repository, not a repository-wide one.
KSI-PIY-RSD also reaches this control and is deliberately NOT claimed. That indicator asks whether the effectiveness of building security into the SDLC is persistently reviewed, and a scan record is an input to such a review rather than the review itself — the same reason the secret-scanning recipe declined the rotation limb of its adjacent indicator. KSI-CNA-MAT is claimed because an injection sink reachable from untrusted input is attack surface in the indicator's own terms — the dataset's own glossary counts code among machine-based information resources — and a recurring analysis over a named repository set is the persistent review it asks for.
Whether the queries that find information disclosure through an error message or a stack trace ran over this part of the boundary — which languages were selected, whether the recurring scan is still scheduled and when it last completed — and then the open findings those queries produced, identified by the CWE tags the queries carry rather than by their names. The run record is the load-bearing half here as it is on every scanning recipe: a finding names a leak that exists, and only the record of a scan having run over a selected language makes the absence of findings a statement about anything.
partial · api · every continuous · /collect/error-handling-information-exposure-scanning
$ gh api --paginate "/orgs/<ORG>/code-security/configurations"
$ gh api --paginate "/orgs/<ORG>/code-security/configurations/<CONFIGURATION_ID>/repositories?status=attached,enforced&per_page=100"
$ gh api "/repos/<ORG>/<REPO>/code-scanning/default-setup"
$ gh api --paginate "/repos/<ORG>/<REPO>/code-scanning/analyses?tool_name=CodeQL&ref=refs/heads/<DEFAULT_BRANCH>&per_page=100"
$ gh api --paginate "/orgs/<ORG>/code-scanning/alerts?state=open&tool_name=CodeQL&per_page=100"
Expected output: Report names used below, and the command each comes from: `configurations` is the first, `configuration-repositories` the second, `default-setup` the third, `analyses` the fourth and `alerts-open` the fifth.
RUN COMMANDS 3 AND 4 ONCE PER REPOSITORY, and read the run-record clauses ONCE PER ANALYSED LANGUAGE. Neither default setup nor analyses has an organization-scoped endpoint; the alerts endpoint does and returns the owning `repository` on each alert, so the findings arrive in one walk while the run record is assembled a repository at a time. `<CATEGORY>` in the two run-record clauses is a placeholder the collector fills once per entry in `default-setup.languages`, read off the `category` field of the response rather than constructed — GitHub documents `category` as what distinguishes analyses "for the same tool and commit, but performed on different languages or different parts of the code", the analyses endpoint has no `category` filter, and on a multi-language repository the newest unfiltered row is whichever language finished last. The command pins `ref` to the default branch for the same class of reason.
From `default-setup`, `state` (`configured | not-configured`), `languages`, `query_suite`, `schedule` and `updated_at`. From `analyses`, `ref`, `commit_sha`, `analysis_key`, `environment`, `category`, `created_at`, `results_count`, `rules_count`, `tool` (name, version, guid), `sarif_id`, `deletable` and `warning`.
From `alerts-open`, alerts whose `rule` object carries `id`, `name`, `severity`, `security_severity_level`, `description`, `full_description` and `tags` — that last array is what the CWE clause reads. CodeQL puts CWE classification there in the form `external/cwe/cwe-209`, and three query help pages were fetched for this recipe to establish that the error-handling queries carry it: `js/stack-trace-exposure` ("Information exposure through a stack trace", severity warning, security severity 5.4), `java/stack-trace-exposure` (same name, severity error, security severity 5.4) and `py/stack-trace-exposure` ("Information exposure through an exception", severity error, security severity 5.4). All three carry both `external/cwe/cwe-209` and `external/cwe/cwe-497` — alongside a plain `security` tag, so the array holds three entries and the two this recipe joins on are not the whole of it — and all three are listed in their language's `*-code-scanning.qls` suite as well as the extended and quality suites — which is the fact that entitles this recipe to be run against a default-setup repository at all rather than only against one that opted into `security-extended`.
Two properties of `tags` decide how the clause below is written, and both are inherited from the sibling input-validation recipe rather than rediscovered. It is documented "array of string or null", and a null array is the case a `contains()` filter cannot state an opinion about, so a separate guard counts the rows that have no array at all. And the alerts endpoint has NO server-side tag filter — the organization-scoped form's documented filters are `tool_name`, `tool_guid`, `state`, `severity`, `sort`, `direction` and `assignees`, with `ref` available on the repository form only — so the CWE narrowing is client-side over a FULL paginated walk, and a collector that fetches one page of thirty and filters it has produced a smaller answer rather than a filtered one.
SI-11 has two limbs and this plane can see one of them. Error messages must provide the information necessary for corrective actions without revealing information that could be exploited, and they must be revealed only to defined personnel. The first is code, and a query that traces an exception to a response is real telemetry about it. The second is a runtime access decision — who can read the response, who can read the log the trace landed in — and no code scan observes it. This recipe is rated `partial` for the first limb alone and claims nothing about the second, which is what the register's disposition for this control said before it was authored and remains true after.
WHETHER A PARTICULAR MESSAGE REVEALS EXPLOITABLE INFORMATION IS THE JUDGEMENT NOBODY AUTOMATES, AND THE QUERY DOES NOT PRETEND OTHERWISE. `js/stack-trace-exposure` finds a stack trace reaching an HTTP response; whether that trace names an internal host, a query fragment and a framework version, or says only that something failed, is a human reading of the finding. The clause below is offender form over the alerts those queries raised, so a green result is "the queries found no such path" — a narrower sentence than the control's, and the gap between them is the rating.
WHY THE CWE TAGS AND NOT THE QUERY NAMES. `rule.id` is stable per query and per language, so a clause naming `js/stack-trace-exposure` would be green on a Java service by construction. The tag array is the language-independent join: the three query help pages fetched for this recipe — JavaScript, Java and Python — each carry `external/cwe/cwe-209` and `external/cwe/cwe-497`, so one clause covers those three without naming a query per language. It covers those three and not the whole enum, which the paragraph below states as a residue rather than leaving it to be inferred from the word "regardless". CWE-497 is included beside CWE-209 because the queries themselves carry both and dropping it would narrow the clause below what the ruleset actually asserts.
THE RULESET-COVERAGE RESIDUE IS THE SAME ONE THE SIBLING RECIPE DOCUMENTED AND IT IS A PROPERTY OF THE API. The clause an assessment wants is "a query for CWE-209 was in this run", and it is not writable: `rule.tags` exists only on alerts that exist, so the tags are visible exactly when something was found and invisible in the case where the claim matters, and `rules_count` counts rules without naming them. What partially closes it here and did not close it there is the query-suite membership: the three pages fetched list these queries in `javascript-code-scanning.qls`, `java-code-scanning.qls` and `python-code-scanning.qls`, which is the DEFAULT suite, so a default-setup repository in one of those languages ran them. THREE OF THE NINE LANGUAGES DEFAULT SETUP ACCEPTS, AND SIX THAT WERE NOT CHECKED. The documented `languages` enum is `actions`, `c-cpp`, `csharp`, `go`, `java-kotlin`, `javascript-typescript`, `python`, `ruby` and `swift`, and the suite-membership fact above was established for JavaScript, Java and Python only. On a c-cpp, csharp, go, ruby, swift or actions repository the run-record clauses below pass and the CWE clause is green with nothing verified about whether a CWE-209 query was in that language's default suite at all. Whether equivalent tagged queries exist there was not fetched, is not claimed, and is the first thing to close if this recipe is extended. That is a published fact about the suite rather than an inference from the response, it is cited, and it is why `query_suite` is collected and not asserted — requiring `extended` would be authoring a preference as a control, and the queries this recipe reads are in the default suite anyway.
THE SEVEN-DAY FRESHNESS CLAUSE TAKES ITS NUMBER FROM THE PLATFORM AND NOT FROM THE CONTROL. Seven is what `schedule: weekly` means, asserted a clause earlier so that a gap reads as a missed scan rather than as a repository nobody pushed to; GitHub documents the weekly schedule switching itself off after six months without a push or pull request, which is how a quiet boundary repository stays `configured`, keeps a real analysis history and is no longer analysed. On an actively developed repository the gap closes by itself because default setup also scans on pushes and pull requests.
THE DEFAULT-BRANCH LIMIT APPLIES AS IT DOES ACROSS THIS PLATFORM. Alert status reflects the default branch, the organization-scoped alerts endpoint takes no `ref` filter, and the analyses command pins `ref` for that reason. This is a default-branch statement about a repository, not a repository-wide one.
TWO OF THE THREE INDICATORS THAT REACH THIS CONTROL ARE DELIBERATELY NOT CLAIMED. KSI-MLA-ALA — authorizing log access — is SI-11's second limb almost exactly, and it is the limb this plane cannot see at all; crediting a code scan with it would be the clearest possible case of dressing a document up as a command. KSI-PIY-RSD is declined for the reason the input-validation recipe already gave when it declined the same indicator: a scan record is an input to a review of the SDLC's effectiveness rather than the review itself. KSI-CNA-MAT is claimed because a stack trace reaching a response is attack surface in the indicator's own terms — it hands an attacker internal paths, versions and structure, which is reconnaissance for exactly the lateral movement the indicator asks to be minimized — and because a recurring analysis over a named repository set is the persistent review it asks for.
PIY — Policy and Inventory
5 recipes · 2 of 8 controls in scope reached
The machine-maintained component inventory — Config's recorder status and discovered-resource counts proving supported resources are tracked continuously and the list stays current without anyone editing a spreadsheet, plus Systems Manager Inventory's node and installed-application metadata for what runs inside them
partial · cli · every daily · /collect/config-asset-inventory
$ aws configservice describe-configuration-recorder-status --query 'ConfigurationRecordersStatus[].{Name:name,Recording:recording,LastStatus:lastStatus,LastStart:lastStartTime,Error:lastErrorMessage}'$ aws configservice get-discovered-resource-counts
$ aws configservice select-resource-config --expression "SELECT resourceId, resourceType, awsRegion WHERE resourceType = 'AWS::EC2::Instance'"
$ aws ssm get-inventory --aggregators Expression=AWS:InstanceInformation.PlatformType
$ aws ssm list-inventory-entries --instance-id i-0123456789abcdef0 --type-name AWS:Application
Proves: CM-08
Expected output: A recorder status with recording true and lastStatus SUCCESS — read this first, because a stopped or failing recorder makes everything below stale — then a resourceCounts array giving a count per resourceType alongside totalDiscoveredResources, a Results list naming each recorded resource of the type you queried, an aggregation of managed nodes grouped by platform, and an Entries list of installed applications stamped with the CaptureTime they were collected.
GovCloud: AWS Config and Systems Manager Inventory are available in AWS GovCloud (US-East) and (US-West); resource and node ARNs use partition arn:aws-us-gov
This proves the inventory is machine-maintained and current (CM-08.01, and the automated-currency half of CM-02.02) — it does not prove the inventory is complete. Config sees only supported resource types, only in the regions and accounts where a recorder runs, and only within the recording group you configured; unsupported types, an un-recorded region, on-premises hosts, SaaS components and in-container software are invisible here and need their own source. SSM Inventory covers only managed nodes with a running agent and an inventory association, collects no more often than every 30 minutes, and the console's Inventory cards hide stopped and terminated nodes even though the API still returns them. The accountability attributes CM-08 asks for — system owner, function, criticality — live in your tags or CMDB, not in a resource count, so join them before calling this an inventory. Substitute your real instance id. Detecting unauthorized components (CM-08.03) is a different question; the prohibited-software half is in the least-functionality recipe.
A Region-by-Region inventory of the resources that can hold information, the classification tags you asserted on them, and — where Macie exists — a sampled machine judgement about which S3 buckets actually contain sensitive data
partial · cli · every monthly · /collect/information-location-and-classification
$ aws configservice select-aggregate-resource-config --configuration-aggregator-name <ORG_AGGREGATOR> --expression "SELECT awsRegion, resourceType, COUNT(*) WHERE resourceType IN ('AWS::S3::Bucket', 'AWS::RDS::DBInstance', 'AWS::DynamoDB::Table', 'AWS::EFS::FileSystem') GROUP BY awsRegion, resourceType"$ aws resourcegroupstaggingapi get-resources --tag-filters Key=DataClassification --region us-gov-west-1
$ aws macie2 get-automated-discovery-configuration
$ aws macie2 describe-buckets --query 'buckets[].{bucket:bucketName,region:region,score:sensitivityScore,monitored:automatedDiscoveryMonitoringStatus,lastAnalyzed:lastAutomatedDiscoveryTime,unclassifiable:unclassifiableObjectCount.total}'Expected output: A per-Region, per-type count of information-bearing resources across every account in the aggregator — aggregation queries page at 500 rows by default and plain SELECTs at 25, so page or raise --max-results before treating a result set as the whole estate. Then a ResourceTagMappingList of ARNs carrying your DataClassification key, one Region per call. Then Macie's status ENABLED or DISABLED with firstEnabledAt, lastUpdatedAt, classificationScopeId and sensitivityInspectionTemplateId; and per bucket a sensitivityScore — documented as -1 for a classification error, 1 for an empty bucket, 50 for a bucket excluded from recent analyses, up to 100 for sensitive — alongside automatedDiscoveryMonitoringStatus MONITORED or NOT_MONITORED, lastAutomatedDiscoveryTime and the unclassifiable object count
GovCloud: Amazon Macie is not available in AWS GovCloud (US): the AWS General Reference lists no macie2 endpoint for us-gov-east-1 or us-gov-west-1, and the macie-status-check Config rule is excluded from both GovCloud Regions. In GovCloud the last two commands have nothing to call and CM-12(1)'s automated identification by information type needs another tool. The first two do work — AWS Config in both Regions, and the Resource Groups Tagging API at tagging.us-gov-east-1.amazonaws.com and tagging.us-gov-west-1.amazonaws.com — but Config in GovCloud does not record third-party or custom resource types, so anything you model that way is invisible to the aggregate query. ARNs use partition arn:aws-us-gov
Three different qualities of evidence are stacked here, and conflating them is the trap. The Config aggregate query is solid on where storage lives — resource type by Region, across accounts — and that is the part of CM-12 most often undocumented. The tag query is only as true as your tagging: GetResources by design never returns untagged resources, so an unclassified bucket is absent from the answer rather than flagged, which is precisely backwards for an inventory control; run it beside the aggregate count and treat the difference as your unclassified population. Macie is the only machine-derived opinion about information type, and it is a sample rather than a census — automated sensitive data discovery continually selects representative objects from your buckets and scores each bucket from those, so a MONITORED bucket with a low score means ‘nothing sensitive in what was sampled’, never ‘no sensitive data here’. The unclassifiable object count is the population Macie could not read at all because of storage class or file format, and per-file size quotas mean a large archive can be skipped entirely, so read coverage before reading scores. Macie also only looks at S3: nothing above inspects an RDS table, an EFS volume, a DynamoDB item or a Parameter Store value, and the Region field tells you where a bucket is, which is the CM-12 question, not what is in it. What no command produces is CM-12 itself — the documented location of each information type, the users authorized to access it and the purpose it is held for. That is a record you write and then check against this telemetry, not one you derive from it.
The rule requiring the designated owners of the changed code to approve before it merges, the file that names who those owners are, the platform's own report of whether that file actually parses — and, per change, who approved, on which commit, and when.
partial · api · every continuous · /collect/security-representative-change-approval
$ gh api --paginate "/orgs/<ORG>/repos?per_page=100"
$ gh api "/repos/<ORG>/<REPO>/rules/branches/<DEFAULT_BRANCH>"
$ gh api "/repos/<ORG>/<REPO>/codeowners/errors?ref=<DEFAULT_BRANCH>"
$ gh api -H "Accept: application/vnd.github.raw" "/repos/<ORG>/<REPO>/contents/.github/CODEOWNERS?ref=<DEFAULT_BRANCH>"
$ gh api --paginate "/repos/<ORG>/<REPO>/pulls?state=closed&base=<DEFAULT_BRANCH>&per_page=100"
$ gh api --paginate "/repos/<ORG>/<REPO>/pulls/<PULL_NUMBER>/reviews?per_page=100"
Expected output: Report names used below, and the command each comes from: `repos` is the first command, `branch-rules` the second, `codeowners-errors` the third, `codeowners-file` the fourth, `pulls-closed` the fifth, `reviews` the sixth.
RUN COMMANDS 2 THROUGH 5 ONCE PER REPOSITORY AND ASSESSED BRANCH, and command 6 once per pull request selected from the fifth. The fourth and fifth commands are a SAMPLE AND WALK for a human reader, not a population any clause below reads — the closed pull request list is unbounded and no assertion here is written over it.
From `branch-rules`, the rule objects in effect on the branch, of which this recipe reads the `pull_request` type and its parameters `required_approving_review_count`, `dismiss_stale_reviews_on_push`, `require_code_owner_review`, `require_last_push_approval`, `required_review_thread_resolution` and `allowed_merge_methods`.
From `codeowners-errors`, an object with an `errors` array, each error carrying `line`, `column`, `kind`, `source`, `suggestion`, `message` and `path`. The `ref` parameter is "A branch, tag or commit name used to determine which version of the CODEOWNERS file to use" and DEFAULTS TO THE REPOSITORY'S DEFAULT BRANCH — the command passes it explicitly, because a recipe assessing any other branch would otherwise be checking a different file from the one in force there.
From `codeowners-file`, the file itself. GitHub documents three legal locations — "the .github/, root, or docs/ directory of the repository" — so a 404 from the path above is not an absence, it is the wrong one of the three; try `CODEOWNERS` and `docs/CODEOWNERS` before concluding anything.
From `pulls-closed`, the closed pull requests targeting the branch, and from `reviews`, the review records: `id`, `user`, `body`, a `state` — the page documents it as a required string and enumerates nothing, so `APPROVED`, `COMMENTED`, `DISMISSED` and `PENDING` are the values to expect rather than a closed set, and no clause here depends on the enum being complete, `html_url`, `submitted_at`, `commit_id` and an `author_association` of `COLLABORATOR`, `CONTRIBUTOR`, `FIRST_TIMER`, `FIRST_TIME_CONTRIBUTOR`, `MANNEQUIN`, `MEMBER`, `NONE` or `OWNER`. `commit_id` is the field that makes a review an answer rather than a timestamp: it says WHICH revision was approved.
CM-03 (04) requires an information security representative to be a MEMBER of the configuration change control element. This is the closest this plane comes to a membership claim it can check: a rule requiring the designated owners of the changed code to approve, a file naming who those owners are, and per-change approval records carrying identities, revisions and timestamps. What none of it establishes is that those identities ARE the security and privacy representatives the control names. That mapping lives in the change control charter, and a repository team called `security` is an assertion about a name. The gap is an identity mapping rather than a missing signal, which is exactly what the disposition said, and no amount of further collection on this platform closes it.
THE SKIPPED LINE — this recipe's vacuity trap, and unusually it is documented by the vendor rather than inferred from an API shape. GitHub states plainly that "If any line in your CODEOWNERS file contains invalid syntax, that line will be skipped." So a `pull_request` rule with `require_code_owner_review` set to true, sitting over a CODEOWNERS file whose one line covering the infrastructure directory has a typo, requires review from the code owners of that directory — of which there are now none. The rule is green, the platform requests no reviewer, and the change merges on whatever approvals its author could find. Nothing in the rules API can see this, and nothing in a screenshot of the branch protection settings can either. `GET /repos/{owner}/{repo}/codeowners/errors` is the only endpoint in this recipe that sees it, and it is the entire reason the second clause exists.
A MISSING FILE AND A CLEAN FILE ARE NOT DISTINGUISHED BY THE CLAUSE, AND THE THIRD COMMAND IS WHY. Nothing fetched documents what the errors endpoint returns when there is no CODEOWNERS file at all, so an empty `errors` array is the passing shape for a valid file and the plausible shape for an absent one — the plane's standing emptiness problem, arriving through a door that looks like a validator. The third command reads the file itself for exactly this reason: it is collected so a human can confirm there IS a file and see who it names. No clause is written over its contents, because who ought to own which path is the identity mapping this recipe cannot make, and a clause counting lines would be a clause asserting that a file is long.
WHY THE STALE-APPROVAL CLAUSE IS HERE AND A LAST-PUSH ONE IS NOT. `dismiss_stale_reviews_on_push` is what stops an approval from outliving the change it approved: without it, a code owner approves, the author pushes again, and the approval carries over to code no owner has seen — a membership failure wearing the appearance of a membership success, and the one failure mode in this recipe that leaves a complete and convincing paper trail. `require_last_push_approval`, GitHub's option to "require an approval from someone other than the last person to push to a branch", is deliberately NOT asserted: it is a separation-of-duty property rather than a membership one, a small team can be operating correctly without it, and CM-03 (04) asks who sits on the element rather than whether two distinct people acted.
WHAT A REVIEW RECORD PROVES AND WHERE IT STOPS. A review carries `state`, `user`, `submitted_at`, `commit_id` and `author_association`, so the record says a named platform identity approved a named revision at a named time — and `commit_id` is what makes it an approval of something rather than a timestamp. It does not say that the identity belongs to a person, that the person holds the role, or that the approval was informed. `author_association` is the closest thing to a role anywhere in the response and it is a repository-relationship enum — `MEMBER`, `COLLABORATOR`, `OWNER` — describing a relationship to the repository rather than a position in the organization, so no clause is written over it. The fourth and fifth commands are collected as a sample for a human to read against the charter, and no assertion reads them; a clause over an unbounded list of closed pull requests would be a clause about how many were sampled.
NO `enforcement` CLAUSE APPEARS IN THIS RECIPE, and after the audit of this batch that is the correct answer for a better reason than the one first written. Every clause here except the second reads `/rules/branches/{branch}`, which GitHub documents as returning "all active rules that apply to the specified branch" and from which "Rules in rulesets with \"evaluate\" or \"disabled\" enforcement statuses are not returned" — so a dry-run ruleset cannot satisfy the first or third clause, and no guard against it is needed. The second clause reads a file parser that no ruleset governs at all: a CODEOWNERS file with a syntax error is broken whether the ruleset requiring code-owner review is active, evaluating or absent. An assessment wanting the dry-run finding itself gets it from `developer-change-control-and-integrity` on the same repository, where it belongs, being a property of the repository rather than of this control.
KSI-CMT-RVP — the effectiveness of documented change management procedures is persistently reviewed — is claimed on the MECHANISM limb. A required approval per change, with the approver and the revision recorded, is the material a review of procedure effectiveness reads, and the rule is the documented procedure in executable form. Whether the procedure is EFFECTIVE is a judgement, and no API in this recipe makes it. KSI-PIY-RSD — the effectiveness of building security and privacy considerations into the Software Development Lifecycle and aligning with CISA Secure By Design principles is persistently reviewed — is claimed narrowly and should be read narrowly: requiring a security owner's approval at merge is one security consideration built into one point of the lifecycle. The indicator is about the whole lifecycle and about alignment with an external body of principles that nothing here reads.
The evidence platform is itself an external system, and on this control the dependency runs through the identity provider as well; see `external_system`.
Which policy decisions were actually enforced against the infrastructure definitions the boundary deploys from: that a policy scan ran, on which branch, how many rules it applied and when it last ran, together with the failures still open and the record of which were dismissed and with what justification. The load-bearing half is the scan record rather than the findings. SA-08 asks whether security engineering principles were applied, and a clean findings list is the same output whether every principle held or the scan applied no rules, ran last quarter, or parsed nothing — so the count of rules run and the date it ran are the part of this evidence that makes the rest of it mean anything.
partial · cli · every continuous · /collect/infrastructure-policy-scan-coverage-and-deviations
$ checkov --version
$ checkov -d <IAC_ROOT> --framework terraform --output sarif --output-file-path <OUT_DIR>
$ checkov -d <IAC_ROOT> --framework terraform --output json --output-file-path <OUT_DIR>
$ gh api --paginate "/orgs/<ORG>/repos?per_page=100"
$ gh api --paginate "/repos/<ORG>/<REPO>/code-scanning/analyses?tool_name=<IAC_TOOL_NAME>&ref=refs/heads/<DEFAULT_BRANCH>&sort=created&direction=desc&per_page=100"
$ gh api --paginate "/repos/<ORG>/<REPO>/code-scanning/alerts?state=open&tool_name=<IAC_TOOL_NAME>&ref=refs/heads/<DEFAULT_BRANCH>&per_page=100"
$ gh api --paginate "/repos/<ORG>/<REPO>/code-scanning/alerts?state=dismissed&tool_name=<IAC_TOOL_NAME>&ref=refs/heads/<DEFAULT_BRANCH>&per_page=100"
Proves: SA-08
Expected output: Report names used below, and the command each comes from: `version` is the first, `sarif` the second, `scan-json` the third, `repos` the fourth, `analyses` the fifth, `alerts-open` the sixth and `alerts-dismissed` the seventh.
EVERY REPORT EXCEPT `repos` IS ONE REPOSITORY'S. This recipe is run per repository and the reports are read together, which is a correction rather than a preference: an earlier form of it read the ORGANIZATION alert endpoints beside the repository analyses endpoint, so the existence guard below held for one repository while three offender counts ranged over forty. A guard and the counts it guards have to read the same population or the guard is decoration. `repos` stays organization-scoped because it is the enumeration — it is the list this recipe is run ONCE PER ENTRY OF, and the reconciliation between that list and the repositories that produced an analysis is the coverage step `scan_scope` names.
`<IAC_TOOL_NAME>` IS NOT A CONSTANT AND MUST NOT BE GUESSED. The value the two alert commands and the analyses command filter on is the SARIF `tool.driver.name` the scanner emitted, which is a property of the scanner's output and is not documented on any page cited here. Read it once from an UNFILTERED analyses list for the repository — the same call with `tool_name` dropped — and use what is there. A filter naming a tool that never uploaded returns an empty list, and every offender count below is then green for the most banal possible reason.
From `analyses`, GitHub documents `ref`, `commit_sha`, `analysis_key`, `environment`, `category`, `error`, `created_at`, `results_count`, `rules_count`, `id`, `url`, `sarif_id`, `tool` with `name`, `version` and `guid`, `deletable` and `warning`. Two of those carry documented caveats this recipe is written around. `rules_count` is "the number of rules that were run in the analysis", and the page adds that "For very old analyses this data is not available, and 0 is returned in this field". And the endpoint carries a closing-down notice for the `tool_name` field in its RESPONSE; the request-side filter is a separate parameter and the durable selector for a set of results is `tool.guid` or the analysis `category`, so treat the tool-name filter as a spelling to re-verify at collection time rather than as a stable key. `sort` is documented with a default of `created`, and the fifth command requests the ordering explicitly rather than relying on it.
From `alerts-open` and `alerts-dismissed`, `number`, `created_at`, `updated_at`, `state` (documented in the RESPONSE as `open | dismissed | fixed | null`; `closed` is a value the request filter accepts and is not a state an alert comes back in, and the two enums are worth keeping apart for the same reason a console label and a rule identifier are), `fixed_at`, `dismissed_by`, `dismissed_at`, `dismissed_reason` (documented required, string or null, `false positive | won't fix | used in tests | mitigated | null` — FIVE values, and `mitigated` is the one an earlier reading of this page missed), `dismissed_comment` (documented string or null, maximum 280 characters), `rule` carrying `id`, `name`, `severity`, `security_severity_level` (`low | medium | high | critical`), `description`, `full_description`, `tags`, `help` and `help_uri`, `tool` with `name`, `version` and `guid`, `most_recent_instance` and `repository`. `severity` and `security_severity_level` are DIFFERENT fields on the same rule — the first is the SARIF level the scanner emitted, the second is the security scale GitHub populates from the SARIF `security-severity` property — and a third-party SARIF that omits that property leaves the second unset. A clause narrowing on it would then read a population that excludes exactly those alerts, which is why the unset case is asserted separately below rather than assumed away.
`sarif` is the artifact the scan uploads and is not read directly by any clause here; the upload path is the `upload-sarif` action or the code scanning SARIF endpoint, and GitHub documents that it calculates `partialFingerprints` when the file omits them and that each uploaded file needs a unique `runAutomationDetails.id` so its results are a distinguishable set. `scan-json` and `version` are collected for the human step and are deliberately unasserted — see `notes`.
WHY PARTIAL, STATED AGAINST THE CONTROL'S OWN LIMBS. SA-08 requires the principles to be applied in specification, design, development, implementation and modification. Implementation is the limb a pipeline can see — a rule that fails a world-readable bucket or an over-broad role is telemetry about layered protection or least privilege actually holding in the artifact being deployed — and it is one limb of five. Which principles were selected, and how they were applied in specification and design, is an SDLC document; a passing rule evidences A principle without naming the one that was chosen. The dataset in this repo carries no FedRAMP-specific parameter or guidance for SA-08 at all, so there is no organization-defined value here to read a selection out of, which is the same absence SA-22 has and it lands in the same place: the selection is the provider's to write down.
THE INLINE SUPPRESSION BLIND SPOT, AND IT IS THE LARGEST ONE HERE. Checkov's documented suppression syntax is a comment of the form `checkov:skip=<check_id>:<suppression_comment>`, and the documentation is explicit that the comment is OPTIONAL. A check suppressed that way is not a finding: it produces no SARIF result, so it never becomes an alert, so no clause in this recipe can see it, and the suppression can carry no stated reason at all. The dismissal clause below reads dismissals made in the PLATFORM, which is a different act by a different person leaving a different record. A boundary can therefore hold a green alert list and a repository full of skips, and the two facts are collected by different commands here on purpose.
WHY `scan-json` AND `version` ARE COLLECTED AND NOT ASSERTED. The third command is how a reader enumerates the skipped checks the paragraph above describes, along with parse errors and the resource count — the scan that parsed nothing is the vacuity case that reaches the alert list looking identical to the clean one. No clause reads it, because Checkov's JSON schema is not documented on any page cited here, and this repo does not assert over field names it has not verified. That is a limit of the citation and it is written down rather than papered over with a plausible path; a batch that fetches a schema for it can add the clauses and should.
`--soft-fail` IS THE OTHER WAY THIS GOES GREEN. Checkov documents `--soft-fail` as "Runs checks but always returns a 0 exit code". A workflow can run the scan, upload the SARIF, populate every report this recipe reads, and never block a merge. Nothing in the analyses or alerts output says whether the scan gates anything — that question is a ruleset question and it belongs to the sibling recipes on CM-04 (02) and SA-10, which read required status checks and rule suites directly. Read this recipe as evidence that the policy scan RUNS and what it FINDS, never as evidence that a failing policy stops a deployment.
WHAT THE DISMISSAL CLAUSE ESTABLISHES. `dismissed_reason` is documented required, string or null, with five values: `false positive`, `won't fix`, `used in tests`, `mitigated` and null. An earlier reading of that page recorded three of them and built this paragraph on `won't fix` alone, which was the wrong shape twice over — the enum was short, and `mitigated` is a direct competitor for the role the paragraph was giving `won't fix`. Both are records that a policy failure was closed by a decision rather than by a fix, and they say different things: `won't fix` is a principle deliberately not satisfied in a named place, `mitigated` is a claim that something else covers it, and THAT claim is a compensating-control argument no field here carries the text of. Between them they are the closest thing this plane holds to a recorded deviation, and the clause below asserts of both only that a justification was typed. The clause asserts that a justification was TYPED, not that it was a good one, and a dismissal with a written reason is unambiguously better evidence than one without — which is all it claims.
KSI-PIY-RSD is the only indicator that reaches this control. Its statement is about the effectiveness of building security into the SDLC being PERSISTENTLY REVIEWED, and the review is a human act on a schedule; what this recipe supplies is the material that review reads and the evidence that the material is current.
For a service that ships code to a browser, the two things a pipeline can say about the mobile code it delivers: what was allowed INTO it, and whether what shipped is what this pipeline built. The first is the dependency diff for the change — every component added, its ecosystem, its version, its licence and any advisory against it, separated by whether it reaches the runtime or stops at the build — and the gate that makes the check mandatory rather than advisory. The second is a provenance attestation over the built bundle, verified against the repository and workflow that are supposed to have produced it. Neither is a statement about which mobile code technologies the organization decided to permit, and that is the control's first limb.
partial · api · every on-change · /collect/mobile-code-admission-and-bundle-provenance
$ gh api --paginate "/orgs/<ORG>/repos?per_page=100"
$ gh api "/repos/<ORG>/<REPO>/rules/branches/<DEFAULT_BRANCH>"
$ gh api "/repos/<ORG>/<REPO>/dependency-graph/compare/<BASE_SHA>...<HEAD_SHA>"
$ gh attestation verify <BUNDLE_PATH> --repo <ORG>/<REPO> --signer-workflow <ORG>/<REPO>/.github/workflows/<BUILD_WORKFLOW> --format json
Expected output: Report names used below, and the command each comes from: `repos` is the first, `branch-rules` the second, `dependency-review` the third and `verify-results` the fourth.
From `branch-rules`, GitHub documents the endpoint as returning "all active rules that apply to the specified branch" — the effective set, which is what this recipe needs, because a rule can arrive from a repository ruleset or from an organization one and a reader asking whether the branch is gated does not care which. For the `required_status_checks` rule type the documented parameters are a required `required_status_checks` array of "Status checks that are required", each entry carrying a required `context` — "The status check context name that must be present on the commit" — and an optional `integration_id`, "The optional integration ID that this status check must originate from"; a required boolean `strict_required_status_checks_policy`, "Whether pull requests targeting a matching branch must be tested with the latest code" — whose documentation continues "This setting will not take effect unless at least one status check is enabled", which is why the clause asserting it is not the first clause in this recipe and why the existence clause that precedes it is not redundant with it; and an optional `do_not_enforce_on_create`.
From `dependency-review`, the endpoint is `GET /repos/{owner}/{repo}/dependency-graph/compare/{basehead}` and `basehead` is documented as expecting the form `{base}...{head}`. Each returned change carries `change_type`, documented `added | removed`; required `manifest`, `ecosystem`, `name` and `version`; `package_url`, `license` and `source_repository_url`, each documented string or null; a required `vulnerabilities` array whose entries carry `severity`, `advisory_ghsa_id`, `advisory_summary` and `advisory_url`; and `scope`, documented `unknown | runtime | development`.
From `verify-results`, the CLI documents `gh attestation verify [<file-path> | oci://<image-uri>] [--owner | --repo]`, so the same command verifies a built file and not only a registry image — which is the form this recipe needs, because a browser bundle is a file. With `--format json` it emits an array with one entry per verified attestation, each carrying `attestation` and a `verificationResult` holding the parsed bundle: `signature.certificate`, `verifiedTimestamps` and `statement`. `--signer-workflow` is documented as enforcing that the signing workflow matches `[host/]<owner>/<repo>/<path>/<to>/<workflow>`, and it is the flag that carries this clause's weight; `--predicate-type` defaults to `https://slsa.dev/provenance/v1`.
WHY PARTIAL, AGAINST THE CONTROL'S TWO LIMBS. SC-18 has a definition limb — establishing which mobile code technologies are acceptable — and an enforcement limb: authorizing, monitoring and controlling their use, with the discussion contemplating mobile code digitally signed by a trusted source. This plane reaches the second limb and cannot reach the first. A gate enforces an acceptability list and does not establish one, and no output collected here names a single decision about a technology. That is the same shape the sibling recipes on CM-03 (04) and SA-22 carry, and it is why this is `partial` rather than `full` even though two of its clauses are as mechanical as any in this overlay.
DEPENDENCY REVIEW ONLY SEES PULL REQUESTS THAT TOUCH A MANIFEST. GitHub documents the feature for "pull requests that contain changes to package manifests or lock files". A change that alters the delivered mobile code without touching one — a vendored script edited in place, an inline handler added to a template, a CDN URL swapped in a page, a build configuration change that pulls a different chunk — produces a diff with no entries, and both dependency clauses below are then vacuously green over a change that did exactly what this control is about. The clauses are written in offender form so an empty result is empty rather than false, and this note is the honest reading of what an empty result means.
WHAT `scope=='runtime'` DOES AND DOES NOT DELIMIT. The scope value is the ecosystem's own classification of a dependency as production or development, and for a browser bundle it is the closest available proxy for "ends up in the code the user's browser executes". It is a proxy: the mapping from a runtime-scoped package to bytes actually emitted belongs to the bundler, not the platform, and tree-shaking, lazy chunks and server-only imports all break it. Where the scope IS populated it breaks in the safe direction — more components are claimed as delivered than are delivered. Where it is not, it broke the other way and this recipe shipped the clause wrong until the audit: a component the ecosystem cannot classify resolves to `unknown`, falls outside a clause narrowed to `runtime`, and takes its advisories out of the count with it. That is now asserted separately rather than described, because a residue that hides findings is not a residue, and the sentence you are reading replaced one that claimed the failure direction was safe in both cases.
WHY THE ATTESTATION CLAUSE IS THIN AND THE COMMAND IS NOT. The clause below is an existence check over a parsed statement, and nearly all of this evidence's weight is carried by the flags on the command that produced it: `--repo` scopes the attestation lookup and `--signer-workflow` is documented as enforcing that the signing workflow matches a named path. A reader who drops those flags gets a verification that proves an attestation exists somewhere for the bytes, which is a much weaker claim than the one this recipe describes. The sibling recipe `build-provenance-attestation-verification` reads the same machinery at depth on SI-07 (07), including the version-specific reason a clause has to read the parsed statement rather than trust the exit code, and this recipe deliberately does not repeat it.
WHAT AN ATTESTATION CANNOT SAY, IN THE PLATFORM'S OWN WORDS. GitHub's page states that "artifact attestations are not a guarantee that an artifact is secure. Instead, artifact attestations link you to the source code and the build instructions that produced them", and that defining and evaluating the policy is the consumer's job. For SC-18 that is the right size of claim and is worth stating as a limit rather than as a caveat: the enforcement limb asks that mobile code be signed by a trusted source, and this establishes that the delivered bundle came from a named repository and workflow. It says nothing whatsoever about what the code in it does.
TWO INDICATORS REACH THIS CONTROL AND THEY ARE CLAIMED FOR DIFFERENT HALVES. KSI-SCR-MIT — persistently identify, review and mitigate supply chain risks — is claimed for the admission half, where a component with a known advisory or an undeterminable licence is identified before it enters the delivered artifact. KSI-PIY-RSD, whose statement is about the effectiveness of building security into the SDLC being persistently reviewed, is claimed for the gate itself: a required check and a signed build are the SDLC's shape, and their persistent review is the human act neither clause performs.