Skip to content

Decoupling

Decoupling #270

Workflow file for this run

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 && node scripts/build-yaml.js
- uses: actions/upload-artifact@v4
with:
name: configurator
path: |
configurator.html
configurator.yaml
# ── 1. CloudFormation lint ─────────────────────────────────────────────────
cfn-lint:
needs: build-configurator
name: CloudFormation Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: configurator
path: .
- name: Install cfn-lint
run: pip install cfn-lint
- name: Lint CloudFormation template
run: cfn-lint configurator.yaml -I W
# ── 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:
needs: build-configurator
name: cfn-nag (advisory)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: configurator
path: .
- 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 configurator.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:
needs: build-configurator
name: cfn-guard (advisory)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: configurator
path: .
- 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 configurator.yaml \
--show-summary all || true
# ── 2. Lambda Python syntax check ─────────────────────────────────────────
python-syntax:
needs: build-configurator
name: Lambda Python Syntax
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: configurator
path: .
- name: Extract and syntax-check Lambda code from YAML
run: |
python3 - <<'EOF'
import re, sys, py_compile, tempfile, os
with open('configurator.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:
needs: build-configurator
name: bandit (advisory)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: configurator
path: .
- 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('configurator.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:
needs: build-configurator
name: Handler Regression Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/download-artifact@v4
with:
name: configurator
path: .
- 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', '--', 'configurator.yaml'],
capture_output=True, text=True
)
diff = result.stdout
if not diff:
print("No changes to configurator.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('configurator.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: .
- 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');
['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('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', '--', 'configurator.yaml'],
capture_output=True, text=True
)
diff = result.stdout
if not diff:
print("No changes to configurator.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
# ── 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:
needs: build-configurator
name: Handler Coverage Regression Gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: configurator
path: .
- 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: .
- 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 configurator.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: .
- 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: .
- 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: .
- 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 the generated configurator.yaml
# 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:
needs: build-configurator
name: IAM Completeness (native-dispatch)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: configurator
path: .
- name: Derive IAM actions from Lambda source and cross-check canonical list
run: python3 .github/scripts/generate_iam.py --check