Decoupling #265
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Lint & Static Analysis | |
| on: | |
| push: | |
| branches: [ main ] | |
| pull_request: | |
| branches: [ main ] | |
| jobs: | |
| # ── 0. Build configurator.html from src/ ────────────────────────────────── | |
| build-configurator: | |
| name: Build Configurator | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| - run: npm install && node scripts/build.js | |
| - uses: actions/upload-artifact@v4 | |
| with: | |
| name: configurator | |
| path: build/configurator.html | |
| # ── 1. CloudFormation lint ───────────────────────────────────────────────── | |
| cfn-lint: | |
| name: CloudFormation Lint | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install cfn-lint | |
| run: pip install cfn-lint | |
| - name: Lint CloudFormation template | |
| run: cfn-lint map2-auto-tagger-optimized.yaml | |
| # ── 1a. cfn-nag (anti-pattern scanner, advisory) ───────────────────────── | |
| # AWS's long-established scanner for common CFN anti-patterns (open security | |
| # groups, wildcard IAM outside of our documented tagging permissions, missing | |
| # log retention, etc). Runs as advisory — warnings printed, does NOT fail the | |
| # build. Promote to required once the backlog of suppressions is vetted. | |
| # Reference: https://w.amazon.com/bin/view/AWS/Teams/WWPS/Engineering/SecurityWorkstream/CDKCFNNag/ | |
| cfn-nag: | |
| name: cfn-nag (advisory) | |
| runs-on: ubuntu-latest | |
| continue-on-error: true | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install cfn_nag | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y ruby | |
| sudo gem install cfn-nag | |
| - name: Scan CloudFormation template | |
| run: cfn_nag_scan --input-path map2-auto-tagger-optimized.yaml || true | |
| # ── 1b. cfn-guard (policy-as-code, advisory) ───────────────────────────── | |
| # AWS-published declarative policy engine. Validates the CFN template against | |
| # the AWS Guard Rules Registry (security + well-architected). Advisory in | |
| # Layer 1 — findings are printed but do not block. Reference: | |
| # https://w.amazon.com/bin/view/LabContentSecurityHub/LCSR/Security_Scanners/CFN-Guard_Runbook/ | |
| cfn-guard: | |
| name: cfn-guard (advisory) | |
| runs-on: ubuntu-latest | |
| continue-on-error: true | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install cfn-guard | |
| run: | | |
| curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/aws-cloudformation/cloudformation-guard/main/install-guard.sh | sh | |
| echo "$HOME/.guard/bin" >> $GITHUB_PATH | |
| - name: Fetch AWS Guard Rules Registry | |
| run: | | |
| git clone --depth 1 https://github.com/aws-cloudformation/aws-guard-rules-registry.git /tmp/guard-rules | |
| - name: Validate template against rules | |
| run: | | |
| cfn-guard validate \ | |
| --rules /tmp/guard-rules/rules/aws \ | |
| --data map2-auto-tagger-optimized.yaml \ | |
| --show-summary all || true | |
| # ── 2. Lambda Python syntax check ───────────────────────────────────────── | |
| python-syntax: | |
| name: Lambda Python Syntax | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Extract and syntax-check Lambda code from YAML | |
| run: | | |
| python3 - <<'EOF' | |
| import re, sys, py_compile, tempfile, os | |
| with open('map2-auto-tagger-optimized.yaml') as f: | |
| content = f.read() | |
| # Find all ZipFile blocks — extract by tracking indentation boundary | |
| zipfile_positions = [m.start() for m in re.finditer(r'ZipFile: \|', content)] | |
| if not zipfile_positions: | |
| print("ERROR: No ZipFile blocks found in YAML") | |
| sys.exit(1) | |
| blocks = [] | |
| for pos in zipfile_positions: | |
| block_start = content.find('\n', pos) + 1 | |
| lines = content[block_start:].split('\n') | |
| code_lines = [] | |
| for line in lines: | |
| if line == '' or line.startswith(' ' * 10): | |
| code_lines.append(line[10:] if line.startswith(' ' * 10) else '') | |
| else: | |
| break | |
| blocks.append('\n'.join(code_lines)) | |
| errors = [] | |
| for i, block in enumerate(blocks): | |
| code = block | |
| with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: | |
| f.write(code) | |
| tmp = f.name | |
| try: | |
| py_compile.compile(tmp, doraise=True) | |
| print(f" ✅ Lambda block {i+1}: syntax OK ({len(code.splitlines())} lines)") | |
| except py_compile.PyCompileError as e: | |
| errors.append(f"Lambda block {i+1}: {e}") | |
| print(f" ❌ Lambda block {i+1}: {e}") | |
| finally: | |
| os.unlink(tmp) | |
| if errors: | |
| sys.exit(1) | |
| EOF | |
| # ── 2a. bandit Python security scan (advisory) ─────────────────────────── | |
| # Scans the inline Lambda code for common security issues (hardcoded secrets, | |
| # insecure deserialization, shell=True, etc). Advisory — current inline code | |
| # has known benign try/except/pass patterns that bandit flags as B110; these | |
| # are intentional fallbacks in scope-detection code paths. Upgrade to required | |
| # once suppressions are added with `# nosec` comments. | |
| bandit: | |
| name: bandit (advisory) | |
| runs-on: ubuntu-latest | |
| continue-on-error: true | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install bandit | |
| run: pip install bandit | |
| - name: Extract and scan inline Lambda code | |
| run: | | |
| python3 - <<'EOF' | |
| import re, sys, tempfile, subprocess, os | |
| with open('map2-auto-tagger-optimized.yaml') as f: | |
| content = f.read() | |
| zipfile_positions = [m.start() for m in re.finditer(r'ZipFile: \|', content)] | |
| if not zipfile_positions: | |
| print("No ZipFile blocks found — skipping bandit") | |
| sys.exit(0) | |
| # Extract each block into a temp file, then run bandit on all of them. | |
| extracted_files = [] | |
| for i, pos in enumerate(zipfile_positions): | |
| block_start = content.find('\n', pos) + 1 | |
| code_lines = [] | |
| for line in content[block_start:].split('\n'): | |
| if line == '' or line.startswith(' ' * 10): | |
| code_lines.append(line[10:] if line.startswith(' ' * 10) else '') | |
| else: | |
| break | |
| code = '\n'.join(code_lines) | |
| tmp = tempfile.NamedTemporaryFile( | |
| mode='w', suffix=f'_lambda_block_{i}.py', delete=False | |
| ) | |
| tmp.write(code) | |
| tmp.close() | |
| extracted_files.append(tmp.name) | |
| # Run bandit — `-ll` reports medium+ severity; `-ii` medium+ confidence. | |
| # Exit code 0 means no findings, 1 means findings. We print but don't | |
| # fail (continue-on-error in the workflow step). | |
| result = subprocess.run( | |
| ['bandit', '-ll', '-ii'] + extracted_files, | |
| capture_output=True, text=True | |
| ) | |
| print(result.stdout) | |
| if result.returncode != 0: | |
| print("bandit found issues (advisory — not blocking).") | |
| for f in extracted_files: | |
| os.unlink(f) | |
| EOF | |
| # ── 3. Handler regression check ─────────────────────────────────────────── | |
| handler-regression: | |
| name: Handler Regression Check | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Check for removed handlers without replacement | |
| run: | | |
| python3 - <<'EOF' | |
| import re, sys, subprocess | |
| # Get list of changed files in this PR vs main | |
| result = subprocess.run( | |
| ['git', 'diff', 'origin/main...HEAD', '--', 'map2-auto-tagger-optimized.yaml'], | |
| capture_output=True, text=True | |
| ) | |
| diff = result.stdout | |
| if not diff: | |
| print("No changes to map2-auto-tagger-optimized.yaml — skipping") | |
| sys.exit(0) | |
| # Extract removed lines (- prefix, not --- header) | |
| removed = [l[1:] for l in diff.split('\n') if l.startswith('-') and not l.startswith('---')] | |
| added = [l[1:] for l in diff.split('\n') if l.startswith('+') and not l.startswith('+++')] | |
| removed_text = '\n'.join(removed) | |
| added_text = '\n'.join(added) | |
| # Find event handler patterns removed: "elif event_name == 'Xyz'" | |
| removed_handlers = re.findall(r"elif event_name == '([^']+)'", removed_text) | |
| added_handlers = re.findall(r"elif event_name == '([^']+)'", added_text) | |
| warnings = [] | |
| for h in removed_handlers: | |
| if h not in added_handlers: | |
| # Check if it still exists in the full file | |
| with open('map2-auto-tagger-optimized.yaml') as f: | |
| full = f.read() | |
| if f"event_name == '{h}'" not in full: | |
| warnings.append(h) | |
| if warnings: | |
| print(f"⚠️ {len(warnings)} handler(s) removed entirely (not present in final file):") | |
| for h in warnings: | |
| print(f" - {h}") | |
| print() | |
| print("If intentional, document the reason in the PR description.") | |
| print("This is a warning — PR can still merge.") | |
| else: | |
| if removed_handlers: | |
| print(f"✅ {len(removed_handlers)} handler(s) removed — all confirmed present elsewhere in file") | |
| else: | |
| print("✅ No handlers removed") | |
| EOF | |
| # ── 4. Configurator HTML check ──────────────────────────────────────────── | |
| configurator-check: | |
| needs: build-configurator | |
| name: Configurator HTML Check | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| name: configurator | |
| path: build/ | |
| - name: Install Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| - name: Check HTML is well-formed | |
| run: | | |
| node - <<'EOF' | |
| const fs = require('fs'); | |
| ['build/configurator.html'].forEach(file => { | |
| if (!fs.existsSync(file)) { | |
| console.log(` ⚠️ ${file} not found — skipping`); | |
| return; | |
| } | |
| const html = fs.readFileSync(file, 'utf8'); | |
| // Check for unclosed script tags | |
| const openScript = (html.match(/<script/g) || []).length; | |
| const closeScript = (html.match(/<\/script>/g) || []).length; | |
| if (openScript !== closeScript) { | |
| console.error(`❌ ${file}: mismatched <script> tags (${openScript} open, ${closeScript} close)`); | |
| process.exit(1); | |
| } | |
| // Check for unclosed style tags | |
| const openStyle = (html.match(/<style/g) || []).length; | |
| const closeStyle = (html.match(/<\/style>/g) || []).length; | |
| if (openStyle !== closeStyle) { | |
| console.error(`❌ ${file}: mismatched <style> tags (${openStyle} open, ${closeStyle} close)`); | |
| process.exit(1); | |
| } | |
| // Check file isn't empty | |
| if (html.trim().length < 100) { | |
| console.error(`❌ ${file}: suspiciously small file (${html.length} bytes)`); | |
| process.exit(1); | |
| } | |
| console.log(` ✅ ${file}: OK (${Math.round(html.length/1024)}KB, ${openScript} script blocks)`); | |
| }); | |
| EOF | |
| - name: Extract and syntax-check Lambda code from configurator.html | |
| run: | | |
| python3 - <<'EOF' | |
| import re, sys, py_compile, tempfile, os | |
| with open('build/configurator.html') as f: | |
| content = f.read() | |
| # Lambda code is inside ZipFile: | blocks within JS template literals | |
| blocks = re.findall(r'ZipFile: \\\|\\n((?:.*\\n)+?)(?=\s*(?:Timeout|Role|Handler|Environment|FunctionName))', content) | |
| if not blocks: | |
| print(" ⚠️ No Lambda ZipFile blocks found in configurator.html — skipping Python check") | |
| sys.exit(0) | |
| errors = [] | |
| for i, block in enumerate(blocks): | |
| # Unescape \\n → \n and strip indent | |
| code = block.replace('\\n', '\n').replace("\\'", "'") | |
| code = '\n'.join( | |
| line[10:] if line.startswith(' ' * 10) else line.lstrip() | |
| for line in code.split('\n') | |
| ) | |
| if len(code.strip()) < 50: | |
| continue | |
| with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: | |
| f.write(code) | |
| tmp = f.name | |
| try: | |
| py_compile.compile(tmp, doraise=True) | |
| print(f" ✅ Configurator Lambda block {i+1}: syntax OK") | |
| except py_compile.PyCompileError as e: | |
| errors.append(str(e)) | |
| print(f" ❌ Configurator Lambda block {i+1}: {e}") | |
| finally: | |
| os.unlink(tmp) | |
| if errors: | |
| sys.exit(1) | |
| EOF | |
| # ── 5. New handler E2E coverage check ───────────────────────────────────── | |
| new-handler-coverage-check: | |
| name: New Handler E2E Coverage Check | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Check new handlers have matching E2E resource scripts | |
| run: | | |
| python3 - <<'EOF' | |
| import re, subprocess, sys | |
| from pathlib import Path | |
| result = subprocess.run( | |
| ['git', 'diff', 'origin/main...HEAD', '--', 'map2-auto-tagger-optimized.yaml'], | |
| capture_output=True, text=True | |
| ) | |
| diff = result.stdout | |
| if not diff: | |
| print("No changes to map2-auto-tagger-optimized.yaml — skipping") | |
| sys.exit(0) | |
| # Lines added in this PR (+ prefix, excluding +++ header) | |
| added_lines = [l[1:] for l in diff.split('\n') | |
| if l.startswith('+') and not l.startswith('+++')] | |
| # Find newly added handler blocks: "elif event_name == 'Xxx' and event_source == 'yyy'" | |
| handler_pattern = re.compile( | |
| r"elif\s+event_name\s*==\s*'([^']+)'(?:.*?event_source\s*==\s*'([^']+)')?" | |
| ) | |
| new_handlers = [] | |
| for line in added_lines: | |
| m = handler_pattern.search(line) | |
| if m: | |
| new_handlers.append((m.group(1), m.group(2) or '')) | |
| if not new_handlers: | |
| print("No new event handlers added — skipping coverage check") | |
| sys.exit(0) | |
| print(f"Found {len(new_handlers)} new handler(s) to check:") | |
| # Load all resource_group scripts content once | |
| rg_dir = Path('.github/scripts/resource_groups') | |
| rg_content = '' | |
| if rg_dir.is_dir(): | |
| for py_file in rg_dir.glob('*.py'): | |
| rg_content += py_file.read_text() | |
| warnings = [] | |
| for event_name, event_source in new_handlers: | |
| # Check for exact name or snake_case variant | |
| snake = re.sub(r'(?<=[a-z])(?=[A-Z])', '_', event_name).lower() | |
| found = (event_name in rg_content or snake in rg_content) | |
| status = "covered" if found else "MISSING" | |
| print(f" [{status}] {event_name} ({event_source or 'source unspecified'})") | |
| if not found: | |
| warnings.append((event_name, event_source)) | |
| if warnings: | |
| print() | |
| for event_name, event_source in warnings: | |
| src_hint = event_source or 'unknown' | |
| print(f"⚠️ New handler '{event_name}' ({src_hint}) has no matching E2E test resource.") | |
| print(f" Add resource creation to .github/scripts/resource_groups/<appropriate_module>.py") | |
| print(f" This is a warning — PR can still merge, but E2E coverage will be incomplete.") | |
| print() | |
| sys.exit(0) | |
| EOF | |
| # ── 6. YAML ↔ Configurator sync check ──────────────────────────────────── | |
| sync-drift-check: | |
| needs: build-configurator | |
| name: YAML ↔ Configurator Sync Check | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| name: configurator | |
| path: build/ | |
| - name: Check IAM permissions and critical handler sync | |
| run: python3 .github/scripts/sync-check.py | |
| # ── 7. Handler coverage regression gate ────────────────────────────────── | |
| # The `new-handler-coverage-check` above only warns on NEW handlers added | |
| # by the current PR. This gate enforces the full baseline: any handler | |
| # that was E2E-covered on main must stay covered. Regenerate the baseline | |
| # with `python3 .github/scripts/audit_handler_coverage.py --update` after | |
| # intentional coverage additions/removals. | |
| handler-coverage-regression-gate: | |
| name: Handler Coverage Regression Gate | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.12' | |
| - name: Enforce no handler-coverage regressions vs baseline | |
| run: python3 .github/scripts/audit_handler_coverage.py --check | |
| - name: Print full coverage report (for PR review) | |
| if: always() | |
| run: python3 .github/scripts/audit_handler_coverage.py --report | |
| # ── 8. EventBridge prefix parity ───────────────────────────────────────── | |
| # Every `event_name == 'Verb...'` handler in the Lambda must have its | |
| # leading verb in the EventBridge rule's `- prefix:` list. Otherwise | |
| # EventBridge drops the event before it reaches SQS — silent failure | |
| # (no DLQ, no alarm). Enforced in both YAML and configurator.html. | |
| eventbridge-prefix-parity: | |
| needs: build-configurator | |
| name: EventBridge Prefix Parity | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| name: configurator | |
| path: build/ | |
| - name: Enforce every handler verb is in the EventBridge prefix list | |
| run: python3 .github/scripts/lint_event_prefixes.py | |
| # ── 9. CFN correctness (catches PR #41 and PR #42 classes) ───────────── | |
| # cfn-lint is silent on AWS service-side constraints (resource-name | |
| # length limits, IAM RoleName character class, unsubbed pseudo-params | |
| # outside !Sub). Those errors only surface at stack-create time. This | |
| # check catches them at merge time. | |
| # | |
| # Five checks: unsubbed ${AWS::...}, name-length overruns, IAM | |
| # RoleName character class, dangling !Ref/!GetAtt, undefined !Sub | |
| # template variables. Covers both map2-auto-tagger-optimized.yaml | |
| # and configurator.html's inline CFN template. | |
| cfn-correctness: | |
| needs: build-configurator | |
| name: CFN Correctness | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| name: configurator | |
| path: build/ | |
| - name: Enforce CFN correctness (name lengths, !Sub coverage, ref targets) | |
| run: python3 .github/scripts/lint_cfn_correctness.py | |
| # ── 10. Shell injection guard (catches the U4 RCE shape) ───────────────── | |
| # configurator.html emits a bash script at runtime. A partner-controlled | |
| # `customerName` field previously flowed into a double-quoted shell | |
| # variable, where `$(...)`, backticks, `\`, and `$VAR` still expand — | |
| # a single-line RCE against any customer who pastes the generated | |
| # deploy.sh into their CloudShell. This check enforces that customer- | |
| # derived values are emitted only inside single-quoted shell strings | |
| # via the shellSingleQuote helper. | |
| shell-injection-guard: | |
| needs: build-configurator | |
| name: Shell Injection Guard | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| name: configurator | |
| path: build/ | |
| - name: Enforce single-quoted containment for customer-controlled shell values | |
| run: python3 .github/scripts/lint_shell_injection.py | |
| # ── 11. BatchSize floor (§1.123/§1.124 regression guard, plan-PR #57) ─── | |
| # v20.8.0 raised EventQueueMapping.BatchSize from 1 to 10 with | |
| # ReportBatchItemFailures to close the Phase 16 drain-rate bottleneck | |
| # (1.3 msg/s per Lambda → ~10× higher). A silent revert to BatchSize=1 | |
| # would re-open §1.123 without any Layer 2 signal, because our E2E | |
| # creates ~50 resources and never stresses the drain path. This lint | |
| # enforces both: BatchSize >= 10 AND ReportBatchItemFailures present. | |
| batchsize-floor: | |
| needs: build-configurator | |
| name: BatchSize Floor | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| name: configurator | |
| path: build/ | |
| - name: Enforce EventQueueMapping BatchSize >= 10 + ReportBatchItemFailures | |
| run: python3 .github/scripts/lint_batchsize_floor.py | |
| # IAM completeness guard (§1.27, §1.38, §1.58, §1.64, §1.99, §1.100, §1.105 / plan-PR #42). | |
| # Lambda adds a native-dispatch branch in do_tag (e.g. boto3.client('newsvc').tag_resource) | |
| # without the matching IAM grant → silent AccessDenied → SNS alert to a possibly-void topic | |
| # → customer loses credits. Static gate: | |
| # 1. Parse `boto3.client('<svc>')` and `get_service_client('<svc>')` from the YAML Lambda | |
| # 2. For each discovered service, look up its required IAM actions in | |
| # .github/scripts/generate_iam.py's NATIVE_IAM_REQUIREMENTS map | |
| # (hand-curated from AWS's IAM Service Authorization Reference). | |
| # 3. Fail if any required action is missing from .github/sync/tagging-permissions.txt | |
| # The author MUST add the row to both the canonical list and the YAML ServiceSpecificTagging | |
| # policy when they add a native-dispatch handler. | |
| iam-completeness: | |
| name: IAM Completeness (native-dispatch) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Derive IAM actions from Lambda source and cross-check canonical list | |
| run: python3 .github/scripts/generate_iam.py --check |