|
| 1 | +// src/compliance/policy.ts — policy-as-code 门禁(响应 GitHub issue #2) |
| 2 | +// |
| 3 | +// 在 Git/CI 边界用声明式策略约束扫描结果:项目根放 `.shellward.json`, |
| 4 | +// CI(shellward scan --ci)据此判定通过/失败。把"策略在 push 时声明 → 运行时执行" |
| 5 | +// 的纵深防御补上 push 这一端。无策略文件时回退到默认(有 critical 即失败)。 |
| 6 | +// |
| 7 | +// 示例 .shellward.json: |
| 8 | +// { |
| 9 | +// "failOn": ["secret", "pii"], // 命中这些"类别"或"严重度"即失败 |
| 10 | +// "maxFindings": 0, // 总发现数上限 |
| 11 | +// "allowOverseas": ["OpenAI"] // 允许的境外厂商(不计入失败) |
| 12 | +// } |
| 13 | + |
| 14 | +import { readFileSync } from 'fs' |
| 15 | +import { join } from 'path' |
| 16 | +import type { ProjectScanResult, ProjectFinding, FindingKind } from './project-scan.js' |
| 17 | + |
| 18 | +export interface ShellwardPolicy { |
| 19 | + /** 命中即失败:可填类别(secret/pii/overseas/env-perm) 或 严重度(critical/high/medium) */ |
| 20 | + failOn?: string[] |
| 21 | + /** 总发现数上限(含被 allowOverseas 豁免后的) */ |
| 22 | + maxFindings?: number |
| 23 | + /** 允许的境外大模型厂商(provider 名,命中这些的 overseas 发现被豁免) */ |
| 24 | + allowOverseas?: string[] |
| 25 | +} |
| 26 | + |
| 27 | +export interface PolicyResult { |
| 28 | + pass: boolean |
| 29 | + source: 'file' | 'default' |
| 30 | + violations: string[] |
| 31 | + policy: ShellwardPolicy |
| 32 | +} |
| 33 | + |
| 34 | +const KINDS: FindingKind[] = ['overseas', 'secret', 'pii', 'env-perm'] |
| 35 | +const SEVERITIES = ['critical', 'high', 'medium'] |
| 36 | + |
| 37 | +/** 读取项目根的 .shellward.json;无/坏则返回默认策略(有 critical 即失败) */ |
| 38 | +export function loadPolicy(root: string): { policy: ShellwardPolicy; source: 'file' | 'default' } { |
| 39 | + try { |
| 40 | + const raw = readFileSync(join(root, '.shellward.json'), 'utf-8') |
| 41 | + const p = JSON.parse(raw) |
| 42 | + if (p && typeof p === 'object') return { policy: sanitize(p), source: 'file' } |
| 43 | + } catch { /* 无策略文件或解析失败 → 默认 */ } |
| 44 | + return { policy: { failOn: ['critical'] }, source: 'default' } |
| 45 | +} |
| 46 | + |
| 47 | +function sanitize(p: any): ShellwardPolicy { |
| 48 | + const out: ShellwardPolicy = {} |
| 49 | + if (Array.isArray(p.failOn)) out.failOn = p.failOn.filter((x: any) => typeof x === 'string') |
| 50 | + if (typeof p.maxFindings === 'number' && p.maxFindings >= 0) out.maxFindings = Math.floor(p.maxFindings) |
| 51 | + if (Array.isArray(p.allowOverseas)) out.allowOverseas = p.allowOverseas.filter((x: any) => typeof x === 'string') |
| 52 | + return out |
| 53 | +} |
| 54 | + |
| 55 | +/** 把被 allowOverseas 豁免的 overseas 发现去掉 */ |
| 56 | +function effective(findings: ProjectFinding[], allow: string[]): ProjectFinding[] { |
| 57 | + if (!allow.length) return findings |
| 58 | + const allowLower = new Set(allow.map(a => a.toLowerCase())) |
| 59 | + return findings.filter(f => { |
| 60 | + if (f.kind !== 'overseas') return true |
| 61 | + const prov = (f.provider_en || f.provider_zh || '').toLowerCase() |
| 62 | + return !allowLower.has(prov) |
| 63 | + }) |
| 64 | +} |
| 65 | + |
| 66 | +/** 根据策略评估扫描结果,返回是否通过 + 违规说明 */ |
| 67 | +export function evaluatePolicy(scan: ProjectScanResult, policy: ShellwardPolicy): PolicyResult { |
| 68 | + const allow = policy.allowOverseas || [] |
| 69 | + const findings = effective(scan.findings, allow) |
| 70 | + const violations: string[] = [] |
| 71 | + |
| 72 | + const failOn = policy.failOn || [] |
| 73 | + for (const token of failOn) { |
| 74 | + if (KINDS.includes(token as FindingKind)) { |
| 75 | + const n = findings.filter(f => f.kind === token).length |
| 76 | + if (n > 0) violations.push(`failOn "${token}": 命中 ${n} 项`) |
| 77 | + } else if (SEVERITIES.includes(token)) { |
| 78 | + const n = findings.filter(f => f.severity === token).length |
| 79 | + if (n > 0) violations.push(`failOn 严重度 "${token}": 命中 ${n} 项`) |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + if (typeof policy.maxFindings === 'number' && findings.length > policy.maxFindings) { |
| 84 | + violations.push(`发现数 ${findings.length} 超过上限 maxFindings=${policy.maxFindings}`) |
| 85 | + } |
| 86 | + |
| 87 | + return { pass: violations.length === 0, source: 'default', violations, policy } |
| 88 | +} |
| 89 | + |
| 90 | +/** 加载 + 评估的便捷封装 */ |
| 91 | +export function checkPolicy(scan: ProjectScanResult, root: string): PolicyResult { |
| 92 | + const { policy, source } = loadPolicy(root) |
| 93 | + const r = evaluatePolicy(scan, policy) |
| 94 | + r.source = source |
| 95 | + return r |
| 96 | +} |
0 commit comments