资讯详情

资讯详情

零成本搭建本地JS漏洞扫描器:Python爬虫+Ollama AI实战

从 0 到 1 自己动手做本地 JS 漏洞扫描器是前阵子我利用业余时间完成的一个小工程。核心组合就两样Python 爬虫负责把网页和 JavaScript 文件抓回来Ollama AI 负责在本地做语义分析整个方案不需要购买商业工具授权也没有把代码上传到第三方平台真正做到了零成本替代商业工具的基础场景。我在做前端安全测试时最头疼的一件事就是目标站点里散落着大量压缩过、混淆过的 JS 文件光靠人工翻代码根本看不出门道。后来我干脆自己写了一套流程先爬取页面和 JS 资源整理出代码片段再用本地大模型去判断哪些代码实际存在风险。这篇文章就是把这套流程完整拆出来包括环境搭建、爬虫实现、检测模块、报告生成以及我踩过的几个坑。适合有 Python 基础、对前端安全和 AI 应用感兴趣的人参考也适合团队里想把安全扫描接入日常开发流程的伙伴。1. 项目思路拆解为什么这个扫描器要这么做1.1 商业工具的痛点不是价格而是场景不匹配市场上不是没有现成的 JS 漏洞扫描器我也认真评估过几款商业产品。界面好看、报告精美、更新及时这些都是优点但对于我一个开发者来说最大的问题在于它设计给的是一个固定的审计流程而不是我手头这个灵活多变的项目。商业扫描器通常按域名、按扫描次数甚至按流量计费一次完整的 JS 扫描可能就要消耗不少额度。如果只扫一两个页面费用倒不算夸张可一旦你要对多个站点做巡检或者把一个项目的每次构建都纳入扫描成本就会直线上升。而且我更在意的是代码隐私前端代码虽然本身就在用户浏览器里运行但完整源码和业务逻辑集中上传到第三方云端总归不是所有人都能接受的。在涉及未公开功能或内部管理系统的时候这种顾虑更明显。所以当我想到“零成本替代商业工具”这个方向时目标不是要做出一模一样的商业级产品而是针对最常见的几种前端漏洞场景用最轻量、完全本地化的方式跑通“抓取、分析、报告”这条链路。1.2 JS 漏洞扫描到底扫描什么先把概念对齐一下。JS 漏洞扫描器扫描的不是操作系统漏洞也不是后端 SQL 注入而是前端 JavaScript 代码本身存在的安全隐患。常见目标有四类第一类是 DOM 型 XSS。代码里直接把用户输入交给innerHTML、document.write、eval或者从location、window.name取数据后拼接进 HTML都有可能导致浏览器执行非预期脚本。第二类是敏感信息泄露。很多前端项目会把 API 地址、第三方密钥、内部路径、调试开关写在 JS 里一旦公网上线等于把这些信息直接暴露给所有人。第三类是危险函数调用。eval、new Function、setTimeout的字符串重载、document.location直接赋值这些操作如果没做严格校验很容易成为攻击入口。第四类是依赖和信任链问题。页面加载了什么来源的 JS、是否引入不可信 CDN 资源、有没有完整性校验虽然传统的正则很难判断但通过把资源和上下文喂给大模型可以给出有效提示。很多人以为漏洞扫描就是在 JavaScript 里搜索eval其实那只是最原始的一步。真正需要的是把代码放在具体上下文里分析这个eval的参数来自用户输入还是写死的字符串这个innerHTML接收的数据有没有经过编码这些上下文判断正好是传统规则难以做到、而本地大模型比较擅长的事情。1.3 Python 爬虫 Ollama AI 的组合逻辑我选择 Python 做爬虫部分没什么特别的理由就是因为生态成熟。requests负责最普通的页面抓取BeautifulSoup负责解析 HTML 里的 script 标签Playwright用于处理现代前端框架那种动态渲染页面一套下来几乎覆盖所有前端项目形态。Ollama 则是整个方案里最关键的一环。它把大模型运行在本地电脑上不需要把代码片段发送到云端接口也不需要按 token 付费。对于安全审计这种对数据敏感的场景这个优势非常明显。而且 Ollama 提供了很简单的 HTTP APIPython 可以直接去调用不用额外包装一层中间服务。整个流程我简单描述一下爬虫先抓取目标页面提取页面上所有 JS 文件链接下载后做格式化接着用一组静态规则把可疑代码片段筛出来然后把这些片段连同上下文发送给本地 Ollama 模型让模型判断是否存在实际风险最后把判断结果按风险等级输出成报告。这里没有任何被黑洞化的黑魔法每一步都是可控的。2. 环境准备把本地 AI 和爬虫基础跑起来2.1 工具链选型清单在开始写代码之前先列一张表格把需要用到的东西梳理清楚这样后面搭建环境不会乱。组件用途选择理由Python 3.10主开发语言类型标注和异步支持更完善requests发起 HTTP 请求简单直接适合抓取 HTMLBeautifulSoup4解析 HTML 标签对 script/src 提取非常方便jsbeautifier格式化压缩 JS把压缩代码变成可读形式便于规则匹配和模型分析Playwright渲染动态页面可以模拟浏览器获取 Petal 渲染后的 DOM 和网络请求Ollama本地运行大模型离线推理免费API 友好ThreadPoolExecutor并发下载 JS 文件Python 自带不需要额外依赖可能有人会问为什么不直接用 Node.jsNode 在解析 JS 方面确实有天然优势但如果要把整个扫描流程和后续报告整合在一起Python 的数据处理生态更顺手而且调用 Ollama 时 Python 写起来也更简洁。对我个人来说Python 是维护成本最低的选择。2.2 安装 Python 依赖我习惯先建虚拟环境避免跟系统 Python 打架。命令很简单python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate然后安装依赖库pip install requests beautifulsoup4 lxml jsbeautifier playwright注意lxml不是必须的但解析 HTML 时性能更好强烈建议一起装上。playwright只装 Python 包还不够还要初始化浏览器内核playwright install chromium这一步会下载一个 Chromium 浏览器体积比较大大概一百多 MB。如果放在 CI 环境里建议单独做一个缓存层别每次构建都重新下载。2.3 安装 Ollama 并验证本地服务Ollama 的安装过程没什么特别之处官网有对应系统的安装包。安装完成后要先拉取一个合适的模型。我这里以常见的 7B 参数通用模型为例因为 7B 模型在普通笔记本电脑上还能跑得动内存占用在 8GB 到 12GB 之间效果也勉强够用。ollama pull qwen2.5:7b ollama serveollama serve会启动本地服务默认监听本地地址 11434 端口。我们先用一行命令验证服务是否正常curl http://localhost:11434/api/tags如果返回一段包含模型列表的 JSON说明服务已经起来了。接下来在 Python 里调用 Ollama只需要向http://localhost:11434/api/generate发送 POST 请求把模型名和提示词放进 payload 里就行。这个接口很简洁完全可以当成一个本地的 API 来用。提醒一句模型并不是越大越好。7B 模型虽然理解能力不如 70B 那么强但对识别常见前端漏洞模式已经够了关键是响应速度和内存占用都能接受。如果你只有 16GB 内存建议优先考虑 7B 级别模型。3. 爬虫模块实现把目标 JS 资源抓回来3.1 基础抓取与链接去重爬虫的第一步是拿到页面 HTML。我一般直接用requests但会模拟一个正常的浏览器 User-Agent不然很容易被基础防护拦截。import requests from bs4 import BeautifulSoup from urllib.parse import urljoin, urlparse headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Safari/537.36 } session requests.Session() session.headers.update(headers) def fetch_html(url): resp session.get(url, timeout15) resp.raise_for_status() return resp.text def extract_js_urls(html, base_url): soup BeautifulSoup(html, lxml) js_urls [] for script in soup.find_all(script): src script.get(src) if src: js_urls.append(urljoin(base_url, src)) # 内联脚本也可以记录后面单独处理 inline_codes [script.string for script in soup.find_all(script) if script.string] return js_urls, inline_codes这里有两个细节需要关注。第一urljoin非常重要因为页面里很多 script src 是相对路径直接保存会丢掉协议和域名。第二内联脚本不要忽略尤其是一些后台管理系统关键逻辑就内嵌在 HTML 里。链接提取后还要做去重和域名过滤不然会把外部的统计脚本、广告脚本都抓进来。比如只保留当前主域名和它的子域名def is_same_domain(url, base_domain): host urlparse(url).netloc return host base_domain or host.endswith(. base_domain) seen set() def collect_unique_js_urls(html, base_url, base_domain): candidates, _ extract_js_urls(html, base_url) result [] for url in candidates: if url in seen: continue if not is_same_domain(url, base_domain): continue seen.add(url) result.append(url) return result实际项目中如果发现目标页面引用了一个来自第三方域名的重要 JS 文件我会手动放宽过滤条件。但默认情况下只扫描同域资源是安全的能减少大量无关噪声。3.2 处理动态渲染页面现在的网站十个里有六七个是 React、Vue 或 Angular 写的直接用requests拿到 HTML 往往是空的只是渲染前的骨架。这时候就得请出 Playwright。它能启动一个真实浏览器内核等页面 JavaScript 执行完毕后再抓取结果。from playwright.sync_api import sync_playwright def fetch_rendered_html(url, wait_selectorNone): with sync_playwright() as p: browser p.chromium.launch(headlessTrue) context browser.new_context(user_agentheaders[User-Agent]) page context.new_page() page.goto(url, wait_untilnetworkidle, timeout30000) if wait_selector: page.wait_for_selector(wait_selector, timeout10000) html page.content() browser.close() return htmlwait_untilnetworkidle是综合考虑后的选择虽说在网络请求多的时候会多等几秒但能避免拿到半残状态页面。动态页面的另一个麻烦是JS 文件可能是运行时才加载的直接用page.content()里的 script src 还不全。我更推荐在 Playwright 启动后监听网络请求把所有 JS 请求收集下来。你可以在页面加载过程中记录这些地址拿到比 HTML 静态提取更完整的资源清单。3.3 并发下载与资源保护抓完 JS 链接之后就要把每个 JS 文件内容下载到本地并保存。如果一个页面引用了几十个脚本一个个下载会很慢我用ThreadPoolExecutor做并发把速度拉上去。from concurrent.futures import ThreadPoolExecutor import time import random def download_js(url): try: resp session.get(url, timeout20) if resp.status_code 200: return url, resp.text except Exception as e: print(f[!] 下载失败 {url}: {e}) return url, None def download_all_js(js_urls, max_workers5): results {} with ThreadPoolExecutor(max_workersmax_workers) as pool: futures [pool.submit(download_js, url) for url in js_urls] for future in futures: url, content future.result() if content: results[url] content return results不过我得说一个教训并发太高会显得像在攻击目标站点而且某些站点对相同 IP 的高频请求非常敏感轻则封 IP重则触发告警。所以我建议并发控制在 5 以内而且在每次请求之间加一个 0.5 到 1.5 秒的随机延时模拟人点击页面的节奏。下载下来的 JS 文件建议立刻用jsbeautifier格式化再存盘这样后面的正则和 LLM 分析都能拿到结构化程度更高的文本。import jsbeautifier def beautify_js(content): opts jsbeautifier.default_options() opts.indent_size 2 return jsbeautifier.beautify(content, opts)4. 漏洞检测静态规则 Ollama AI 本地判断4.1 静态规则先筛一遍拿到 JS 源码后我不会直接全量丢给大模型而是先用静态规则把可疑代码片段标记出来。这样既能减少 LLM 的工作量也能让报告保留一个可追溯的原始依据。静态规则我用正则实现并结合简单的代码片段抽取。以下是一个最小可用的规则集规则名称检测模式风险类型eval 调用eval\s*\(代码执行innerHTML 赋值innerHTML\s*DOM XSSdocument.writedocument\.write\s*\(DOM XSS从 location 取参location.(searchhashlocalStorage 读敏感数localStoragesessionStorage内嵌密钥(api[_-]?keysecretnew Functionnew\sFunction\s*\(代码执行这里要反复强调一句正则命中不等于漏洞。比如eval也可能出现在一个完全无害的 jsonp 解析工具函数里innerHTML也可能只是把写死的固定模板塞进页面。所以静态规则的输出只能叫“候选项”真正的判断要交给下一步。我把这些规则的命中结果统一包装成一个结构def static_scan(js_content, source_url): findings [] patterns { eval: reval\s*\(, innerHTML: rinnerHTML\s*, document.write: rdocument\.write\s*\(, location_param: rlocation\.(search|hash|href), secret_key: r(api[_-]?key|secret|token|password)\s*[:][\][^\][\], new_function: rnew\sFunction\s*\(, } for name, pattern in patterns.items(): for match in re.finditer(pattern, js_content): start max(0, match.start() - 100) end min(len(js_content), match.end() 200) snippet js_content[start:end] findings.append({ rule: name, source_url: source_url, snippet: snippet, position: match.start(), }) return findings每个命中都附带前后 100 到 200 字符的上下文片段这是为了让后面的模型有足够的信息做判断而不只是看到一个孤立的关键词。4.2 让 Ollama 理解代码上下文现在让 Ollama 上场。我们需要设计一个清晰的提示词要求模型以 JSON 格式返回分析结果。模型输出的结构化越好后续报告生成就越方便。import requests import json OLLAMA_ENDPOINT http://localhost:11434/api/generate MODEL_NAME qwen2.5:7b def analyze_with_ollama(snippet, source_url, rule_name): prompt f 你是一个前端安全审计助理。下面是 JavaScript 代码片段它命中了一条静态规则{rule_name}。 请判断这个代码片段是否构成真实风险并进行严重等级评分。 规则说明 - eval: 检查参数是否可能来自用户输入或外部来源。 - innerHTML: 检查赋值内容是否包含外部拼接数据。 - document.write: 检查写入内容是否含外部可控数据。 - location_param: 检查是否有从 URL 取参直接进入危险函数。 - secret_key: 检查是否包含真实的密钥或 token。 - new_function: 检查是否执行动态代码。 代码片段 --- {snippet} --- 请输出 JSON不要输出其他解释格式如下 {{risk: high|medium|low|none, reason: 简要说明, confidence: 0-100}} payload { model: MODEL_NAME, prompt: prompt, stream: False, options: { temperature: 0.1, num_predict: 512 } } resp requests.post(OLLAMA_ENDPOINT, jsonpayload, timeout60) if resp.status_code ! 200: return {risk: unknown, reason: fOllama 调用失败: {resp.status_code}, confidence: 0} text resp.json().get(response, ) try: # 模型偶尔会输出多余内容尝试提取 JSON 块 json_start text.find({) json_end text.rfind(}) 1 parsed json.loads(text[json_start:json_end]) return parsed except Exception as e: return {risk: unknown, reason: f模型输出无法解析: {e}, confidence: 0}有几个地方我特别调整过。temperature设为 0.1是为了让模型回答尽量稳定不要每一次分析同一个代码片段给不同结论。num_predict设置为 512足够输出结构化 JSON也不会等太久。最大超时时间设了 60 秒防止模型偶尔思考过度。4.3 结果合并与误报过滤把静态规则命中的所有片段都跑一遍 Ollama 后得到的结果并不会直接进报告。我会做一层合并逻辑去掉明显是误报的条目只保留risk为high或medium的记录。def final_review(findings): final [] for finding in findings: ai_result analyze_with_ollama( finding[snippet], finding[source_url], finding[rule] ) if ai_result[risk] in (high, medium, unknown): final.append({ **finding, ai_risk: ai_result[risk], ai_reason: ai_result[reason], ai_confidence: ai_result[confidence], }) return final实际上我会加一个缓存机制相同代码片段不重复请求 Ollama。因为扫描的目标站点可能引用同一份公共 JS 库改一行缓存代码能把扫描时间缩短三分之一。5. 报告输出与使用方式5.1 生成 JSON 和 Markdown 报告分析完成后最重要的事是输出一份别人能看懂的报告。我个人习惯同时生成 JSON 和 Markdown 两种格式JSON 方便程序消费Markdown 方便直接在 Git 仓库或工作群里分享。import json from datetime import datetime def generate_report(all_findings, output_dirreport): timestamp datetime.now().strftime(%Y%m%d_%H%M%S) json_file f{output_dir}/scan_{timestamp}.json with open(json_file, w, encodingutf-8) as f: json.dump(all_findings, f, ensure_asciiFalse, indent2) md_lines [# JS 漏洞扫描报告, ] md_lines.append(f生成时间: {timestamp}) md_lines.append(f发现候选问题: {len(all_findings)} 个) md_lines.append() for idx, item in enumerate(all_findings, 1): md_lines.append(f## 问题 {idx}) md_lines.append(f- 风险等级: {item[ai_risk]}) md_lines.append(f- 规则类型: {item[rule]}) md_lines.append(f- 来源文件: {item[source_url]}) md_lines.append(f- 置信度: {item[ai_confidence]}%) md_lines.append(f- 分析理由: {item[ai_reason]}) md_lines.append(f- 代码片段:) md_lines.append(f) md_lines.append(item[snippet]) md_lines.append(f) md_lines.append() md_file f{output_dir}/scan_{timestamp}.md with open(md_file, w, encodingutf-8) as f: f.write(\n.join(md_lines)) return json_file, md_file我把风险等级和置信度放在最显眼的位置开发人员看报告时第一眼就能知道该先处理什么问题。5.2 风险等级评价规则为了让扫描结果更客观我定义了一套简单的分级规则等级判断标准处理建议high数据从用户可控来源进入危险函数可能造成代码执行必须立即修复medium存在敏感信息泄露或潜在 DOM XSS需要人工确认项目发布前处理low仅为可疑代码模式但暂时没有一条清晰的外部输入路径建议记录并跟踪none代码上下文显示为固定字符串或安全操作不在报告中展示unknownOllama 请求失败或输出无法解析回归静态规则交给人工复核这个分级不是苛刻的安全标准但足够在一支小团队里达成共识。高级别的必须处理低级别的至少留个记录。5.3 把扫描流程封装成命令行最后我把整个流程包成命令行工具方便复用。使用方式很简单def main(): import argparse parser argparse.ArgumentParser(description本地 JS 漏洞扫描器) parser.add_argument(url, help目标页面 URL) parser.add_argument(--max-workers, typeint, default5) parser.add_argument(--output-dir, defaultreport) args parser.parse_args() html fetch_html(args.url) js_urls collect_unique_js_urls(html, args.url, urlparse(args.url).netloc) print(f发现 {len(js_urls)} 个 JS 文件) js_contents download_all_js(js_urls, args.max_workers) findings [] for url, content in js_contents.items(): pretty_js beautify_js(content) findings.extend(static_scan(pretty_js, url)) print(f静态命中 {len(findings)} 个候选片段) final final_review(findings) json_file, md_file generate_report(final, args.output_dir) print(f报告已生成: {json_file}) print(f报告已生成: {md_file}) if __name__ __main__: main()运行方式python js_scanner.py https://example.com/path/to/page这样我就能在不知道具体 JS 文件名的前提下一键完成从页面抓取到风险报告输出的全过程。6. 实战踩坑与排查技巧6.1 Ollama 服务跑不动或内存溢出怎么办最常遇到的问题是 Ollama 模型加载到一半就退出或者使用一段时间后系统卡死。本质上都是内存不足。我建议观察两个指标一个系统内存的占用率一个是模型是否被量化过。如果 7B 模型跑起来很吃力最简单的方法是换一个更低参数的量化版本例如拉到 4bit 量化的小模型内存占用能降低三分之一。其次关掉 Ollama 后台自动加载的其他模型。默认情况下Ollama 会把用过的模型留在内存里一旦你换过模型再切回来内存峰值就会很高。ollama stop model_name手动停用不用的模型能留下不少内存给爬虫和 Playwright 使用。6.2 页面里的 JS 文件永远抓不全是怎么回事第一版扫描器在某个动态页面上只抓到 2 个 JS 文件我手动打开浏览器一看实际加载了十几个。原因就是页面在滚动、鼠标事件、接口返回后才创建 script 标签。单纯等networkidle并不够。我后来在 Playwright 里加了全页滚动逻辑同时监听页面里所有script标签的新增collected_js_urls set() page.on(request, lambda req: collected_js_urls.add(req.url) if req.resource_type script else None)然后页面加载完成后继续滚动循环直到没有新请求产生。这个方法能把大多数懒加载的 JS 资源都捕获到。当然也要接受一个现实极个别页面会基于登录状态、权限级别动态下发 JS 内容这时候扫描器只能覆盖当前登录态能看到的代码。这是限制不是 bug。6.3 误报率高到不敢看报告怎么办第一次跑完整的扫描器时报告里几乎每个片段都是 high 风险吓得我差点怀疑全世界的前端代码都有问题。冷静下来后我发现问题不在扫描器而在于静态规则的上下文片段太短。比如一个innerHTML div class\fixed\.../div的固定模板代码静态命中后只截取了 100 字符模型看不到赋值语义自然不敢说它是安全代码。解决办法是我把上下文片段从 100 字符扩到前后 400 字符同时在提示词里强调“如果代码是固定字符串请标记为 none”。这样改完之后误报率直接下降了一半以上。另一个常用技巧是加一个排除目录列表比如打包产物的vendor、polyfill目录完全跳过静态规则因为这些第三方库的误报对最终判断价值不大。6.4 扫描器的下一步扩展思路现在这个版本已经能满足日常前端巡检需求但我觉得还有几个方向值得继续加第一引入 AST 解析。正则筛代码始终是浅层模式匹配如果能用 JavaScript 引擎把代码解析成 AST再结合数据流追踪外部输入判断精度会提升很多。第二增加差异扫描。拿上一版本的 JS 文件和新版本做对比只分析 diff 段新增或修改的内容这样更容易发现“改动引入的漏洞”。第三扩展 Ollama 提示词模板。目前只是单轮问答如果把多个 fragment 放在一起让模型先总结这个文件的风险面再逐一判断每个候选片段报告的逻辑性会更强。对我个人来说先在现有方案上稳定跑两周把误报率降到一个可接受范围比加新功能更重要。毕竟一个经常报错的扫描器很快就会被团队弃用。最后再分享一个小技巧。我在实测中发现提示词里如果只写“请找出问题”模型经常会把所有可疑点都报出来特别积极。后来我改成“请告诉我哪些位置不值得修”模型的判断反而精准许多。原因是它被迫从修复视角反向审视代码更接近一个有经验的安全工程师的判断方式。这个细节是这次做扫描器过程中最大的收获。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →