news 2026/10/9 6:21:43

用Python实现攻击图生成器:自动化挖掘内网攻击路径

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用Python实现攻击图生成器:自动化挖掘内网攻击路径

简介:一套基于Python的自动化攻击图生成器源码,面向安全分析师、渗透测试人员及入门学习者,用于自动发现并可视化攻击者可能利用的路径,辅助安全评估与漏洞排查。包体共101个文件,约37.75MB,核心包含44个JSON配置、10个YAML参数、10个DOT图形定义、10个PDF报告、8个Python脚本及PyC字节码,另含Shell部署脚本与Git版本管理配置。JSON和YAML负责拓扑与策略配置,DOT用于描述攻击图结构,PDF可输出分析报告,整体目录清晰,便于二次开发与部署。已有321人学习下载。通过自动生成攻击图,可大幅降低手工绘制与关联分析的门槛,帮助安全团队直观识别关键风险点,同时为Python安全分析工具的开发提供完整的参考实现,涵盖配置解析、图形生成、结果报告等环节,具有较好的扩展基础。

1. 攻击图生成器:把多步攻击路径从手工枚举变成自动化推理

做红队评估或者攻防演练时,你大概率遇到过这种情况:资产清单几百台、漏洞报告上千条,真正能利用的只有一小部分,而攻击队最关心的不是“哪个漏洞最危险”,是“从入口打到目标机需要哪几步”。如果全靠人肉翻漏洞库、试网络连通性,一个中等规模的内网就能让人盯到怀疑人生。攻击图生成器(attack-graph-generator)就是为了解决这个问题来的:它把主机、权限、漏洞、网络可达性统一建模成一张有向图,节点是“某台机器上的某种权限状态”,边是一次可执行的攻击动作,然后用自动化搜索从初始节点到目标节点的全部可行路径。基于Python实现的这类源码项目,核心就是数据建模加图搜索,工程量和算法都不复杂,但能极大压缩人工分析时间。适合红队成员、安全研究员、也适合写自动化渗透工具的开发者照着改造。

2. 攻击图的核心建模:资产、漏洞与状态转移怎么转成一张有向图

2.1 攻击图为什么不是漏洞列表,而是一个状态转移系统

最早接触攻击图的人容易把它理解成“漏洞之间的依赖图”,觉得两个漏洞有先后关系就拉一条边。但实际做起来就会翻车:漏洞只是攻击动作的一个触发条件,真正决定能否进行下一步的是攻击者在目标主机上获得的权限状态。比如你利用Web应用的一个RCE漏洞拿到了www用户的shell,这并不等于能直接控制整个内网;你往攻击图里加一条边也只该从(web, www)这个节点出发,而不是笼统地从“web主机”出发。

所以常见且可靠的建模方式是做成状态转移系统:每个节点由“资产ID + 权限级别”组成,例如(db04, root),表示你已经攻占db04这台数据库服务器且取得了root权限。每条边对应一次原子攻击动作,边上挂漏洞编号、前置条件、后置条件。后置条件就是攻击成功后的权限提升结果,它会成为下一条边的前置条件。攻击者从初始节点集合出发,沿着边一路转移状态,直到某条路径覆盖目标权限,就是一条完整的攻击路径。这样建模的好处是漏洞利用的条件明确、不会出现“两个漏洞都高但根本连不上”的幻觉,而且天然适配图搜索算法。

2.2 数据模型设计:用JSON描述你的网络和攻击者

我一般习惯把输入做成三个数据文件:资产清单、漏洞清单、可达性关系。用JSON而不是YAML,是因为Python的json库零依赖、不容易在解析时被缩进坑到。资产清单里每一项只保留和攻击图有关的字段:资产ID、主机名、运行的服务、初始权限集合。漏洞清单里每一项必须写明影响哪个资产、前置权限要求、利用成功后的权限,以及CVSS分数。可达性关系单独列出来,描述“从哪台主机可以网络访问到哪台主机的哪个端口”――这一步常常被人省略,导致攻击图上出现大量物理上不可达的边。

一个最小可用的输入长这样:

{ "assets": [ {"id": "web01", "services": ["nginx:80"], "initial_privilege": "nobody"}, {"id": "db01", "services": ["mysql:3306"], "initial_privilege": "mysql"} ], "vulnerabilities": [ {"id": "CVE-2021-44228", "asset_id": "web01", "prerequisite": "nobody", "postcondition": "root", "cvss": 10.0} ], "reachability": [ {"from": "attacker", "to": "web01", "port": 80}, {"from": "web01", "to": "db01", "port": 3306} ] }

这里attacker是一个虚拟起点,它不代表真实主机,只表示攻击者初始能触达的网络入口。在这个模型里,攻击者要先通过web01的漏洞从nobody提升到root,而后才能通过内网横向移动去访问db01。如果你的场景里有域控、跳板机、堡垒机,就继续往资产清单和可达性里加,模型不用变。

2.3 从数据到邻接表:把配置解析成图结构

有了输入数据,下一步就是写构建逻辑。我这里用邻接表表示有向图,键是(asset_id, privilege)这样的节点,值是一个列表,列出所有能从当前状态直接转移到的目标节点。构建过程很直接:遍历每一条漏洞,找到漏洞所在资产,检查该资产当前是否具备漏洞要求的前置权限状态,如果有,就把这条边加进邻接表。

from typing import Dict, List, Tuple import json Node = Tuple[str, str] class AttackGraphBuilder: def __init__(self, config_path: str): with open(config_path, encoding="utf-8") as f: self.config = json.load(f) def build_adjacency(self) -> Dict[Node, List[Node]]: adj: Dict[Node, List[Node]] = {} # 先为每个存在的资产权限状态准备空的邻接表 for asset in self.config["assets"]: initial = asset.get("initial_privilege", "user") adj.setdefault((asset["id"], initial), []) # 遍历漏洞,生成攻击边 for vuln in self.config["vulnerabilities"]: src_node = (vuln["asset_id"], vuln["prerequisite"]) dst_node = (vuln["asset_id"], vuln["postcondition"]) # 源状态不存在时跳过,避免产生悬空的边 if src_node in adj: adj[src_node].append(dst_node) # 遍历可达性,把横向移动作为“无漏洞”转移边 for reach in self.config["reachability"]: src_id = reach["from"] dst_id = reach["to"] for src_priv in self._all_privileges(src_id): for dst_priv in self._all_privileges(dst_id): src_node = (src_id, src_priv) dst_node = (dst_id, dst_priv) adj.setdefault(src_node, []).append(dst_node) return adj def _all_privileges(self, asset_id: str) -> List[str]: # 实际实现时从资产配置的权限列表里取全量权限 for asset in self.config["assets"]: if asset["id"] == asset_id: return [asset.get("initial_privilege", "user")] return []

这段代码里有几个值得注意的参数:initial_privilege是资产的初始运行权限,很多服务默认是nobody或www,如果漏了这一项,攻击图的起点就不对;reachability里的from和to表示网络可达性,未出现在这个列表里的资产之间即使有漏洞也不会生成横向移动边。另一个容易被忽略的点:横向移动不是必须依赖漏洞的,如果能通过凭证复用、SSH信任关系直接跳过去,也应该在可达性里建模成一条普通边。我这里的_all_privileges只是一个简化实现,真实场景里要返回该资产上所有可能拥有的权限集合,否则横向移动的边会少得离谱。

提示:把横向移动当作“零日漏洞”边加入图,是攻击图生成器的一个常见扩展做法。攻击者只要在两台机器之间具备合法凭证和网络连通性,就能完成一次状态转移,这类边在红队实操里往往比漏洞边更重要。

3. 实现一个最小可跑的attack-graph-generator:核心类与构建流程

3.1 定义资产、漏洞、攻击动作三类对象

上一章我们直接用字典解JSON,这章把它们封装成数据类,方便在代码里访问字段、做校验。数据类的选型很简单,Python标准库里的@dataclass足够,不需要引入pydantic之类的重型依赖。每个类只做一件事:资产负责维护主机信息和权限集合,漏洞负责描述一次原子攻击的条件与结果,攻击图负责承载邻接表和提供查询接口。

下面是一个可复用的最小定义:

from dataclasses import dataclass, field from typing import List, Dict, Tuple, Optional @dataclass class Asset: asset_id: str name: str services: List[str] = field(default_factory=list) initial_privilege: str = "user" def __hash__(self): return hash(self.asset_id) def __eq__(self, other): return self.asset_id == other.asset_id @dataclass class Vulnerability: vuln_id: str asset_id: str prerequisite: str postcondition: str cvss: float = 0.0 @dataclass class AttackAction: source: Tuple[str, str] target: Tuple[str, str] vuln_id: Optional[str] = None

三个字段是这套模型的地基:prerequisite和postcondition必须精确匹配权限名称,如果资产初始权限叫user,漏洞前置条件写User,Python字典的key匹配会直接失败,而且这种失败异常隐蔽。我把Asset里加了__hash__和__eq__,是为了后续把资产放进图节点集合时能用set做去重,避免同一台主机被多次加载产出重复节点。

3.2 构建攻击图的入口函数:解析配置、建立边、生成图

有了对象定义,构建逻辑可以收敛到一个入口函数里。这里我把“网络可达性产生的边”和“漏洞利用产生的边”分开写,各用一个私有方法,主流程只负责顺序调用。分开写的好处是以后想给漏洞边和横向移动边赋予不同权重时,不用改主流程。

from typing import Dict, List, Set class AttackGraph: def __init__(self): self.adj: Dict[Tuple[str, str], List[Tuple[str, str]]] = {} self.assets: Dict[str, Asset] = {} self.vulns: List[Vulnerability] = [] def add_state(self, state: Tuple[str, str]): if state not in self.adj: self.adj[state] = [] def add_edge(self, src: Tuple[str, str], dst: Tuple[str, str], vuln_id: Optional[str] = None): self.add_state(src) self.add_state(dst) self.adj[src].append(dst) def load_config(self, assets: List[Asset], vulns: List[Vulnerability], reachability: List[Tuple[str, str]]): self.assets = {a.asset_id: a for a in assets} self.vulns = vulns for asset in assets: self.add_state((asset.asset_id, asset.initial_privilege)) for vuln in vulns: src = (vuln.asset_id, vuln.prerequisite) dst = (vuln.asset_id, vuln.postcondition) if src in self.adj: self.add_edge(src, dst, vuln.vuln_id) for src_id, dst_id in reachability: src_asset = self.assets.get(src_id) dst_asset = self.assets.get(dst_id) if src_asset and dst_asset: src_state = (src_id, src_asset.initial_privilege) dst_state = (dst_id, dst_asset.initial_privilege) self.add_edge(src_state, dst_state)

入口函数load_config里有两个关键参数值得琢磨:reachability里的元素是普通的二元组,例如("web01", "db01"),表示从web01可以触达db01。我不建议在构造阶段把整张网络拓扑全部展开,那会让邻接表爆炸式膨胀。只在存在实际转移意义的机器对之间建立边,保持图稀疏,后续搜索成本会低很多。另一个是vulns列表里的prerequisite,如果它对应的状态在邻接表里不存在,我们直接跳过,不强行创建节点――这样能避免因为输入数据缺了某个中间权限状态而生成大量死路径。

3.3 参数说明与运行方式

这套代码跑起来的方式是这样:先准备资产对象和漏洞对象,再实例化AttackGraph,调用load_config,之后就能访问adj属性做路径搜索了。举个例子:

if __name__ == "__main__": assets = [ Asset("web01", "公共Web服务器", ["nginx:80"], "nobody"), Asset("db01", "内部数据库", ["mysql:3306"], "mysql") ] vulns = [ Vulnerability("CVE-2021-44228", "web01", "nobody", "root", 10.0), Vulnerability("CVE-2024-1234", "db01", "mysql", "root", 9.1) ] reachability = [("attacker", "web01"), ("web01", "db01")] graph = AttackGraph() graph.load_config(assets, vulns, reachability) print(graph.adj)

输出结果里,attacker节点连接web01的nobody状态,然后web01的nobody通过CVE-2021-44228跳到root,再从root连到db01的mysql。这里有个很多初学者容易迷惑的点:为什么attacker到web01会是同权转移?因为在真实网络里,攻击者从外网进入一台主机时,只能拿到该服务默认运行权限,也就是nobody。这个转移是“无漏洞”的,它只是表示网络层面能触达。如果你给attacker预设了初始凭证,就应该把attacker直接连到目标机的user权限节点,这属于攻击者能力建模的范畴。

注意:节点命名建议统一用小写英文加下划线,权限状态就叫user、root、nobody这种约定值。如果某个资产是域控,权限可以是domain_admin,但一定要在全项目里保持一致,否则图构建时会丢边。

4. 自动化攻击路径搜索:从初始节点到目标节点的可达性分析

4.1 为什么用BFS/DFS而不是直接对漏洞列表做笛卡尔积

拿到邻接表之后,攻击路径搜索是个典型的图遍历问题。最容易想到的做法是把所有候选漏洞排列组合,再逐一验证前置条件是否满足。但这个做法在主机数量超过几十台时就会爆炸,因为组合数是指数增长的。更关键的是,排列组合天然忽略了“目标状态”,你其实只关心能到达目标节点的路径,而不是所有可能的状态序列。用图搜索算法从初始节点出发遍历,遇到目标节点再记下路径,复杂度只和实际可达的边数有关,和漏洞总量解耦。

在BFS和DFS之间,我一般优先选DFS。原因是安全分析更关注“某条攻击路径具体怎么走”,DFS能生成完整路径;而BFS更适合验证是否可达,拿到的路径往往缺乏攻击动作的具体顺序。如果你还打算做“最短攻击路径”分析,可以在DFS基础上加深度限制,或者直接改用迭代加深搜索。下面是基于DFS的全路径搜索实现,带深度限制和路径去重。

from typing import Dict, List, Tuple, Set AttackPath = List[Tuple[str, str]] def find_attack_paths(graph: AttackGraph, start: Tuple[str, str], goal: Tuple[str, str], max_depth: int = 6, max_paths: int = 50) -> List[AttackPath]: results: List[AttackPath] = [] visited_states: Set[Tuple[str, str]] = set() def dfs(current: Tuple[str, str], path: AttackPath, depth: int): # 限制单条路径长度,避免在内网大图上无意义深挖 if depth > max_depth: return # 到达目标,记录并截断 if current == goal: results.append(list(path)) return # 超过预设路径条数就停止整棵子树的搜索 if len(results) >= max_paths: return for nxt in graph.adj.get(current, []): # 环检测:当前路径里已经出现过的状态不再重复访问 if nxt in visited_states or nxt in path: continue visited_states.add(nxt) path.append(nxt) dfs(nxt, path, depth + 1) path.pop() visited_states.discard(nxt) visited_states.add(start) dfs(start, [start], 0) return results

这段代码的精髓在环检测和剪枝。visited_states是一个回溯集合,只标记当前搜索路径上的状态,而不是全局访问过的状态。如果直接用全局访问集合,会漏掉大量合法路径,因为很多攻击路径共享前几步但后程不同,全局去重会扼杀掉这些分支。max_paths参数是个防御性设计:真实内网的路径数可能是几千条,全返回回来用不上也存不下,限制前50条足够做人工分析。假如你确实想要全量路径,把max_paths设成很大的值即可,但要做好内存飙升的准备。

4.2 路径深度、去重与剪枝参数怎么调

max_depth的取值直接决定输出质量。在我处理过的内网评估场景里,从外网入点到域管通常需要3到5步,很少超过8步。设成6,既能覆盖大多数横向移动链,又不会让DFS陷入深不见底的递归。如果你发现输出路径都是半途而废,可以把它调到8再看。这里有个代价要清楚:深度每加1,路径数量可能翻倍,因为每个节点的分支数本来就大于1。调参的同时要盯max_paths,避免一次生成几万条结果导致下游分析工具卡死。

去重除了路径上的环,还有一个容易被忽略的问题:同一台主机上多个漏洞能产生相同的状态转移。比如web01上有两个RCE漏洞都能从nobody跳到root,搜索算法会把它当作两条不同路径输出,但它们本质上只是漏洞编号不同,攻击步骤完全一样。我通常会在生成结果后按“状态序列”去重,而不是按“漏洞链”去重。要保留漏洞细节,可以在AttackAction里把vuln_id记录成列表,这样既保留了候选利用方式,又不产生重复路径。

4.3 目标节点的确定和攻击者初始节点的枚举

实际使用中,start节点很少只有一个。攻击者的初始入口可能是多台暴露在公网的主机,也可能是同一个入口的不同权限状态。我习惯写一个前置处理函数,把所有初始状态收集成一个集合,然后逐一作为start传入搜索。目标节点同理,比如“拿到域管的shell”对应多个DC主机上的domain_admin状态,每个状态都要搜索一遍。这样出来的结果需要按“起始节点+目标节点”分组合并,数量可能会很多,建议在输出时标注最短路径、平均路径长度、涉及漏洞数量三个指标,方便决策。

def summarize(paths: List[AttackPath]) -> List[Dict]: summary = [] for p in paths: vuln_steps = [s for s in p if s[1] == "root"] # 按需求定制 summary.append({ "path": p, "steps": len(p) - 1, "privilege_escalations": len(vuln_steps) }) summary.sort(key=lambda x: x["steps"]) return summary

这里只按步数做了简单排序,真实项目里建议把每条路径每一步挂的漏洞CVSS分数加进去,算一个总危害分。至少,这一步能帮你把几十条路径按复杂度排个序,优先看最短链路和权限提升最多的链路。

5. 攻击图生成器常见问题与避坑记录:5条血泪经验

5.1 现象:生成的图里出现大量重复路径,节点数爆炸

第一次跑通代码时,我的邻接表只有十几个节点,但路径搜索结果输出几千条,大部分路径只有最后一步不同。排查后发现原因在横向移动边的建模方式:我把reachability里每对主机之间的所有权限组合都生成了边,导致A到B既有user权限的边,又有root权限的边,而实际上root权限并不是横向移动的必要条件。解决方法是把横向移动边的生成逻辑改成“按目标主机实际暴露的服务端口决定权限”,只保留攻击者通过该服务能获得的唯一初始权限,避免同一条物理链路映射出多条冗余边。

5.2 现象:漏洞利用条件永远不满足,攻击图空转

输入数据里漏洞的前置条件是user,但资产的初始权限写的是www,结果源节点在邻接表里根本不存在,构建时被静默跳过了,搜索自然一条路径都出不来。这种问题不会报错,只会让你怀疑是不是算法写错了。解决方法是构建完图后,先打印所有孤立节点,并做一个前置条件校验:遍历每个漏洞,确认prerequisite状态确实存在于资产可获得的权限集合中。我在代码里加入了一个validate_graph()方法,专门检查每条漏洞边是否成功加入邻接表,并把失败的漏洞以警告日志输出。

5.3 现象:CVSS高分漏洞没有进入任何攻击路径

CVSS 10.0的漏洞一大堆,但攻击图里根本没有它的身影。原因通常是这个漏洞的目标资产不在攻击者的可达性范围内,或者它的前置条件是某个攻击者还没有获得的权限。攻击图生成器永远不会把孤立漏洞当作可利用漏洞,这本是优点,但容易让人误以为项目有bug。遇到这种情况,需要检查两个地方:一是漏洞的asset_id是否在资产清单里;二是资产的这个节点是否处在某条可达性传递链上。我习惯把孤立节点单独导出成一个文件,作为“理论上存在但当前攻击面覆盖不到”的资产列表交给后续评估使用。

5.4 现象:图里出现环路,搜索算法死循环

当两台主机之间配置了双向信任关系,并且漏洞后置条件又回连到自身时,邻接表里就会出现环。比如web01通过漏洞获得root,而root又能通过SSH信任连回web01的user,就会形成(web01, root) -> (web01, user) -> (web01, root)的环。如果没有路径环检测,DFS会无限递归直到栈溢出。我的代码里用if nxt in path直接杜绝了这个问题,但要注意这只是去重当前路径上的重复状态,并不阻止两条合法路径共享同一个中间节点。如果攻击者真的有可能重复进入同一台主机多次,那也属于环,应该被剪掉。

5.5 现象:可视化输出乱成一团,根本没法读

用networkx直接画十几台主机的攻击图,出来的图往往像一碗打翻的意面,节点重叠、边交叉。这不是代码问题,是布局算法和展示粒度的问题。我后来改成只画“最短攻击路径”或“所有路径的并集”,并且用dot层次布局(见下章),才勉强能看。更实用的做法是:不画完整攻击图,而是按搜索结果逐条展示攻击链,每条链单独画一张小图,节点标签用“主机名/权限”格式,边标签用漏洞编号。这样虽然图的数量多了,但每张都清晰可读。

注意:攻击图的输出文件建议统一使用dot文本格式,它既是纯文本、可放进diff,又能被Graphviz工具渲染成SVG、PNG,还能被后续的路径分析脚本二次解析。

6. 进阶:把攻击图导出成Graphviz并用逻辑验证反推遗漏

6.1 导出dot格式与渲染

把搜索到的路径导出成dot格式,是让攻击图真正可交付的关键一步。dot文件的好处是平台无关、可以直接用Graphviz命令行工具生成图片,也能被networkx的nx_pydot读取回来继续分析。我写了这样一个导出函数:

def export_dot(paths: List[AttackPath], output_path: str): with open(output_path, "w", encoding="utf-8") as f: f.write("digraph attack_graph {\n") f.write(" rankdir=LR;\n") for path in paths: for i in range(len(path) - 1): src, dst = path[i], path[i + 1] src_label = f"{src[0]}\\n({src[1]})" dst_label = f"{dst[0]}\\n({dst[1]})" f.write(f' "{src_label}" -> "{dst_label}"\n') f.write("}\n")

导出的文件可以直接执行dot -Tsvg attack_graph.dot -o attack_graph.svg,也可以在Jupyter里用graphviz.Source预览。这里唯一需要动脑的地方是节点标签里的\\n换行符,如果你用Windows且不转义,渲染时换行会失效。建议统一用\\n,不管运行环境。

6.2 用最短路径反推输入缺陷

我个人的习惯是,拿到搜索结果后先验证最短路径是否合理。如果最短路径只有2步,比如attacker -> web01(root) -> db01(root),但实际评估中这个链路的漏洞利用条件极其苛刻,那就说明输入模型漏掉了某个前置权限或可达性限制。反过来,如果最短路径长度比你手工推演的链长很多,大概率是攻击图构建时遗漏了某条横向移动边。这种“用最短路径做合理性校验”的方法,比检查几百条漏洞配置有效得多。

最后一件事:攻击图生成器不是漏洞扫描器,它不会告诉你漏洞存不存在,只告诉你漏洞之间如何被组合成攻击链。投入前要先想清楚你的输入数据质量,如果资产清单和漏洞清单本身就残缺,那生成出的攻击图再漂亮也只是想象图。我踩过最深的坑就是把一个演示网络的攻击图直接套到真实生产环境上,被运维同事追着问“这台机器怎么会出现在图里”――因为它根本不在我的可扫描范围内。从那以后,我每次运行都会先打印资产数量和可达边数量,跟人工清点结果核对一遍再进算法。希望这个习惯也能帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/9 6:19:13

企业微信外部群API自动化管理:从入群欢迎到AI群助理的Python实践

手上一百多个外部群,光靠人工盯群根本盯不过来。这是很多做运营、做销售管理、做客户服务的兄弟都会遇到的真实场景。企业微信的外部群(也就是客户群)和内部群完全是两码事——内部群可以随便拉人随便聊,外部群里每一个客户都是资…

作者头像 李华
网站建设 2026/10/9 6:19:04

面向生产环境的原生AI微服务底座:架构设计与落地实践

1. 为什么“AI 微服务底座”不是又一个脚手架第一次看到“面向生产环境的原生 AI 微服务快速开发平台”这个定位时,我的第一反应是警惕。市面上打着“AI 快速开发”旗号的项目太多了,大多数本质上是把几个大模型 API 包一层 Controller,再配一…

作者头像 李华
网站建设 2026/10/9 6:19:02

SpringBoot智能家庭医保管理系统开发全流程:从建模到权限与状态机

每年三四月份,总有一批毕业生陷入同一种纠结:题目定了,但题目给的只是一句话,剩下的全靠自己脑补。“Java 智能家庭医疗保险管理系统,SpringBoot 做 Web 版家庭医保管理平台”就是这么一类典型题目——看着很有分量&am…

作者头像 李华
网站建设 2026/10/9 6:18:01

GitHub日榜怎么读?从榜单机制到开源项目落地选型的方法

2026年10月2日的GitHub日榜,我照例蹲点刷了一遍。说实话,这一天上去的项目不算惊艳,但恰恰是这种“平常日子”的榜单,最能看出门道。很多人把GitHub热榜当成“今日热门商品橱窗”,看一眼就走,这其实浪费了它…

作者头像 李华