diff --git a/lambdas/library-ingest/app.py b/lambdas/library-ingest/app.py index a5765be..80b3e6d 100644 --- a/lambdas/library-ingest/app.py +++ b/lambdas/library-ingest/app.py @@ -32,16 +32,26 @@ bedrock_agent = boto3.client("bedrock-agent") secrets_client = boto3.client("secretsmanager") _cached_api_key: str | None = None +_cached_api_key_ts: float = 0.0 +_API_KEY_TTL_SECONDS = 300 # Fix: LAM-M9 — re-fetch every 5 minutes def _get_api_key() -> str: - global _cached_api_key - if _cached_api_key is None: - if INTERNAL_API_KEY_SECRET_ARN: - resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN) - _cached_api_key = resp["SecretString"] - else: - _cached_api_key = "" + """Fetch internal API key from Secrets Manager with a 5-minute TTL cache. + + Fix: LAM-M9 — the key was previously cached indefinitely. Now the cached + value expires after _API_KEY_TTL_SECONDS so rotated secrets take effect. + """ + global _cached_api_key, _cached_api_key_ts + now = time.monotonic() + if _cached_api_key is not None and (now - _cached_api_key_ts) < _API_KEY_TTL_SECONDS: + return _cached_api_key + if INTERNAL_API_KEY_SECRET_ARN: + resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN) + _cached_api_key = resp["SecretString"] + else: + _cached_api_key = "" + _cached_api_key_ts = now return _cached_api_key diff --git a/lambdas/pdf-extract/app.py b/lambdas/pdf-extract/app.py index abdef81..75c0325 100644 --- a/lambdas/pdf-extract/app.py +++ b/lambdas/pdf-extract/app.py @@ -33,16 +33,29 @@ bedrock_runtime = boto3.client("bedrock-runtime") secrets_client = boto3.client("secretsmanager") _cached_api_key: str | None = None +_cached_api_key_ts: float = 0.0 +_API_KEY_TTL_SECONDS = 300 # Fix: LAM-M9 — re-fetch every 5 minutes + +# Fix: LAM-M3 — maximum PDF file size (50 MB) +_MAX_PDF_SIZE_BYTES = 50 * 1024 * 1024 def _get_api_key() -> str: - global _cached_api_key - if _cached_api_key is None: - if INTERNAL_API_KEY_SECRET_ARN: - resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN) - _cached_api_key = resp["SecretString"] - else: - _cached_api_key = "" + """Fetch internal API key from Secrets Manager with a 5-minute TTL cache. + + Fix: LAM-M9 — the key was previously cached indefinitely. Now the cached + value expires after _API_KEY_TTL_SECONDS so rotated secrets take effect. + """ + global _cached_api_key, _cached_api_key_ts + now = time.monotonic() + if _cached_api_key is not None and (now - _cached_api_key_ts) < _API_KEY_TTL_SECONDS: + return _cached_api_key + if INTERNAL_API_KEY_SECRET_ARN: + resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN) + _cached_api_key = resp["SecretString"] + else: + _cached_api_key = "" + _cached_api_key_ts = now return _cached_api_key @@ -139,6 +152,22 @@ def process_pdf(proposal_id: str, s3_key: str, vendor_proposal_id: str): def download_pdf(s3_key: str) -> str: + # Fix: LAM-M3 — check file size before downloading to avoid processing + # excessively large PDFs that could exhaust Lambda memory/tmp storage. + head = s3.head_object(Bucket=UPLOADS_BUCKET, Key=s3_key) + file_size = head.get("ContentLength", 0) + if file_size > _MAX_PDF_SIZE_BYTES: + logger.warning( + "Fix: LAM-M3 — PDF too large: key=%s size=%d bytes (max=%d)", + s3_key, + file_size, + _MAX_PDF_SIZE_BYTES, + ) + raise ValueError( + f"PDF file size ({file_size} bytes) exceeds maximum " + f"allowed size ({_MAX_PDF_SIZE_BYTES} bytes) for key: {s3_key}" + ) + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") s3.download_file(UPLOADS_BUCKET, s3_key, tmp.name) tmp.close() diff --git a/lambdas/pdf-generate/app.py b/lambdas/pdf-generate/app.py index a3ab318..9292adf 100644 --- a/lambdas/pdf-generate/app.py +++ b/lambdas/pdf-generate/app.py @@ -38,6 +38,8 @@ s3 = boto3.client("s3") secrets_client = boto3.client("secretsmanager") _cached_api_key: str | None = None +_cached_api_key_ts: float = 0.0 +_API_KEY_TTL_SECONDS = 300 # Fix: LAM-M9 — re-fetch every 5 minutes # Fix: LAM-M8 — pattern for allowed S3 key characters _SAFE_S3_KEY_RE = re.compile(r"^[a-zA-Z0-9\-_./\s]+$") @@ -59,13 +61,21 @@ TERMS_AND_CONDITIONS = """ def _get_api_key() -> str: - global _cached_api_key - if _cached_api_key is None: - if INTERNAL_API_KEY_SECRET_ARN: - resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN) - _cached_api_key = resp["SecretString"] - else: - _cached_api_key = "" + """Fetch internal API key from Secrets Manager with a 5-minute TTL cache. + + Fix: LAM-M9 — the key was previously cached indefinitely. Now the cached + value expires after _API_KEY_TTL_SECONDS so rotated secrets take effect. + """ + global _cached_api_key, _cached_api_key_ts + now = time.monotonic() + if _cached_api_key is not None and (now - _cached_api_key_ts) < _API_KEY_TTL_SECONDS: + return _cached_api_key + if INTERNAL_API_KEY_SECRET_ARN: + resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN) + _cached_api_key = resp["SecretString"] + else: + _cached_api_key = "" + _cached_api_key_ts = now return _cached_api_key diff --git a/lambdas/suggestions/app.py b/lambdas/suggestions/app.py index 547768b..1800886 100644 --- a/lambdas/suggestions/app.py +++ b/lambdas/suggestions/app.py @@ -6,7 +6,9 @@ to generate line item suggestions for new proposals. import json import logging +import math import os +import re import time import boto3 @@ -25,19 +27,129 @@ bedrock_runtime = boto3.client("bedrock-runtime") secrets_client = boto3.client("secretsmanager") _cached_api_key: str | None = None +_cached_api_key_ts: float = 0.0 +_API_KEY_TTL_SECONDS = 300 # Fix: LAM-M9 — re-fetch every 5 minutes def _get_api_key() -> str: - global _cached_api_key - if _cached_api_key is None: - if INTERNAL_API_KEY_SECRET_ARN: - resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN) - _cached_api_key = resp["SecretString"] - else: - _cached_api_key = "" + """Fetch internal API key from Secrets Manager with a 5-minute TTL cache. + + Fix: LAM-M9 — the key was previously cached indefinitely. Now the cached + value expires after _API_KEY_TTL_SECONDS so rotated secrets take effect. + """ + global _cached_api_key, _cached_api_key_ts + now = time.monotonic() + if _cached_api_key is not None and (now - _cached_api_key_ts) < _API_KEY_TTL_SECONDS: + return _cached_api_key + if INTERNAL_API_KEY_SECRET_ARN: + resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN) + _cached_api_key = resp["SecretString"] + else: + _cached_api_key = "" + _cached_api_key_ts = now return _cached_api_key +# --------------------------------------------------------------------------- +# Fix: LAM-M2 — Prompt injection mitigation +# --------------------------------------------------------------------------- +# Blocklist patterns that commonly appear in prompt injection attempts. +# This is a simple defense-in-depth measure, not a full NLP solution. +_INJECTION_PATTERNS = [ + re.compile(r"ignore\s+(all\s+)?previous\s+instructions", re.IGNORECASE), + re.compile(r"ignore\s+(all\s+)?above\s+instructions", re.IGNORECASE), + re.compile(r"disregard\s+(all\s+)?previous", re.IGNORECASE), + re.compile(r"override\s+(all\s+)?instructions", re.IGNORECASE), + re.compile(r"you\s+are\s+now\s+(a|an)\s+", re.IGNORECASE), + re.compile(r"new\s+instructions?\s*:", re.IGNORECASE), + re.compile(r"system\s*:", re.IGNORECASE), + re.compile(r"<\|?\s*(system|im_start|endoftext)\s*\|?>", re.IGNORECASE), + re.compile(r"\[INST\]", re.IGNORECASE), + re.compile(r"```\s*(system|instruction)", re.IGNORECASE), +] + +# Delimiter sequences that could be used to break out of the user-text section +_INJECTION_DELIMITERS = ["```", "---\n", "===\n", "***\n"] + + +def sanitize_user_text(text: str) -> str: + """Strip common prompt injection patterns from user-supplied text. + + Fix: LAM-M2 — basic prompt injection mitigation before including user + text in Bedrock prompts. Applies a blocklist of known injection patterns + and neutralises delimiter sequences. + """ + if not text: + return text + + sanitized = text + + # Remove blocklisted patterns + for pattern in _INJECTION_PATTERNS: + sanitized = pattern.sub("[removed]", sanitized) + + # Neutralise delimiter sequences by replacing them with a safe alternative + for delim in _INJECTION_DELIMITERS: + sanitized = sanitized.replace(delim, " ") + + return sanitized + + +# --------------------------------------------------------------------------- +# Fix: LAM-M6 — Numeric validation for suggestion amounts +# --------------------------------------------------------------------------- +_MAX_LINE_ITEM_VALUE = 10_000_000 # $10M ceiling for any single value + + +def validate_line_item_numerics(items: list[dict]) -> list[dict]: + """Validate and filter line items with unreasonable numeric values. + + Fix: LAM-M6 — after getting suggestions from Bedrock, reject items with + negative values, NaN, or values exceeding the $10M ceiling. + """ + validated = [] + for item in items: + rejected = False + for field in ("quantity", "unitPrice", "totalPrice"): + value = item.get(field) + if value is None: + continue + try: + num = float(value) + except (ValueError, TypeError): + logger.warning( + "Fix: LAM-M6 — rejected line item: %s is not a valid number (%r) " + "in item %r", + field, value, item.get("description", "unknown"), + ) + rejected = True + break + if math.isnan(num) or math.isinf(num): + logger.warning( + "Fix: LAM-M6 — rejected line item: %s is NaN/Inf in item %r", + field, item.get("description", "unknown"), + ) + rejected = True + break + if num < 0: + logger.warning( + "Fix: LAM-M6 — rejected line item: %s is negative (%.2f) in item %r", + field, num, item.get("description", "unknown"), + ) + rejected = True + break + if num > _MAX_LINE_ITEM_VALUE: + logger.warning( + "Fix: LAM-M6 — rejected line item: %s exceeds max (%.2f > %d) in item %r", + field, num, _MAX_LINE_ITEM_VALUE, item.get("description", "unknown"), + ) + rejected = True + break + if not rejected: + validated.append(item) + return validated + + def handler(event, context): batch_item_failures = [] @@ -99,7 +211,7 @@ def process_suggestion(proposal_id: str, trigger: str): logger.info("Proposal %s is in status %s, skipping suggestions", proposal_id, status) return - similar_proposals = retrieve_similar(scope, category) + similar_proposals = retrieve_similar(sanitize_user_text(scope), category) suggested_items = generate_line_items(scope, category, priority, similar_proposals) @@ -204,6 +316,11 @@ def generate_line_items( priority: str, similar_proposals: list[dict], ) -> list[dict]: + # Fix: LAM-M2 — sanitize user-supplied text before including in prompt + safe_scope = sanitize_user_text(scope) + safe_category = sanitize_user_text(category) + safe_priority = sanitize_user_text(priority) + context_block = "" if similar_proposals: context_block = "Here are similar historical proposals and their line items for reference:\n\n" @@ -217,11 +334,11 @@ def generate_line_items( Based on the scope of work and similar historical proposals, generate a detailed list of line items with quantities, units, and estimated pricing. -Service Category: {category} -Priority: {priority} +Service Category: {safe_category} +Priority: {safe_priority} Scope of Work: -{scope} +{safe_scope} {context_block} @@ -259,7 +376,10 @@ Respond ONLY with the JSON array, no additional text.""" content = content.rsplit("```", 1)[0] line_items = json.loads(content) - return line_items if isinstance(line_items, list) else [] + if not isinstance(line_items, list): + return [] + # Fix: LAM-M6 — validate numeric fields before returning suggestions + return validate_line_item_numerics(line_items) except Exception as e: # Fix: LAM-M5 — include stack trace in error logging