#!/usr/bin/env python3
"""Turn the first-pass source evidence into a reviewable change list."""

import csv
import html
import json
from collections import defaultdict
from pathlib import Path

from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill


HERE = Path(__file__).resolve().parent
inventory = {i["repo"]: i for i in json.loads((HERE / "inventory.json").read_text())}
validation = json.loads((HERE / "validation.json").read_text())
signals = list(csv.DictReader((HERE / "signals.csv").open()))
EXCLUDE = {"vendor", "thinkphp", "extend", "node_modules", "dist", "build", "tests", "test", "samples", "public"}


def targets(repo, rule, first_party=False):
    grouped = defaultdict(list)
    for signal in signals:
        if signal["repo"] != repo or signal["rule"] != rule:
            continue
        if first_party and any(part in EXCLUDE for part in Path(signal["path"]).parts):
            continue
        grouped[signal["path"]].append(int(signal["line"]))
    return [(path, sorted(set(lines))) for path, lines in sorted(grouped.items())]


packages = [
    {
        "id": "SEC-01", "order": "先核验，再改", "repo": "store", "type": "私钥文件入库",
        "scope": "11 个受跟踪 PEM；支付模板通过 application/common/model/PaymentTemplate.php 动态读取 data/payment/<方式>/<店铺> 目录。",
        "change": "先由支付负责人确认这两份不同的私钥内容是否仍有效。若有效，安排微信支付侧轮换；让部署从受控密钥目录提供证书文件，保证运行时路径可用；随后停止 Git 跟踪私钥并给运行目录加入忽略规则。历史访问与清理另行评审。",
        "accept": "新密钥支付、退款、回调验签均通过；旧密钥撤销；当前 HEAD 不再跟踪私钥；部署能稳定读取新证书；仓库中不出现新私钥。",
        "gate": "必须先确认商户身份、证书现用状态和支付回归环境；不能先删运行文件。",
        "targets": targets("store", "private_key_material"),
    },
    {
        "id": "SEC-02", "order": "先核验，再改", "repo": "tnxl5.0_pc", "type": "私钥文件入库",
        "scope": "application/base/private.pem；当前源码未发现对文件名 private.pem 的直接字面引用。",
        "change": "先核实它是否为现用签名密钥及是否通过动态路径加载。若现用，轮换并迁到受控密钥位置、停止 Git 跟踪；若已废弃，确认无运行依赖后从当前版本撤下，并评审历史访问风险。",
        "accept": "签名或解密流程用新配置通过回归；当前 HEAD 不跟踪私钥；旧密钥状态有负责人回读记录。",
        "gate": "用途未确认前不删除，也不声称已经泄露现用凭据。",
        "targets": targets("tnxl5.0_pc", "private_key_material"),
    },
    {
        "id": "SEC-03", "order": "核实后决定改动", "repo": "ai_api", "type": "受跟踪环境文件",
        "scope": "18 个受跟踪 .env 文件；56 处疑似已填入凭据的字段赋值。",
        "change": "由配置负责人逐文件区分占位值和真实现用值。真实凭据先轮换，改为部署环境变量或受控密钥注入；仓库仅保留无密钥模板，停止跟踪真实环境文件。同步核对各客户环境的部署映射。",
        "accept": "各目标环境启动及关键接口回归通过；当前 HEAD 不含真实凭据；模板能让新部署明确填写所需字段；旧凭据已撤销或轮换。",
        "gate": "不能直接批量替换或删除 18 个文件；客户环境映射与负责人待核对。",
        "targets": targets("ai_api", "sensitive_filename"),
    },
    {
        "id": "SEC-04", "order": "核实后决定改动", "repo": "food_safety", "type": "受跟踪环境文件",
        "scope": "2 个运行配置 .env 文件有 7 处疑似凭据赋值；1 个 .env.example 还有 1 处需核验的示例值。",
        "change": "核对根目录及 gateway-service 的实际部署配置。真实现用值轮换并迁到受控注入；只留下无密钥模板，同时检查镜像构建和部署包是否还包含旧值。",
        "accept": "网关、数据库和相关应用在目标环境启动通过；仓库与新构建产物不含真实凭据；旧值轮换有回读。",
        "gate": "先区分示例、测试和现用值；不要在报告或工单中复制明文。",
        "targets": [x for x in targets("food_safety", "sensitive_filename") if x[0] in {".env", "services/gateway-service/.env", "services/gateway-service/.env.example"}],
    },
]

tls_details = {
    "store": ("支付/一卡通及通用 HTTP、硬件请求", "优先修复 application/common/service/onecard 的 HTTP 客户端，再逐个处理通用和硬件调用；开启证书链与主机名校验，配置可信 CA，保留明确失败处理。", "一卡通支付/取令牌与相关外部接口在有效证书下成功；错误证书、错误主机名均被拒绝；老设备兼容回归通过。"),
    "ai_api": ("一卡通、微信、物流、健康设备及通用网络库", "优先修复 PaymentClient 和公共 Network/HTTP 帮助方法；对不同服务分别确认 CA 与主机名，移除关闭校验的默认分支。", "支付、令牌、微信/物流/设备接口正常；失效证书和错误主机名被拒绝；失败不变成成功响应。"),
    "tnxl5.0_pc": ("Gymware、OuraRing、Vald 设备服务和通用 HTTP", "逐个供应商确认证书链；把关闭校验改为使用可信 CA 或证书固定，并修复通用 helper 的默认值。", "各供应商有效连接通过；证书失效或主机名不符时拒绝；设备同步回归通过。"),
    "sxtyj": ("同步、视频、统一登录与用户令牌", "优先检查统一登录与令牌请求，随后修复 SyncBase/VideoBase；开启 peer 和 host 校验，配置真实可信 CA。", "登录/令牌/同步/视频接口在有效证书下通过，错误证书被拒绝，登录失败路径可恢复。"),
    "datacenter": ("Polar、Firstbeat 与通用 HTTP", "先核实远端设备和厂商端点，再在三个调用模块中启用正确 CA/主机名校验；避免把兼容开关做成生产默认。", "设备数据同步在有效证书下通过；错误证书拒绝；任务失败可观察、可重试。"),
    "ydysskz": ("通用 HTTP、硬件和餐饮请求", "修复 common.php 的公共请求函数及两处调用点，核对自签名设备证书的信任链。", "正常设备与接口可用；不可信证书被拒绝；订餐和硬件业务回归通过。"),
    "food_safety": ("MySQL 连接及迁移、等待脚本", "将 db.service.ts 与迁移/等待脚本里的 rejectUnauthorized:false 改为可验证的数据库 TLS 配置，提供可信 CA 与服务端身份；各脚本保持一致。", "服务启动、迁移与等待脚本连接成功；无效 CA 和错误主机名被拒绝；数据库初始化流程回归通过。"),
}
for index, (repo, (scope, change, accept)) in enumerate(tls_details.items(), 5):
    current = targets(repo, "tls_verification_disabled", first_party=True)
    packages.append({
        "id": f"SEC-{index:02d}", "order": "修复前核验端点", "repo": repo, "type": "关闭 TLS 校验",
        "scope": f"{scope}；{len(current)} 个文件、{sum(len(lines) for _, lines in current)} 行设置。",
        "change": change, "accept": accept,
        "gate": "先核验目标实际使用 HTTPS/TLS、证书和历史设备兼容；HTTP 路径上的设置仅清理无效代码，不按漏洞处置。",
        "targets": current,
    })

def escaped(value):
    return html.escape(str(value))


md = [
    "# 受控源码首轮检查：需要修改什么",
    "",
    "日期：2026-09-24。来源：[总体报告](portfolio-security-audit.md)。本清单是整改计划，不代表 11 个漏洞，也未修改业务源码。",
    "",
    "## 一句话顺序",
    "",
    "先确认并轮换仍有效的密钥；再修复经核实会走 HTTPS/TLS 的调用；最后用有效/错误证书和业务流程回归。环境文件中的值先核验，不能把疑似值直接当作真实凭据。",
    "",
    "## 需要安排的 11 个工作包",
    "",
    "| 编号 | 仓库 | 先处理什么 | 范围 | 要改的内容 | 完成标准 | 开始前门禁 |",
    "| --- | --- | --- | --- | --- | --- | --- |",
]
for p in packages:
    md.append("| " + " | ".join(str(p[k]).replace("|", "\\|") for k in ["id", "repo", "type", "scope", "change", "accept", "gate"]) + " |")
md += [
    "",
    "## 不能直接当作修改项的线索",
    "",
    "- 333 条规则命中：包含重复、框架代码、示例、字段名匹配；不应开 333 张缺陷单。",
    "- 第一方源码 10 处 shell 执行 API 命中、49 处疑似硬编码赋值：先追踪输入来源、值的用途和调用权限，证实有具体缺陷后再形成代码改动。",
    "- OCR 的 6 条建议来自 3 个抽样文件，其中 2 条 TLS 建议已经被上面的工作包覆盖；其余先核实调用路径和业务影响。",
    "",
    "## 文件级定位",
    "",
    "下面的文件清单仅记录路径和行号，不含私钥或环境变量的值。行号基于检查时的 HEAD/工作树快照；修改前要重新核对分支和 HEAD。",
    "",
    "| 工作包 | 仓库 | 文件 | 命中行 |",
    "| --- | --- | --- | --- |",
]
target_rows = []
for p in packages:
    for path, lines in p["targets"]:
        target_rows.append((p["id"], p["repo"], path, ", ".join(map(str, lines)) if lines != [0] else "文件级"))
        md.append("| " + " | ".join([p["id"], p["repo"], path.replace("|", "\\|"), ", ".join(map(str, lines)) if lines != [0] else "文件级"]) + " |")
md += [
    "",
    "## 验收与边界",
    "",
    "1. 每个仓库各自开独立修复任务/分支；支付、统一登录和数据库连接的改动需要对应负责人和真实回归环境。",
    "2. 先有密钥轮换和部署路径方案，再取消 Git 跟踪；不能删除运行中的证书文件或直接改写已推送历史。",
    "3. TLS 改动必须同时证明‘有效证书正常’与‘错误证书拒绝’，再做支付、设备、登录、数据库或迁移的业务冒烟。",
    "4. 本清单仍缺少密钥现用状态、各外部端点证书链和完整六阶段审计证据；这些缺口未关闭前不宣布全部安全问题已整改。",
]
(HERE / "remediation-plan.md").write_text("\n".join(md) + "\n")

def make_table(headers, rows):
    return '<div class="scroll"><table><thead><tr>' + "".join(f"<th>{escaped(h)}</th>" for h in headers) + '</tr></thead><tbody>' + "".join("<tr>" + "".join(f"<td>{escaped(v)}</td>" for v in row) + "</tr>" for row in rows) + "</tbody></table></div>"

css = """
:root{--bg:#f5f7f8;--card:#fff;--ink:#19323b;--muted:#62767d;--line:#dce5e7;--teal:#126f78;--amber:#a46216}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.7 -apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif}.page{max-width:1180px;margin:auto;padding:32px 22px 80px}.hero{background:linear-gradient(125deg,#173e4b,#126c73);color:#fff;border-radius:24px;padding:46px 48px}.hero small{letter-spacing:.1em;color:#bde6e8;font-weight:700}.hero h1{font-size:clamp(30px,4vw,45px);line-height:1.2;margin:14px 0}.hero p{color:#d8ecec;max-width:850px}.meta{font-size:13px}.summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin:20px 0}.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:20px}.card strong{font-size:27px;color:var(--teal);display:block}.card span{color:var(--muted)}section{margin-top:38px}h2{font-size:24px;margin:0 0 12px}p.lede{color:var(--muted)}.scroll{overflow:auto;background:var(--card);border:1px solid var(--line);border-radius:16px}table{border-collapse:collapse;width:100%;min-width:1100px;font-size:13px}th,td{padding:11px 12px;text-align:left;vertical-align:top;border-bottom:1px solid var(--line)}th{background:#eaf1f2;white-space:nowrap}tr:last-child td{border-bottom:0}.note{border-left:4px solid var(--amber);background:#fff1dc;padding:15px 18px;border-radius:12px}.list li{margin:9px 0}.footer{margin-top:45px;padding-top:18px;border-top:1px solid var(--line);color:var(--muted);font-size:13px}a{color:var(--teal)}@media(max-width:740px){.page{padding:12px 12px 45px}.hero{padding:32px 24px}.summary{grid-template-columns:1fr}.card strong{font-size:24px}}
"""
package_rows = [(p["id"],p["repo"],p["type"],p["scope"],p["change"],p["accept"],p["gate"]) for p in packages]
html_page = f'''<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>受控源码整改清单</title><style>{css}</style></head><body><main class="page"><header class="hero"><small>INTERNAL · REMEDIATION PLAN</small><h1>受控源码整改清单</h1><p>先核验密钥，再修复真实 TLS 调用，最后做拒绝错误证书和业务回归。这里的 11 个工作包是组织改动的单位，并不代表 11 个漏洞。</p><div class="meta">2026-09-24 · 来源：31 仓库首轮只读筛查</div></header><div class="summary"><div class="card"><strong>2 个仓库</strong><span>私钥文件需要核验与迁出 Git</span></div><div class="card"><strong>2 个仓库</strong><span>环境文件凭据线索待确认</span></div><div class="card"><strong>7 个仓库</strong><span>TLS 校验关闭点待逐仓修复</span></div></div><div class="note">工作顺序：确认凭据是否有效 → 轮换并准备部署路径 → 取消跟踪；TLS 先核验端点证书，再改代码并测试。不要直接删除运行文件或一次性批量替换。</div><section><h2>11 个工作包</h2>{make_table(['编号','仓库','类别','范围','要改的内容','完成标准','开始前门禁'],package_rows)}</section><section><h2>文件级定位</h2><p class="lede">路径和行号来自本次快照，不含密钥值。修改前需再次核对分支与 HEAD。</p>{make_table(['工作包','仓库','文件','命中行'],target_rows)}</section><section><h2>仍需调查的线索</h2><ul class="list"><li>333 条原始规则命中包含重复和误报，不逐条建单。</li><li>第一方源码 10 处 shell 执行 API、49 处疑似硬编码赋值，先追踪数据流和权限，再确定是否修改。</li><li>OCR 仅抽样 3 个文件、6 条建议；其中 2 条 TLS 建议已包含在本清单。</li></ul></section><section><h2>验收底线</h2><ol class="list"><li>密钥确认和轮换先于撤销 Git 跟踪；业务运行文件保留到新方案验收。</li><li>TLS 同时验证有效证书成功、错误证书拒绝，并做关联业务冒烟。</li><li>按仓库独立任务和分支执行，完整六阶段审计仍需满足强制沙箱条件。</li></ol></section><footer class="footer">内部资料 · <a href="portfolio-security-audit.html">返回总体报告</a> · 详细可编辑版：remediation-plan.md</footer></main></body></html>'''
(HERE / "remediation-plan.html").write_text(html_page)

wb = Workbook()
sheet = wb.active
sheet.title = "整改工作包"
sheet.append(["编号","顺序","仓库","类别","范围","要改的内容","完成标准","开始前门禁"])
for p in packages:
    sheet.append([p[k] for k in ["id","order","repo","type","scope","change","accept","gate"]])
sheet = wb.create_sheet("文件级定位")
sheet.append(["工作包","仓库","文件","命中行"])
for row in target_rows:
    sheet.append(row)
for sheet in wb:
    sheet.freeze_panes = "A2"
    sheet.auto_filter.ref = sheet.dimensions
    for cell in sheet[1]:
        cell.font = Font(name="微软雅黑", size=11, bold=True, color="FFFFFF")
        cell.fill = PatternFill("solid", fgColor="176B73")
    for col in sheet.columns:
        sheet.column_dimensions[col[0].column_letter].width = min(65,max(14,max(len(str(c.value or "")) for c in col[:min(len(col),60)])+3))
    for row in sheet.iter_rows(min_row=2):
        for cell in row:
            cell.font = Font(name="微软雅黑",size=10)
            cell.alignment = Alignment(vertical="top",wrap_text=True)
wb.save(HERE / "remediation-plan.xlsx")
print(f"Wrote 11 work packages, {len(target_rows)} file targets")
