1515
1616import { createServer } from 'http'
1717import { spawn } from 'child_process'
18- import { mkdtempSync , rmSync , existsSync , statSync } from 'fs'
18+ import { mkdtempSync , rmSync , existsSync , statSync , mkdirSync , writeFileSync } from 'fs'
1919import { tmpdir } from 'os'
20- import { join , resolve } from 'path'
20+ import { join , resolve , dirname , normalize , isAbsolute } from 'path'
2121import { runProjectComplianceAudit } from '../compliance/audit.js'
2222import { renderHtmlReport } from '../compliance/html-report.js'
2323import { DEFAULT_CONFIG , resolveLocale } from '../types.js'
@@ -52,6 +52,12 @@ export function startWebServer(opts: WebServerOptions): void {
5252 if ( u . pathname === '/' || u . pathname === '' ) {
5353 return send ( res , 200 , 'text/html' , formPage ( ! ! opts . local ) )
5454 }
55+ // 本地客户端:选文件夹上传(仅本地模式;数据只到 localhost、不出本机)
56+ if ( u . pathname === '/scan-files' && req . method === 'POST' ) {
57+ if ( ! opts . local ) return send ( res , 403 , 'text/html' , errorPage ( '公网模式不支持上传;请用「公开仓库 URL」。' ) )
58+ if ( active >= MAX_CONCURRENT ) return send ( res , 503 , 'text/html' , errorPage ( '服务繁忙,请稍后再试' ) )
59+ return await handleUpload ( req , res , locale , ( ) => { active ++ } , ( ) => { active -- } )
60+ }
5561 if ( u . pathname === '/scan' ) {
5662 if ( active >= MAX_CONCURRENT ) {
5763 return send ( res , 503 , 'text/html' , errorPage ( '服务繁忙,请稍后再试(并发上限)' ) )
@@ -120,6 +126,52 @@ async function handleLocal(res: any, path: string, locale: 'zh' | 'en', inc: ()
120126 }
121127}
122128
129+ const MAX_UPLOAD_BYTES = 16 * 1024 * 1024 // 16MB JSON 上限
130+
131+ /** 本地上传:客户端把选中的文件夹读成 {path,content}[] 发来,写入临时目录后扫描 */
132+ async function handleUpload ( req : any , res : any , locale : 'zh' | 'en' , inc : ( ) => void , dec : ( ) => void ) {
133+ let body = ''
134+ let size = 0
135+ let aborted = false
136+ await new Promise < void > ( ( resolveBody ) => {
137+ req . on ( 'data' , ( c : Buffer ) => {
138+ size += c . length
139+ if ( size > MAX_UPLOAD_BYTES ) { aborted = true ; req . destroy ( ) ; resolveBody ( ) ; return }
140+ body += c . toString ( 'utf8' )
141+ } )
142+ req . on ( 'end' , ( ) => resolveBody ( ) )
143+ req . on ( 'error' , ( ) => { aborted = true ; resolveBody ( ) } )
144+ } )
145+ if ( aborted ) return send ( res , 413 , 'text/html' , errorPage ( '内容过大或读取失败(上限 16MB)。大项目请用本地 CLI:npx shellward scan' ) )
146+
147+ let payload : { root ?: string ; files ?: { path : string ; content : string } [ ] }
148+ try { payload = JSON . parse ( body ) } catch { return send ( res , 400 , 'text/html' , errorPage ( '上传数据格式错误' ) ) }
149+ const files = Array . isArray ( payload . files ) ? payload . files : [ ]
150+ if ( files . length === 0 ) return send ( res , 400 , 'text/html' , errorPage ( '未选择任何文件' ) )
151+
152+ const dir = mkdtempSync ( join ( tmpdir ( ) , 'sw-up-' ) )
153+ inc ( )
154+ try {
155+ for ( const f of files ) {
156+ if ( ! f || typeof f . path !== 'string' || typeof f . content !== 'string' ) continue
157+ // 路径安全:去掉绝对路径/.. 逃逸,落在临时目录内
158+ const rel = normalize ( f . path ) . replace ( / ^ ( \. \. ( \/ | \\ | $ ) ) + / , '' )
159+ if ( isAbsolute ( rel ) || rel . includes ( '..' ) ) continue
160+ const dest = join ( dir , rel )
161+ if ( ! dest . startsWith ( dir ) ) continue
162+ try { mkdirSync ( dirname ( dest ) , { recursive : true } ) ; writeFileSync ( dest , f . content ) } catch { /* skip */ }
163+ }
164+ const { report, scan } = runProjectComplianceAudit ( DEFAULT_CONFIG , dir )
165+ const rootName = typeof payload . root === 'string' && payload . root ? payload . root : '(uploaded folder)'
166+ send ( res , 200 , 'text/html' , renderHtmlReport ( report , scan , locale , { root : rootName } ) )
167+ } catch ( e : any ) {
168+ send ( res , 500 , 'text/html' , errorPage ( '扫描失败:' + esc ( e ?. message || String ( e ) ) ) )
169+ } finally {
170+ dec ( )
171+ try { rmSync ( dir , { recursive : true , force : true } ) } catch { /* ignore */ }
172+ }
173+ }
174+
123175/** 浅克隆公开仓库到临时目录(不鉴权、超时、不执行任何仓库代码) */
124176function cloneRepo ( url : string , dir : string ) : Promise < void > {
125177 return new Promise ( ( res , rej ) => {
@@ -144,27 +196,69 @@ function send(res: any, code: number, type: string, body: string) {
144196}
145197
146198function formPage ( local : boolean ) : string {
147- const field = local
148- ? `<label>本地项目路径</label>
149- <input name="path" placeholder="/Users/you/your-ai-project" autofocus>
150- <p class="hint">本地模式:代码不上传、不出本机(客户端体验)。</p>`
151- : `<label>公开仓库地址</label>
152- <input name="repo" placeholder="https://github.com/owner/repo" autofocus>
153- <p class="hint">仅支持公开仓库(GitHub / GitLab / Gitee / Bitbucket)。<b>私有/敏感代码请用本地 CLI</b>:<code>npx shellward scan</code>(不上传)。</p>`
199+ const urlForm = `
200+ <form action="/scan" method="get">
201+ <label>${ local ? '② ' : '' } 公开仓库地址</label>
202+ <input name="repo" placeholder="https://github.com/owner/repo"${ local ? '' : ' autofocus' } >
203+ <button type="submit">${ local ? '体检该仓库 →' : '开始体检 →' } </button>
204+ <p class="hint">仅支持公开仓库(GitHub / GitLab / Gitee / Bitbucket)。${ local ? '' : '<b>私有/敏感代码请用本地客户端或 CLI</b>:<code>npx shellward web --local</code> / <code>npx shellward scan</code>(不上传)。' } </p>
205+ </form>`
206+
207+ const uploadForm = local ? `
208+ <form id="dirform">
209+ <label>① 选择本地项目文件夹(推荐)</label>
210+ <input type="file" id="dir" webkitdirectory directory multiple>
211+ <button id="dbtn" type="submit">开始体检 →</button>
212+ <p class="hint">📂 直接选你的项目文件夹,无需敲路径。文件仅发送到<b>本机的本地服务</b>处理,<b>不经过任何外部服务器、不出本机</b>。</p>
213+ </form>
214+ <div class="or">— 或 —</div>` : ''
215+
154216 return page ( 'ShellWard 合规体检' , `
155217 <div class="hero">
156218 <div class="logo">🛡️ Shell<span>Ward</span> 合规网关</div>
157219 <h1>AI 应用合规体检</h1>
158- <p class="sub">${ local ? '填本地路径' : '贴公开仓库链接' } ,30 秒查出数据出境 / 硬编码密钥 / 个人信息暴露等中国合规红线。</p>
159- <form action="/scan" method="get">
160- ${ field }
161- <button type="submit">开始体检 →</button>
162- </form>
220+ <p class="sub">${ local ? '选项目文件夹或贴公开仓库链接' : '贴公开仓库链接' } ,30 秒查出数据出境 / 硬编码密钥 / 个人信息暴露等中国合规红线。</p>
221+ ${ uploadForm }
222+ ${ urlForm }
163223 <p class="foot">网安法 2026 · PIPL · 等保2.0 · 数据出境 · AI标识 | 零依赖 · 开源 ·
164224 <a href="https://github.com/jnMetaCode/shellward">GitHub ⭐</a></p>
165- </div>` )
225+ </div>
226+ ${ local ? UPLOAD_SCRIPT : '' } ` )
166227}
167228
229+ // 客户端:读取所选文件夹 → 过滤(跳过 node_modules 等、仅文本/配置、限大小) → POST 到本机服务
230+ const UPLOAD_SCRIPT = `<script>
231+ (function(){
232+ var SKIP=/(^|\\/)(node_modules|\\.git|dist|build|\\.next|out|vendor|coverage|\\.venv|venv|__pycache__|target|\\.cache)(\\/|$)/;
233+ var EXT=/\\.(ts|tsx|js|jsx|mjs|cjs|py|go|rb|java|php|rs|json|yaml|yml|toml|ini|conf|sh|txt|csv)$/i;
234+ var ENV=/(^|\\/)\\.env(\\.|$)/; var DEP=/^(package\\.json|requirements\\.txt|pyproject\\.toml|go\\.mod)$/;
235+ var form=document.getElementById('dirform'); if(!form) return;
236+ form.addEventListener('submit', async function(e){
237+ e.preventDefault();
238+ var inp=document.getElementById('dir'), btn=document.getElementById('dbtn');
239+ if(!inp.files||!inp.files.length){alert('请先选择项目文件夹');return;}
240+ btn.disabled=true; btn.textContent='读取中…';
241+ var picked=[], total=0, root='';
242+ for(var i=0;i<inp.files.length;i++){
243+ var f=inp.files[i], rel=f.webkitRelativePath||f.name; if(!root)root=rel.split('/')[0];
244+ if(SKIP.test(rel)) continue;
245+ var base=rel.split('/').pop();
246+ if(!(EXT.test(rel)||ENV.test(rel)||DEP.test(base))) continue;
247+ if(f.size>524288) continue;
248+ if(picked.length>=3000||total>8388608) break;
249+ total+=f.size; picked.push(f);
250+ }
251+ if(!picked.length){alert('未找到可扫描的源码/配置文件');btn.disabled=false;btn.textContent='开始体检 →';return;}
252+ btn.textContent='扫描中… ('+picked.length+' 个文件)';
253+ var out=[]; for(var j=0;j<picked.length;j++){ try{ out.push({path:picked[j].webkitRelativePath||picked[j].name, content:await picked[j].text()}); }catch(_){} }
254+ try{
255+ var resp=await fetch('/scan-files',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({root:root,files:out})});
256+ var html=await resp.text(); document.open(); document.write(html); document.close();
257+ }catch(err){ alert('扫描失败: '+err); btn.disabled=false; btn.textContent='开始体检 →'; }
258+ });
259+ })();
260+ </script>`
261+
168262function errorPage ( msg : string ) : string {
169263 return page ( '出错了' , `<div class="hero"><div class="logo">🛡️ Shell<span>Ward</span></div>
170264 <h1>⚠️ 无法完成</h1><p class="sub">${ esc ( msg ) } </p>
@@ -189,8 +283,11 @@ input{padding:14px 16px;border:1px solid #cbd5e1;border-radius:10px;font-size:16
189283input:focus{outline:none;border-color:#cb0000;box-shadow:0 0 0 3px rgba(203,0,0,.12)}
190284.hint{font-size:12.5px;color:#64748b;margin:2px 0 6px}
191285.hint code{background:#f1f5f9;padding:1px 6px;border-radius:5px}
286+ input[type=file]{padding:12px;background:#f8fafc;cursor:pointer}
192287button{background:#cb0000;color:#fff;border:0;border-radius:10px;padding:14px;font-size:16px;
193288font-weight:700;cursor:pointer;margin-top:4px}button:hover{background:#a80000}
289+ button:disabled{background:#94a3b8;cursor:default}
290+ form{margin:0 0 14px}.or{text-align:center;color:#94a3b8;font-size:13px;margin:6px 0 14px}
194291.foot{margin:24px 0 0;font-size:12.5px;color:#94a3b8}.foot a,.back{color:#cb0000;text-decoration:none}
195292.back{font-weight:600}
196293</style></head><body>${ body } </body></html>`
0 commit comments