Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy-ec2-ssm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ jobs:
npm run test:buildup-import
npm run test:integration-contract
npm run typecheck
npm audit --omit=dev --audit-level=critical
npm run security:audit

- name: Enable Docker Buildx
id: buildx
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ This version has breaking changes — APIs, conventions, and file structure may
- Keep the recorded previous slot running through the observation window. Roll back through the workflow; do not restore a database or type an image digest manually.
- Record the Issue, PR, final commit, Actions runs, SSM Parameter version, image digest, readiness result, rollback result and continuity probe counts. Never include secret values.
- Deployment-speed optimization may reuse caches or shorten polling latency, but it must not remove source tests, immutable digest promotion, ECR critical/high scan gates, candidate readiness, external digest verification or automatic rollback.
- Dependency audit exceptions are governed only by `deploy/SECURITY_ADVISORIES.md`. A new advisory, package topology, PptxGenJS image path, Prisma config change or expired review date must fail `npm run security:audit` before image build.
- Keep ECR `evn-warp` immutable and scan-gated for release images. Store mutable BuildKit data only in `evn-warp-buildcache`; never deploy from that repository or grant the EC2 instance role access to it.
- Treat build cache as disposable performance data, not release evidence. `prepare` must fail when release/cache repository mutability differs from the Runbook, while cache export failure may not replace final image build, digest verification or scanning.
- The cache repository lifecycle and least-privilege role policy are governed by `deploy/aws/buildcache-lifecycle-policy.json` and `deploy/aws/github-deploy-policy.json`; keep the applied AWS state aligned with those files.
Expand Down
2 changes: 1 addition & 1 deletion deploy/RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ GitHub Actions의 **Deploy WARP Blue-Green via SSM**에서 `release`를 한 번
1. source·ENV 검증 및 image 준비
- ENV 값은 출력하지 않는다.
- 필수 key, URL, secret 길이, path 형식을 검사한다.
- critical npm audit과 source tests를 통과한다.
- `npm run security:audit`과 source tests를 통과한다. Critical·미검토 High는 즉시 차단하고, 미패치 High는 [`SECURITY_ADVISORIES.md`](./SECURITY_ADVISORIES.md)의 exact advisory·도달성·만료일 통제를 모두 만족해야 한다.
- image를 build하거나 같은 SHA image를 재사용한다.
- immutable release ECR과 mutable cache ECR의 경계를 먼저 검증한다.
- ECR OS scan의 critical/high가 모두 0이어야 한다.
Expand Down
20 changes: 20 additions & 0 deletions deploy/SECURITY_ADVISORIES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# WARP dependency advisory policy

Owner: `OziinG`

Production release runs `npm run security:audit`. Critical advisories and unreviewed High advisories stop before image build. Do not replace this with `npm audit fix --force`, an unverified transitive override or a lower audit threshold.

## Time-bounded compensating controls

The upstream packages below have no compatible patched release as of 2026-08-24. The release gate accepts only these exact advisory IDs until 2026-10-01 and fails when the package topology, controlled source surface or review date changes.

| Advisory | Current reachability | Enforced control |
| --- | --- | --- |
| `GHSA-w3rx-r6r6-pgpr`, `GHSA-5p2g-fcmc-qvqq` | `pptxgenjs@4.0.1` carries `image-size@1.2.1`, but WARP generates text, shapes and tables only. No `addImage` path exists. | Scan every application and script source for PptxGenJS imports and image calls. A new import surface or image call blocks release. |
| `GHSA-ggr8-5vv4-36mx` | Prisma CLI loads a static repository-owned config during trusted build and migration commands. No request or provider payload reaches recursive config merge. | Pin the reviewed Prisma topology and exact `prisma.config.ts` digest. Any change blocks release. |

Prisma CLI, Client and libSQL adapter are aligned at 7.9.1. This removes the previous Hono and Valibot Moderate advisory paths but upstream Prisma still carries `deepmerge-ts@7.1.5`.

## Removal

Re-run the audit before the review date. When PptxGenJS/image-size or Prisma/deepmerge-ts publishes a compatible fixed graph, update the direct packages normally, remove the corresponding exception and retain the regression checks that protect PPTX generation, Prisma generate, migrations, typecheck and production build.
184 changes: 184 additions & 0 deletions deploy/audit_production_dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Fail deployment on unreviewed High/Critical npm advisories."""

from __future__ import annotations

import datetime as dt
import hashlib
import json
import re
import subprocess
import sys
from pathlib import Path


ROOT = Path(__file__).parents[1]
REVIEW_BY = dt.date(2026, 10, 1)
ALLOWED_ADVISORIES = {
1138808: "image-size ICNS parser denial of service",
1138809: "image-size JXL/HEIF parser denial of service",
1145093: "deepmerge-ts recursive graph stack exhaustion",
}
IMAGE_ADVISORIES = {1138808, 1138809}
PRISMA_ADVISORIES = {1145093}
EXPECTED_PPTX_IMPORTS = {
"app/api/a3/[id]/export/route.ts",
"scripts/sample-ppt.ts",
}
EXPECTED_PRISMA_CONFIG_SHA256 = (
"c1bcbd0ff267c8b6885cd467ed8122cf6508bdac6c929d69f9b512ccde65a033"
)


class AuditPolicyError(RuntimeError):
pass


def leaf_advisories(report: dict) -> dict[int, dict]:
vulnerabilities = report.get("vulnerabilities", {})
leaves: dict[int, dict] = {}

def visit(package: str, stack: tuple[str, ...] = ()) -> set[int]:
if package in stack:
raise AuditPolicyError(f"cyclic npm audit graph at {package}")
vulnerability = vulnerabilities.get(package)
if not isinstance(vulnerability, dict):
raise AuditPolicyError(f"missing npm audit node for {package}")
resolved: set[int] = set()
for entry in vulnerability.get("via", []):
if isinstance(entry, str):
resolved.update(visit(entry, stack + (package,)))
elif isinstance(entry, dict) and isinstance(entry.get("source"), int):
leaves[entry["source"]] = entry
resolved.add(entry["source"])
else:
raise AuditPolicyError(f"unrecognized npm audit entry for {package}")
if not resolved:
raise AuditPolicyError(f"High/Critical npm audit node has no advisory source: {package}")
return resolved

for package, vulnerability in vulnerabilities.items():
if vulnerability.get("severity") in {"high", "critical"}:
visit(package)
return leaves


def validate_audit(report: dict) -> set[int]:
metadata = report.get("metadata", {}).get("vulnerabilities", {})
if metadata.get("critical", 0):
raise AuditPolicyError("Critical npm advisory detected")

leaves = leaf_advisories(report)
unknown = set(leaves) - set(ALLOWED_ADVISORIES)
if unknown:
details = ", ".join(
f"{source}:{leaves[source].get('dependency', 'unknown')}" for source in sorted(unknown)
)
raise AuditPolicyError(f"unreviewed High npm advisory detected: {details}")
return set(leaves)


def package_versions() -> dict[str, str | None]:
lock = json.loads((ROOT / "package-lock.json").read_text(encoding="utf-8"))
packages = lock.get("packages", {})
names = (
"prisma",
"@prisma/client",
"@prisma/adapter-libsql",
"@prisma/config",
"deepmerge-ts",
"pptxgenjs",
"image-size",
)
return {
name: packages.get(f"node_modules/{name}", {}).get("version") for name in names
}


def validate_image_control() -> None:
versions = package_versions()
expected = {"pptxgenjs": "4.0.1", "image-size": "1.2.1"}
if any(versions[name] != version for name, version in expected.items()):
raise AuditPolicyError("PptxGenJS advisory topology changed; review the control")

lock = json.loads((ROOT / "package-lock.json").read_text(encoding="utf-8"))
root_dependencies = lock.get("packages", {}).get("", {}).get("dependencies", {})
if "image-size" in root_dependencies:
raise AuditPolicyError("image-size must remain transitive to the controlled PPTX surface")

imports: set[str] = set()
add_image = re.compile(r"(?:\.\s*addImage\b|\[\s*['\"]addImage['\"]\s*\])")
pptx_import = re.compile(
r"(?:from\s+['\"]pptxgenjs['\"]|require\(\s*['\"]pptxgenjs['\"]\s*\)|import\(\s*['\"]pptxgenjs['\"]\s*\))"
)
for directory in ("app", "lib", "scripts"):
for path in (ROOT / directory).rglob("*"):
if path.suffix not in {".js", ".mjs", ".cjs", ".ts", ".tsx"}:
continue
content = path.read_text(encoding="utf-8")
relative = path.relative_to(ROOT).as_posix()
if pptx_import.search(content):
imports.add(relative)
if add_image.search(content):
raise AuditPolicyError(f"PptxGenJS image input requires a new security review: {relative}")
if imports != EXPECTED_PPTX_IMPORTS:
raise AuditPolicyError(f"PptxGenJS import surface changed: {sorted(imports)}")


def validate_prisma_control() -> None:
versions = package_versions()
expected = {
"prisma": "7.9.1",
"@prisma/client": "7.9.1",
"@prisma/adapter-libsql": "7.9.1",
"@prisma/config": "7.9.1",
"deepmerge-ts": "7.1.5",
}
if any(versions[name] != version for name, version in expected.items()):
raise AuditPolicyError("Prisma advisory topology changed; review the control")
digest = hashlib.sha256((ROOT / "prisma.config.ts").read_bytes()).hexdigest()
if digest != EXPECTED_PRISMA_CONFIG_SHA256:
raise AuditPolicyError("Prisma config changed; recursive merge reachability must be reviewed")


def enforce(report: dict, today: dt.date | None = None) -> set[int]:
allowed = validate_audit(report)
if not allowed:
return allowed
if (today or dt.date.today()) > REVIEW_BY:
raise AuditPolicyError(f"compensating control expired on {REVIEW_BY.isoformat()}")
if allowed & IMAGE_ADVISORIES:
validate_image_control()
if allowed & PRISMA_ADVISORIES:
validate_prisma_control()
return allowed


def main() -> int:
result = subprocess.run(
["npm", "audit", "--omit=dev", "--audit-level=high", "--json"],
cwd=ROOT,
capture_output=True,
text=True,
check=False,
)
try:
report = json.loads(result.stdout)
allowed = enforce(report)
except (json.JSONDecodeError, AuditPolicyError, OSError) as error:
print(f"security_audit=failed reason={error}", file=sys.stderr)
return 1

counts = report.get("metadata", {}).get("vulnerabilities", {})
print(
"security_audit=passed "
f"critical={counts.get('critical', 0)} "
f"high_packages={counts.get('high', 0)} "
f"reviewed_advisories={','.join(map(str, sorted(allowed))) or 'none'} "
f"review_by={REVIEW_BY.isoformat() if allowed else 'not-required'}"
)
return 0


if __name__ == "__main__":
raise SystemExit(main())
57 changes: 57 additions & 0 deletions deploy/tests/test_audit_production_dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import datetime as dt
import importlib.util
import unittest
from pathlib import Path


ROOT = Path(__file__).parents[2]
SPEC = importlib.util.spec_from_file_location(
"audit_production_dependencies", ROOT / "deploy/audit_production_dependencies.py"
)
MODULE = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(MODULE)


def report(source: int = 1145093) -> dict:
return {
"vulnerabilities": {
"prisma": {"severity": "high", "via": ["@prisma/config"]},
"@prisma/config": {"severity": "high", "via": ["deepmerge-ts"]},
"deepmerge-ts": {
"severity": "high",
"via": [
{
"source": source,
"dependency": "deepmerge-ts",
"severity": "high",
}
],
},
},
"metadata": {"vulnerabilities": {"critical": 0, "high": 3}},
}


class ProductionDependencyAuditTest(unittest.TestCase):
def test_known_advisory_requires_current_repository_control(self):
allowed = MODULE.enforce(report(), today=dt.date(2026, 8, 24))
self.assertEqual(allowed, {1145093})

def test_unknown_high_advisory_is_rejected(self):
with self.assertRaisesRegex(MODULE.AuditPolicyError, "unreviewed High"):
MODULE.enforce(report(9999999), today=dt.date(2026, 8, 24))

def test_high_node_without_advisory_source_is_rejected(self):
payload = report()
payload["vulnerabilities"]["deepmerge-ts"]["via"] = []
with self.assertRaisesRegex(MODULE.AuditPolicyError, "no advisory source"):
MODULE.enforce(payload, today=dt.date(2026, 8, 24))

def test_expired_compensating_control_is_rejected(self):
with self.assertRaisesRegex(MODULE.AuditPolicyError, "expired"):
MODULE.enforce(report(), today=dt.date(2026, 10, 2))


if __name__ == "__main__":
unittest.main()
2 changes: 2 additions & 0 deletions deploy/tests/test_deployment_optimization_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ def test_prepare_reuses_dependencies_and_builder_layers(self):
self.assertIn("--cache-from \"type=registry,ref=$cache_repository_uri:buildcache-main\"", workflow)
self.assertIn("mode=max,oci-mediatypes=true,image-manifest=true,ignore-error=true", workflow)
self.assertIn("Require clean ECR operating-system scan", workflow)
self.assertIn("npm run security:audit", workflow)
self.assertNotIn("npm audit --omit=dev --audit-level=critical", workflow)
build_step = workflow.split("- name: Build or reuse immutable image", 1)[1].split(
"- name: Require clean ECR operating-system scan", 1
)[0]
Expand Down
41 changes: 41 additions & 0 deletions deploy/tests/test_pptx_security_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import subprocess
import textwrap
import unittest
from pathlib import Path


ROOT = Path(__file__).parents[2]


class PptxSecurityContractTest(unittest.TestCase):
def test_controlled_png_and_text_generate_a_valid_pptx(self):
program = textwrap.dedent(
"""
import assert from 'node:assert/strict'
import PptxGenJS from 'pptxgenjs'

const pptx = new PptxGenJS()
const slide = pptx.addSlide()
slide.addText('WARP security smoke', { x: 0.5, y: 0.5, w: 3, h: 0.5 })
slide.addImage({
data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
x: 0.5, y: 1.2, w: 0.2, h: 0.2,
})
const output = await pptx.write({ outputType: 'nodebuffer' })
const buffer = Buffer.from(output)
assert.equal(buffer.subarray(0, 2).toString(), 'PK')
assert.ok(buffer.length > 1000)
"""
)
result = subprocess.run(
["node", "--input-type=module", "-e", program],
cwd=ROOT,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)


if __name__ == "__main__":
unittest.main()
Loading