#!/usr/bin/env python3
"""Build a redacted portfolio audit brief from local, read-only evidence."""

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

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


HERE = Path(__file__).resolve().parent
inventory = json.loads((HERE / "inventory.json").read_text())
summary = json.loads((HERE / "summary.json").read_text())
validation = json.loads((HERE / "validation.json").read_text())
signals = list(csv.DictReader((HERE / "signals.csv").open()))
by_repo = Counter(s["repo"] for s in signals)

known = [
    ("K01", "store", "Git 跟踪含私钥标记的 PEM", "11 个文件、2 个不同文件内容；支付目录", "优先核验密钥是否在用，限制访问并安排轮换"),
    ("K02", "tnxl5.0_pc", "Git 跟踪含私钥标记的 PEM", "1 个文件；application/base/private.pem", "核验用途和有效期，安排轮换与移出仓库"),
]
for repo, count in validation["first_party_tls_off_repos"].items():
    known.append((f"T{len(known)-1:02d}", repo, "第一方源码关闭 TLS 校验", f"{count} 行设置；属于规则命中，不等于 {count} 个漏洞", "核验实际调用与端点，再恢复证书和主机名校验"))
pending = [
    ("C01", "ai_api", "受跟踪环境文件存在疑似已填入的凭据字段", "18 个 .env 文件；56 处字段赋值", "负责人核验真实凭据及现用状态，必要时轮换"),
    ("C02", "food_safety", "受跟踪环境文件存在疑似已填入的凭据字段", "2 个运行 .env 有 7 处；1 个 .env.example 有 1 处待判定", "负责人核验真实凭据及现用状态，必要时轮换"),
]

ocr = [
    ("qc", "src/utils/storage.ts", "2 条", "JSON 解析异常待复核；localStorage 加密建议缺少具体边界，未采纳为漏洞"),
    ("store", "application/common/service/onecard/ApiClient.php", "2 条", "TLS 关闭与静态证据一致；HTTP 状态码处理待复核；OCR 的 critical 等级未采纳"),
    ("ai_api", "app/common/service/onecard/PaymentClient.php", "2 条", "TLS 关闭与静态证据一致；md5(uniqid()) 回退 nonce 待复核"),
]

notes = [
    "本次是跨仓库第一轮只读源码筛查。Cloudflare security-audit 六阶段正式审计未运行：当前未证明可对目标代码同时提供无外网、空环境、只读源码、独占 scratch 与完整 CPU/内存/进程/磁盘/时间限制；Docker daemon 也不可用。正式审计状态为 incomplete，不给出“全部漏洞数”。",
    "OCR 仅扫描 3 个定向文件，使用本机 Ollama qwen3:14b、127.0.0.1:11434、关闭 telemetry、清空继承环境，并用 macOS sandbox-exec 限制非本机网络；OCR 的 6 条原始建议没有直接并入确认问题数。",
    "31 个登记仓库均取得本地源码快照，其中 kxxl 和 datacenter 是审计缓存中的浅克隆；kxxl 取 master_jiangxitks，datacenter 取 master。其他分支、远端最新提交、Git 历史、运行配置、依赖漏洞和生产可达性未全面覆盖。ai_app 与 playwright-ui-tests 有未提交改动，按工作树读取。",
    "333 条规则命中会重复、包含框架或示例代码，也包含仅需上下文核验的用法；它们不是 333 个漏洞。私钥和 TLS 结论已从对应 Git HEAD 再次回读。环境文件只记录字段数量，没有保存或展示任何字段值。",
]


def md_table(headers, rows):
    result = ["| " + " | ".join(headers) + " |", "| " + " | ".join(["---"] * len(headers)) + " |"]
    result += ["| " + " | ".join(str(v).replace("|", "\\|") for v in row) + " |" for row in rows]
    return "\n".join(result)


repo_rows = []
for item in inventory:
    state = "审计浅克隆" if item["source"] == "audit_clone" else ("本地有改动" if item["dirty_paths"] else "本地干净")
    repo_rows.append((item["repo"], state, item["branch"], item["head"][:10], item["tracked_files"], item["scanned_text_files"], by_repo[item["repo"]]))

markdown = [
    "# 受控源码仓库安全检查：首轮总体报告",
    "",
    "日期：2026-09-24 · 范围：`control/zhctproject-repositories.csv` 登记的 31 个仓库 · 状态：第一轮只读筛查，正式六阶段审计未完成",
    "",
    "具体要改什么：[整改工作清单](remediation-plan.md)。",
    "",
    "## 先说清楚结论",
    "",
    "**现在不能回答‘一共有多少个漏洞’。** 本次只完成首轮源码筛查，没有完成逐条利用条件验证和 Cloudflare 六阶段审计。‘未知’不表示没有漏洞。",
    "",
    "计数口径如下：",
    "",
    "1. **31 个仓库**：登记清单里的 31 个仓库都取得了一个源码快照；没有审遍每个仓库的所有分支和历史。",
    "2. **333 条机器提醒**：规则在文件或代码行上匹配了 333 次，有重复和误报，不能当作 333 个问题。",
    "3. **2 类已核实的源码现象**：私钥文件被 Git 跟踪、源码关闭 TLS 校验。按‘现象 × 仓库’合并为 **2 + 7 = 9 项**；这是整理台账的数量，不是 9 个已证实可利用的漏洞。",
    "4. **2 项待核验线索**：`ai_api`、`food_safety` 的环境文件有疑似凭据字段，尚未判断值是否真实、有效、仍在使用。",
    "5. **OCR 6 条建议**：只抽查 3 个文件；两条 TLS 建议与上面的现象重复，其余尚未证实，不能与 9 相加。",
    "",
    "本轮检查了 **64,788** 个 Git 跟踪文件中的 **28,156** 个文本源码文件。实际可利用漏洞总数：**未得出**。",
    "",
    "## 已核实的源码现象（按仓库合并为 9 项）",
    "",
    md_table(["编号", "仓库", "事实", "证据范围", "建议"], known),
    "",
    "## 高优先级待核验（2 项）",
    "",
    md_table(["编号", "仓库", "线索", "证据范围", "下一步"], pending),
    "",
    "## 原始命中分布",
    "",
    md_table(["规则", "命中行/文件数", "解释"], [
        ("关闭 TLS 校验", 66, "其中 62 行在第一方源码，涉及 7 仓库、36 文件"),
        ("shell 执行 API", 88, "仅 10 行位于初筛的第一方源码；需追踪输入来源"),
        ("疑似硬编码敏感赋值", 97, "大量为字段映射、测试或示例；不能直接定性"),
        ("敏感文件名", 70, "包含证书、环境模板等；仅文件名命中"),
        ("私钥 PEM 标记", 12, "12 个受跟踪文件，实际为 3 个不同文件内容"),
    ]),
    "",
    "## OCR 本地模型复核",
    "",
    md_table(["仓库", "文件", "原始建议", "人工归类"], ocr),
    "",
    "## 仓库覆盖矩阵",
    "",
    md_table(["仓库", "快照状态", "分支", "HEAD", "跟踪文件", "筛查文本", "原始命中"], repo_rows),
    "",
    "## 方法与边界",
    "",
    *[f"- {n}" for n in notes],
    "",
    "## 处置顺序",
    "",
    "1. 由密钥所有者核验 `store` 与 `tnxl5.0_pc` 中私钥的用途、有效期和接触面；对仍有效的凭据先安排轮换，再处理 Git 跟踪和历史保留策略。不要把密钥内容复制进工单。",
    "2. 由 `ai_api` 和 `food_safety` 负责人逐项核验环境文件中的疑似凭据，区分示例值和真实现用值；真实值按同样流程轮换与迁移到受控密钥存储。",
    "3. 先处理支付、一卡通、数据库连接相关的 TLS 关闭点，核验目标证书链、主机名、历史设备兼容与回归测试后修复；其他调用点按仓库排期。",
    "4. 在满足 Cloudflare Skill 的 OS 强制沙箱和独立验证条件后，按支付/身份/多租户/外部接入优先分批完成六阶段审计；对 shell 执行和疑似硬编码命中做数据流复核。",
    "",
    "## 交付与验证",
    "",
    "- 只读筛查脚本：`scan_portfolio.py`；登记清单与 HEAD：`inventory.json`；原始位置：`signals.csv`；HEAD 复核计数：`validation.json`。所有清单仅含路径、行号和数量，不含凭据值。",
    "- 该报告属于内部敏感安全资料，保留本机查看；未公开发布，也未修改任何业务仓库源码。",
]
(HERE / "portfolio-security-audit.md").write_text("\n".join(markdown) + "\n")


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


css = """
:root{--bg:#f4f6f7;--card:#fff;--ink:#172b35;--muted:#60717a;--line:#dce4e8;--teal:#147c83;--pale:#d9eef0;--amber:#a35d12;--amber-bg:#fff2df;--red:#9d3535;--red-bg:#fdecec;--radius:18px}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.65 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1160px;margin:auto;padding:36px 24px 80px}.hero{background:linear-gradient(125deg,#123f4c,#0d6570);color:#fff;border-radius:28px;padding:52px 56px}.eyebrow{font-size:13px;letter-spacing:.12em;text-transform:uppercase;color:#b7e3e4;font-weight:700}.hero h1{font-size:clamp(30px,4vw,48px);line-height:1.15;margin:14px 0}.hero p{max-width:780px;color:#d5e7e8;margin:0}.meta{font-size:14px;margin-top:24px!important}.grid{display:grid;gap:14px}.metrics{grid-template-columns:repeat(4,minmax(0,1fr));margin:20px 0 38px}.metric,.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:22px}.metric strong{display:block;font-size:34px;line-height:1.1;color:var(--teal)}.metric span{display:block;color:var(--muted);font-size:14px;margin-top:8px}.section{margin-top:42px}.section h2{font-size:25px;line-height:1.25;margin:0 0 10px}.section>p{color:var(--muted);margin:0 0 18px}.section-kicker{display:block;color:var(--teal);font-size:12px;font-weight:800;letter-spacing:.1em;margin-bottom:7px}.callout{padding:16px 19px;border-radius:14px;background:var(--amber-bg);border-left:4px solid var(--amber);margin:18px 0}.callout strong{color:#78440b}.table-wrap{overflow:auto;background:var(--card);border:1px solid var(--line);border-radius:16px}table{width:100%;border-collapse:collapse;font-size:14px;min-width:730px}th,td{padding:11px 13px;text-align:left;vertical-align:top;border-bottom:1px solid var(--line)}th{background:#eaf1f3;color:#32505a;font-size:12px;white-space:nowrap}tbody tr:last-child td{border-bottom:0}tbody tr:hover{background:#f8fbfb}code{font-size:.92em;background:#e9eff0;padding:2px 5px;border-radius:5px;overflow-wrap:anywhere}.action-list{padding-left:23px}.action-list li{margin:10px 0}.notes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.notes .card{color:var(--muted)}.footer{color:var(--muted);font-size:13px;margin-top:50px;border-top:1px solid var(--line);padding-top:18px}a{color:#056c75}@media(max-width:760px){.page{padding:14px 12px 48px}.hero{padding:34px 24px;border-radius:20px}.metrics{grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.metric{padding:15px}.metric strong{font-size:27px}.notes{grid-template-columns:1fr}.section{margin-top:34px}}
"""
css += """
.logic{background:#fff;border:1px solid var(--line);border-radius:18px;padding:26px 30px;margin:20px 0 26px}.logic h2{font-size:22px;margin:0 0 12px}.logic ol{padding-left:22px;margin:0}.logic li{padding:6px 0}.logic strong{color:var(--teal)}.logic .unknown{color:#8f4114;font-weight:800}@media(max-width:760px){.logic{padding:20px}}
"""
sections = [
    ('01 / VERIFIED SOURCE FACTS', '已核实的源码现象', '<p>9 的计算方式是“私钥入库涉及 2 个仓库 + TLS 校验关闭涉及 7 个仓库”。同一仓库可出现两类现象，因此不是 9 个不同仓库，更不是 9 个已证实可利用的漏洞。</p>' + table(["编号", "仓库", "事实", "证据范围", "建议"], known)),
    ('02 / NEEDS OWNER VALIDATION', '高优先级待核验', '<p>仅确认 Git 跟踪文件里存在非占位、凭据形态的字段赋值。没有读取或展示其值。</p>' + table(["编号", "仓库", "线索", "证据范围", "下一步"], pending)),
    ('03 / RAW SIGNALS', '规则命中不等于漏洞', table(["规则", "原始命中", "解释"], [("关闭 TLS 校验",66,"62 行在第一方源码，涉及 7 仓库、36 文件"),("shell 执行 API",88,"10 行在初筛第一方源码，需追踪输入"),("疑似硬编码敏感赋值",97,"含字段映射、测试和示例"),("敏感文件名",70,"只看文件名"),("私钥 PEM 标记",12,"12 个文件、3 个不同内容")])),
    ('04 / LOCAL OCR SAMPLE', '本地 OCR 定向复核', '<p>仅抽查 3 个文件，原始模型建议 6 条；其中两条 TLS 建议与静态证据相同，其余需要上下文复核或已排除泛化判断。</p>' + table(["仓库", "文件", "原始建议", "人工归类"], ocr)),
    ('05 / COVERAGE', '31 个仓库覆盖矩阵', '<p>分支和 HEAD 是本次检查时的快照。原始命中是规则计数，不能横向比较仓库安全程度。</p>' + table(["仓库", "快照状态", "分支", "HEAD", "跟踪文件", "筛查文本", "原始命中"], repo_rows)),
]
body = "".join(f'<section class="section"><span class="section-kicker">{html.escape(k)}</span><h2>{html.escape(title)}</h2>{content}</section>' for k,title,content in sections)
body += '<section class="section"><span class="section-kicker">06 / DECISION</span><h2>建议处理顺序</h2><ol class="action-list"><li>先核验并轮换仍有效的私钥，同时收紧仓库访问与历史保留策略。</li><li>由 ai_api 和 food_safety 负责人核验受跟踪环境文件，真实现用凭据迁移到受控密钥存储。</li><li>从支付、一卡通、数据库连接着手修复 TLS 校验，先做证书链和兼容回归。</li><li>具备完整 OS 强制沙箱后，分批运行 Cloudflare 六阶段审计，再复核 shell 执行与硬编码线索。</li></ol></section>'
body += '<section class="section"><span class="section-kicker">07 / LIMITS</span><h2>方法与边界</h2><div class="notes">' + "".join(f'<div class="card">{html.escape(n)}</div>' for n in notes) + '</div></section>'
logic = '''<section class="logic"><h2>这几个数字怎么来的</h2><ol><li><strong>31 个仓库</strong>是本次拿到源码快照的范围，不代表审遍全部分支与历史。</li><li><strong>333 条机器提醒</strong>只是规则在文件或代码行上匹配的次数，包含重复与误报。</li><li>回读 Git HEAD 后，确认<strong>两类源码现象</strong>：私钥文件被跟踪（2 个仓库）；TLS 校验关闭（7 个仓库）。按“现象 × 仓库”记账为 <strong>2 + 7 = 9 项</strong>，并非 9 个已证实可利用的漏洞。</li><li>还有<strong>2 个仓库</strong>的环境文件含疑似凭据字段，真实有效性待核验。OCR 抽样建议与前述现象有重复，不再相加。</li><li>因此，实际可利用漏洞总数：<span class="unknown">尚未得出</span>。</li></ol></section>'''
page = f'''<!DOCTYPE html><html lang="zh-CN" data-theme="corporate-clean"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>受控源码仓库安全检查 · 首轮总体报告</title><style>{css}</style></head><body class="webpage"><main class="page"><header class="hero"><div class="eyebrow">INTERNAL · SOURCE PORTFOLIO REVIEW</div><h1>受控源码仓库<br>安全检查</h1><p>先说结论：这次完成的是首轮源码筛查，尚不能回答“总共有多少个漏洞”。</p><p class="meta">2026-09-24 · 31 个登记仓库 · 只读检查 · 内部查看</p></header><div class="grid metrics"><div class="metric"><strong>31/31</strong><span>取得源码快照</span></div><div class="metric"><strong>2 类</strong><span>已核实的源码现象</span></div><div class="metric"><strong>9 项</strong><span>按现象 × 仓库合并的记录</span></div><div class="metric"><strong>未知</strong><span>实际可利用漏洞总数</span></div></div>{logic}<div class="callout"><strong>不要直接相加：</strong>333 是原始规则命中，2 是待核验的环境文件线索，OCR 的 6 条建议只来自 3 个抽样文件。Cloudflare 六阶段正式审计尚未运行。</div>{body}<footer class="footer">证据文件：inventory.json、signals.csv、validation.json、portfolio-security-audit.md。报告不含密钥值，未公开发布。</footer></main></body></html>'''
page = page.replace("证据文件：inventory.json", '<a href="remediation-plan.html">查看整改清单</a> · 证据文件：inventory.json')
(HERE / "portfolio-security-audit.html").write_text(page)

wb = Workbook()
ws = wb.active
ws.title = "仓库覆盖"
ws.append(["仓库", "快照状态", "分支", "HEAD", "跟踪文件", "筛查文本", "原始命中"])
for row in repo_rows:
    ws.append(row)
for title, headers, rows in [
    ("源码现象9项", ["编号", "仓库", "事实", "证据范围", "建议"], known),
    ("待核验", ["编号", "仓库", "线索", "证据范围", "下一步"], pending),
    ("OCR抽样", ["仓库", "文件", "原始建议", "人工归类"], ocr),
    ("原始命中", ["仓库", "规则", "路径", "行号"], [(s["repo"],s["rule"],s["path"],int(s["line"])) for s in signals]),
]:
    sheet = wb.create_sheet(title)
    sheet.append(headers)
    for row in 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")
        cell.alignment = Alignment(vertical="center")
    sheet.row_dimensions[1].height = 27
    for col in sheet.columns:
        letter = col[0].column_letter
        sheet.column_dimensions[letter].width = min(68, max(14, max(len(str(c.value or "")) for c in col[:min(len(col), 100)]) + 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 / "portfolio-security-audit.xlsx")
print("Wrote Markdown, HTML and XLSX report")
