制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗The LoopProof connector. LoopProof turns your AWS Security Hub findings into Linear tickets; this module gives it the access it needs in your account, and nothing more:
Connecting a third-party service to Security Hub usually means a hand-made role with broad Security Hub permissions, a long-lived ExternalId in a tfvars file, and an ARN pasted into a web form. This module:
AssumeRole, and without a change to your Terraform;loopproof-<environment>-connector, assumable only by the control plane's
loopproof-<environment>-* roles presenting one of one or two ExternalIdssecurityhub:GetFindings and securityhub:BatchUpdateFindings, the latter confined
to the status and the commentecr:ListTagsForResource on your account's repositories, and config:SelectAggregateResourceConfig
on your account's Config aggregators in the module's Region, such as AWS Control Tower's, which returns any
configuration they hold, not only tagsexcluded_accounts never leave your account, and the list is
published in SSM for LoopProof to readthreat_accept_rules and vulnerability_accept_rules and changed by
pull request in your repository, each published in SSM for LoopProof to read and never to writeevents:PutEvents on LoopProof's buscurl, retried on connection errors and 5xx answers onlyLoopProof's onboarding shows you the ExternalId and a registration token, once, and generates the Terraform for you, in two parts that go in the same root. It looks like this.
Part 1, the values. Two SSM SecureString parameters in the account and Region where you install the connector,
created with the placeholder unset. The placeholder is write-only (value_wo), so Terraform never stores or
compares the value, and never writes it again: you fill the parameters yourself, below. Write-only arguments need
Terraform 1.11.
resource "aws_ssm_parameter" "loopproof_external_ids" {
name = "/loopproof/production/external-ids"
description = "ExternalIds that LoopProof presents to the connector role, as a JSON list."
type = "SecureString"
value_wo = "unset"
value_wo_version = 1
}
resource "aws_ssm_parameter" "loopproof_registration_token" {
name = "/loopproof/production/registration-token"
description = "Registration token from LoopProof's onboarding."
type = "SecureString"
value_wo = "unset"
value_wo_version = 1
}
Part 2, the connector. The module takes the two parameters' ARNs, never their values, and reads them itself with the AWS provider's credentials:
module "loopproof_connector" {
source = "registry.infrahouse.com/infrahouse/loopproof-connector/aws"
version = "1.3.0"
environment = "production"
control_plane_account_id = "123456789012"
external_ids_parameter_arn = aws_ssm_parameter.loopproof_external_ids.arn
registration_token_parameter_arn = aws_ssm_parameter.loopproof_registration_token.arn
event_bus_arn = "arn:aws:events:us-west-1:123456789012:event-bus/loopproof-production-findings"
registration_url = "https://api.loopproof.ai/connector/registrations"
}
If you manage parameters another way, skip Part 1 and pass the ARNs of your own SecureString parameters as strings.
terraform apply. It creates the two parameters. The module waits for them, reads the placeholder, and
stops with an error that names the parameter to fill. Nothing else is created yet.terraform apply again. It creates the connector and registers the role with LoopProof.terraform apply -target on the two parameters, then a plain apply, does the same without the first error.
Each command asks for the value without echoing it, and hands it to the AWS CLI on its standard input, so it is on no command line and in no shell history. Use credentials and a Region for the account where you install the connector, and the parameter names from Part 1.
The ExternalIds, as a JSON list:
printf 'ExternalId: ' >&2; stty -echo; read -r value; stty echo; printf '\n' >&2
printf '["%s"]' "$value" | aws ssm put-parameter --overwrite --type SecureString \
--name /loopproof/production/external-ids --value file:///dev/stdin; unset value
The registration token:
printf 'Registration token: ' >&2; stty -echo; read -r value; stty echo; printf '\n' >&2
printf '%s' "$value" | aws ssm put-parameter --overwrite --type SecureString \
--name /loopproof/production/registration-token --value file:///dev/stdin; unset value
If Part 1 gives the parameters a customer-managed KMS key (key_id), add --key-id with that key to both
commands: without it, SSM re-encrypts the value with the default key aws/ssm.
Install it in the account where Security Hub collects your findings, in Security Hub's home Region:
There, Security Hub's event feed includes the findings of the member accounts and of the linked Regions.
Besides managing IAM roles, EventBridge rules and SSM parameters:
ssm:GetParameter on /loopproof/<environment>/*: the module reads both parameters on every plan and apply;kms:Decrypt on the key, if Part 1 gives the parameters a customer-managed key instead of the default
aws/ssm, whose key policy already allows it to the account's principals;curl 7.76.0 or later on the machine that runs Terraform, Linux or macOS, with HTTPS access to
registration_url. Windows isn't supported.The fill commands need ssm:PutParameter, and with a customer-managed key kms:Encrypt and kms:GenerateDataKey.
LoopProof generates the ExternalId and the token, and treats both as credentials; so does the module:
(sensitive value);curl through its
environment and standard input, never a command line;The ExternalIds are in the state, and so in any saved plan file (terraform plan -out), in the module's read and
the trust policy, and readable in the role by anyone in your account allowed iam:GetRole. That is unavoidable, and
inside your account; protect saved plans, such as CI artifacts, as you protect the state.
At TF_LOG=DEBUG the AWS provider logs the responses of its AWS calls but an ephemeral read's, so a debug log of
this root holds both values: refreshing Part 1's parameters reads them decrypted, and the module's read of the
ExternalIds and the role's trust policy show the ExternalIds. Treat such a log as a secret.
terraform destroy deletes the parameters with their values, which is right for an uninstall; know it before you
destroy the root for another reason.
["<old>","<new>"], and apply. LoopProof's calls keep working with the old
one.["<new>"], and apply.Your Terraform doesn't change: each apply reads the parameter and updates the trust policy in place. IAM applies a trust policy change gradually. For a few minutes after step 1, some STS requests still see the old policy and refuse the new value, while the old value keeps working throughout. That's why step 2 waits, and why the old value stays listed until LoopProof has switched.
On the apply that creates the connector role, the module POSTs the role's ARN and the Region it's applied in to
registration_url, once, with curl, and Authorization: Bearer <token> from the token parameter. Later plans and
applies make no call.
LoopProof reads your Security Hub findings in that Region, the AWS provider's Region where you apply the module: Security Hub's home Region, if you install it where it belongs. An IAM role ARN names no Region, so LoopProof can't learn it from the ARN.
A refused call (HTTP 4xx, such as an expired token) fails the apply. The error shows LoopProof's answer and says to get a new registration token in LoopProof and put it in the token parameter. 4xx answers aren't retried.
Where a new token comes from depends on whether LoopProof has proven your account, a later onboarding step in which it assumes the role. The module can't tell, so the error names both places:
200 and changes nothing.A new token replaces the one before it, which stops working at once, the one in your parameter included.
The paste. The error also gives the role's ARN and the Region. Before LoopProof has proven your account, you can paste them into LoopProof's onboarding instead. After, LoopProof takes no paste.
Once the new token is in the parameter, apply again. The failed registration is tainted, so the plain apply registers by itself.
Connection errors and 5xx answers are retried, four attempts in all, before the apply fails the same way.
The call runs again when the role is replaced, and with terraform apply -replace on
module.<name>.terraform_data.registration. LoopProof answers 200 for a role it already has.
While the call runs, Terraform shows its output as (output suppressed due to ephemeral value in config), because
the token is in its environment; when it fails, the error shows everything it printed. It never prints the token.
Leave the registration token in its parameter after the connector registers, expired or not. It is what registers
the role again when the role is replaced, or with terraform apply -replace: LoopProof answers 200 to its tenant's
token for the role it already has, whatever the token's expiry, until a new one replaces it. A parameter put back to
unset, or holding another value, makes the next of those registrations fail on every apply, until it holds a token
from LoopProof again: before LoopProof has proven your account, a new one from LoopProof's onboarding; after, one an
admin of your tenant mints on LoopProof's Settings page.
A new token is unused, and LoopProof refuses an unused token once its 24 hours have passed, so register with it as
soon as it is in the parameter: terraform apply -replace=module.<name>.terraform_data.registration, which LoopProof
answers with 200 and which changes nothing, or a plain terraform apply if the registration is tainted. Once it has
registered, it keeps working for the role until a newer one replaces it.
The ARN is only a hint: LoopProof proves the account later by assuming the role.
The registration needs HTTPS from the machine that runs Terraform to registration_url. Without it, the apply
creates everything else, then fails at the registration with curl's error, the role's ARN and the Region. Before
LoopProof has proven your account, paste those into LoopProof's onboarding, which registers the role without a
token. The registration stays tainted, so each later apply tries again and fails the same way, until one runs from a
machine that can reach LoopProof.
Once LoopProof has proven your account, it takes no paste: the registration needs a registration token and a machine
that reaches LoopProof. Replacing the role, or terraform apply -replace on the registration, from a machine that
can't reach LoopProof, fails at the registration, and so does every later apply from it, until one runs from a
machine that can. LoopProof keeps using the role meanwhile, since a replaced role keeps its ARN. See
Troubleshooting.
To keep an account out of LoopProof, such as an account outside your audit scope or a decommissioned one, list it in
excluded_accounts:
module "loopproof_connector" {
# ...
excluded_accounts = ["111122223333"]
}
cloud.account.uid), so they
never leave your account.String parameter
/loopproof/<environment>/excluded-accounts, in the module's Region; the excluded_accounts_parameter output
names it. The parameter exists, holding [], when you exclude nothing. LoopProof reads it with the connector role
when it proves your account, and daily after, to keep those accounts out of its scope. In SSM, the role may read
that parameter and the accept rules, and nothing else: not the ExternalIds' or the token's.securityhub:GetFindings to some accounts, so the connector role can still read an excluded
account's findings through GetFindingsV2. The list is how LoopProof knows to leave them out.A risk you accept, such as port probes on a VPN endpoint that is public by design, is an accept rule: a threat in
threat_accept_rules, a vulnerability in vulnerability_accept_rules. LoopProof suppresses the findings a rule
matches, citing the rule, its version and its register entry, instead of filing them as work. The rule lives in your
Terraform, so you add, change or remove it by pull request in your repository, with your own review. LoopProof reads
it and never writes it.
module "loopproof_connector" {
# ...
threat_accept_rules = [
{
id = "vpn-port-probes"
finding_types = ["Recon:EC2/PortProbeUnprotectedPort"]
where = {
tags = { role = "vpn" }
accounts = ["111122223333"] # narrows, never alone
}
reason = "VPN endpoints are public by design"
review_by = "2027-03-31"
decision_ref = "SEC-42" # its register entry
},
]
vulnerability_accept_rules = [
{
id = "openssl-unreachable"
cves = ["CVE-2024-12797"]
where = {
image_repositories = ["batch-worker"]
}
reason = "The worker never opens a TLS connection"
review_by = "2027-01-15"
decision_ref = "SEC-43"
},
]
}
A rule has:
id: 1 to 64 lowercase letters, digits or hyphens, starting with a letter or digit, and no other rule's, in
either input;reason, and review_by, a date (YYYY-MM-DD), after which the rule stops applying unless a change renews it;decision_ref: its register entry's Linear issue identifier, such as SEC-42;finding_types; for a vulnerability, cves, or a source package's name and version;where, at least one of resource_ids, tags and, for a vulnerability,
image_repositories. accounts narrows a rule further but never stands alone: an account alone would accept
everything in it;behavioral_exception: false unless set; see below.The input a rule is in gives its kind, which LoopProof reads as threat_response or vuln_remediation; a rule
doesn't write it.
How it works:
String parameter,
/loopproof/<environment>/accept-rules/<id>, in the module's Region; the accept_rules_parameter_path output
names the path. With no rule, there is no parameter. The connector role may read the parameters under that path,
and nothing else new.acounts, publishes without
it. So does a field of the other kind, such as image_repositories in a threat rule.LoopProof judges what a rule means when it loads it, and refuses, as a load error it shows in its Settings:
Backdoor, PrivilegeEscalation or CredentialAccess, or whose family is ReverseShell or
InstanceCredentialExfiltration;behavioral_exception = true, the only kind that accepts a denylisted threat,
unless it names one finding type and one resource by its id, with no tags or accounts, and a review_by at most 30
days after LoopProof first loads the rule.A change that fails to load leaves the rule's last good version in force; remove the rule from your Terraform to stop it.
Where a finding's resource carries no tags, LoopProof takes them from a related resource: an ECR image's from its repository, which neither Inspector's nor Security Hub's finding carries. The connector role reads them one of two ways, and needs no input for either:
In its own account. The role may ecr:ListTagsForResource on every repository of the account you install it
in, in every Region, and nothing else of ECR. That's all a single account needs.
From AWS Control Tower's Config aggregator, for an organization with Control Tower, whose findings come from
other accounts. The role may config:SelectAggregateResourceConfig on every Config aggregator of the account you
install it in, in the module's Region. In the Audit account that includes Control Tower's,
aws-controltower-GuardrailsComplianceAggregator, which LoopProof queries by its name. Control Tower keeps it in
Control Tower's home Region; if that isn't the Region you apply the module in, the role can't reach it.
IAM names an aggregator by an ARN that ends in an ID AWS Config generates, not its name, so the grant is
config-aggregator/*: it reaches any other aggregator of that account and Region too, such as one you made
yourself, and matches nothing where there is none. A query can read whatever an aggregator holds: the
configuration of every resource it covers, not only their tags, and the excluded accounts' too. IAM can't limit a
query to some fields or accounts; as with the findings, the excluded accounts' list is how LoopProof knows to leave
them out.
Where neither read reaches the related resource, LoopProof marks the tags as not derived; it never holds a ticket back for them.
Until LoopProof has proven your account (a later onboarding step, in which it assumes the role), its bus refuses
your events. EventBridge doesn't retry a permission error; without a dead-letter queue, it drops the event and
counts it in the rule's FailedInvocations metric. Nothing is lost: once the account is proven, LoopProof imports
your findings' history through the connector role. You don't need to debug those failures.
Change the module's version and apply. The plan adds the connector role's policy accept-rules-parameters, its read
of the accept rules, which is there with no rule, and changes the role's module_version tag in place. Until you set
threat_accept_rules or vulnerability_accept_rules it publishes nothing; it replaces nothing, and the role doesn't
register again. See Upgrading.
Change the module's version and apply. The plan adds the connector role's policies ecr-repository-tags and
config-aggregator-query, and changes the role's description, which names them, and its module_version tag, in
place; it replaces nothing, and the role doesn't register again. See Upgrading.
This release takes the two parameters' ARNs in place of external_ids and registration_token, and drops the
registered output and the hashicorp/http provider. An installed tenant upgrades with the registration token it
installed with. See
Upgrading.
Full documentation is at infrahouse.github.io/terraform-aws-loopproof-connector:
>= 1.10 for the module, and >= 1.11 for Part 1's write-only placeholder>= 6.0curl 7.76.0 or later, on Linux or macOS, with HTTPS access to LoopProof's API from the machine that runs
Terraform| Name | Version |
|---|---|
| terraform | >= 1.10 |
| aws | >= 6.0 |
| Name | Version |
|---|---|
| aws | >= 6.0 |
| terraform | n/a |
No modules.
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
| control_plane_account_id | ID of LoopProof's control-plane AWS account for this environment. Twelve digits. | string | n/a | yes |
| environment | LoopProof's environment, such as staging or production. It names the connector role(loopproof--connector) and selects the control-plane roles allowed to assume it(loopproof--*). | string | n/a | yes |
| event_bus_arn | ARN of LoopProof's control-plane EventBridge bus that receives the forwarded findings. | string | n/a | yes |
| excluded_accounts | IDs of the AWS accounts you exclude from LoopProof, such as an account outside your audit scope or adecommissioned one. The forwarding rule drops their findings, so they never leave this account, and themodule publishes the list in the SSM parameter /loopproof//excluded-accounts, where LoopProofreads it. A change takes effect on the next apply. | list(string) | [] | no |
| external_ids_parameter_arn | ARN of the SSM SecureString parameter that holds the ExternalIds LoopProof presents when it assumes theconnector role, as a JSON list: one value, or two during a rotation. Pass the ARN of the parameter yourroot creates, aws_ssm_parameter..arn, never the value. The module reads it with the AWSprovider's credentials. Until you fill it, it holds the placeholder "unset", and the apply stops there. |
| Name | Description |
|---|---|
| accept_rules_parameter_path | Path of the SSM parameters that publish accept_rules to LoopProof, /loopproof//accept-rules: oneString parameter per rule, named by its id. |
| event_rule_arn | ARN of the EventBridge rule that forwards Security Hub findings to LoopProof. |
| excluded_accounts_parameter | Name of the SSM parameter that publishes excluded_accounts to LoopProof, /loopproof//excluded-accounts. |
| region | Region the module is applied in, where LoopProof reads the Security Hub findings. Before LoopProof has provenyour account, paste it into LoopProof's onboarding with role_arn if the registration call can't succeed. |
| role_arn | ARN of the connector role. Before LoopProof has proven your account, paste it into LoopProof's onboarding, withregion, if the registration call can't succeed, such as from a machine that can't reach LoopProof. After,LoopProof takes no paste. |
| role_name | Name of the connector role, loopproof--connector. |
See examples/ for a working configuration.
See CONTRIBUTING.md.
| resource |
| aws_ssm_parameter.excluded_accounts | resource |
| terraform_data.registration | resource |
| aws_caller_identity.current | data source |
| aws_iam_policy_document.connector_accept_rules | data source |
| aws_iam_policy_document.connector_config_aggregator | data source |
| aws_iam_policy_document.connector_excluded_accounts | data source |
| aws_iam_policy_document.connector_permissions | data source |
| aws_iam_policy_document.connector_repository_tags | data source |
| aws_iam_policy_document.connector_trust | data source |
| aws_iam_policy_document.forwarder_permissions | data source |
| aws_iam_policy_document.forwarder_trust | data source |
| aws_partition.current | data source |
| aws_region.current | data source |
| aws_ssm_parameter.external_ids | data source |
string |
| n/a |
| yes |
| registration_token_parameter_arn | ARN of the SSM SecureString parameter that holds the registration token (lprt_...) from LoopProof'sonboarding. Pass the ARN of the parameter your root creates, aws_ssm_parameter..arn, never thevalue. The module reads it as an ephemeral value, so the token is in neither the plan nor the state, anduses it once: on the apply that creates the connector role, it reports the role's ARN and the Region toregistration_url. | string | n/a | yes |
| registration_url | LoopProof's connector registration endpoint, such ashttps://api.staging.loopproof.ai/connector/registrations. The module POSTs the connectorrole's ARN and the Region it is applied in there. It must use https://;http:// is accepted only for 127.0.0.1 and localhost, for tests. | string | n/a | yes |
| severities | Severities of the Security Hub findings to forward, as they appear in the OCSF severity field:Unknown, Informational, Low, Medium, High, Critical, Fatal or Other. GuardDuty's findings areforwarded at every severity, whatever this lists. | list(string) | [ "High", "Critical", "Fatal"] | no |
| threat_accept_rules | The threats you accept, GuardDuty's findings, each rule with its register entry in Linear. The modulepublishes each rule as JSON, with the kind threat_response, in the SSM parameter/loopproof//accept-rules/, where LoopProof reads it with the connector role, and checkseach rule's shape at plan. LoopProof judges what a rule means when it loads it: the behavioral denylist, anexception rule's limits, and the register entry. See "Accepting risks" in the README. | list(object({ id = string reason = optional(string) review_by = optional(string) decision_ref = optional(string) # What the rule accepts: GuardDuty's finding types. finding_types = optional(list(string)) # Where: at least one of resource_ids and tags; accounts only narrows them. where = optional(object({ resource_ids = optional(list(string)) tags = optional(map(string)) accounts = optional(list(string)) })) behavioral_exception = optional(bool, false) })) | [] | no |
| vulnerability_accept_rules | The vulnerabilities you accept, each rule with its register entry in Linear. The module publishes each ruleas JSON, with the kind vuln_remediation, in the SSM parameter /loopproof//accept-rules/,where LoopProof reads it with the connector role, and checks each rule's shape at plan. LoopProof judgeswhat a rule means when it loads it, and checks its register entry. An id must not be a threat rule's too.See "Accepting risks" in the README. | list(object({ id = string reason = optional(string) review_by = optional(string) decision_ref = optional(string) # What the rule accepts: cves, or a source package. cves = optional(list(string)) package = optional(object({ name = optional(string) version = optional(string) })) # Where: at least one of resource_ids, tags and image_repositories; accounts only narrows them. where = optional(object({ resource_ids = optional(list(string)) tags = optional(map(string)) image_repositories = optional(list(string)) accounts = optional(list(string)) })) })) | [] | no |