From b2be34a923f94b758501e3dd710f6e6ff010dafc Mon Sep 17 00:00:00 2001 From: ogt Date: Fri, 10 Jul 2026 19:13:22 +0800 Subject: [PATCH] fix(security): harden image context and runtime receipts --- .dockerignore | 48 ++++++++ .gitea/workflows/cd.yaml | 10 +- .gitignore | 4 +- CONSTITUTION.md | 2 +- config.py | 2 +- docker-compose.yml | 3 + docs/AI_INTELLIGENCE_MODULE_SOT.md | 2 +- .../ai_automation_mainline_work_items.md | 2 +- governance/ewoooc_asset_inventory.json | 4 +- .../ops/report_security_governance_review.py | 14 +++ .../security_governance_review_service.py | 116 ++++++++++++++++-- ...test_security_governance_review_service.py | 55 +++++++++ 12 files changed, 246 insertions(+), 16 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6f69cd8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,48 @@ +.git +.gitea +.github +.claude +.docker +.env +.env.* +auth.json +**/auth.json +*.pem +*.key +*.crt +*.p12 +*.pfx +id_rsa* +ssl +secrets +**/secrets +data +logs +backups +runtime_artifacts +config/google_credentials.json +config/google_token.json +config/google_token.pickle +config/*.json +config/*.pickle +*.db +*.db-* +*.sqlite +*.sqlite3 +venv +.venv +node_modules +__pycache__ +*.pyc +.pytest_cache +.coverage +htmlcov +tests +docs +memory +k8s +n8n-workflows +aiops-core +*.md +._* +.DS_Store diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index c8ba063..7298903 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -41,6 +41,7 @@ on: - 'monitoring/blackbox.yml' # 需重建 image 的檔案 - 'Dockerfile' + - '.dockerignore' - 'requirements.txt' - 'docker-compose.yml' # 腳本工具 @@ -108,7 +109,7 @@ jobs: exit 0 fi CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || echo "") - if echo "$CHANGED" | grep -qE '^(Dockerfile|requirements\.txt|docker-compose\.yml)$'; then + if echo "$CHANGED" | grep -qE '^(Dockerfile|\.dockerignore|requirements\.txt|docker-compose\.yml)$'; then echo "type=rebuild" >> $GITHUB_OUTPUT echo "label=🔨 重建 Docker Image" >> $GITHUB_OUTPUT else @@ -129,7 +130,14 @@ jobs: fi - name: 資訊安全與治理 release gate + env: + GITEA_SHA: ${{ gitea.sha }} run: | + set -eu + mkdir -p governance + python3 scripts/ops/report_security_governance_review.py \ + --strict --source-commit "$GITEA_SHA" \ + > governance/security_governance_source_receipt.json python3 scripts/ops/report_security_governance_review.py --compact --strict # 設定 SSH 金鑰 + 主機驗證(C2 fix: 移除 StrictHostKeyChecking no) diff --git a/.gitignore b/.gitignore index 5126dbc..5c95dbe 100644 --- a/.gitignore +++ b/.gitignore @@ -137,10 +137,12 @@ ehthumbs.db Desktop.ini # Docker -.dockerignore docker-compose.override.yml .docker/ +# CD-generated governance receipts (deployed evidence, not source) +governance/security_governance_source_receipt.json + # SSL 憑證 ssl/ *.pem diff --git a/CONSTITUTION.md b/CONSTITUTION.md index fcb30ff..997199a 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -2,7 +2,7 @@ > 本文件定義專案開發的核心準則與不可違反的規範 > **建立日期**: 2026-01-12 -> **當前版本**: V10.771 (Security governance automation review) +> **當前版本**: V10.772 (Security governance automation review) > **最後更新**: 2026-07-10 > **治理基準**: `global_product_governance_v2` + ADR-038 diff --git a/config.py b/config.py index 3e632f2..84affc4 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.771" +SYSTEM_VERSION = "V10.772" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docker-compose.yml b/docker-compose.yml index 2bcfe3e..cfd19f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,6 +65,7 @@ services: - ./gunicorn.conf.py:/app/gunicorn.conf.py:ro - ./scheduler.py:/app/scheduler.py:ro - ./scripts:/app/scripts:ro + - ./governance:/app/governance:ro - ./migrations:/app/migrations:ro - ./services:/app/services:ro - ./routes:/app/routes:ro @@ -217,6 +218,7 @@ services: - ./scheduler.py:/app/scheduler.py:ro - ./run_scheduler.py:/app/run_scheduler.py:ro - ./scripts:/app/scripts:ro + - ./governance:/app/governance:ro - ./services:/app/services:ro - ./routes:/app/routes:ro - ./database:/app/database:ro # 資料庫模型 @@ -285,6 +287,7 @@ services: - ./config:/app/config - ./config.py:/app/config.py:ro - ./run_telegram_bot.py:/app/run_telegram_bot.py:ro + - ./governance:/app/governance:ro - ./services:/app/services:ro - ./routes:/app/routes:ro - ./database:/app/database:ro diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index 865ed8a..f60c69f 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-07-10 (台北時間) > **狀態**: 🟠 Partial。四 Agent、PixelRAG、MCP/RAG registry、PChome controlled-preview families 與多項 smoke/readback 已建立;但 access control、database identity/RBAC、full asset reconciliation、software supply chain、same-run controlled apply、restore drill、internal RAG canary 與 MCP/RAG runtime closure 尚未全部完成。任何局部 receipt 不得代表整體閉環完成。 -> **適用版本**: V10.771 +> **適用版本**: V10.772 --- diff --git a/docs/guides/ai_automation_mainline_work_items.md b/docs/guides/ai_automation_mainline_work_items.md index 31a4ef4..3e53a8d 100644 --- a/docs/guides/ai_automation_mainline_work_items.md +++ b/docs/guides/ai_automation_mainline_work_items.md @@ -19,7 +19,7 @@ | 1 | `SEC-P0-001` | In progress | Deny-by-default route access control | Central policy now protects vendor/import/CI-CD/crawler/category/log/operator and sales-detail surfaces; every anonymous GET is explicitly allowlisted. Exit: anonymous production matrix passes and `/metrics` moves behind edge/internal auth. Next: `verify_production_anonymous_matrix_and_move_metrics_behind_edge_auth`. | | 2 | `SEC-P0-002` | Not started | Database identity + least-privilege RBAC | Replace shared admin password, default-admin fallback, process-local lockout and spoofable proxy identity. Exit: shadow auth, role matrix, durable audit/lockout, session revocation and canary cutover. | | 3 | `SEC-P0-003` | In progress | Webhook trust and replay protection | Telegram secret-token verification code exists; production secret activation remains unproven. Exit: secret provisioned outside source, required mode enabled, invalid-secret 401 and authorized callback canary pass. | -| 4 | `SUPPLY-P0-001` | In progress | Gitea-only secure software supply chain | Gitea-native checkout and governance gate added. Exit: exact dependency lock, internal SAST/SCA/secret scan, SBOM, image digest/provenance, vulnerability SLA and production digest readback. | +| 4 | `SUPPLY-P0-001` | In progress | Gitea-only secure software supply chain | Gitea-native checkout, secret-safe `.dockerignore`, commit-bound source receipt and governance gate are active. Exit: exact dependency lock, internal SAST/SCA/secret scan, SBOM, image digest/provenance, vulnerability SLA and production digest readback. | | 5 | `GOV-P0-001` | In progress | Canonical full asset graph + runtime reconciliation | `governance/ewoooc_asset_inventory.json` seeds hosts, services, data, AI, routes, supply chain, observability and recovery. Exit: same-run probe receipt for every asset; drift auto-creates work items. | | 6 | `GOV-P0-002` | Not started | Unified controlled-apply envelope | Introduce one `trace_id/run_id/work_item_id` across sensor, identity, SOT diff, decision, risk, dry-run, execution, verifier, rollback/retry and learning acknowledgement. Start with EventRouter + AutoHeal. | | 7 | `RAG-P0-001` | Not started | Internal RAG candidate canary | V10.770 stops at candidate preview. Exit: bounded pgvector candidate canary, signature/readback/feedback receipt, no price write and no premature `ai_insights` promotion. Next: `run_internal_rag_candidate_canary`. | diff --git a/governance/ewoooc_asset_inventory.json b/governance/ewoooc_asset_inventory.json index 2b7c5cc..6b54fcf 100644 --- a/governance/ewoooc_asset_inventory.json +++ b/governance/ewoooc_asset_inventory.json @@ -320,10 +320,10 @@ "canonical_id": "supply-chain:ewoooc:python-container", "asset_type": "packages_images_sbom", "owner_lane": "software-supply-chain", - "source_truth": "requirements, image digest, SBOM and vulnerability receipts", + "source_truth": "requirements, .dockerignore, source receipt, image digest, SBOM and vulnerability receipts", "runtime_identity": "EwoooC Python 3.11 container image", "signal_freshness": {"source": "CD security gate", "runtime_target": "deployed image digest", "max_age_minutes": 1440}, - "policy": ["Gitea/internal sources", "exact dependency lock target", "no GitHub action dependency"], + "policy": ["Gitea/internal sources", "secret-safe Docker context", "exact dependency lock target", "no GitHub action dependency"], "executor": "Gitea CD build lane", "verifier": "SAST, SCA, secret scan, SBOM and provenance checks", "backup_restore": "known-good immutable image digest", diff --git a/scripts/ops/report_security_governance_review.py b/scripts/ops/report_security_governance_review.py index fbe097a..04f98c1 100644 --- a/scripts/ops/report_security_governance_review.py +++ b/scripts/ops/report_security_governance_review.py @@ -23,6 +23,11 @@ def _parser() -> argparse.ArgumentParser: parser.add_argument("--root", type=Path, default=ROOT) parser.add_argument("--detail-limit", type=int, default=30) parser.add_argument("--compact", action="store_true") + parser.add_argument( + "--source-commit", + default="", + help="Bind emitted source receipt to the caller-provided Gitea commit SHA.", + ) parser.add_argument( "--strict", action="store_true", @@ -37,6 +42,15 @@ def main(argv: list[str] | None = None) -> int: root=args.root, detail_limit=args.detail_limit, ) + source_commit = str(args.source_commit or "").strip() + if source_commit: + if len(source_commit) != 40 or any( + char not in "0123456789abcdefABCDEF" for char in source_commit + ): + print("--source-commit must be a 40-character hexadecimal Gitea SHA", file=sys.stderr) + return 2 + report["receipt_kind"] = "security_governance_source_receipt" + report["source_commit"] = source_commit if args.compact: output = { "policy": report["policy"], diff --git a/services/security_governance_review_service.py b/services/security_governance_review_service.py index 9a29666..b43cbe5 100644 --- a/services/security_governance_review_service.py +++ b/services/security_governance_review_service.py @@ -20,6 +20,7 @@ POLICY = "global_product_governance_v2_security_review_v1" ROOT = Path(__file__).resolve().parents[1] ASSET_INVENTORY_PATH = Path("governance/ewoooc_asset_inventory.json") QUALITY_BASELINE_PATH = Path("governance/security_governance_review_baseline.json") +SOURCE_RECEIPT_PATH = Path("governance/security_governance_source_receipt.json") MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"} AUTH_DECORATOR_MARKERS = ( @@ -58,6 +59,19 @@ ASSET_REQUIRED_FIELDS = { "backup_restore", "learning_targets", } +DOCKERIGNORE_REQUIRED_PATTERNS = { + ".git", + ".gitea", + ".env", + ".env.*", + "data", + "logs", + "backups", + "config/google_credentials.json", + "config/google_token.json", + "config/google_token.pickle", + "config/*.json", +} def _read(path: Path) -> str: @@ -250,6 +264,53 @@ def _quality_baseline(root: Path) -> dict[str, Any]: } +def _source_receipt(root: Path) -> dict[str, Any]: + path = root / SOURCE_RECEIPT_PATH + try: + payload = json.loads(_read(path)) + except (TypeError, json.JSONDecodeError): + payload = {} + checks = payload.get("checks") if isinstance(payload, dict) else [] + checks_by_id = { + item.get("id"): item + for item in checks + if isinstance(item, dict) and item.get("id") + } if isinstance(checks, list) else {} + source_commit = str(payload.get("source_commit") or "") if isinstance(payload, dict) else "" + commit_shape_valid = len(source_commit) == 40 and all( + char in "0123456789abcdefABCDEF" for char in source_commit + ) + return { + "path": SOURCE_RECEIPT_PATH.as_posix(), + "available": bool(payload) and payload.get("receipt_kind") == "security_governance_source_receipt", + "source_commit": source_commit, + "source_commit_shape_valid": commit_shape_valid, + "generated_at": payload.get("generated_at") if isinstance(payload, dict) else None, + "release_gate_status": ( + (payload.get("release_gate") or {}).get("status") + if isinstance(payload, dict) else None + ), + "_checks": checks_by_id, + } + + +def _docker_build_context_posture(root: Path) -> dict[str, Any]: + path = root / ".dockerignore" + patterns = { + line.strip().rstrip("/") + for line in _read(path).splitlines() + if line.strip() and not line.lstrip().startswith("#") + } + missing = sorted(DOCKERIGNORE_REQUIRED_PATTERNS - patterns) + return { + "path": ".dockerignore", + "exists": path.is_file(), + "required_pattern_count": len(DOCKERIGNORE_REQUIRED_PATTERNS), + "missing_patterns": missing, + "safe": path.is_file() and not missing, + } + + def _workflow_supply_chain(root: Path) -> dict[str, Any]: files = sorted((root / ".gitea" / "workflows").glob("*.y*ml")) forbidden: list[dict[str, Any]] = [] @@ -395,7 +456,25 @@ def build_security_governance_review( route_posture["unclassified_unguarded"] = route_posture["unclassified_unguarded"][:detail_limit] route_posture["mutating_unguarded"] = route_posture["mutating_unguarded"][:detail_limit] assets = _asset_inventory(scan_root) + source_receipt = _source_receipt(scan_root) supply_chain = _workflow_supply_chain(scan_root) + docker_context = _docker_build_context_posture(scan_root) + supply_chain["docker_build_context"] = docker_context + supply_chain["source_chain_ready"] = bool( + supply_chain["gitea_only"] and docker_context["safe"] + ) + receipt_supply = source_receipt["_checks"].get("GV.SC-gitea-only-supply-chain", {}) + if ( + supply_chain["workflow_count"] == 0 + and source_receipt["available"] + and source_receipt["source_commit_shape_valid"] + and receipt_supply.get("status") == "pass" + ): + receipt_evidence = receipt_supply.get("evidence") or {} + supply_chain = dict(receipt_evidence) if isinstance(receipt_evidence, dict) else {} + supply_chain["evidence_source"] = "cd_source_receipt" + supply_chain["source_commit"] = source_receipt["source_commit"] + supply_chain["source_chain_ready"] = True dependencies = _dependency_posture(scan_root) quality = _quality_baseline(scan_root) large_files = _large_python_files(scan_root) @@ -447,6 +526,24 @@ def build_security_governance_review( ) if marker in constitution ] + policy_alignment_ready = bool(constitution) and not policy_drift_markers + policy_evidence = { + "legacy_markers": policy_drift_markers, + "evidence_source": "source_file" if constitution else "missing", + } + receipt_policy = source_receipt["_checks"].get("GV.PO-policy-alignment", {}) + if ( + not constitution + and source_receipt["available"] + and source_receipt["source_commit_shape_valid"] + and receipt_policy.get("status") == "pass" + ): + policy_alignment_ready = True + policy_evidence = { + "legacy_markers": [], + "evidence_source": "cd_source_receipt", + "source_commit": source_receipt["source_commit"], + } access_contract_ready = bool( central_policy_registered @@ -498,12 +595,12 @@ def build_security_governance_review( _check( "GV.SC-gitea-only-supply-chain", "GOVERN/PROTECT", - "pass" if supply_chain["gitea_only"] else "fail", + "pass" if supply_chain["source_chain_ready"] else "fail", "critical", - "Gitea workflows have no external action or forbidden GitHub source reference." if supply_chain["gitea_only"] else "Workflow still depends on an external action/source.", + "Gitea workflow and Docker build context exclude external actions, forbidden sources, secrets and runtime data." if supply_chain["source_chain_ready"] else "Source workflow or Docker build context boundary is incomplete.", supply_chain, - "replace_external_action_resolution_with_gitea_job_token_checkout", - release_blocking=not supply_chain["gitea_only"], + "keep_gitea_checkout_and_generate_commit_bound_source_receipt", + release_blocking=not supply_chain["source_chain_ready"], ), _check( "ID.RA-dependency-vulnerability-management", @@ -544,12 +641,12 @@ def build_security_governance_review( _check( "GV.PO-policy-alignment", "GOVERN", - "pass" if not policy_drift_markers else "fail", + "pass" if policy_alignment_ready else "fail", "high", - "Constitution is aligned to current production/governance doctrine." if not policy_drift_markers else "Legacy security/deployment doctrine still conflicts with production truth.", - {"legacy_markers": policy_drift_markers}, + "Constitution is aligned to current production/governance doctrine." if policy_alignment_ready else "Policy source is missing or legacy doctrine still conflicts with production truth.", + policy_evidence, "sunset_legacy_rules_with_owner_expiry_replacement_and_verifier", - release_blocking=bool(policy_drift_markers), + release_blocking=not policy_alignment_ready, ), _check( "GV.OV-modularization", @@ -645,6 +742,9 @@ def build_security_governance_review( }, "checks": checks, "quality_baseline": quality, + "source_receipt": { + key: value for key, value in source_receipt.items() if key != "_checks" + }, "release_gate": { "status": "pass" if not blocking else "fail", "blocking_failure_count": len(blocking), diff --git a/tests/test_security_governance_review_service.py b/tests/test_security_governance_review_service.py index a6f9d2e..671b99c 100644 --- a/tests/test_security_governance_review_service.py +++ b/tests/test_security_governance_review_service.py @@ -35,6 +35,9 @@ def test_security_governance_review_is_honest_and_release_gate_passes(): assert access["unguarded_count"] == 8 dependencies = checks["ID.RA-dependency-vulnerability-management"]["evidence"] assert dependencies["sbom_files"] == [] + supply = checks["GV.SC-gitea-only-supply-chain"] + assert supply["status"] == "pass" + assert supply["evidence"]["docker_build_context"]["safe"] is True quality = checks["GV.QA-test-governance"] assert quality["status"] == "partial" assert quality["evidence"]["collection_error_count"] == 1 @@ -72,6 +75,58 @@ def test_security_governance_cli_strict_mode_passes_source_gate(): assert payload["status"] == "partial" +def test_security_governance_cli_can_emit_commit_bound_source_receipt(): + source_commit = "a" * 40 + completed = subprocess.run( + [ + sys.executable, + str(ROOT / "scripts" / "ops" / "report_security_governance_review.py"), + "--root", + str(ROOT), + "--strict", + "--source-commit", + source_commit, + ], + cwd=ROOT, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert completed.returncode == 0, completed.stderr or completed.stdout + payload = json.loads(completed.stdout) + assert payload["receipt_kind"] == "security_governance_source_receipt" + assert payload["source_commit"] == source_commit + assert payload["release_gate"]["status"] == "pass" + + +def test_runtime_subset_uses_commit_bound_source_receipt(tmp_path): + from services.security_governance_review_service import ( + build_security_governance_review, + ) + + source_report = build_security_governance_review(root=ROOT) + source_report["receipt_kind"] = "security_governance_source_receipt" + source_report["source_commit"] = "b" * 40 + governance = tmp_path / "governance" + governance.mkdir() + (governance / "security_governance_source_receipt.json").write_text( + json.dumps(source_report, ensure_ascii=False), + encoding="utf-8", + ) + + runtime_report = build_security_governance_review(root=tmp_path) + checks = {item["id"]: item for item in runtime_report["checks"]} + + supply = checks["GV.SC-gitea-only-supply-chain"] + policy = checks["GV.PO-policy-alignment"] + assert supply["status"] == "pass" + assert supply["evidence"]["evidence_source"] == "cd_source_receipt" + assert policy["status"] == "pass" + assert policy["evidence"]["evidence_source"] == "cd_source_receipt" + + def test_sensitive_blueprint_policy_denies_anonymous_api(monkeypatch): import auth from services.http_access_policy_service import enforce_http_access_policy