mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-10-04 21:51:57 +00:00
fix(webhook): kms:ViaService pins, https-only delivery, cross-account principal CI pin
GPT-4.1 cross-family review of the policy surface (no BLOCK): FIX applied to the cross-account shoc-backend-dev Decrypt statement and both Lambda role KMS grants (the key is only ever used via Secrets Manager); its invariant-enforcement QUESTION answered durably with tests/test_cross_account_principal_pin.py (any new foreign IAM principal in cdk/ fails CI). Scanner mediums fixed: delivery.py and the replay script now refuse non-https URLs (urllib follows file:// and http://). SQS metadata-action and dynamodb:ListStreams NITs skipped: standard CDK grant shapes; ListStreams has no resource-level scoping. The 4 gitleaks HIGHs on docs/shoc-webhook-test-vectors.json are deliberate non-secrets (shared receiver-verification vectors) suppressed machine-level with justification.
This commit is contained in:
parent
eb56d39b93
commit
2f2fc83a82
4 changed files with 109 additions and 2 deletions
|
|
@ -477,11 +477,19 @@ def _add_shoc_webhook_emitter(stack, work_orders_table, comments_table, alarm_to
|
||||||
# policy below is the other half; either one alone fails silently at
|
# policy below is the other half; either one alone fails silently at
|
||||||
# the receiver). resources=["*"] is key-scoped, not account-wide --
|
# the receiver). resources=["*"] is key-scoped, not account-wide --
|
||||||
# KMS key policies only ever apply to this key.
|
# KMS key policies only ever apply to this key.
|
||||||
|
# kms:ViaService pins the grant to Secrets Manager decrypt paths only
|
||||||
|
# (GPT-4.1 cross-review FIX): a compromised shoc-backend-dev cannot use
|
||||||
|
# this key for arbitrary KMS operations outside the secret fetch.
|
||||||
shoc_webhook_key.add_to_resource_policy(
|
shoc_webhook_key.add_to_resource_policy(
|
||||||
iam.PolicyStatement(
|
iam.PolicyStatement(
|
||||||
actions=["kms:Decrypt"],
|
actions=["kms:Decrypt"],
|
||||||
principals=[shoc_consumer_principal],
|
principals=[shoc_consumer_principal],
|
||||||
resources=["*"],
|
resources=["*"],
|
||||||
|
conditions={
|
||||||
|
"StringEquals": {
|
||||||
|
"kms:ViaService": (f"secretsmanager.{stack.region}.amazonaws.com")
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -577,7 +585,25 @@ def _add_shoc_webhook_emitter(stack, work_orders_table, comments_table, alarm_to
|
||||||
resources=[shoc_hmac_secret.secret_arn],
|
resources=[shoc_hmac_secret.secret_arn],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
shoc_webhook_key.grant_encrypt_decrypt(shoc_hmac_rotator)
|
# Explicit statement instead of grant_encrypt_decrypt so the grant can
|
||||||
|
# carry kms:ViaService (cross-review FIX): the rotator only ever touches
|
||||||
|
# this key through Secrets Manager put/get, never the KMS API directly.
|
||||||
|
shoc_hmac_rotator.add_to_role_policy(
|
||||||
|
iam.PolicyStatement(
|
||||||
|
actions=[
|
||||||
|
"kms:Decrypt",
|
||||||
|
"kms:Encrypt",
|
||||||
|
"kms:GenerateDataKey*",
|
||||||
|
"kms:ReEncrypt*",
|
||||||
|
],
|
||||||
|
resources=[shoc_webhook_key.key_arn],
|
||||||
|
conditions={
|
||||||
|
"StringEquals": {
|
||||||
|
"kms:ViaService": (f"secretsmanager.{stack.region}.amazonaws.com")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
shoc_hmac_secret.add_rotation_schedule(
|
shoc_hmac_secret.add_rotation_schedule(
|
||||||
"Rotation",
|
"Rotation",
|
||||||
rotation_lambda=shoc_hmac_rotator,
|
rotation_lambda=shoc_hmac_rotator,
|
||||||
|
|
@ -669,7 +695,20 @@ def _add_shoc_webhook_emitter(stack, work_orders_table, comments_table, alarm_to
|
||||||
work_orders_table.grant_stream_read(shoc_emitter)
|
work_orders_table.grant_stream_read(shoc_emitter)
|
||||||
comments_table.grant_stream_read(shoc_emitter)
|
comments_table.grant_stream_read(shoc_emitter)
|
||||||
shoc_hmac_secret.grant_read(shoc_emitter)
|
shoc_hmac_secret.grant_read(shoc_emitter)
|
||||||
shoc_webhook_key.grant_decrypt(shoc_emitter)
|
# Explicit statement instead of grant_decrypt so the grant carries
|
||||||
|
# kms:ViaService (cross-review FIX): the emitter only decrypts this key
|
||||||
|
# through Secrets Manager GetSecretValue.
|
||||||
|
shoc_emitter.add_to_role_policy(
|
||||||
|
iam.PolicyStatement(
|
||||||
|
actions=["kms:Decrypt"],
|
||||||
|
resources=[shoc_webhook_key.key_arn],
|
||||||
|
conditions={
|
||||||
|
"StringEquals": {
|
||||||
|
"kms:ViaService": (f"secretsmanager.{stack.region}.amazonaws.com")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
shoc_emitter_rejected_queue.grant_send_messages(shoc_emitter)
|
shoc_emitter_rejected_queue.grant_send_messages(shoc_emitter)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,13 @@ def deliver(envelope: dict) -> tuple[str, int]:
|
||||||
Raises RetryableDeliveryError for 429/5xx/timeout/connection failures so
|
Raises RetryableDeliveryError for 429/5xx/timeout/connection failures so
|
||||||
the caller can block the shard (in-order retry, contract section 7).
|
the caller can block the shard (in-order retry, contract section 7).
|
||||||
"""
|
"""
|
||||||
|
if not SHOC_WEBHOOK_URL.startswith("https://"):
|
||||||
|
# Fail closed on any non-HTTPS scheme (file://, http://, ...): the
|
||||||
|
# URL is operator-set env config, but urllib would happily follow
|
||||||
|
# other schemes and the HMAC only protects an HTTPS transport.
|
||||||
|
raise RetryableDeliveryError(
|
||||||
|
"SHOC_WEBHOOK_URL is not an https:// URL; refusing to deliver"
|
||||||
|
)
|
||||||
raw_body = json.dumps(envelope).encode("utf-8")
|
raw_body = json.dumps(envelope).encode("utf-8")
|
||||||
timestamp = int(time.time())
|
timestamp = int(time.time())
|
||||||
signing_key = _get_hmac_keys()[0]
|
signing_key = _get_hmac_keys()[0]
|
||||||
|
|
|
||||||
|
|
@ -355,6 +355,10 @@ def main():
|
||||||
if bool(args.work_order_id) == bool(args.since):
|
if bool(args.work_order_id) == bool(args.since):
|
||||||
sys.exit("ERROR: pass exactly one of --work-order-id or --since.")
|
sys.exit("ERROR: pass exactly one of --work-order-id or --since.")
|
||||||
|
|
||||||
|
if not args.url.startswith("https://"):
|
||||||
|
# urllib follows file:// and http:// too; the feed is HTTPS-only.
|
||||||
|
sys.exit("ERROR: --url must be an https:// URL.")
|
||||||
|
|
||||||
session = boto3.Session(
|
session = boto3.Session(
|
||||||
profile_name=args.profile or "seahaven-prod", region_name=REGION
|
profile_name=args.profile or "seahaven-prod", region_name=REGION
|
||||||
)
|
)
|
||||||
|
|
|
||||||
57
tests/test_cross_account_principal_pin.py
Normal file
57
tests/test_cross_account_principal_pin.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""Pin the cross-account principal surface of the CDK app.
|
||||||
|
|
||||||
|
The SHOC integration deliberately trusts EXACTLY ONE foreign principal:
|
||||||
|
``arn:aws:iam::396287094661:role/shoc-backend-dev`` (read API resource
|
||||||
|
policy in procurement_api_stack.py, HMAC secret + KMS grants in
|
||||||
|
wo_stack.py). Future shoc-backend-staging/-prod roles are each a
|
||||||
|
deliberate, individually-reviewed policy addition — so any new foreign
|
||||||
|
account id or role ARN appearing in cdk/ must consciously update this
|
||||||
|
pin (and go through the mandatory GPT-4.1 cross-family IAM review).
|
||||||
|
|
||||||
|
Raised as a QUESTION in the 2026-07-24 cross-family review of the
|
||||||
|
webhook emitter policy surface: "how is the exact-one-principal
|
||||||
|
invariant enforced over time?" — this test is the answer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
CDK_DIR = REPO_ROOT / "cdk"
|
||||||
|
|
||||||
|
# The one foreign principal the app may reference, and the only files
|
||||||
|
# allowed to reference it.
|
||||||
|
ALLOWED_FOREIGN_PRINCIPAL = "arn:aws:iam::396287094661:role/shoc-backend-dev"
|
||||||
|
ALLOWED_FILES = {"procurement_api_stack.py", "wo_stack.py"}
|
||||||
|
|
||||||
|
# Accounts that are not "foreign": seahaven-prod (the deploy target).
|
||||||
|
HOME_ACCOUNTS = {"011934824531"}
|
||||||
|
|
||||||
|
_IAM_ARN_RE = re.compile(r"arn:aws:iam::(\d{12}):\S*?(?=[\"'\s])")
|
||||||
|
|
||||||
|
|
||||||
|
def _cdk_sources():
|
||||||
|
return sorted(CDK_DIR.glob("*.py"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_the_pinned_foreign_principal_appears_in_cdk_sources():
|
||||||
|
findings = []
|
||||||
|
for path in _cdk_sources():
|
||||||
|
for match in _IAM_ARN_RE.finditer(path.read_text()):
|
||||||
|
account = match.group(1)
|
||||||
|
if account in HOME_ACCOUNTS:
|
||||||
|
continue
|
||||||
|
findings.append((path.name, match.group(0)))
|
||||||
|
|
||||||
|
unexpected = [
|
||||||
|
(name, arn)
|
||||||
|
for name, arn in findings
|
||||||
|
if arn != ALLOWED_FOREIGN_PRINCIPAL or name not in ALLOWED_FILES
|
||||||
|
]
|
||||||
|
assert not unexpected, (
|
||||||
|
"Unexpected foreign IAM principal(s) in cdk/ — every cross-account "
|
||||||
|
f"trust addition must update this pin deliberately: {unexpected}"
|
||||||
|
)
|
||||||
|
# Both grant sites must still reference the pinned role (deleting one
|
||||||
|
# half of the secret/KMS grant pair fails silently at the receiver).
|
||||||
|
assert {name for name, _ in findings} == ALLOWED_FILES
|
||||||
Loading…
Add table
Reference in a new issue