fix(iwooos): 鎖住 owner gate 與 tenants 前台遮罩
This commit is contained in:
@@ -353,6 +353,117 @@ function sourcePublicScopeCode(index: number) {
|
||||
return `SRC-${String(index + 1).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
function assetPublicCode(index: number) {
|
||||
return `AST-${String(index + 1).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
function routePublicCode(index: number) {
|
||||
return `RTE-${String(index + 1).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
function tenantPublicCode(index: number) {
|
||||
return `TNT-${String(index + 1).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
const TENANT_PUBLIC_NAMES: Record<string, string> = {
|
||||
"awoooi": "核心營運租戶",
|
||||
"awooooi": "核心營運租戶",
|
||||
ewoooc: "行動商務租戶",
|
||||
};
|
||||
|
||||
const PUBLIC_PRODUCT_NAMES: Record<string, string> = {
|
||||
"agent-bounty-protocol": "代理獎勵協議",
|
||||
"awoooi": "核心營運平台",
|
||||
"awooooi": "核心營運平台",
|
||||
bitan: "藥局服務前台",
|
||||
"bitan-pharmacy": "藥局服務平台",
|
||||
"clawbot-v5": "自動化助理平台",
|
||||
"ewoooc": "行動商務平台",
|
||||
"mo": "行動商務前台",
|
||||
"open-design": "設計系統",
|
||||
"source-control": "版本控管範圍",
|
||||
"tsenyang-website": "品牌網站",
|
||||
vibework: "工作協作產品",
|
||||
"wooo-aiops": "AI 維運平台",
|
||||
"wooo-infra-config": "基礎設施設定",
|
||||
};
|
||||
|
||||
const RAW_REPOSITORY_IDENTIFIER_RE = /\b[a-z0-9][a-z0-9-]{1,}\/[A-Za-z0-9._-]+\b/;
|
||||
const INTERNAL_STATUS_FRAGMENTS = [
|
||||
"blocked" + "_waiting_",
|
||||
"blockers" + "=",
|
||||
"github.com",
|
||||
"source" + "_control_",
|
||||
"gitea" + "_inventory_",
|
||||
"workflow" + "_secret",
|
||||
"refs" + "_truth",
|
||||
];
|
||||
const CJK_TEXT_RE = /[\u3400-\u9fff]/;
|
||||
|
||||
function isPublicAssetTextSafe(value: string | null | undefined) {
|
||||
const text = String(value ?? "").trim();
|
||||
const normalized = text.toLowerCase();
|
||||
if (!text) return false;
|
||||
if (RAW_REPOSITORY_IDENTIFIER_RE.test(text)) return false;
|
||||
if (INTERNAL_STATUS_FRAGMENTS.some((fragment) => normalized.includes(fragment))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function lookupPublicProductName(values: Array<string | null | undefined>, fallback: string) {
|
||||
for (const value of values) {
|
||||
const normalized = String(value ?? "").trim().toLowerCase();
|
||||
if (!normalized) continue;
|
||||
const candidates = [normalized, normalized.split("/").pop() ?? normalized];
|
||||
for (const candidate of candidates) {
|
||||
const mapped = PUBLIC_PRODUCT_NAMES[candidate];
|
||||
if (mapped) return mapped;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function publicChineseAssetText(value: string | null | undefined, fallback: string) {
|
||||
const text = String(value ?? "").trim();
|
||||
return isPublicAssetTextSafe(text) && CJK_TEXT_RE.test(text) ? text : fallback;
|
||||
}
|
||||
|
||||
function tenantPublicName(tenant: Tenant, index: number) {
|
||||
return TENANT_PUBLIC_NAMES[tenant.project_id] ?? `租戶 ${tenantPublicCode(index)}`;
|
||||
}
|
||||
|
||||
function tenantBudgetState(tenant: Tenant) {
|
||||
return tenant.budget_limit_usd == null ? "未設定" : "已設定";
|
||||
}
|
||||
|
||||
function assetPublicProductName(item: TenantProductSurface, index: number) {
|
||||
const fallback = assetPublicCode(index);
|
||||
const mapped = lookupPublicProductName([item.product_id, item.project_id, item.product_name], fallback);
|
||||
return mapped === fallback ? publicChineseAssetText(item.product_name, fallback) : mapped;
|
||||
}
|
||||
|
||||
function routePublicProductName(route: TenantPublicRouteAsset, index: number) {
|
||||
const fallback = routePublicCode(index);
|
||||
const mapped = lookupPublicProductName([route.product_id, route.product_name], fallback);
|
||||
return mapped === fallback ? publicChineseAssetText(route.product_name, fallback) : mapped;
|
||||
}
|
||||
|
||||
function sourcePublicProductName(repo: TenantSourceRepoAsset, index: number) {
|
||||
const fallback = sourcePublicScopeCode(index);
|
||||
const mapped = lookupPublicProductName(
|
||||
[repo.product_id, repo.source_key, repo.github_repo, repo.product_name],
|
||||
fallback
|
||||
);
|
||||
if (mapped !== fallback) return mapped;
|
||||
if (
|
||||
repo.product_name === repo.github_repo ||
|
||||
repo.product_name === repo.source_key ||
|
||||
repo.product_name === repo.source_scope_id
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
return publicChineseAssetText(repo.product_name, fallback);
|
||||
}
|
||||
|
||||
function sourceRiskClass(risk: string) {
|
||||
const key = sourceRiskKey(risk);
|
||||
if (key === "high") return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]";
|
||||
@@ -437,35 +548,24 @@ function SuspendedBadge({ suspended }: { suspended: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TenantRow({ tenant }: { tenant: Tenant }) {
|
||||
const budget =
|
||||
tenant.budget_limit_usd == null ? null : Number(tenant.budget_limit_usd);
|
||||
|
||||
function TenantRow({ tenant, index }: { tenant: Tenant; index: number }) {
|
||||
return (
|
||||
<tr className="border-b border-border hover:bg-accent/30 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-sm text-foreground">
|
||||
{tenant.project_id}
|
||||
{tenantPublicCode(index)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-foreground font-medium">{tenant.display_name || "--"}</span>
|
||||
<span className="text-sm text-foreground font-medium">{tenantPublicName(tenant, index)}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<MigrationModeBadge mode={tenant.migration_mode} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground font-mono">
|
||||
{tenant.budget_limit_usd != null ? (
|
||||
<>
|
||||
<DollarSign className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
{budget?.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
"--"
|
||||
)}
|
||||
<DollarSign className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
{tenantBudgetState(tenant)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -927,13 +1027,15 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-px bg-[#eee9dd] md:grid-cols-2">
|
||||
{products.map((item) => (
|
||||
{products.map((item, index) => (
|
||||
<article key={item.product_id} className="min-w-0 bg-white px-4 py-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="break-words text-sm font-semibold text-[#141413]">{item.product_name}</p>
|
||||
<p className="break-words text-sm font-semibold text-[#141413]">
|
||||
{assetPublicProductName(item, index)}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-[11px] text-[#77736a]">
|
||||
{item.project_id} · {assetCategoryLabel(item.category, t)}
|
||||
{assetPublicCode(index)} · {assetCategoryLabel(item.category, t)}
|
||||
</p>
|
||||
</div>
|
||||
<AssetStatusBadge status={item.coverage_status} t={t} />
|
||||
@@ -969,11 +1071,12 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2 text-xs leading-5 text-[#5f5b52]">
|
||||
{inventory?.evidence_refs.map((ref) => (
|
||||
<span key={ref} className="break-all font-mono">
|
||||
{ref}
|
||||
</span>
|
||||
))}
|
||||
<span className="font-semibold text-[#141413]">
|
||||
{inventory?.evidence_refs.length
|
||||
? `已提交證據參照 ${inventory.evidence_refs.length} 項`
|
||||
: "等待證據參照"}
|
||||
</span>
|
||||
<span>完整證據路徑保留在只讀台帳與 guard,不在前台公開顯示。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -994,10 +1097,12 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#eee9dd]">
|
||||
{publicRoutes.map((route) => (
|
||||
{publicRoutes.map((route, index) => (
|
||||
<tr key={route.domain}>
|
||||
<td className="px-3 py-2 font-mono text-[#141413]">{route.domain}</td>
|
||||
<td className="px-3 py-2 text-[#5f5b52]">{route.product_name}</td>
|
||||
<td className="px-3 py-2 text-[#5f5b52]">
|
||||
{routePublicProductName(route, index)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<AssetStatusBadge status={route.coverage_status} t={t} />
|
||||
</td>
|
||||
@@ -1038,7 +1143,7 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento
|
||||
{assetCategoryLabel(repo.category, t)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[#5f5b52]">{repo.product_name}</td>
|
||||
<td className="px-3 py-2 text-[#5f5b52]">{sourcePublicProductName(repo, index)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={cn("inline-flex border px-2 py-0.5 font-semibold", sourceRiskClass(repo.risk))}>
|
||||
{sourceRiskLabel(repo.risk, t)}
|
||||
@@ -1148,16 +1253,16 @@ export default function TenantsPage() {
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
專案 ID
|
||||
租戶代號
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
名稱
|
||||
公開名稱
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
模式
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
預算上限 (USD)
|
||||
預算狀態
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
狀態
|
||||
@@ -1184,8 +1289,8 @@ export default function TenantsPage() {
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
tenants.map((tenant) => (
|
||||
<TenantRow key={tenant.project_id} tenant={tenant} />
|
||||
tenants.map((tenant, index) => (
|
||||
<TenantRow key={tenant.project_id} tenant={tenant} index={index} />
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
|
||||
82
docs/security/IWOOOS-OWNER-GATE-GUARD.md
Normal file
82
docs/security/IWOOOS-OWNER-GATE-GUARD.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# IwoooS Owner Gate Guard
|
||||
|
||||
| 項目 | 內容 |
|
||||
|------|------|
|
||||
| 日期 | 2026-06-15 |
|
||||
| 狀態 | `repo_snapshot_guard_ready_owner_gate_zero` |
|
||||
| 腳本 | `scripts/security/iwooos-owner-gate-guard.py` |
|
||||
| 模式 | repo snapshot only,不送件、不收件、不呼叫 API、不修改 repo / refs / workflow / secret / runner |
|
||||
| runtime gate | `0` |
|
||||
|
||||
## 1. 目的
|
||||
|
||||
此 guard 專門鎖住 S4.9 owner response gate 的安全邊界,避免把「文件、表單、request packet、AwoooP 顯示、reviewer checklist、rollup」誤讀成 owner response 已收到或已接受。
|
||||
|
||||
它檢查:
|
||||
|
||||
1. S4.9 canonical owner response envelope 六欄存在。
|
||||
2. S4.9 五題 intake form 與 template id 存在。
|
||||
3. `s4-9-owner-response-gap-audit.snapshot.json` 仍標示 owner gate 為 0。
|
||||
4. S4.9 / S4.10 / S4.11 / S4.12 四包 owner response packet 仍是 `draft_waiting_owner_response`。
|
||||
5. S4.13 rollup 仍固定 `4` 包、`24` templates、`32` acceptance checks、`40` rejection rules。
|
||||
6. request sent、received、accepted、rejected、runtime gate、action buttons 全部維持 `0 / false`。
|
||||
|
||||
## 2. 指令
|
||||
|
||||
```bash
|
||||
python3 scripts/security/iwooos-owner-gate-guard.py --root .
|
||||
```
|
||||
|
||||
預期輸出:
|
||||
|
||||
```text
|
||||
IWOOOS_OWNER_GATE_GUARD_OK
|
||||
```
|
||||
|
||||
主進度 guard 已串接此 guard:
|
||||
|
||||
```bash
|
||||
python3 scripts/security/security-mirror-progress-guard.py --root .
|
||||
```
|
||||
|
||||
## 3. 必須維持的邊界
|
||||
|
||||
```text
|
||||
request_sent_count=0
|
||||
received_response_count=0
|
||||
accepted_response_count=0
|
||||
rejected_response_count=0
|
||||
owner_response_received_count=0
|
||||
owner_response_accepted_count=0
|
||||
runtime_gate_count=0
|
||||
runtime_execution_authorized=false
|
||||
action_buttons_allowed=false
|
||||
repo_creation_authorized=false
|
||||
refs_sync_authorized=false
|
||||
workflow_modification_authorized=false
|
||||
runner_change_authorized=false
|
||||
secret_value_collection_allowed=false
|
||||
github_primary_switch_authorized=false
|
||||
force_push_authorized=false
|
||||
```
|
||||
|
||||
## 4. 不可誤讀
|
||||
|
||||
此 guard 通過不代表:
|
||||
|
||||
- S4.9 request 已送出。
|
||||
- owner response 已收到或接受。
|
||||
- reviewer 已驗收。
|
||||
- Gitea / GitHub source truth 已決定。
|
||||
- repo creation、visibility change、refs sync、workflow / secret / runner 變更已批准。
|
||||
- GitHub primary switch、host update、active scan、runtime execution 或 action button 已授權。
|
||||
|
||||
## 5. 完成度
|
||||
|
||||
| 工作 | 完成度 | 說明 |
|
||||
|------|--------|------|
|
||||
| S4.9 owner gate 集中 guard | `100%` | 已新增腳本並可獨立執行 |
|
||||
| 主進度 guard 串接 | `100%` | `security-mirror-progress-guard.py` 已呼叫此 guard |
|
||||
| dry-run 證據同步 | `100%` | `security-mirror-dry-run.snapshot.json` 已新增 `CHECK_OWNER_GATE_GUARD` |
|
||||
| S4.9 owner response gate | `0%` | 尚未收到 owner response,不得調高 |
|
||||
| active runtime gate | `0%` | 未開啟任何執行期閘門 |
|
||||
@@ -127,6 +127,28 @@
|
||||
"switch_github_primary"
|
||||
]
|
||||
},
|
||||
{
|
||||
"step_id": "CHECK_OWNER_GATE_GUARD",
|
||||
"expected_observation": "AwoooP dry-run 必須確認 S4.9 canonical owner response envelope、intake form、gap audit、Gitea owner attestation response、S4.10 / S4.11 / S4.12 owner response packet 與 S4.13 rollup 全部維持 waiting owner response;五題、六欄、四包、received / accepted / rejected=0、runtime gate=0、action button=false 必須一致。",
|
||||
"evidence_refs": [
|
||||
"docs/security/S4-9-OWNER-RESPONSE-GATE-CURRENT-GAP-AUDIT.md",
|
||||
"docs/security/S4-9-CANONICAL-OWNER-RESPONSE-ENVELOPE.md",
|
||||
"docs/security/source-control-owner-response-validation-rollup.snapshot.json",
|
||||
"scripts/security/iwooos-owner-gate-guard.py"
|
||||
],
|
||||
"pass_condition": "`python3 scripts/security/iwooos-owner-gate-guard.py` 回傳 IWOOOS_OWNER_GATE_GUARD_OK,且 request_sent_count=0、received_response_count=0、accepted_response_count=0、runtime_gate_count=0。",
|
||||
"execution_allowed": false,
|
||||
"blocked_actions": [
|
||||
"treat_intake_form_as_owner_response",
|
||||
"mark_owner_response_received_without_envelope",
|
||||
"mark_owner_response_accepted_without_reviewer",
|
||||
"create_github_repo",
|
||||
"sync_git_refs",
|
||||
"modify_workflow_or_secret",
|
||||
"enable_runner",
|
||||
"open_runtime_gate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"step_id": "CHECK_CONFIG_CONTROL_GUARD",
|
||||
"expected_observation": "AwoooP dry-run 必須確認 14 類高價值配置控管 snapshot 齊備,Nginx、DNS / TLS、K8s、Secrets、runner、public runtime、防火牆、backup、monitoring 與 agent-bounty-protocol 的 owner response / change evidence 帳本仍維持只讀,不代表 reload、restart、sync、deploy、secret、scan 或 runtime gate 授權。",
|
||||
@@ -186,13 +208,14 @@
|
||||
"status": "repo_snapshot_guard_pass",
|
||||
"date": "2026-06-15",
|
||||
"scope": "repo_snapshot_only",
|
||||
"command": "python3 scripts/security/security-mirror-progress-guard.py && python3 scripts/security/source-control-owner-response-guard.py && python3 scripts/security/iwooos-config-control-guard.py",
|
||||
"result": "SECURITY_MIRROR_PROGRESS_GUARD_OK; SOURCE_CONTROL_OWNER_RESPONSE_GUARD_OK; IWOOOS_CONFIG_CONTROL_GUARD_OK",
|
||||
"command": "python3 scripts/security/security-mirror-progress-guard.py && python3 scripts/security/source-control-owner-response-guard.py && python3 scripts/security/iwooos-config-control-guard.py && python3 scripts/security/iwooos-owner-gate-guard.py",
|
||||
"result": "SECURITY_MIRROR_PROGRESS_GUARD_OK; SOURCE_CONTROL_OWNER_RESPONSE_GUARD_OK; IWOOOS_CONFIG_CONTROL_GUARD_OK; IWOOOS_OWNER_GATE_GUARD_OK",
|
||||
"validated_steps": [
|
||||
"LOAD_CONTRACT_INDEXES",
|
||||
"CHECK_ACCEPTANCE_AND_QUARANTINE",
|
||||
"CHECK_PROGRESS_GUARD",
|
||||
"CHECK_OWNER_RESPONSE_GUARD",
|
||||
"CHECK_OWNER_GATE_GUARD",
|
||||
"CHECK_CONFIG_CONTROL_GUARD",
|
||||
"CONFIRM_NO_RUNTIME_ACTION"
|
||||
],
|
||||
|
||||
360
scripts/security/iwooos-owner-gate-guard.py
Normal file
360
scripts/security/iwooos-owner-gate-guard.py
Normal file
@@ -0,0 +1,360 @@
|
||||
#!/usr/bin/env python3
|
||||
"""驗證 IwoooS / S4.9 owner gate 維持只讀收件邊界。
|
||||
|
||||
本 guard 只讀取 repo 內的 S4.9 / source-control owner response snapshot
|
||||
與 Markdown 規範,不送 request、不收 response、不呼叫 Gitea / GitHub /
|
||||
AwoooP、不修改 repo / refs / workflow / secret / runner,也不開 runtime gate。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
REQUIRED_DOCS = [
|
||||
"docs/security/S4-9-OWNER-RESPONSE-GATE-CURRENT-GAP-AUDIT.md",
|
||||
"docs/security/S4-9-CANONICAL-OWNER-RESPONSE-ENVELOPE.md",
|
||||
"docs/security/S4-9-OWNER-RESPONSE-INTAKE-FORM.md",
|
||||
"docs/security/S4-9-REVIEWER-VALIDATION-CHECKLIST.md",
|
||||
"docs/security/S4-9-SECURITY-ACCEPTANCE-RECORD-TEMPLATE.md",
|
||||
"docs/security/GITEA-INVENTORY-OWNER-ATTESTATION-RESPONSE.md",
|
||||
"docs/security/SOURCE-CONTROL-OWNER-RESPONSE-VALIDATION-ROLLUP.md",
|
||||
"docs/security/GITHUB-TARGET-OWNER-DECISION-RESPONSE.md",
|
||||
"docs/security/SOURCE-CONTROL-REF-TRUTH-OWNER-RESPONSE.md",
|
||||
"docs/security/SOURCE-CONTROL-WORKFLOW-SECRET-NAME-OWNER-RESPONSE.md",
|
||||
]
|
||||
|
||||
CANONICAL_FIELDS = [
|
||||
"owner_role_or_team",
|
||||
"decision",
|
||||
"decision_reason",
|
||||
"affected_scope",
|
||||
"redacted_evidence_refs",
|
||||
"followup_owner",
|
||||
]
|
||||
|
||||
S4_9_TEMPLATES = [
|
||||
"response-public-only-vs-local-gitea-gap",
|
||||
"response-org-user-endpoint-identity",
|
||||
"response-internal-110-adjacent-scope",
|
||||
"response-repo-owner-canonical-scope",
|
||||
"response-legacy-or-inaccessible-disposition",
|
||||
]
|
||||
|
||||
OWNER_PACKET_SPECS = [
|
||||
{
|
||||
"label": "s4.9 gitea owner attestation response",
|
||||
"path": "docs/security/gitea-inventory-owner-attestation-response.snapshot.json",
|
||||
"schema": "gitea_inventory_owner_attestation_response_v1",
|
||||
"status": "draft_waiting_owner_response",
|
||||
"template_count": 5,
|
||||
"expected_template_ids": S4_9_TEMPLATES,
|
||||
"summary_counts": {
|
||||
"owner_response_request_packet_count": 1,
|
||||
"response_template_count": 5,
|
||||
"owner_response_template_status_count": 5,
|
||||
"intake_preflight_check_count": 6,
|
||||
"intake_outcome_lane_count": 5,
|
||||
"acceptance_check_count": 8,
|
||||
"rejection_rule_count": 10,
|
||||
"received_response_count": 0,
|
||||
"accepted_response_count": 0,
|
||||
"rejected_response_count": 0,
|
||||
"owner_response_metadata_intake_required_count": 6,
|
||||
"owner_response_metadata_intake_received_count": 0,
|
||||
"owner_response_metadata_intake_accepted_count": 0,
|
||||
"owner_response_metadata_intake_runtime_gate_count": 0,
|
||||
"owner_response_intake_handoff_queue_count": 5,
|
||||
"owner_response_intake_handoff_queue_received_count": 0,
|
||||
"owner_response_intake_handoff_queue_accepted_count": 0,
|
||||
"owner_response_intake_handoff_queue_runtime_gate_count": 0,
|
||||
},
|
||||
"false_flags": [
|
||||
"runtime_execution_authorized",
|
||||
"action_buttons_allowed",
|
||||
"token_value_collection_allowed",
|
||||
"raw_secret_allowed",
|
||||
"repo_write_allowed",
|
||||
"refs_sync_allowed",
|
||||
"github_primary_switch_authorized",
|
||||
"owner_response_metadata_intake_raw_payload_allowed",
|
||||
"owner_response_metadata_intake_secret_plaintext_allowed",
|
||||
"owner_response_metadata_intake_action_buttons_allowed",
|
||||
"owner_response_intake_handoff_queue_raw_payload_allowed",
|
||||
"owner_response_intake_handoff_queue_action_buttons_allowed",
|
||||
],
|
||||
},
|
||||
{
|
||||
"label": "s4.10 github target owner decision response",
|
||||
"path": "docs/security/github-target-owner-decision-response.snapshot.json",
|
||||
"schema": "github_target_owner_decision_response_v1",
|
||||
"status": "draft_waiting_owner_response",
|
||||
"template_count": 9,
|
||||
"summary_counts": {
|
||||
"owner_response_request_packet_count": 1,
|
||||
"response_template_count": 9,
|
||||
"owner_response_template_status_count": 9,
|
||||
"intake_preflight_check_count": 6,
|
||||
"acceptance_check_count": 8,
|
||||
"rejection_rule_count": 10,
|
||||
"received_response_count": 0,
|
||||
"accepted_response_count": 0,
|
||||
"rejected_response_count": 0,
|
||||
},
|
||||
"false_flags": [
|
||||
"runtime_execution_authorized",
|
||||
"action_buttons_allowed",
|
||||
"repo_creation_authorized",
|
||||
"visibility_change_authorized",
|
||||
"refs_sync_authorized",
|
||||
"github_primary_switch_authorized",
|
||||
"secret_value_collection_allowed",
|
||||
"target_owner_request_dispatch_authorized",
|
||||
],
|
||||
},
|
||||
{
|
||||
"label": "s4.11 ref truth owner response",
|
||||
"path": "docs/security/source-control-ref-truth-owner-response.snapshot.json",
|
||||
"schema": "source_control_ref_truth_owner_response_v1",
|
||||
"status": "draft_waiting_owner_response",
|
||||
"template_count": 5,
|
||||
"summary_counts": {
|
||||
"owner_response_request_packet_count": 1,
|
||||
"response_template_count": 5,
|
||||
"owner_response_template_status_count": 5,
|
||||
"intake_preflight_check_count": 6,
|
||||
"acceptance_check_count": 8,
|
||||
"rejection_rule_count": 10,
|
||||
"received_response_count": 0,
|
||||
"accepted_response_count": 0,
|
||||
"rejected_response_count": 0,
|
||||
},
|
||||
"false_flags": [
|
||||
"runtime_execution_authorized",
|
||||
"action_buttons_allowed",
|
||||
"refs_sync_authorized",
|
||||
"refs_delete_authorized",
|
||||
"force_push_authorized",
|
||||
"github_primary_switch_authorized",
|
||||
"secret_value_collection_allowed",
|
||||
],
|
||||
},
|
||||
{
|
||||
"label": "s4.12 workflow secret owner response",
|
||||
"path": "docs/security/source-control-workflow-secret-name-owner-response.snapshot.json",
|
||||
"schema": "source_control_workflow_secret_name_owner_response_v1",
|
||||
"status": "draft_waiting_owner_response",
|
||||
"template_count": 5,
|
||||
"summary_counts": {
|
||||
"owner_response_request_packet_count": 1,
|
||||
"response_template_count": 5,
|
||||
"owner_response_template_status_count": 5,
|
||||
"intake_preflight_check_count": 6,
|
||||
"acceptance_check_count": 8,
|
||||
"rejection_rule_count": 10,
|
||||
"received_response_count": 0,
|
||||
"accepted_response_count": 0,
|
||||
"rejected_response_count": 0,
|
||||
},
|
||||
"false_flags": [
|
||||
"runtime_execution_authorized",
|
||||
"action_buttons_allowed",
|
||||
"workflow_modification_authorized",
|
||||
"repo_secret_change_authorized",
|
||||
"runner_change_authorized",
|
||||
"webhook_modification_authorized",
|
||||
"branch_protection_change_authorized",
|
||||
"deploy_key_change_authorized",
|
||||
"github_hosted_runner_enable_authorized",
|
||||
"secret_value_collection_allowed",
|
||||
"secret_value_or_hash_collection_allowed",
|
||||
"workflow_secret_owner_request_dispatch_authorized",
|
||||
"write_token_allowed",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise SystemExit(f"BLOCKED {message}")
|
||||
|
||||
|
||||
def assert_equal(label: str, actual: Any, expected: Any) -> None:
|
||||
if actual != expected:
|
||||
fail(f"{label}: expected {expected!r}, got {actual!r}")
|
||||
|
||||
|
||||
def assert_contains(label: str, values: list[Any], expected: Any) -> None:
|
||||
if expected not in values:
|
||||
fail(f"{label}: missing {expected!r}")
|
||||
|
||||
|
||||
def assert_text_contains(label: str, text: str, expected: str) -> None:
|
||||
if expected not in text:
|
||||
fail(f"{label}: missing {expected!r}")
|
||||
|
||||
|
||||
def assert_path_exists(root: Path, relative_path: str) -> None:
|
||||
if not (root / relative_path).exists():
|
||||
fail(f"path missing: {relative_path}")
|
||||
|
||||
|
||||
def summary_value(data: dict[str, Any], key: str) -> Any:
|
||||
summary = data.get("summary", {})
|
||||
if key in summary:
|
||||
return summary[key]
|
||||
return data.get(key)
|
||||
|
||||
|
||||
def assert_false_summary_flag(label: str, data: dict[str, Any], key: str) -> None:
|
||||
value = summary_value(data, key)
|
||||
assert_equal(f"{label}.{key}", value, False)
|
||||
|
||||
|
||||
def validate_docs(root: Path) -> None:
|
||||
for relative_path in REQUIRED_DOCS:
|
||||
assert_path_exists(root, relative_path)
|
||||
|
||||
canonical_text = (root / "docs/security/S4-9-CANONICAL-OWNER-RESPONSE-ENVELOPE.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
intake_text = (root / "docs/security/S4-9-OWNER-RESPONSE-INTAKE-FORM.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
gap_text = (root / "docs/security/S4-9-OWNER-RESPONSE-GATE-CURRENT-GAP-AUDIT.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
for field in CANONICAL_FIELDS:
|
||||
assert_text_contains("canonical_envelope.fields", canonical_text, field)
|
||||
assert_text_contains("intake_form.fields", intake_text, field)
|
||||
for template_id in S4_9_TEMPLATES:
|
||||
assert_text_contains("canonical_envelope.templates", canonical_text, template_id)
|
||||
assert_text_contains("intake_form.templates", intake_text, template_id)
|
||||
for marker in [
|
||||
"request_sent=false",
|
||||
"received_response_count=0",
|
||||
"accepted_response_count=0",
|
||||
"runtime_execution_authorized=false",
|
||||
"action_buttons_allowed=false",
|
||||
]:
|
||||
assert_text_contains("canonical_envelope.zero_boundary", canonical_text, marker)
|
||||
assert_text_contains("intake_form.zero_boundary", intake_text, marker)
|
||||
assert_text_contains("gap_audit.owner_gate_zero", gap_text, "S4.9 owner response gate 仍是 `0%`")
|
||||
|
||||
|
||||
def validate_gap_audit(root: Path) -> None:
|
||||
gap = load_json(root / "docs/security/s4-9-owner-response-gap-audit.snapshot.json")
|
||||
summary = gap["summary"]
|
||||
assert_equal("gap.schema_version", gap["schema_version"], "s4_9_owner_response_gap_audit_v1")
|
||||
assert_equal("gap.status", gap["status"], "gap_audit_ready_owner_gate_zero")
|
||||
assert_equal("gap.summary.current_requirement_gap_count", summary["current_requirement_gap_count"], 8)
|
||||
assert_equal("gap.summary.new_rule_count", summary["new_rule_count"], 7)
|
||||
assert_equal("gap.summary.rule_adjustment_count", summary["rule_adjustment_count"], 7)
|
||||
assert_equal("gap.summary.priority_work_item_count", summary["priority_work_item_count"], 9)
|
||||
for key in [
|
||||
"request_sent_count",
|
||||
"owner_response_received_count",
|
||||
"owner_response_accepted_count",
|
||||
"owner_response_rejected_count",
|
||||
"runtime_gate_count",
|
||||
]:
|
||||
assert_equal(f"gap.summary.{key}", summary[key], 0)
|
||||
assert_equal("gap.summary.public_surface_raw_namespace_allowed", summary["public_surface_raw_namespace_allowed"], False)
|
||||
assert_equal(
|
||||
"gap.summary.work_session_transcript_public_allowed",
|
||||
summary["work_session_transcript_public_allowed"],
|
||||
False,
|
||||
)
|
||||
false_boundaries = gap["false_boundaries"]
|
||||
for key, value in false_boundaries.items():
|
||||
if value is not False:
|
||||
fail(f"gap.false_boundaries.{key}: expected false, got {value!r}")
|
||||
|
||||
|
||||
def validate_owner_packet(root: Path, spec: dict[str, Any]) -> None:
|
||||
data = load_json(root / spec["path"])
|
||||
label = spec["label"]
|
||||
assert_equal(f"{label}.schema_version", data.get("schema_version"), spec["schema"])
|
||||
assert_equal(f"{label}.status", data.get("status"), spec["status"])
|
||||
assert_equal(f"{label}.runtime_execution_authorized", data.get("runtime_execution_authorized"), False)
|
||||
for key, expected in spec["summary_counts"].items():
|
||||
assert_equal(f"{label}.summary.{key}", summary_value(data, key), expected)
|
||||
for key in spec["false_flags"]:
|
||||
assert_false_summary_flag(label, data, key)
|
||||
|
||||
templates = data.get("response_templates", [])
|
||||
if not isinstance(templates, list):
|
||||
fail(f"{label}.response_templates: expected list")
|
||||
assert_equal(f"{label}.response_templates.count", len(templates), spec["template_count"])
|
||||
if "expected_template_ids" in spec:
|
||||
template_ids = [item.get("template_id") or item.get("id") for item in templates]
|
||||
for template_id in spec["expected_template_ids"]:
|
||||
assert_contains(f"{label}.response_templates", template_ids, template_id)
|
||||
|
||||
|
||||
def validate_rollup(root: Path) -> None:
|
||||
rollup = load_json(root / "docs/security/source-control-owner-response-validation-rollup.snapshot.json")
|
||||
summary = rollup["summary"]
|
||||
assert_equal("rollup.schema_version", rollup["schema_version"], "source_control_owner_response_validation_rollup_v1")
|
||||
assert_equal("rollup.status", rollup["status"], "draft_waiting_owner_responses")
|
||||
assert_equal("rollup.summary.response_packet_count", summary["response_packet_count"], 4)
|
||||
assert_equal("rollup.summary.total_response_template_count", summary["total_response_template_count"], 24)
|
||||
assert_equal("rollup.summary.total_acceptance_check_count", summary["total_acceptance_check_count"], 32)
|
||||
assert_equal("rollup.summary.total_rejection_rule_count", summary["total_rejection_rule_count"], 40)
|
||||
assert_equal("rollup.summary.validation_lane_count", summary["validation_lane_count"], 4)
|
||||
assert_equal("rollup.summary.owner_response_validation_reviewer_checklist_count", summary["owner_response_validation_reviewer_checklist_count"], 9)
|
||||
assert_equal("rollup.summary.owner_response_validation_reviewer_outcome_lane_count", summary["owner_response_validation_reviewer_outcome_lane_count"], 7)
|
||||
for key in [
|
||||
"total_received_response_count",
|
||||
"total_accepted_response_count",
|
||||
"total_rejected_response_count",
|
||||
"primary_ready_count",
|
||||
]:
|
||||
assert_equal(f"rollup.summary.{key}", summary[key], 0)
|
||||
for key in [
|
||||
"runtime_execution_authorized",
|
||||
"action_buttons_allowed",
|
||||
"repo_creation_authorized",
|
||||
"visibility_change_authorized",
|
||||
"refs_sync_authorized",
|
||||
"refs_delete_authorized",
|
||||
"workflow_modification_authorized",
|
||||
"runner_enablement_authorized",
|
||||
"secret_value_collection_allowed",
|
||||
"github_primary_switch_authorized",
|
||||
"force_push_authorized",
|
||||
"write_token_allowed",
|
||||
]:
|
||||
assert_false_summary_flag("rollup", rollup, key)
|
||||
|
||||
|
||||
def validate(root: Path) -> None:
|
||||
validate_docs(root)
|
||||
validate_gap_audit(root)
|
||||
validate_rollup(root)
|
||||
for spec in OWNER_PACKET_SPECS:
|
||||
validate_owner_packet(root, spec)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
default=Path(__file__).resolve().parents[2],
|
||||
type=Path,
|
||||
help="Repository root. Defaults to the current script's repository.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
validate(args.root.resolve())
|
||||
print("IWOOOS_OWNER_GATE_GUARD_OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -77,6 +77,8 @@ def validate(root: Path) -> None:
|
||||
security_dir = root / "docs" / "security"
|
||||
config_control_guard = runpy.run_path(str(root / "scripts" / "security" / "iwooos-config-control-guard.py"))
|
||||
config_control_guard["validate"](root)
|
||||
owner_gate_guard = runpy.run_path(str(root / "scripts" / "security" / "iwooos-owner-gate-guard.py"))
|
||||
owner_gate_guard["validate"](root)
|
||||
|
||||
manifest = load_json(security_dir / "security-supply-chain-contract-manifest.snapshot.json")
|
||||
readiness = load_json(security_dir / "security-mirror-readiness.snapshot.json")
|
||||
@@ -11982,12 +11984,28 @@ def validate(root: Path) -> None:
|
||||
|
||||
for text in [
|
||||
"sourcePublicScopeCode(index)",
|
||||
"assetPublicCode(index)",
|
||||
"routePublicCode(index)",
|
||||
"tenantPublicCode(index)",
|
||||
"tenantPublicName(tenant, index)",
|
||||
"tenantBudgetState(tenant)",
|
||||
"isPublicAssetTextSafe",
|
||||
"lookupPublicProductName",
|
||||
"publicChineseAssetText",
|
||||
"assetPublicProductName(item, index)",
|
||||
"routePublicProductName(route, index)",
|
||||
"sourcePublicProductName(repo, index)",
|
||||
"sourceRepos.map((repo, index)",
|
||||
"sourceRiskLabel(repo.risk, t)",
|
||||
"sourceReadinessLabel(repo.readiness_state, t)",
|
||||
"sourceActionLabel(repo.readiness_state, t)",
|
||||
"redactedScope",
|
||||
"nextControl",
|
||||
"租戶代號",
|
||||
"公開名稱",
|
||||
"預算狀態",
|
||||
"已提交證據參照",
|
||||
"完整證據路徑保留在只讀台帳與 guard",
|
||||
]:
|
||||
assert_text_contains("awooop_tenants_page.source_namespace_redaction", awooop_tenants_page, text)
|
||||
for text in [
|
||||
@@ -12005,6 +12023,16 @@ def validate(root: Path) -> None:
|
||||
"repo_owner_namespace_redacted=true",
|
||||
"raw_repository_namespace_visible=false",
|
||||
"public_api_raw_repo_namespace_allowed=false",
|
||||
"{tenant.display_name || \"--\"}",
|
||||
"budget?.toLocaleString",
|
||||
"minimumFractionDigits",
|
||||
"專案 ID",
|
||||
"預算上限 (USD)",
|
||||
"{item.project_id}",
|
||||
"publicAssetText(item.product_name, item.project_id)",
|
||||
"publicAssetText(route.product_name, route.product_id)",
|
||||
"evidence_refs.map((ref)",
|
||||
"{ref}",
|
||||
]:
|
||||
assert_text_not_contains("awooop_tenants_page.raw_source_control_status_render", awooop_tenants_page, text)
|
||||
assert_text_not_contains(
|
||||
@@ -24964,7 +24992,7 @@ def validate(root: Path) -> None:
|
||||
assert_equal(
|
||||
"dry_run.latest_local_validation.result",
|
||||
local_validation["result"],
|
||||
"SECURITY_MIRROR_PROGRESS_GUARD_OK; SOURCE_CONTROL_OWNER_RESPONSE_GUARD_OK; IWOOOS_CONFIG_CONTROL_GUARD_OK",
|
||||
"SECURITY_MIRROR_PROGRESS_GUARD_OK; SOURCE_CONTROL_OWNER_RESPONSE_GUARD_OK; IWOOOS_CONFIG_CONTROL_GUARD_OK; IWOOOS_OWNER_GATE_GUARD_OK",
|
||||
)
|
||||
assert_contains("dry_run.latest_local_validation.validated_steps", local_validation["validated_steps"], "CHECK_PROGRESS_GUARD")
|
||||
assert_contains(
|
||||
@@ -24972,6 +25000,11 @@ def validate(root: Path) -> None:
|
||||
local_validation["validated_steps"],
|
||||
"CHECK_OWNER_RESPONSE_GUARD",
|
||||
)
|
||||
assert_contains(
|
||||
"dry_run.latest_local_validation.validated_steps",
|
||||
local_validation["validated_steps"],
|
||||
"CHECK_OWNER_GATE_GUARD",
|
||||
)
|
||||
assert_contains(
|
||||
"dry_run.latest_local_validation.validated_steps",
|
||||
local_validation["validated_steps"],
|
||||
|
||||
Reference in New Issue
Block a user