diff --git a/audit/report.py b/audit/report.py index 086f4c3..d82065f 100644 --- a/audit/report.py +++ b/audit/report.py @@ -166,22 +166,22 @@ def _render_html(payload: dict[str, Any]) -> str:
Java Audit Lab {__version__}
@@ -215,8 +218,8 @@ def _render_html(payload: dict[str, Any]) -> str:
审计阶段 {playbook_progress.get('completed', 0)} / {playbook_total}
0%
{reviewed_start} / {total}人工复核进度
- -

{html.escape(project['name'])}

{html.escape(project['build_system'])}{project['java_files']} 文件{html.escape(framework_text)}最高关注指数 {risk_score}
JSONSARIFMD
+ +

{html.escape(project['name'])}

{html.escape(project['build_system'])}{project['java_files']} 文件{html.escape(framework_text)}最高关注指数 {risk_score}
JSONSARIFMD
全部线索{total}等待人工判断
严重{counts['critical']}优先复核
高危{counts['high']}需要关注
中危{counts['medium']}结合上下文
基线新增{len(baseline.get('new', []))}本轮变化
已修复{len(baseline.get('fixed', []))}相对基线
@@ -235,13 +238,17 @@ def _render_html(payload: dict[str, Any]) -> str: function matchesQuery(c,q){{if(!q)return true;return q.split(/\\s+/).every(token=>{{const parts=token.split(':',2);if(parts.length<2)return c.dataset.search.includes(token);const [key,value]=parts;if(key==='cwe')return c.dataset.cwe.includes(value);if(key==='path')return c.dataset.path.includes(value);if(key==='scanner')return c.dataset.scanner.includes(value);if(key==='priority')return Number(c.dataset.priority)>=Number(value||0);return c.dataset.search.includes(token)}})}} function groupBy(){{const mode=document.querySelector('#view-mode').value,list=document.querySelector('#finding-list');cards.forEach(c=>c.style.display='');if(mode==='list'){{const sorted=cards.slice().sort((a,b)=>Number(b.dataset.priority)-Number(a.dataset.priority)||a.dataset.path.localeCompare(b.dataset.path));list.innerHTML='';sorted.forEach(c=>list.appendChild(c));return}}const groups={{}},order={{'critical':0,'high':1,'medium':2,'low':3,'info':4}};cards.forEach(c=>{{if(c.classList.contains('hidden'))return;let key;if(mode==='by-severity')key=c.dataset.severity;else if(mode==='by-rule')key=c.dataset.cwe;else if(mode==='by-file'){{const p=c.dataset.path,i=p.lastIndexOf('/');key=i<0?p:p.substring(i+1)}}else if(mode==='by-path'){{const p=c.dataset.path,i=p.indexOf('/');key=i<0?'(root)':p.substring(0,i)}}else if(mode==='by-scanner'){{key=c.dataset.scanner.split(' + ')[0]||'unknown'}};(groups[key]=groups[key]||[]).push(c)}});list.innerHTML='';const keys=Object.keys(groups);if(mode==='by-severity')keys.sort((a,b)=>(order[a]??9)-(order[b]??9));else keys.sort();keys.forEach(key=>{{const h=document.createElement('div'),label=document.createElement('span'),meta=document.createElement('span'),button=document.createElement('button');h.className='group-header';label.textContent=key;meta.className='group-meta';meta.textContent=groups[key].length+' 条';button.className='group-btn';button.textContent='折叠';button.onclick=()=>{{const collapsed=h.dataset.collapsed==='true';h.dataset.collapsed=String(!collapsed);groups[key].forEach(c=>c.style.display=collapsed?'':'none');button.textContent=collapsed?'折叠':'展开'}};h.append(label,meta,button);list.appendChild(h);groups[key].forEach(c=>list.appendChild(c))}})}} function applyFilters(){{const q=document.querySelector('#search').value.trim().toLowerCase(),sev=document.querySelector('#severity').value,status=document.querySelector('#review-filter').value;let visible=0;cards.forEach(c=>{{const verdict=state[c.dataset.fp]?.verdict||'unreviewed',show=matchesQuery(c,q)&&(sev==='all'||c.dataset.severity===sev)&&(status==='all'||verdict===status);c.classList.toggle('hidden',!show);if(show)visible++}});document.querySelector('#visible-count').textContent=visible;groupBy()}} -cards.forEach(card=>{{const fp=card.dataset.fp;state[fp]=state[fp]||{{}};state[fp].answers=state[fp].answers||{{}};const apply=()=>{{const verdict=state[fp].verdict||'unreviewed';card.dataset.status=verdict;card.querySelectorAll('[data-verdict]').forEach(b=>b.classList.toggle('active',verdict===b.dataset.verdict));card.querySelectorAll('[data-question]').forEach(b=>b.classList.toggle('active',state[fp].answers[b.dataset.question]===b.dataset.answer));card.querySelector('textarea').value=state[fp].notes||'';card.querySelector('[data-confidence]').value=state[fp].confidence||50;card.querySelector('[data-confidence-value]').textContent=(state[fp].confidence||50)+'%';updateProgress();applyFilters()}};apply();card.querySelectorAll('[data-question]').forEach(b=>b.onclick=()=>{{state[fp].answers[b.dataset.question]=b.dataset.answer;state[fp].updatedAt=new Date().toISOString();save();apply()}});card.querySelectorAll('[data-verdict]').forEach(b=>b.onclick=()=>{{state[fp].verdict=b.dataset.verdict;state[fp].updatedAt=new Date().toISOString();save();apply()}});card.querySelector('textarea').oninput=e=>{{state[fp].notes=e.target.value;save()}};card.querySelector('[data-confidence]').oninput=e=>{{state[fp].confidence=e.target.value;state[fp].updatedAt=new Date().toISOString();save();apply()}}}}); +cards.forEach(card=>{{const fp=card.dataset.fp;state[fp]=state[fp]||{{}};state[fp].answers=state[fp].answers||{{}};const apply=()=>{{const verdict=state[fp].verdict||'unreviewed',labels={{unreviewed:'尚未复核',confirmed:'已确认漏洞','false-positive':'已标记误报',uncertain:'仍不确定'}};card.dataset.status=verdict;card.querySelectorAll('[data-verdict]').forEach(b=>b.classList.toggle('active',verdict===b.dataset.verdict));card.querySelectorAll('[data-question]').forEach(b=>b.classList.toggle('active',state[fp].answers[b.dataset.question]===b.dataset.answer));card.querySelector('textarea').value=state[fp].notes||'';card.querySelector('[data-confidence]').value=state[fp].confidence||50;card.querySelector('[data-confidence-value]').textContent=(state[fp].confidence||50)+'%';const badge=card.querySelector('[data-review-state]');if(badge)badge.textContent=labels[verdict]||verdict;updateProgress();applyFilters()}};apply();card.querySelectorAll('[data-question]').forEach(b=>b.onclick=()=>{{state[fp].answers[b.dataset.question]=b.dataset.answer;state[fp].updatedAt=new Date().toISOString();save();apply()}});card.querySelectorAll('[data-verdict]').forEach(b=>b.onclick=()=>{{state[fp].verdict=b.dataset.verdict;state[fp].updatedAt=new Date().toISOString();save();apply()}});card.querySelector('textarea').oninput=e=>{{state[fp].notes=e.target.value;save()}};card.querySelector('[data-confidence]').oninput=e=>{{state[fp].confidence=e.target.value;state[fp].updatedAt=new Date().toISOString();save();apply()}}}}); ['search','severity','review-filter','view-mode'].forEach(id=>document.querySelector('#'+id).addEventListener('input',applyFilters)); +const authzSearch=document.querySelector('#authz-search'),authzFilter=document.querySelector('#authz-filter'),authzRows=[...document.querySelectorAll('[data-authz-status]')]; +function applyAuthzFilters(){{if(!authzSearch||!authzFilter)return;const q=authzSearch.value.trim().toLowerCase(),filter=authzFilter.value;let visible=0;authzRows.forEach(row=>{{const statusMatch=filter==='all'||row.dataset.authzStatus===filter||(filter==='anonymous'&&row.dataset.authzRequirement==='anonymous'),show=statusMatch&&(!q||row.dataset.authzSearch.includes(q));row.hidden=!show;if(show)visible++}});const count=document.querySelector('#authz-visible');if(count)count.textContent=visible}} +if(authzSearch&&authzFilter){{authzSearch.addEventListener('input',applyAuthzFilters);authzFilter.addEventListener('input',applyAuthzFilters)}} document.querySelector('#export').onclick=()=>{{const blob=new Blob([JSON.stringify({{schema_version:'1.2',project:{project_json},exported_at:new Date().toISOString(),reviews:state}},null,2)],{{type:'application/json'}}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='review.json';a.click();URL.revokeObjectURL(a.href)}}; document.querySelector('#clear').onclick=()=>{{if(confirm('确定清空当前项目保存在浏览器中的复核记录?')){{localStorage.removeItem(storageKey);location.reload()}}}}; const themeKey='java-audit-theme',root=document.documentElement;root.dataset.theme=localStorage.getItem(themeKey)||'light'; -const toggleTheme=()=>{{root.dataset.theme=root.dataset.theme==='dark'?'light':'dark';localStorage.setItem(themeKey,root.dataset.theme)}}; -document.querySelector('#theme').onclick=toggleTheme;const tm=document.querySelector('#theme-m');if(tm)tm.onclick=toggleTheme; +const syncThemeUi=()=>{{const dark=root.dataset.theme==='dark';document.querySelectorAll('[data-theme-label]').forEach(el=>el.textContent=dark?'浅色模式':'深色模式');document.querySelectorAll('.theme-icon').forEach(el=>el.textContent=dark?'☀':'☾');const mobile=document.querySelector('#theme-m');if(mobile)mobile.textContent=dark?'☀':'◐'}}; +const toggleTheme=()=>{{root.dataset.theme=root.dataset.theme==='dark'?'light':'dark';localStorage.setItem(themeKey,root.dataset.theme);syncThemeUi()}}; +document.querySelector('#theme').onclick=toggleTheme;const tm=document.querySelector('#theme-m');if(tm)tm.onclick=toggleTheme;syncThemeUi(); const navLinks=[...document.querySelectorAll('.side-link')];const spy=new IntersectionObserver(entries=>entries.forEach(e=>{{if(e.isIntersecting)navLinks.forEach(l=>l.classList.toggle('active',l.getAttribute('href')==='#'+e.target.id))}}),{{rootMargin:'-35% 0px -58% 0px'}});{spy_ids}.forEach(id=>{{const el=document.getElementById(id);if(el)spy.observe(el)}}); document.querySelectorAll('[data-copy]').forEach(b=>b.onclick=async()=>{{await navigator.clipboard.writeText(b.dataset.copy);const old=b.textContent;b.textContent='已复制';setTimeout(()=>b.textContent=old,900)}}); document.addEventListener('keydown',e=>{{if(['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName))return;if(!['j','k'].includes(e.key))return;const visible=cards.filter(c=>!c.classList.contains('hidden')),current=visible.findIndex(c=>c===document.activeElement||c.contains(document.activeElement)),next=e.key==='j'?Math.min(visible.length-1,current+1):Math.max(0,current<0?0:current-1);visible[next]?.focus();visible[next]?.scrollIntoView({{behavior:'smooth',block:'center'}})}}); @@ -509,22 +516,33 @@ def _authz_section(payload: dict[str, Any]) -> str: if len(ops) > 4: ops_text += f" +{len(ops) - 4}" expression = str(endpoint.get("expression") or "") - risk_mark = "" if requirement in {"role", "permission", "ownership"} else "" + search_text = " ".join([ + str(endpoint.get("route", "")), str(endpoint.get("handler", "")), + str(endpoint.get("path", "")), str(endpoint.get("http_method", "")), + role_text, ops_text, requirement_labels.get(requirement, requirement), + status_labels.get(status, status), + ]).lower() + route = html.escape(str(endpoint.get("route", "")) or "/") + handler = html.escape(str(endpoint.get("handler", ""))) + source_path = html.escape(str(endpoint.get("path", ""))) + method = html.escape(str(endpoint.get("http_method", ""))) + requirement_label = html.escape(requirement_labels.get(requirement, requirement)) + status_label = html.escape(status_labels.get(status, status)) rows.append( - f'' - f'{html.escape(str(endpoint.get("route", "")) or "/")}' - f'
{html.escape(str(endpoint.get("handler", "")))} · {html.escape(str(endpoint.get("path", "")))}:{endpoint.get("line", 0)}
' - f'{html.escape(str(endpoint.get("http_method", "")))}' - f'{html.escape(requirement_labels.get(requirement, requirement))}{risk_mark}' - f'{html.escape(role_text)}' - + (f'
{html.escape(expression[:80])}
' if expression else "") + f'' + f'{route}' + f'{handler} · {source_path}:{endpoint.get("line", 0)}' + f'{method}' + f'{requirement_label}' + f'{html.escape(role_text)}' + + (f'{html.escape(expression[:120])}' if expression else "") + '' - f'{html.escape(ops_text)}' - f'{html.escape(status_labels.get(status, status))}' + f'{html.escape(ops_text)}' + f'{status_label}' f'' ) chips = "".join( - f'{label} {coverage.get(key, 0)}' + f'{label} {coverage.get(key, 0)}' for label, key in (("端点", "endpoints"), ("权限已证明", "proven"), ("权限缺失", "missing"), ("匿名可访问", "anonymous"), ("配置规则", "rules")) ) high_risk = int(coverage.get("unprotected_high_risk", 0) or 0) @@ -537,17 +555,15 @@ def _authz_section(payload: dict[str, Any]) -> str: return ( '

端点权限矩阵

' '

证据来源:方法级注解 → 安全配置匹配 → Shiro 过滤链;没有匹配到的端点标记为权限缺失,需人工确认

' - f'
{chips}
' - '
' - '' - '' - '' - '' - '' - '' - '' - '' - '' + "".join(rows) + "
接口方法身份要求角色 / 权限危险操作证据状态
" + warning + "
" + f'
{chips}
' + '
' + '' + '' + f'
显示 {len(rows)} / {len(rows)} 个端点
' + '
' + '' + '' + '' + "".join(rows) + "
接口与代码位置方法身份要求角色 / 权限危险操作证据状态
" + warning + "" ) @@ -677,7 +693,10 @@ def _loc_text(rec: dict[str, Any]) -> str: f'
人工复核步骤
    {steps}
' f'
学习提示与修复方向

{html.escape(finding.get("learning_note", ""))}

{html.escape(finding["remediation"])}

' f'{ai_block}' - f'
' + f'
人工复核记录用于记录你的最终判断;内容只保存在当前浏览器,点击“导出复核”可备份。
尚未复核
' + f'
复核结论
' + f'
' + f'
' f'' ) diff --git a/tests/test_core.py b/tests/test_core.py index a11ebf7..e9cd3cc 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -16,7 +16,7 @@ from audit.findings import Finding, Location, assess_path, consolidate_findings, generalize_findings from audit.benchmark import run_benchmark from audit.playbook import evaluate_playbook -from audit.report import write_reports +from audit.report import _authz_section, write_reports from audit.runners.builtin import BuiltinRunner, load_extra_rules from audit.surface import collect_surface @@ -56,10 +56,42 @@ def test_report_contains_review_controls(self): self.assertIn("误报", page) self.assertIn("审计阶段 · Evidence Driven Playbook", page) self.assertIn("漏洞成立五问", page) + self.assertIn("人工复核记录", page) + self.assertIn("data-review-state", page) + self.assertIn("深色模式", page) + self.assertIn(".page{width:100%;max-width:none", page) + self.assertIn("text-overflow:ellipsis", page) + self.assertIn("@media(max-width:760px)", page) + self.assertNotIn("&#x20;", page) markdown = md_path.read_text(encoding="utf-8") self.assertIn("待人工确认项", markdown) self.assertIn("授权状态:由使用者自行确认", markdown) + def test_authorization_matrix_has_readable_filters_and_fixed_columns(self): + section = _authz_section({ + "authz": { + "coverage": {"endpoints": 1, "proven": 0, "missing": 1, "anonymous": 0, "rules": 0}, + "endpoints": [{ + "route": "/admin/users/{id}", + "handler": "AdminController.deleteUser", + "path": "src/main/java/example/AdminController.java", + "line": 42, + "http_method": "DELETE", + "requirement": "unknown", + "status": "missing", + "roles": [], + "permissions": [], + "dangerous_ops": ["delete"], + "expression": "", + }], + } + }) + self.assertIn('id="authz-search"', section) + self.assertIn('id="authz-filter"', section) + self.assertIn('class="authz-table"', section) + self.assertIn('data-authz-status="missing"', section) + self.assertIn('class="method-badge"', section) + @unittest.skipUnless(shutil.which("node"), "Node.js is required for JavaScript syntax validation") def test_report_inline_javascript_is_valid(self): with TemporaryDirectory() as temp: