-
GitHub 候选发现阶段不会直接调用大模型。等你选中目标并开始审计后,系统会先下载本地审计镜像,再执行规则层和 LLM 复核。
-
-
-
-
-
-
-
-
-
-
-
导入本地仓库后,系统会先在工作区生成过滤后的源码镜像,再执行规则审计和 LLM 二次复核。
-
+
正在读取环境信息…
+
-
-
-
-
审计 Skill
-
这些 Skill 是内置的防御性代码审计规则,不会从外部 GitHub 自动抓取。
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
环境自检
-
-
- 正在检测运行环境…
-
-
-
-
-
-
项目记忆
-
-
- 正在读取项目记忆…
-
-
-
- 团队规则
-
-
-
-
-
-
-
-
任务状态
-
-
-
-
-
-
- 目标选择与报告
- 先启动一次任务,然后在这里选择要审计的项目并查看规则层与 LLM 复核结果。
-
-
-
+
+
+
+
+
-
+
diff --git a/public/settings.html b/public/settings.html
new file mode 100644
index 0000000..eaa8a3f
--- /dev/null
+++ b/public/settings.html
@@ -0,0 +1,140 @@
+
+
+
+
+
+
设置中心 - 代码审计批量辅助
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/styles.css b/public/styles.css
index 475bf11..2a7c3d5 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -1,38 +1,32 @@
-:root {
- --bg: #f5eee4;
- --bg-deep: #eadfce;
+:root {
+ --bg: #f4ecdf;
+ --bg-2: #eadfce;
--ink: #1f1b17;
- --muted: #6c6258;
- --panel: rgba(255, 250, 243, 0.82);
- --panel-strong: rgba(255, 253, 248, 0.9);
- --line: rgba(52, 39, 26, 0.12);
- --line-strong: rgba(52, 39, 26, 0.18);
+ --muted: #6d655e;
+ --panel: rgba(255, 250, 243, 0.84);
+ --panel-2: rgba(255, 255, 255, 0.62);
+ --line: rgba(47, 35, 25, 0.1);
--accent: #0f766e;
--accent-soft: rgba(15, 118, 110, 0.12);
- --accent-2: #b45309;
- --accent-2-soft: rgba(180, 83, 9, 0.12);
+ --warn: #b45309;
--danger: #b42318;
- --shadow: 0 24px 64px rgba(43, 28, 17, 0.12);
- --shadow-soft: 0 12px 30px rgba(43, 28, 17, 0.08);
+ --shadow: 0 18px 48px rgba(41, 27, 16, 0.08);
}
* {
box-sizing: border-box;
}
-html {
- scroll-behavior: smooth;
-}
-
body {
margin: 0;
+ min-height: 100vh;
+ overflow-x: hidden;
color: var(--ink);
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background:
- radial-gradient(circle at 0% 0%, rgba(15, 118, 110, 0.18), transparent 28%),
- radial-gradient(circle at 100% 12%, rgba(180, 83, 9, 0.16), transparent 24%),
- radial-gradient(circle at 50% 100%, rgba(129, 91, 50, 0.14), transparent 20%),
- linear-gradient(180deg, #f8f2e8 0%, #efe5d8 58%, #eadfce 100%);
+ radial-gradient(circle at 0% 0%, rgba(15, 118, 110, 0.14), transparent 28%),
+ radial-gradient(circle at 100% 0%, rgba(180, 83, 9, 0.12), transparent 24%),
+ linear-gradient(180deg, #f8f2e8 0%, #f0e5d8 60%, #eadfce 100%);
}
#particle-field,
@@ -44,775 +38,482 @@ body {
#particle-field {
z-index: 0;
- opacity: 0.95;
}
.page-grain {
z-index: 1;
- opacity: 0.22;
+ opacity: 0.18;
background-image:
- linear-gradient(rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.08)),
- url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180' viewBox='0 0 180 180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.1' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)' opacity='0.13'/%3E%3C/svg%3E");
+ linear-gradient(rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.06)),
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180' viewBox='0 0 180 180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.1' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)' opacity='0.12'/%3E%3C/svg%3E");
}
-.shell {
+.app-shell {
position: relative;
z-index: 2;
- width: min(1220px, calc(100vw - 32px));
- margin: 0 auto;
- padding: 36px 0 80px;
-}
-
-h1,
-h2,
-h3,
-h4 {
- font-family: Georgia, "Noto Serif SC", serif;
-}
-
-h1 {
- margin: 0;
- font-size: clamp(2.5rem, 4vw, 4.9rem);
- line-height: 0.95;
- letter-spacing: -0.03em;
-}
-
-h2,
-h3,
-h4,
-p {
- margin-top: 0;
-}
-
-.eyebrow {
- margin: 0 0 12px;
- color: var(--accent);
- font-size: 0.83rem;
- letter-spacing: 0.18em;
- text-transform: uppercase;
-}
-
-.lede {
- max-width: 760px;
- color: var(--muted);
- font-size: 1.06rem;
- line-height: 1.7;
-}
-
-.hero {
- margin-bottom: 24px;
-}
-
-.hero-grid {
display: grid;
- grid-template-columns: 1.4fr 0.9fr;
+ grid-template-columns: 260px 1fr;
gap: 24px;
- align-items: start;
-}
-
-.hero-copy {
- padding: 16px 8px 0;
-}
-
-.hero-strip {
- display: flex;
- flex-wrap: wrap;
- gap: 12px;
- margin-top: 22px;
-}
-
-.hero-pill {
- min-width: 160px;
- padding: 14px 16px;
- border: 1px solid rgba(52, 39, 26, 0.1);
- border-radius: 18px;
- background: rgba(255, 252, 246, 0.64);
- box-shadow: var(--shadow-soft);
- backdrop-filter: blur(10px);
-}
-
-.hero-pill strong,
-.hero-pill span {
- display: block;
-}
-
-.hero-pill span {
- margin-top: 4px;
- color: var(--muted);
+ width: min(1440px, calc(100vw - 28px));
+ margin: 0 auto;
+ padding: 20px 0 48px;
}
+.sidebar,
.panel {
+ min-width: 0;
background: var(--panel);
+ backdrop-filter: blur(18px);
border: 1px solid var(--line);
- border-radius: 28px;
+ border-radius: 26px;
box-shadow: var(--shadow);
- padding: 22px;
- backdrop-filter: blur(18px);
-}
-
-.compact-panel {
- padding: 18px;
}
-.panel-head {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 12px;
-}
-
-.wrap-head {
- align-items: flex-start;
- flex-wrap: wrap;
-}
-
-.launch-panel {
- background:
- linear-gradient(135deg, rgba(255, 250, 243, 0.92), rgba(250, 243, 233, 0.78)),
- radial-gradient(circle at top right, rgba(15, 118, 110, 0.07), transparent 35%);
+.sidebar {
+ position: sticky;
+ top: 20px;
+ height: fit-content;
+ padding: 20px;
}
-.hidden-panel {
- display: none !important;
+.brand {
+ display: grid;
+ gap: 6px;
+ color: var(--ink);
+ text-decoration: none;
+ margin-bottom: 24px;
}
-.button-row,
-.chip-row,
-.pager-row,
-.selection-actions {
- display: flex;
- gap: 10px;
- flex-wrap: wrap;
- align-items: center;
+.brand strong {
+ font-size: 1.15rem;
}
+.brand span,
.note,
-.mini-note,
-.page-label {
+.lede,
+.sidebar-status span,
+.task-card span,
+.task-card small {
color: var(--muted);
}
-.grid {
- margin-top: 24px;
- display: grid;
- grid-template-columns: 330px 1fr;
- gap: 24px;
-}
-
-.results-grid {
- align-items: start;
-}
-
-label {
+.nav {
display: grid;
gap: 8px;
- font-size: 0.95rem;
+ margin-bottom: 20px;
}
-input,
-button,
-select,
-textarea {
- border-radius: 16px;
- border: 1px solid var(--line);
+.nav a {
padding: 12px 14px;
- font: inherit;
- transition: border-color 0.18s ease, transform 0.18s ease, background-color 0.18s ease, box-shadow 0.18s ease;
-}
-
-input,
-select,
-textarea {
- background: rgba(255, 255, 255, 0.82);
-}
-
-input:focus,
-select:focus,
-textarea:focus {
- outline: none;
- border-color: rgba(15, 118, 110, 0.46);
- box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.1);
-}
-
-button {
- cursor: pointer;
- background: linear-gradient(135deg, #1f1b17, #2d2722);
- color: #fff;
- box-shadow: var(--shadow-soft);
-}
-
-button:hover:not(:disabled) {
- transform: translateY(-1px);
-}
-
-button:disabled {
- opacity: 0.65;
- cursor: wait;
-}
-
-button.ghost {
- background: rgba(255, 255, 255, 0.5);
+ border-radius: 16px;
color: var(--ink);
- box-shadow: none;
-}
-
-button.ghost:hover:not(:disabled) {
- background: rgba(255, 255, 255, 0.82);
-}
-
-button.ghost.danger {
- color: var(--danger);
+ text-decoration: none;
+ background: rgba(255, 255, 255, 0.45);
+ border: 1px solid transparent;
}
-.task-form {
- display: grid;
- grid-template-columns: 2fr 180px 180px auto;
- gap: 14px;
- align-items: end;
+.nav a.active {
+ background: linear-gradient(135deg, rgba(15, 118, 110, 0.14), rgba(255, 255, 255, 0.78));
+ border-color: rgba(15, 118, 110, 0.2);
}
-.launch-fields {
+.sidebar-status {
display: grid;
- gap: 14px;
+ gap: 10px;
}
-.source-switch {
+.status-card,
+.summary-card,
+.info-item {
display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 14px;
+ gap: 6px;
+ padding: 14px;
+ border-radius: 18px;
+ border: 1px solid rgba(47, 35, 25, 0.08);
+ background: rgba(255, 255, 255, 0.56);
}
-.source-card {
+.main {
display: grid;
- grid-template-columns: 20px 1fr;
- gap: 12px;
- padding: 16px;
- border-radius: 22px;
- border: 1px solid rgba(52, 39, 26, 0.1);
- background: rgba(255, 255, 255, 0.58);
-}
-
-.source-card span {
- color: var(--muted);
-}
-
-.source-card:has(input:checked),
-.skill-card:has(input:checked) {
- border-color: rgba(15, 118, 110, 0.36);
- background: linear-gradient(135deg, rgba(245, 255, 253, 0.92), rgba(255, 251, 244, 0.9));
+ gap: 24px;
+ min-width: 0;
}
-.panel-subsection {
- margin-top: 10px;
- padding: 18px;
- border-radius: 24px;
- border: 1px solid rgba(52, 39, 26, 0.08);
- background: rgba(255, 255, 255, 0.48);
+.page-header {
+ padding: 8px 8px 0;
}
-.skill-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 12px;
+.eyebrow {
+ margin: 0 0 8px;
+ font-size: 0.8rem;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--accent);
}
-.skill-card {
- display: grid;
- grid-template-columns: 20px 1fr;
- gap: 12px;
- padding: 14px;
- border-radius: 18px;
- border: 1px solid rgba(52, 39, 26, 0.08);
- background: rgba(255, 255, 255, 0.72);
+h1,
+h2,
+h3,
+h4,
+h5 {
+ margin: 0;
+ font-family: Georgia, "Noto Serif SC", serif;
}
-.skill-card p {
- margin-bottom: 0;
- color: var(--muted);
+h1 {
+ font-size: clamp(2rem, 3vw, 3.2rem);
}
-.settings-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 14px;
+.lede {
+ max-width: 860px;
+ line-height: 1.75;
+ margin: 10px 0 0;
}
-.stack,
-.detail,
-.small-detail,
-.memory-form {
+.page-grid {
display: grid;
- gap: 12px;
+ grid-template-columns: minmax(0, 360px) minmax(0, 1fr);
+ gap: 24px;
+ align-items: start;
}
-.full-span {
- grid-column: 1 / -1;
+.panel {
+ padding: 20px;
}
-.form-footer {
+.panel-head,
+.form-footer,
+.button-row,
+.toolbar {
display: flex;
- justify-content: space-between;
+ gap: 12px;
align-items: center;
- gap: 16px;
+ justify-content: space-between;
+ flex-wrap: wrap;
}
-.status-grid {
+.stack,
+.detail,
+.sidebar-status,
+.plain-list,
+.finding-list {
display: grid;
- grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
-.status-card {
- border-radius: 20px;
- padding: 14px;
- background: rgba(255, 255, 255, 0.72);
- display: grid;
- gap: 6px;
- border: 1px solid rgba(52, 39, 26, 0.08);
-}
-
-.status-card strong {
- font-size: 0.88rem;
-}
-
-.status-card span {
- color: var(--muted);
-}
-
-.chip {
- padding: 9px 12px;
- background: rgba(255, 255, 255, 0.56);
- color: var(--ink);
-}
-
-.chip:hover {
- background: rgba(15, 118, 110, 0.12);
-}
-
-.task-card {
+.grid-two,
+.summary-grid,
+.info-grid,
+.review-columns,
+.source-switch,
+.skill-grid {
display: grid;
- gap: 8px;
- text-align: left;
- background: rgba(255, 255, 255, 0.62);
- color: var(--ink);
+ gap: 14px;
}
-.task-card.active {
- border-color: rgba(15, 118, 110, 0.42);
- outline: 2px solid rgba(15, 118, 110, 0.2);
+.grid-two,
+.summary-grid,
+.info-grid,
+.skill-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
}
-.detail-panel {
- min-height: 560px;
+.summary-grid {
+ margin-bottom: 14px;
}
-.detail-block {
- padding: 16px 18px;
- border: 1px solid rgba(52, 39, 26, 0.08);
+.detail-block,
+.panel-subsection,
+.candidate-card,
+.review-card-block {
+ min-width: 0;
+ padding: 16px;
border-radius: 22px;
- background: rgba(255, 255, 255, 0.5);
+ border: 1px solid rgba(47, 35, 25, 0.08);
+ background: var(--panel-2);
}
-.summary-block {
- display: grid;
- grid-template-columns: 1.4fr 0.9fr;
- gap: 18px;
-}
-
-.mini-metrics {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 10px;
-}
-
-.mini-stat {
- padding: 14px;
+.callout.warning {
+ padding: 14px 16px;
border-radius: 18px;
- background: rgba(255, 255, 255, 0.74);
- border: 1px solid rgba(52, 39, 26, 0.08);
-}
-
-.mini-stat strong,
-.mini-stat span {
- display: block;
-}
-
-.mini-stat strong {
- font-size: 1.25rem;
+ background: rgba(180, 83, 9, 0.1);
+ border: 1px solid rgba(180, 83, 9, 0.16);
+ color: #734210;
+ margin-bottom: 14px;
}
-.mini-stat span {
- margin-top: 5px;
- color: var(--muted);
- font-size: 0.9rem;
-}
-
-.selection-toolbar {
+label {
display: grid;
- grid-template-columns: 1.6fr 220px auto;
- gap: 12px;
- align-items: end;
-}
-
-.selection-toolbar-local {
- grid-template-columns: 1.6fr auto;
+ gap: 8px;
}
-.toolbar-field {
- margin: 0;
+input,
+textarea,
+select,
+button {
+ font: inherit;
+ border-radius: 16px;
+ border: 1px solid rgba(47, 35, 25, 0.12);
+ padding: 12px 14px;
}
-.toolbar-check {
- align-self: end;
- padding-bottom: 12px;
+input,
+textarea,
+select {
+ background: rgba(255, 255, 255, 0.82);
+ color: var(--ink);
}
-.selection-actions {
- justify-content: space-between;
- margin-top: 12px;
- margin-bottom: 12px;
+textarea {
+ resize: vertical;
}
-.candidate-list {
- display: grid;
- gap: 12px;
+button {
+ cursor: pointer;
+ color: #fff;
+ background: linear-gradient(135deg, #1f1b17, #2e2822);
}
-.candidate-card {
- display: grid;
- grid-template-columns: 24px 1fr;
- gap: 14px;
- padding: 18px;
- border: 1px solid var(--line);
- border-radius: 22px;
- background: var(--panel-strong);
- box-shadow: var(--shadow-soft);
+button.ghost,
+.ghost-link {
+ color: var(--ink);
+ background: rgba(255, 255, 255, 0.56);
+ text-decoration: none;
}
-.candidate-card:hover {
- transform: translateY(-1px);
- border-color: rgba(15, 118, 110, 0.28);
+button.danger {
+ color: var(--danger);
}
-.candidate-card.selected {
- border-color: rgba(15, 118, 110, 0.36);
- background: linear-gradient(135deg, rgba(245, 255, 253, 0.92), rgba(255, 251, 244, 0.9));
+.hidden-panel {
+ display: none !important;
}
-.candidate-checkbox {
- width: 18px;
- height: 18px;
- margin-top: 4px;
+.task-card,
+.task-row,
+.candidate-card {
+ text-align: left;
+ min-width: 0;
}
-.candidate-body {
+.task-card,
+.task-row {
display: grid;
- gap: 10px;
-}
-
-.candidate-head {
- display: flex;
- justify-content: space-between;
- gap: 16px;
- align-items: start;
-}
-
-.candidate-head p {
- margin-bottom: 0;
-}
-
-.candidate-meta {
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
-}
-
-.candidate-meta span,
-.usage-pill {
- display: inline-flex;
- align-items: center;
- padding: 6px 10px;
- border-radius: 999px;
- background: rgba(255, 255, 255, 0.72);
- border: 1px solid rgba(52, 39, 26, 0.08);
- color: var(--muted);
- font-size: 0.88rem;
-}
-
-.usage-pill {
- color: var(--accent);
- background: rgba(15, 118, 110, 0.08);
-}
-
-.candidate-path {
- color: var(--muted);
- word-break: break-all;
-}
-
-.finding-group {
- border-top: 1px solid rgba(52, 39, 26, 0.08);
- padding-top: 16px;
+ gap: 6px;
+ padding: 14px;
+ border-radius: 18px;
+ border: 1px solid rgba(47, 35, 25, 0.08);
+ background: rgba(255, 255, 255, 0.6);
+ color: var(--ink);
+ text-decoration: none;
}
-.finding-group ul {
- padding-left: 18px;
+.task-card.active {
+ outline: 2px solid rgba(15, 118, 110, 0.18);
+ border-color: rgba(15, 118, 110, 0.28);
}
-.project-review {
+.candidate-card {
display: grid;
- gap: 14px;
+ grid-template-columns: 20px 1fr;
+ gap: 12px;
}
-.review-columns {
- display: grid;
+.source-switch {
grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 14px;
}
-.review-card {
+.source-card,
+.skill-card {
+ display: grid;
+ grid-template-columns: 20px 1fr;
+ gap: 12px;
padding: 16px;
border-radius: 20px;
- background: rgba(255, 255, 255, 0.5);
- border: 1px solid rgba(52, 39, 26, 0.08);
-}
-
-.review-card h5 {
- margin: 0 0 8px;
- font-family: Georgia, "Noto Serif SC", serif;
+ border: 1px solid rgba(47, 35, 25, 0.08);
+ background: rgba(255, 255, 255, 0.6);
}
-.mode-note,
-.result-callout {
- margin: 0 0 14px;
- padding: 12px 14px;
- border-radius: 16px;
- border: 1px solid rgba(140, 112, 82, 0.16);
- background: rgba(255, 255, 255, 0.56);
-}
-
-.result-callout strong {
- display: block;
- margin-bottom: 6px;
-}
-
-.progress-card {
- display: grid;
- gap: 10px;
-}
-
-.progress-head {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 12px;
+.source-card:has(input:checked),
+.skill-card:has(input:checked) {
+ border-color: rgba(15, 118, 110, 0.24);
+ background: linear-gradient(135deg, rgba(245, 255, 253, 0.92), rgba(255, 251, 244, 0.88));
}
.progress-track {
- position: relative;
- overflow: hidden;
width: 100%;
- height: 12px;
+ height: 14px;
border-radius: 999px;
- background: rgba(140, 112, 82, 0.12);
+ overflow: hidden;
+ background: rgba(47, 35, 25, 0.08);
}
.progress-fill {
height: 100%;
border-radius: inherit;
- background: linear-gradient(90deg, var(--accent), var(--accent-2));
- box-shadow: 0 0 18px rgba(15, 118, 110, 0.18);
- transition: width 0.35s ease;
+ background: linear-gradient(90deg, var(--accent), var(--warn));
}
-.progress-meta {
- margin: 0;
- color: var(--muted);
- word-break: break-word;
+.review-columns {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ margin-top: 12px;
}
-.review-status {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- margin: 8px 0 12px;
+.review-pane {
+ min-width: 0;
+ padding: 14px;
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.55);
}
-.review-meta {
- color: var(--muted);
- margin: 8px 0 0;
+.review-head,
+.finding-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ align-items: center;
}
.finding-list {
+ list-style: none;
margin: 0;
- padding-left: 18px;
-}
-
-.sev {
- display: inline-block;
- margin-left: 8px;
- padding: 3px 9px;
- border-radius: 999px;
- font-size: 0.8rem;
-}
-
-.sev-low {
- background: rgba(15, 118, 110, 0.12);
+ padding: 0;
}
-.sev-medium {
- background: rgba(180, 83, 9, 0.14);
-}
-
-.sev-high {
- background: rgba(180, 35, 24, 0.14);
-}
-
-.sev-source {
- background: rgba(104, 82, 165, 0.14);
+.finding-list li,
+.plain-list li {
+ line-height: 1.7;
}
-.sev-status {
- background: rgba(140, 112, 82, 0.14);
+.brand,
+.brand strong,
+.brand span,
+.lede,
+.note,
+.sidebar-status span,
+.status-card span,
+.summary-card span,
+.info-item span,
+.task-card strong,
+.task-card span,
+.task-card small,
+.task-row strong,
+.task-row span,
+.candidate-card strong,
+.candidate-card span,
+.candidate-card p,
+.detail,
+.detail p,
+.detail li,
+.detail-block p,
+.panel-subsection p,
+.review-pane p,
+.review-card-block p,
+.finding-list li,
+.plain-list li,
+.empty,
+.empty-card,
+.callout,
+textarea,
+input,
+select {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ word-break: break-word;
}
-.sev-called {
- background: rgba(15, 118, 110, 0.12);
+#fingerprint-projects,
+#fingerprint-detail,
+#asset-match-result,
+#task-list,
+#task-detail,
+#overview-tasks,
+#env-report {
+ min-width: 0;
}
-.sev-skipped {
- background: rgba(180, 83, 9, 0.14);
+#asset-input {
+ width: 100%;
+ min-width: 0;
}
-.sev-failed {
- background: rgba(180, 35, 24, 0.14);
+.badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 10px;
+ border-radius: 999px;
+ background: rgba(180, 83, 9, 0.12);
+ color: #8a4b08;
+ font-size: 0.85rem;
}
.empty,
.empty-card {
+ padding: 14px;
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.58);
color: var(--muted);
}
-.empty-card {
- padding: 22px;
- border-radius: 20px;
- border: 1px dashed var(--line-strong);
- background: rgba(255, 255, 255, 0.45);
-}
-
-textarea {
- width: 100%;
- resize: vertical;
- min-height: 120px;
-}
-
.checkbox-row {
display: flex;
- align-items: center;
gap: 10px;
-}
-
-.checkbox-row input {
- width: 18px;
- height: 18px;
-}
-
-.inline-check {
- padding-bottom: 12px;
+ align-items: center;
}
.download-link {
- display: inline-block;
- padding: 10px 14px;
- border-radius: 14px;
- background: rgba(15, 118, 110, 0.08);
-}
-
-.download-link:hover {
- background: rgba(15, 118, 110, 0.14);
+ color: var(--accent);
}
.toast {
position: fixed;
- right: 20px;
- bottom: 20px;
- z-index: 5;
+ right: 22px;
+ bottom: 22px;
+ z-index: 10;
padding: 12px 16px;
border-radius: 14px;
- color: white;
- background: var(--ink);
+ background: #1f1b17;
+ color: #fff;
box-shadow: var(--shadow);
- transition: opacity 0.2s ease, transform 0.2s ease;
}
.toast.hidden {
- opacity: 0;
- transform: translateY(8px);
- pointer-events: none;
-}
-
-.toast.show {
- opacity: 1;
- transform: translateY(0);
-}
-
-.toast.success {
- background: var(--accent);
-}
-
-.toast.info {
- background: var(--accent-2);
-}
-
-a {
- color: var(--accent);
+ display: none;
}
-.warning-list {
- color: #8a5b22;
+.detail-panel {
+ min-height: 70vh;
}
-.skill-row {
- margin-top: 12px;
+.full-span {
+ grid-column: 1 / -1;
}
-@media (max-width: 980px) {
- .hero-grid,
- .grid,
- .task-form,
- .settings-grid,
- .status-grid,
- .summary-block,
- .selection-toolbar,
- .source-switch,
- .skill-grid,
- .review-columns {
- grid-template-columns: 1fr;
- }
-
- .form-footer,
- .selection-actions,
- .candidate-head {
- flex-direction: column;
- align-items: stretch;
- }
-
- .mini-metrics {
+@media (max-width: 1280px) {
+ .page-grid,
+ .review-columns,
+ .summary-grid,
+ .info-grid {
grid-template-columns: 1fr;
}
}
-@media (max-width: 640px) {
- .shell {
- width: min(100vw - 20px, 100%);
- padding-top: 22px;
+@media (max-width: 1100px) {
+ .app-shell {
+ grid-template-columns: 1fr;
}
- .panel {
- padding: 18px;
- border-radius: 22px;
+ .sidebar {
+ position: static;
}
- h1 {
- font-size: 2.35rem;
+ .page-grid,
+ .grid-two,
+ .summary-grid,
+ .info-grid,
+ .review-columns,
+ .skill-grid,
+ .source-switch {
+ grid-template-columns: 1fr;
}
}
diff --git a/server.js b/server.js
index f8401a4..f120ceb 100644
--- a/server.js
+++ b/server.js
@@ -5,11 +5,13 @@ import { fileURLToPath } from "node:url";
import { FrameworkScoutAgent } from "./src/agents/frameworkScoutAgent.js";
import { LocalRepoScoutAgent } from "./src/agents/localRepoScoutAgent.js";
import { AuditAnalystAgent } from "./src/agents/auditAnalystAgent.js";
+import { FofaScoutAgent } from "./src/agents/fofaScoutAgent.js";
import { getAuditSkillCatalog } from "./src/config/auditSkills.js";
import { getProviderPreset, maskSecret, resolveLlmConfig } from "./src/config/llmProviders.js";
import { buildEnvironmentReport } from "./src/services/environmentReport.js";
import { DefensiveLlmReviewer } from "./src/services/llmReviewService.js";
import { createMemoryStore } from "./src/services/memoryStore.js";
+import { createFingerprintService } from "./src/services/fingerprintService.js";
import { writeAuditHtmlReport } from "./src/services/reportWriter.js";
import { createSettingsStore } from "./src/services/settingsStore.js";
import { createTaskStore } from "./src/store/taskStore.js";
@@ -27,11 +29,15 @@ const scoutAgent = new FrameworkScoutAgent({
downloadsDir,
getGithubConfig: async () => (await settingsStore.read()).github
});
+const fofaScoutAgent = new FofaScoutAgent({
+ getFofaConfig: async () => (await settingsStore.read()).fofa
+});
const localScoutAgent = new LocalRepoScoutAgent({ downloadsDir });
const llmReviewer = new DefensiveLlmReviewer();
const auditAgent = new AuditAnalystAgent({ llmReviewer });
const tasks = createTaskStore();
const memoryStore = createMemoryStore({ filePath: memoryFile });
+const fingerprintService = createFingerprintService({ downloadsDir });
await fs.mkdir(downloadsDir, { recursive: true });
await fs.mkdir(reportsDir, { recursive: true });
@@ -74,6 +80,11 @@ const server = http.createServer(async (req, res) => {
token: body?.github?.token ? body.github.token : current.github.token,
ownerFilter: body?.github?.ownerFilter ?? current.github.ownerFilter,
notes: body?.github?.notes ?? current.github.notes
+ },
+ fofa: {
+ email: body?.fofa?.email ?? current.fofa.email,
+ apiKey: body?.fofa?.apiKey ? body.fofa.apiKey : current.fofa.apiKey,
+ notes: body?.fofa?.notes ?? current.fofa.notes
}
});
return sendJson(res, 200, sanitizeSettings(updated));
@@ -92,6 +103,33 @@ const server = http.createServer(async (req, res) => {
return sendJson(res, 200, await memoryStore.read());
}
+ if (req.method === "GET" && url.pathname === "/api/fingerprint/projects") {
+ return sendJson(res, 200, await fingerprintService.listProjects());
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/fingerprint/analyze") {
+ const body = await readJson(req);
+ return sendJson(res, 200, await fingerprintService.analyzeProject(String(body?.projectId || "")));
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/fingerprint/match") {
+ const body = await readJson(req);
+ return sendJson(res, 200, await fingerprintService.matchAssets({
+ projectId: String(body?.projectId || ""),
+ assetText: String(body?.assetText || "")
+ }));
+ }
+
+ if (req.method === "GET" && url.pathname === "/api/fofa/quick") {
+ const settings = await settingsStore.read();
+ if (!settings.fofa.apiKey) {
+ return sendJson(res, 400, { error: "未配置 FOFA API Key" });
+ }
+ const query = url.searchParams.get("q") || "";
+ const result = await fofaScoutAgent.run({ query, size: 10 });
+ return sendJson(res, 200, result);
+ }
+
if (req.method === "POST" && url.pathname === "/api/memory") {
const body = await readJson(req);
return sendJson(res, 200, await memoryStore.write({ preferences: body.preferences || {}, rules: Array.isArray(body.rules) ? body.rules : undefined }));
@@ -127,6 +165,22 @@ const server = http.createServer(async (req, res) => {
return sendJson(res, 200, tasks.listTasks());
}
+ if (req.method === "POST" && url.pathname === "/api/tasks/cancel") {
+ const body = await readJson(req);
+ const taskId = body?.taskId;
+ const task = tasks.getTask(taskId);
+ if (!task) {
+ return sendJson(res, 404, { error: "Task not found" });
+ }
+ tasks.updateTask(taskId, { status: "cancelled", phase: "cancelled", message: "Task cancelled by user." });
+ return sendJson(res, 200, tasks.getTask(taskId));
+ }
+
+ if (req.method === "GET" && url.pathname.startsWith("/api/tasks/") && url.pathname.endsWith("/stream")) {
+ const id = url.pathname.split("/")[3];
+ return serveSse(res, id, tasks);
+ }
+
if (req.method === "GET" && url.pathname.startsWith("/api/tasks/")) {
const id = url.pathname.split("/")[3];
const task = tasks.getTask(id);
@@ -172,7 +226,12 @@ async function runScout(taskId) {
const task = tasks.getTask(taskId);
const scoutResult = task.sourceType === "local"
? await localScoutAgent.run({ localRepoPaths: task.localRepoPaths })
- : await scoutAgent.run({ query: task.query });
+ : await scoutAgent.run({
+ query: task.query,
+ cmsType: task.cmsType,
+ industry: task.industry,
+ minAdoption: task.minAdoption
+ });
tasks.updateTask(taskId, {
status: "awaiting_selection",
phase: "target-selection",
@@ -366,6 +425,13 @@ function sanitizeSettings(settings) {
ownerFilter: settings.github.ownerFilter,
notes: settings.github.notes
},
+ fofa: {
+ email: settings.fofa.email,
+ apiKeyConfigured: Boolean(settings.fofa.apiKey),
+ apiKeyMasked: maskSecret(settings.fofa.apiKey),
+ notes: settings.fofa.notes,
+ safeMode: "stored-only"
+ },
updatedAt: settings.updatedAt
};
}
@@ -377,8 +443,14 @@ function providerDefaults(providerId) {
async function testConnections(settings) {
const llm = resolveLlmConfig(process.env, settings.llm);
- const [llmTest, githubTest] = await Promise.all([testLlmConnection(llm), testGithubConnection(settings.github)]);
- return { testedAt: new Date().toISOString(), llm: llmTest, github: githubTest, overall: llmTest.ok && githubTest.ok ? "pass" : llmTest.ok || githubTest.ok ? "partial" : "warn" };
+ const [llmTest, githubTest, fofaTest] = await Promise.all([
+ testLlmConnection(llm),
+ testGithubConnection(settings.github),
+ testFofaConnection(settings.fofa)
+ ]);
+ const allOk = llmTest.ok && githubTest.ok && fofaTest.ok;
+ const someOk = llmTest.ok || githubTest.ok || fofaTest.ok;
+ return { testedAt: new Date().toISOString(), llm: llmTest, github: githubTest, fofa: fofaTest, overall: allOk ? "pass" : someOk ? "partial" : "warn" };
}
async function testGithubConnection(github) {
@@ -415,6 +487,27 @@ async function testGithubConnection(github) {
}
}
+async function testFofaConnection(fofa) {
+ if (!fofa.apiKey) return { ok: false, status: "warn", message: "未配置 FOFA API Key" };
+ if (!fofa.email) return { ok: false, status: "warn", message: "未配置 FOFA Email" };
+ try {
+ const encoded = btoa(`${fofa.email}:${fofa.apiKey}`);
+ const response = await fetch("https://api.fofa.com/v1/search/all?size=1&qbase64=IiI=", {
+ headers: {
+ Authorization: `Basic ${encoded}`,
+ Accept: "application/json"
+ }
+ });
+
+ if (response.ok) {
+ return { ok: true, status: "pass", message: "FOFA API 可用" };
+ }
+ return { ok: false, status: "warn", message: `FOFA 返回 ${response.status}` };
+ } catch (error) {
+ return { ok: false, status: "warn", message: error instanceof Error ? error.message : String(error) };
+ }
+}
+
async function testLlmConnection(llm) {
if (!llm.apiKey) return { ok: false, status: "warn", message: "未配置 LLM API Key" };
try {
@@ -452,39 +545,45 @@ function applyMemoryDefaults(body, memory) {
.map((item) => item.trim())
.filter(Boolean);
- if (sourceType === "local") {
- return {
- ...body,
- sourceType,
- selectedSkillIds,
- localRepoPaths,
- useMemory,
- query: "local repository import",
- minAdoption: 0
- };
- }
+ if (sourceType === "local") {
+ return {
+ ...body,
+ sourceType,
+ selectedSkillIds,
+ localRepoPaths,
+ useMemory,
+ query: "local repository import",
+ cmsType: "all",
+ industry: "all",
+ minAdoption: 0
+ };
+ }
if (!useMemory) {
return {
...body,
sourceType,
+ selectedSkillIds,
+ localRepoPaths: [],
+ useMemory: false,
+ query: body.query || 'topic:cms OR "headless cms" OR "content management system"',
+ cmsType: body.cmsType || "all",
+ industry: body.industry || "all",
+ minAdoption: Number(body.minAdoption || 100)
+ };
+ }
+ return {
+ ...body,
+ sourceType,
selectedSkillIds,
localRepoPaths: [],
- useMemory: false,
- query: body.query || 'topic:cms OR "headless cms" OR "content management system"',
- minAdoption: Number(body.minAdoption || 100)
+ useMemory,
+ query: body.query || memory.preferences.preferredQuery,
+ cmsType: body.cmsType || "all",
+ industry: body.industry || "all",
+ minAdoption: Number(body.minAdoption || memory.preferences.preferredMinAdoption || 100)
};
}
- return {
- ...body,
- sourceType,
- selectedSkillIds,
- localRepoPaths: [],
- useMemory,
- query: body.query || memory.preferences.preferredQuery,
- minAdoption: Number(body.minAdoption || memory.preferences.preferredMinAdoption || 100)
- };
-}
function buildMemorySnapshot(memory) {
return { rules: memory.rules, preferences: memory.preferences, learnedPatterns: memory.learnedPatterns.slice(0, 5) };
@@ -538,6 +637,23 @@ function sendJson(res, statusCode, payload) {
res.end(JSON.stringify(payload, null, 2));
}
+function serveSse(res, taskId, taskStore) {
+ res.writeHead(200, {
+ "Content-Type": "text/event-stream; charset=utf-8",
+ "Cache-Control": "no-store, max-age=0",
+ "Connection": "keep-alive"
+ });
+
+ const unsubscribe = taskStore.subscribe(taskId, (event) => {
+ res.write(`event: ${event.event}\n`);
+ res.write(`data: ${JSON.stringify(event.task)}\n\n`);
+ });
+
+ res.on("close", () => {
+ unsubscribe();
+ });
+}
+
const port = process.env.PORT || 3000;
server.listen(port, () => console.log(`Safe audit agents listening on http://localhost:${port}`));
diff --git a/src/agents/auditAnalystAgent.js b/src/agents/auditAnalystAgent.js
index baf46cd..410f717 100644
--- a/src/agents/auditAnalystAgent.js
+++ b/src/agents/auditAnalystAgent.js
@@ -2,6 +2,384 @@ import { promises as fs } from "node:fs";
import path from "node:path";
import { resolveAuditSkills } from "../config/auditSkills.js";
+// 精确规则模式:每个规则包含多个必须同时满足的条件 + 排除逻辑
+const PRECISE_RULES = {
+ // 访问控制规则
+ "access-control": [
+ {
+ id: "ac-obj-1",
+ name: "对象级访问控制缺失",
+ severity: "high",
+ minConfidence: 0.75,
+ requireA: /\brequest\s*\.\s*(params|query|body)\s*\.\s*[a-zA-Z_][a-zA-Z0-9_]*/,
+ requireB: /\b(where|find|findOne|findById|getOne|filter)\s*\(/,
+ exclude: /\b(authorize|can|permission|policy|guard|checkOwnership|verifyOwner|tenant|isOwner)\s*\(/i,
+ pathFilter: /(controller|route|handler|service|api|resolver)/i,
+ evidence: "客户端可控对象标识直接用于数据库查询,未发现权限校验逻辑"
+ },
+ {
+ id: "ac-obj-2",
+ name: "用户ID直接用于数据查询",
+ severity: "high",
+ minConfidence: 0.8,
+ requireA: /\b(userId|user_id|uid|authorId|author_id)\s*[=.]/,
+ requireB: /\b(where|find|findOne|select|query)\s*\(/,
+ exclude: /\b(authorize|can|permission|policy)\s*\(/i,
+ pathFilter: /(model|schema|controller|service)/i,
+ evidence: "userId 直接作为查询条件,缺少权限校验"
+ },
+ {
+ id: "ac-role-1",
+ name: "公共角色权限过宽",
+ severity: "critical",
+ minConfidence: 0.85,
+ requireA: /\b(public|anonymous|guest|visitor)\s*[:=]/i,
+ requireB: /\b(create|update|delete|write|admin|manage|upload|execute)\b/i,
+ exclude: /\bread\s*[-=]|\breadonly\b/i,
+ pathFilter: /(permission|role|acl|rbac|access)/i,
+ evidence: "公共/匿名角色被授予写入或管理权限"
+ },
+ {
+ id: "ac-route-1",
+ name: "管理路由显式关闭认证",
+ severity: "critical",
+ minConfidence: 0.9,
+ requireA: /auth\s*[:\s]*false|skipAuth|bypassAuth|isPublic\s*[:\s]*true/i,
+ requireB: /(admin|manage|setting|plugin|system|user|role)/i,
+ pathFilter: /(route|router|app\.use|controller)/i,
+ evidence: "管理相关路由显式关闭认证"
+ },
+ {
+ id: "ac-api-1",
+ name: "API 无认证保护",
+ severity: "high",
+ minConfidence: 0.8,
+ requireA: /\b@Public\b|@AllowAnonymous\b|@NoAuth\b/i,
+ requireB: /@Query|@Param|@Body/i,
+ pathFilter: /(controller|resolver|api)/i,
+ evidence: "API endpoint 允许匿名访问且接受用户输入"
+ }
+ ],
+
+ // 初始化配置规则
+ "bootstrap-config": [
+ {
+ id: "bc-init-1",
+ name: "首次管理员创建可重复触发",
+ severity: "critical",
+ minConfidence: 0.85,
+ requireA: /\b(bootstrap|seed|init|createFirst|registerInitial)\b.*(Admin|User)/i,
+ requireB: /if\s*\([^)]*(!|count|exists|length)/,
+ exclude: /process\.env\.NODE_ENV\s*===\s*['"]production['"]|RUN_ONCE/,
+ pathFilter: /(seed|migration|init|setup|bootstrap)/i,
+ evidence: "管理员初始化逻辑缺少生产环境强制校验或一次性执行保护"
+ },
+ {
+ id: "bc-dev-1",
+ name: "开发模式硬编码启用",
+ severity: "high",
+ minConfidence: 0.85,
+ requireA: /\b(DEBUG|DEBUG_MODE|DEV_MODE|DEVELOPMENT)\s*[:=]\s*true/i,
+ requireB: /./,
+ exclude: /process\.env/i,
+ pathFilter: /(config|env|setting)/i,
+ evidence: "开发调试模式在代码中硬编码为 true"
+ },
+ {
+ id: "bc-pass-1",
+ name: "默认弱密码",
+ severity: "critical",
+ minConfidence: 0.95,
+ requireA: /\b(password|passwd)\s*[:=]\s*['"](?!.*\$\{)[a-zA-Z0-9!@#$%^&*]{0,12}['"]/i,
+ requireB: /^(?!.*\$\{).*(admin|root|test|demo|default|123456|password|changeme)/i,
+ exclude: /process\.env|generatePassword|hashPassword/,
+ pathFilter: /(config|seed|init)/i,
+ evidence: "配置中存在默认弱密码"
+ }
+ ],
+
+ // 上传存储规则
+ "upload-storage": [
+ {
+ id: "us-path-1",
+ name: "文件路径存在遍历风险",
+ severity: "critical",
+ minConfidence: 0.85,
+ requireA: /\b(upload|move|rename|copy)\s*\(.*[\+\.]\s*req\.|params\.|body\./i,
+ requireB: /path|fileName|name/,
+ exclude: /\b(path\.join|path\.resolve|normalize|sanitize)\b/,
+ pathFilter: /(upload|middleware|controller|service)/i,
+ evidence: "文件操作中直接使用用户输入的路径"
+ },
+ {
+ id: "us-type-1",
+ name: "文件类型校验缺失",
+ severity: "high",
+ minConfidence: 0.8,
+ requireA: /\b(upload|multer|formidable|busboy)\b/i,
+ requireB: /file|mime|type|ext\s*\(/i,
+ exclude: /\b(mimeType|fileType|checkType|validateType|allowedTypes|whitelist)\b/i,
+ pathFilter: /(upload|middleware|config)/i,
+ evidence: "上传处理未发现严格的文件类型校验"
+ },
+ {
+ id: "us-ext-1",
+ name: "允许危险文件扩展名",
+ severity: "high",
+ minConfidence: 0.9,
+ requireA: /\.(exe|sh|bat|cmd|ps1|vbs|jar|asp|jsp|php|cgi)\b/i,
+ requireB: /\b(upload|move|write|save)\s*\(/i,
+ exclude: /\b(allowedExt|permitted|whiteList)\b/i,
+ pathFilter: /(upload|middleware)/i,
+ evidence: "文件上传允许危险扩展名"
+ }
+ ],
+
+ // 查询安全规则
+ "query-safety": [
+ {
+ id: "qs-sql-1",
+ name: "SQL 原始查询存在注入风险",
+ severity: "critical",
+ minConfidence: 0.85,
+ requireA: /\b(raw|query|execute|run)\s*\(\s*[`'"]/i,
+ requireB: /(\$\{|req\.|params\.|body\.|query\.)/,
+ exclude: /\b(stmt|prepared|parameterized|bind|escape|sanitize|placeholder)\b/i,
+ pathFilter: /(model|repository|dao|service)/i,
+ evidence: "原始 SQL 查询直接拼接用户输入"
+ },
+ {
+ id: "qs-sql-2",
+ name: "动态排序字段未白名单校验",
+ severity: "high",
+ minConfidence: 0.8,
+ requireA: /\b(orderBy|order|sort)\s*\(\s*req\.|params\.|body\./i,
+ requireB: /./,
+ exclude: /\b(allowed|whitelist|permit|map|switch)\b/i,
+ pathFilter: /(controller|service)/i,
+ evidence: "排序字段直接来自用户输入"
+ },
+ {
+ id: "qs-nosql-1",
+ name: "NoSQL 注入风险",
+ severity: "high",
+ minConfidence: 0.8,
+ requireA: /\bfind\([^}]*\$where|\$\s*ne\s*|\$gt\s*|\$lt\s*|\$nin\b/i,
+ requireB: /req\.|params\.|body\./,
+ exclude: /\b(sanitize|validate|escape)\b/i,
+ pathFilter: /(model|controller|service)/i,
+ evidence: "NoSQL 查询中使用用户输入的操作符"
+ }
+ ],
+
+ // 敏感信息规则
+ "secret-exposure": [
+ {
+ id: "se-env-1",
+ name: "前端暴露敏感环境变量",
+ severity: "critical",
+ minConfidence: 0.95,
+ requireA: /\b(NEXT_PUBLIC_|VITE_|PUBLIC_|REACT_APP_)[A-Z0-9_]*\b/i,
+ requireB: /\b(secret|key|token|password|auth|PRIVATE|API_KEY)\b/i,
+ exclude: /\b(URL|ENDPOINT|PUBLIC)\b/,
+ pathFilter: /\.env\.|\.env\./i,
+ evidence: "前端环境变量中包含敏感信息"
+ },
+ {
+ id: "se-hard-1",
+ name: "硬编码密钥",
+ severity: "critical",
+ minConfidence: 0.9,
+ requireA: /(apiKey|apiSecret|clientSecret|privateKey|accessToken)\s*[:=]\s*['"][a-zA-Z0-9_-]{20,}['"]/i,
+ requireB: /./,
+ exclude: /process\.env|generate|create.*Key/,
+ pathFilter: /(config|constant|setting)/i,
+ evidence: "代码中硬编码了 API 密钥"
+ },
+ {
+ id: "se-jwt-1",
+ name: "JWT 密钥弱或硬编码",
+ severity: "critical",
+ minConfidence: 0.95,
+ requireA: /\bjwt\s*\(\s*\{[^}]*secret\s*[:=]\s*['"][^'"]+['"]/i,
+ requireB: /./,
+ exclude: /process\.env|generateSecret/,
+ pathFilter: /(config|auth|middleware)/i,
+ evidence: "JWT 密钥为硬编码"
+ },
+ {
+ id: "se-aws-1",
+ name: "AWS 密钥硬编码",
+ severity: "critical",
+ minConfidence: 0.95,
+ requireA: /\b(AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY)\s*=\s*['"][A-Z0-9]{20,}['"]/i,
+ requireB: /./,
+ exclude: /process\.env/,
+ pathFilter: /(config|env)/i,
+ evidence: "AWS 密钥硬编码在代码中"
+ }
+ ],
+
+ // SSRF 规则
+ "ssrf": [
+ {
+ id: "sr-fetch-1",
+ name: "用户可控 URL 存在 SSRF 风险",
+ severity: "critical",
+ minConfidence: 0.85,
+ requireA: /\b(fetch|axios|request|http\.get|http\.post|got)\s*\(.*req\.|params\.|body\./i,
+ requireB: /\burl|link|href|src/,
+ exclude: /\b(validate|whitelist|allowed|isLocal|isPrivateHost|isInternal)\b/i,
+ pathFilter: /(controller|service|proxy)/i,
+ evidence: "允许用户控制 URL 进行网络请求"
+ }
+ ],
+
+ // 命令注入规则
+ "command-injection": [
+ {
+ id: "ci-exec-1",
+ name: "命令注入风险",
+ severity: "critical",
+ minConfidence: 0.9,
+ requireA: /\b(exec|spawn|execSync|system|popen|execFile)\s*\([^)]*(req\.|params\.|body\.|argv)/i,
+ requireB: /./,
+ exclude: /\b(escape|sanitize|arg|command)\b/i,
+ pathFilter: /(controller|service)/i,
+ evidence: "用户输入直接用于命令执行"
+ },
+ {
+ id: "ci-spawn-1",
+ name: "child_process 参数注入",
+ severity: "critical",
+ minConfidence: 0.9,
+ requireA: /\bspawn\([^)]*shell\s*:\s*true/i,
+ requireB: /req\.|params\.|body\./,
+ pathFilter: /(service)/i,
+ evidence: "使用 shell 执行且参数来自用户输入"
+ }
+ ],
+
+ // 路径穿越规则
+ "path-traversal": [
+ {
+ id: "pt-path-1",
+ name: "路径穿越风险",
+ severity: "critical",
+ minConfidence: 0.85,
+ requireA: /\b(readFile|readFileSync|createReadStream|open)\s*\([^)]*\+.*req\.|params\.|body\./i,
+ requireB: /path|file/,
+ exclude: /\b(path\.join|path\.resolve|normalize|baseDir|rootPath)\b/i,
+ pathFilter: /(controller|service|middleware)/i,
+ evidence: "文件读取路径中可能存在路径穿越"
+ }
+ ],
+
+ // XSS 规则
+ "xss": [
+ {
+ id: "xs-ref-1",
+ name: "反射型 XSS 风险",
+ severity: "high",
+ minConfidence: 0.8,
+ requireA: /\bres\.send\(|res\.render\(|innerHTML\s*=|outerHTML\s*=/i,
+ requireB: /req\.|params\.|body\.|query\./,
+ exclude: /\b(escape|encode|sanitize|xss|escapeHtml|textContent)\b/i,
+ pathFilter: /(controller|route|view)/i,
+ evidence: "用户输入未经过滤直接输出到页面"
+ },
+ {
+ id: "xs-vue-1",
+ name: "Vue v-html 可能存在 XSS",
+ severity: "high",
+ minConfidence: 0.85,
+ requireA: /v-html\s*=/i,
+ requireB: /req\.|params\.|body\./,
+ exclude: /\b(sanitize|DOMPurify|escape)\b/,
+ pathFilter: /\.vue|\.jsx|\.tsx/i,
+ evidence: "使用 v-html 绑定用户输入"
+ }
+ ],
+
+ // 不安全的反序列化
+ "deserialization": [
+ {
+ id: "ds-eval-1",
+ name: "Eval 不安全使用",
+ severity: "critical",
+ minConfidence: 0.95,
+ requireA: /\beval\s*\(\s*req\.|params\.|body\./i,
+ requireB: /./,
+ pathFilter: /(controller|route|service)/i,
+ evidence: "eval() 中直接使用用户输入"
+ },
+ {
+ id: "ds-parse-1",
+ name: "不安全的反序列化",
+ severity: "critical",
+ minConfidence: 0.9,
+ requireA: /\bJSON\.parse\(|yaml\.load\(|pickle\.load\(/i,
+ requireB: /req\.|params\.|body\./,
+ exclude: /\b(safe|loadSilent)\b/i,
+ pathFilter: /(controller|service|middleware)/i,
+ evidence: "反序列化用户输入的数据"
+ }
+ ]
+};
+
+// 规则匹配函数
+function matchPreciseRule(content, rule) {
+ // 检查路径过滤
+ if (rule.pathFilter && !rule.pathFilter.test(content)) {
+ return false;
+ }
+
+ // 检查 A 条件
+ if (!rule.requireA.test(content)) {
+ return false;
+ }
+
+ // 检查 B 条件
+ if (!rule.requireB.test(content)) {
+ return false;
+ }
+
+ // 排除条件
+ if (rule.exclude && rule.exclude.test(content)) {
+ return false;
+ }
+
+ return true;
+}
+
+function createFinding(finding) {
+ return {
+ source: "rule",
+ ...finding
+ };
+}
+
+function prioritizeFindings(findings) {
+ const deduped = [];
+ const seen = new Set();
+ for (const finding of findings) {
+ const key = `${finding.title}::${finding.location}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ deduped.push(finding);
+ }
+
+ // 严重性优先级:critical > high > medium > low
+ const severityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
+ return deduped
+ .filter((finding) => finding.confidence >= 0.6)
+ .sort((a, b) => {
+ const sevDiff = (severityOrder[b.severity] || 0) - (severityOrder[a.severity] || 0);
+ if (sevDiff !== 0) return sevDiff;
+ return b.confidence - a.confidence;
+ });
+}
+
export class AuditAnalystAgent {
constructor({ llmReviewer }) {
this.llmReviewer = llmReviewer;
@@ -95,187 +473,46 @@ async function buildHeuristicFindings(project, reviewProfile) {
const findings = [];
const enabledSkills = new Set(reviewProfile.map((skill) => skill.id));
+ // 收集所有文件内容用于跨文件分析
+ const fileContents = new Map();
for (const file of files) {
const content = await fs.readFile(file, "utf8");
const relative = path.relative(sourceRoot, file).replaceAll("\\", "/");
- const loweredPath = relative.toLowerCase();
-
- if (
- enabledSkills.has("access-control") &&
- hasObjectAccessIndicator(content) &&
- !hasAuthGuardIndicator(content) &&
- /(controller|route|resolver|service|api)/.test(loweredPath)
- ) {
- findings.push(createFinding({
- skillId: "access-control",
- title: "对象级访问控制边界值得重点复核",
- severity: "medium",
- confidence: 0.76,
- location: relative,
- impact: "如果控制器或服务层直接信任客户端提交的对象标识,可能导致跨用户或跨租户读取、修改内容。",
- evidence: `在 ${relative} 中发现了客户端可控对象标识的处理痕迹,但同文件附近没有明显的 ownership / policy / guard 校验线索。`,
- remediation: "在对象查询后、返回或修改前统一执行 role、tenant 与 ownership 校验,并让服务层承担二次鉴权职责。",
- safeValidation: "本地复核控制器到服务层的调用链,确认对象查找后的每条读写路径都执行了访问控制。"
- }));
- }
-
- if (
- enabledSkills.has("access-control") &&
- matches(content, /\b(public|anonymous|guest)\b/i, /\b(permission|permissions|role|roles|allow|grant|create|update|delete|read|find)\b/i) &&
- /(permission|policy|role|acl|rbac|config)/.test(loweredPath)
- ) {
- findings.push(createFinding({
- skillId: "access-control",
- title: "公共角色权限配置可能过宽",
- severity: "high",
- confidence: 0.79,
- location: relative,
- impact: "如果匿名或公共角色被默认授予内容管理能力,后台或 API 可能暴露出超出预期的读写面。",
- evidence: `在 ${relative} 中发现了 public / anonymous / guest 角色与权限授予语义同时出现。`,
- remediation: "将公共角色改为 deny-by-default,只为必要的读取接口单独放行,并把管理动作留给显式认证后的角色。",
- safeValidation: "本地检查角色初始化与权限合并逻辑,确认匿名角色不会默认获得管理或写入能力。"
- }));
- }
-
- if (
- enabledSkills.has("bootstrap-config") &&
- matches(content, /\b(bootstrapAdmin|seedAdmin|createFirstAdmin|registerInitialAdmin|setupAdmin|initialAdmin)\b/i, /\b(process\.env|config|if\s*\(!|allowBootstrap|enableBootstrap)\b/i)
- ) {
- findings.push(createFinding({
- skillId: "bootstrap-config",
- title: "初始化管理员入口需要确认关闭条件",
- severity: "high",
- confidence: 0.82,
- location: relative,
- impact: "如果首次管理员创建逻辑缺少严格的单次条件或部署态关闭机制,生产环境可能暴露出高权限初始化入口。",
- evidence: `在 ${relative} 中发现了管理员初始化逻辑,并与环境配置或缺省条件绑定。`,
- remediation: "将首次管理员创建流程改为一次性、显式确认、默认关闭,并确保初始化完成后彻底失效。",
- safeValidation: "本地审查启动与迁移流程,确认生产缺省态下不存在可重复触发的管理员初始化路径。"
- }));
- }
-
- if (
- enabledSkills.has("access-control") &&
- (matches(content, /\b(auth\s*:\s*false|skipAuth|bypassAuth|allowUnauthenticated|publicRoute)\b/i, /\b(route|router|endpoint|admin|panel|plugin)\b/i) ||
- (/(route|router|admin|plugin)/.test(loweredPath) && /\bauth\s*:\s*false\b/i.test(content)))
- ) {
- findings.push(createFinding({
- skillId: "access-control",
- title: "部分管理或插件路由显式关闭认证",
- severity: "high",
- confidence: 0.8,
- location: relative,
- impact: "如果这些路由位于后台、插件或管理入口附近,显式关闭认证可能直接扩大高价值接口的暴露面。",
- evidence: `在 ${relative} 中发现了 auth:false 或类似绕过认证的配置语义。`,
- remediation: "对后台、插件与管理路由采用显式白名单,默认启用鉴权与权限中间件,再按需对公开只读接口单独豁免。",
- safeValidation: "本地检查路由注册代码,确认仅少量公开只读接口会关闭认证,管理与插件路由默认受保护。"
- }));
- }
+ fileContents.set(relative, content);
+ }
- if (
- enabledSkills.has("upload-storage") &&
- matches(content, /\b(upload|multer|formidable|busboy|content-type|multipart)\b/i, /\b(path\.join|fs\.writeFile|writeFileSync|createWriteStream|public\/|static\/)\b/)
- ) {
- findings.push(createFinding({
- skillId: "upload-storage",
- title: "上传与公开文件边界值得重点审查",
- severity: "medium",
- confidence: 0.71,
- location: relative,
- impact: "如果上传内容的类型、文件名或公开访问目录没有被严格隔离,可能引发任意文件覆盖、危险内容托管或后台资源泄露。",
- evidence: `在 ${relative} 中同时出现了上传处理与文件落盘或公开目录语义。`,
- remediation: "对文件类型、扩展名、目标路径和公开目录做统一收口,公开资源目录与后台可执行路径应彻底隔离。",
- safeValidation: "本地复核上传链路,确认文件名、目标路径、MIME 与公开访问目录都经过规范化控制。"
- }));
- }
+ // 应用精确规则
+ for (const [relative, content] of fileContents) {
+ const loweredPath = relative.toLowerCase();
- if (
- enabledSkills.has("secret-exposure") &&
- matches(content, /\b(password|secret|token|api[_-]?key)\b/i, /\b(default|example|changeme|admin123|test|demo|sample)\b/i)
- ) {
- findings.push(createFinding({
- skillId: "secret-exposure",
- title: "疑似存在默认凭据或占位密钥风险",
- severity: "high",
- confidence: 0.74,
- location: relative,
- impact: "如果这些默认值会进入初始化流程、后台登录或第三方集成配置,真实部署时可能留下可猜测的高风险入口。",
- evidence: `在 ${relative} 中发现了凭据命名与默认值样式同时出现。`,
- remediation: "移除可运行的默认凭据;缺失密钥时应 fail closed,而不是退回演示或占位值。",
- safeValidation: "本地检查配置装载与初始化逻辑,确认占位值不会被当作真实凭据接受。"
- }));
+ // 跳过测试文件和文档
+ if (loweredPath.includes("/test/") || loweredPath.includes("/spec/") || loweredPath.includes(".md") || loweredPath.includes("readme")) {
+ continue;
}
- if (
- enabledSkills.has("secret-exposure") &&
- matches(content, /\b(NEXT_PUBLIC_|PUBLIC_|VITE_)\b/, /\b(secret|token|api[_-]?key|admin|password)\b/i)
- ) {
- findings.push(createFinding({
- skillId: "secret-exposure",
- title: "公开前端变量中疑似携带敏感配置",
- severity: "medium",
- confidence: 0.68,
- location: relative,
- impact: "如果敏感令牌或后台配置通过公开构建变量注入前端,可能导致管理能力或集成密钥暴露。",
- evidence: `在 ${relative} 中发现了公开前端环境变量前缀与敏感配置命名同时出现。`,
- remediation: "把敏感配置留在服务端,前端仅使用临时票据、代理接口或最小化公开标识。",
- safeValidation: "本地检查构建配置与运行时注入逻辑,确认公开变量中不包含后台密钥或管理接口凭据。"
- }));
- }
+ for (const [skillId, rules] of Object.entries(PRECISE_RULES)) {
+ if (!enabledSkills.has(skillId)) continue;
- if (
- enabledSkills.has("query-safety") &&
- matches(content, /\b(raw\(|sequelize\.query\(|knex\.raw\(|prisma\.[a-z]+Raw\(|SELECT\b|UPDATE\b|DELETE\b)\b/i, /(`[^`]*\$\{|\+\s*(req|params|query|body)|\b(req|params|query|body)\b)/i)
- ) {
- findings.push(createFinding({
- skillId: "query-safety",
- title: "动态查询构造路径需要重点确认",
- severity: "medium",
- confidence: 0.64,
- location: relative,
- impact: "如果这类动态查询直接拼接外部输入,内容检索、管理后台筛选或插件接口可能出现持久层注入风险。",
- evidence: `在 ${relative} 中发现了原始查询语义,并伴随模板插值或外部输入拼接痕迹。`,
- remediation: "优先改用参数化查询或 ORM 安全接口,并对动态排序、筛选字段做白名单约束。",
- safeValidation: "本地确认原始查询是否始终采用参数绑定,动态字段和值是否都经过白名单控制。"
- }));
+ for (const rule of rules) {
+ if (matchPreciseRule(content, rule)) {
+ findings.push(createFinding({
+ skillId,
+ title: rule.name,
+ severity: rule.severity,
+ confidence: rule.minConfidence,
+ location: relative,
+ evidence: rule.evidence,
+ impact: `该代码存在 ${rule.name} 风险,需要重点人工复核。`,
+ remediation: `建议添加 ${rule.name} 的安全防护措施。`,
+ safeValidation: "建议在本地代码审查中验证此问题是否真实存在。"
+ }));
+ }
+ }
}
}
- return prioritizeFindings(findings).slice(0, 8);
-}
-
-function createFinding(finding) {
- return {
- source: "rule",
- ...finding
- };
-}
-
-function prioritizeFindings(findings) {
- const deduped = [];
- const seen = new Set();
- for (const finding of findings) {
- const key = `${finding.title}::${finding.location}`;
- if (seen.has(key)) continue;
- seen.add(key);
- deduped.push(finding);
- }
-
- return deduped
- .filter((finding) => finding.confidence >= 0.6)
- .sort((a, b) => severityScore(b.severity) - severityScore(a.severity) || b.confidence - a.confidence);
-}
-
-function hasObjectAccessIndicator(content) {
- return /(req|request)\.(params|query)\.[a-zA-Z0-9_]+/.test(content) || /\b(ctx|event)\.(params|query)\.[a-zA-Z0-9_]+/.test(content);
-}
-
-function hasAuthGuardIndicator(content) {
- return /\b(can|authorize|authorization|permission|permissions|policy|guard|rbac|ownership|tenant)\b/i.test(content);
-}
-
-function severityScore(value) {
- return value === "high" ? 3 : value === "medium" ? 2 : 1;
+ // 按置信度排序并限制结果数
+ return prioritizeFindings(findings).slice(0, 15);
}
async function collectFiles(root) {
@@ -291,8 +528,4 @@ async function collectFiles(root) {
} catch {
return [];
}
-}
-
-function matches(content, requiredA, requiredB) {
- return requiredA.test(content) && requiredB.test(content);
-}
+}
\ No newline at end of file
diff --git a/src/agents/fofaScoutAgent.js b/src/agents/fofaScoutAgent.js
new file mode 100644
index 0000000..92f5190
--- /dev/null
+++ b/src/agents/fofaScoutAgent.js
@@ -0,0 +1,97 @@
+const FOFA_API_BASE = "https://api.fofa.com/v1";
+const MAX_RESULTS = 30;
+
+export class FofaScoutAgent {
+ constructor({ getFofaConfig }) {
+ this.getFofaConfig = getFofaConfig || (() => ({}));
+ }
+
+ async run({ query, size = 20 }) {
+ const config = await this.getFofaConfig();
+ if (!config.apiKey) {
+ return {
+ status: "skipped",
+ skipReason: "no-api-key",
+ message: "未配置 FOFA API Key,请先在设置中心填写 FOFA email 和 API Key。",
+ projects: []
+ };
+ }
+
+ try {
+ const results = await this.searchAssets(query, config, Math.min(size || MAX_RESULTS, MAX_RESULTS));
+ return {
+ status: "completed",
+ source: "fofa",
+ query,
+ discoveredAt: new Date().toISOString(),
+ message: `FOFA 资产检索发现 ${results.length} 条结果。`,
+ projects: results
+ };
+ } catch (error) {
+ return {
+ status: "error",
+ skipReason: "api-error",
+ message: `FOFA API 调用失败:${error instanceof Error ? error.message : String(error)}`,
+ projects: []
+ };
+ }
+ }
+
+ async searchAssets(query, config, limit) {
+ const email = config.email;
+ const apiKey = config.apiKey;
+ const q = this.buildQuery(query);
+
+ const encoded = btoa(`${email}:${apiKey}`);
+ const response = await fetch(`${FOFA_API_BASE}/search/all?size=${limit}&qbase64=${encodeURIComponent(q)}`, {
+ headers: {
+ Authorization: `Basic ${encoded}`,
+ Accept: "application/json"
+ }
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`FOFA 返回 ${response.status}: ${errorText}`);
+ }
+
+ const data = await response.json();
+ return this.normalizeResults(data);
+ }
+
+ buildQuery(raw) {
+ const keywords = String(raw || "")
+ .split(/[\s,]+/)
+ .filter(Boolean)
+ .join(" && ");
+
+ if (!keywords) {
+ return "";
+ }
+
+ return `(*="${keywords}")`;
+ }
+
+ normalizeResults(data) {
+ const results = Array.isArray(data?.data) ? data.data : [];
+ return results.map((item, index) => ({
+ id: `fofa-${index}-${Date.now()}`,
+ sourceType: "fofa",
+ name: item.title || item.host || `Asset ${index + 1}`,
+ host: item.host,
+ protocol: item.protocol,
+ port: item.port,
+ banner: item.banner || "",
+ server: item.server || "",
+ country: item.country_name || "",
+ province: item.province || "",
+ city: item.city || "",
+ organization: item.org || "",
+ asn: item.asn || "",
+ latitude: item.latitude,
+ longitude: item.longitude,
+ createdAt: item.created_at,
+ lastUpdatedAt: item.updated_at || item.lastuptime
+ }));
+ }
+}
\ No newline at end of file
diff --git a/src/agents/frameworkScoutAgent.js b/src/agents/frameworkScoutAgent.js
index 827d23c..485f256 100644
--- a/src/agents/frameworkScoutAgent.js
+++ b/src/agents/frameworkScoutAgent.js
@@ -27,18 +27,6 @@ const SAMPLE_REPOS = [
default_branch: "main",
topics: ["cms", "headless-cms", "content-api"]
},
- {
- full_name: "keystonejs/keystone",
- html_url: "https://github.com/keystonejs/keystone",
- description: "The most powerful headless CMS for Node.js.",
- stargazers_count: 9700,
- forks_count: 1200,
- language: "TypeScript",
- updated_at: "2026-04-04T15:30:00Z",
- pushed_at: "2026-04-08T11:45:00Z",
- default_branch: "main",
- topics: ["cms", "headless-cms", "graphql"]
- },
{
full_name: "payloadcms/payload",
html_url: "https://github.com/payloadcms/payload",
@@ -52,19 +40,27 @@ const SAMPLE_REPOS = [
topics: ["cms", "headless-cms", "typescript"]
},
{
- full_name: "appwrite/appwrite",
- html_url: "https://github.com/appwrite/appwrite",
- description: "Build like a team of hundreds with a full platform and admin control plane.",
- stargazers_count: 47000,
- forks_count: 4200,
- language: "TypeScript",
- updated_at: "2026-04-02T10:00:00Z",
- pushed_at: "2026-04-07T18:20:00Z",
+ full_name: "wagtail/wagtail",
+ html_url: "https://github.com/wagtail/wagtail",
+ description: "A Django content management system focused on flexibility and user experience.",
+ stargazers_count: 20000,
+ forks_count: 4500,
+ language: "Python",
+ updated_at: "2026-04-07T10:00:00Z",
+ pushed_at: "2026-04-08T18:20:00Z",
default_branch: "main",
- topics: ["cms", "admin-panel", "backend"]
+ topics: ["cms", "enterprise", "editorial"]
}
];
+const SEARCH_PROFILES = [
+ { label: "cms-ts", query: "topic:cms language:TypeScript archived:false" },
+ { label: "headless-js", query: '"headless cms" language:JavaScript archived:false' },
+ { label: "headless-ts", query: '"headless cms" language:TypeScript archived:false' },
+ { label: "cms-php", query: "topic:cms language:PHP archived:false" },
+ { label: "content-platform", query: '"content management system" archived:false stars:>20' }
+];
+
const CMS_KEYWORDS = [
"cms",
"headless",
@@ -78,13 +74,25 @@ const CMS_KEYWORDS = [
"editorial"
];
-const SEARCH_PROFILES = [
- { label: "cms-ts", query: "topic:cms language:TypeScript archived:false" },
- { label: "headless-js", query: '"headless cms" language:JavaScript archived:false' },
- { label: "headless-ts", query: '"headless cms" language:TypeScript archived:false' },
- { label: "cms-php", query: "topic:cms language:PHP archived:false" },
- { label: "content-platform", query: '"content management system" archived:false stars:>20' }
-];
+const CMS_TYPE_KEYWORDS = {
+ all: [],
+ headless: ["headless", "api-first", "content api", "content-api"],
+ blog: ["blog", "publishing", "editorial", "news"],
+ ecommerce: ["ecommerce", "e-commerce", "shop", "storefront", "shopping"],
+ enterprise: ["enterprise", "digital experience", "portal", "dxp"],
+ education: ["lms", "education", "learning", "course"],
+ flatfile: ["flat-file", "flat file", "markdown"]
+};
+
+const INDUSTRY_KEYWORDS = {
+ all: [],
+ education: ["education", "learning", "course", "student", "campus"],
+ ecommerce: ["ecommerce", "store", "shop", "product", "catalog"],
+ media: ["media", "editorial", "news", "publishing", "magazine"],
+ enterprise: ["enterprise", "portal", "workflow", "business"],
+ government: ["government", "public sector", "civic", "municipal"],
+ community: ["forum", "community", "member", "social"]
+};
const REVIEWABLE_EXTENSIONS = new Set([
".ts",
@@ -133,17 +141,23 @@ export class FrameworkScoutAgent {
this.getGithubConfig = getGithubConfig || (() => ({}));
}
- async run({ query }) {
+ async run({ query, cmsType = "all", industry = "all", minAdoption = 0 }) {
const source = await this.fetchTrendingFrameworks(query);
const projects = [];
for (const repo of source) {
- projects.push(await this.materializeProject(repo));
+ const project = await this.materializeProject(repo);
+ if (!matchesProjectFilters(project, { cmsType, industry, minAdoption })) {
+ continue;
+ }
+ projects.push(project);
}
return {
sourceMode: source === SAMPLE_REPOS ? "sample-fallback" : "live-github",
query: normalizeCmsQuery(query),
+ cmsType,
+ industry,
discoveredAt: new Date().toISOString(),
summary: `已发现 ${projects.length} 个候选开源 CMS。选择目标后会先下载审计镜像,再执行规则层和 LLM 复核。`,
projects
@@ -182,7 +196,6 @@ export class FrameworkScoutAgent {
successCount += 1;
const data = await response.json();
const items = Array.isArray(data.items) ? data.items : [];
-
for (const repo of items) {
if (!isCmsLike(repo)) {
continue;
@@ -212,6 +225,7 @@ export class FrameworkScoutAgent {
const [owner, name] = repo.full_name.split("/");
const estimatedLiveUsage = this.estimateLiveUsage(repo);
const archiveFileName = `${owner}__${name}.json`;
+ const traits = inferProjectTraits(repo);
return {
id: `${owner}-${name}`,
@@ -226,6 +240,9 @@ export class FrameworkScoutAgent {
updatedAt: repo.updated_at,
pushedAt: repo.pushed_at,
downloadArtifact: archiveFileName,
+ cmsType: traits.cmsType,
+ industries: traits.industries,
+ tags: traits.tags,
adoptionSignals: {
stars: repo.stargazers_count || 0,
forks: repo.forks_count || 0,
@@ -439,7 +456,7 @@ export class FrameworkScoutAgent {
}
}
} catch {
- // Ignore metadata lookup failure and fall back to common branch names.
+ // ignore
}
return [...new Set(refs.filter(Boolean))];
@@ -447,7 +464,6 @@ export class FrameworkScoutAgent {
async fetchGithubResource(url, token) {
let lastError = null;
-
for (let attempt = 0; attempt < FETCH_RETRY_LIMIT; attempt += 1) {
try {
let response = await fetch(url, { headers: this.buildGithubHeaders(token) });
@@ -459,13 +475,11 @@ export class FrameworkScoutAgent {
lastError = error;
}
}
-
throw lastError || new Error("GitHub request failed");
}
async fetchRawResource(url, token) {
let lastError = null;
-
for (let attempt = 0; attempt < FETCH_RETRY_LIMIT; attempt += 1) {
try {
let response = await fetch(url, { headers: this.buildRawHeaders(token) });
@@ -477,7 +491,6 @@ export class FrameworkScoutAgent {
lastError = error;
}
}
-
throw lastError || new Error("Raw file request failed");
}
@@ -492,7 +505,7 @@ export class FrameworkScoutAgent {
return headers;
}
- buildRawHeaders(token) {
+ buildRawHeaders() {
return { "User-Agent": "safe-framework-audit-agents" };
}
@@ -549,6 +562,29 @@ function scoreRepo(repo) {
return stars * 1.05 + forks * 2.15 + keywordBoost + freshnessBoost;
}
+function inferProjectTraits(repo) {
+ const text = `${repo.full_name || ""} ${repo.description || ""} ${(repo.topics || []).join(" ")}`.toLowerCase();
+ const cmsType = Object.entries(CMS_TYPE_KEYWORDS).find(([key, values]) => key !== "all" && values.some((value) => text.includes(value)))?.[0] || "generic";
+ const industries = Object.entries(INDUSTRY_KEYWORDS)
+ .filter(([key, values]) => key !== "all" && values.some((value) => text.includes(value)))
+ .map(([key]) => key);
+ const tags = Array.from(new Set([...(repo.topics || []), cmsType, ...(industries.length ? industries : ["general"])]));
+ return { cmsType, industries: industries.length ? industries : ["general"], tags };
+}
+
+function matchesProjectFilters(project, { cmsType, industry, minAdoption }) {
+ if (Number(project.adoptionSignals?.estimatedLiveUsage || 0) < Number(minAdoption || 0)) {
+ return false;
+ }
+ if (cmsType && cmsType !== "all" && project.cmsType !== cmsType) {
+ return false;
+ }
+ if (industry && industry !== "all" && !(project.industries || []).includes(industry)) {
+ return false;
+ }
+ return true;
+}
+
function shouldIncludePath(filePath) {
const lowered = filePath.toLowerCase();
if (IGNORED_SEGMENTS.some((segment) => lowered.includes(segment))) {
@@ -579,7 +615,7 @@ function shouldIncludePath(filePath) {
"schema"
];
- return REVIEWABLE_EXTENSIONS.has(path.extname(filePath).toLowerCase()) && interestingNames.some((name) => lowered.includes(name));
+ return REVIEWABLE_EXTENSIONS.has(path.extname(lowered)) && interestingNames.some((token) => lowered.includes(token));
}
function shouldMirrorPath(filePath) {
@@ -588,72 +624,60 @@ function shouldMirrorPath(filePath) {
return false;
}
- if (!REVIEWABLE_EXTENSIONS.has(path.extname(filePath).toLowerCase())) {
- return false;
- }
+ const boostedSegments = [
+ "auth",
+ "login",
+ "session",
+ "permission",
+ "policy",
+ "upload",
+ "storage",
+ "admin",
+ "config",
+ "controller",
+ "route",
+ "api",
+ "middleware",
+ "access",
+ "rbac",
+ "role",
+ "plugin",
+ "graphql",
+ "bootstrap",
+ "seed",
+ "schema",
+ "security"
+ ];
- return /(auth|permission|policy|access|role|admin|upload|secret|config|route|controller|service|plugin|bootstrap|seed|schema|collection|api|middleware|query|db|database|graphql|resolver)/.test(lowered);
+ return REVIEWABLE_EXTENSIONS.has(path.extname(lowered)) && boostedSegments.some((token) => lowered.includes(token));
}
function rankPath(filePath) {
const lowered = filePath.toLowerCase();
let score = 0;
-
- for (const keyword of ["auth", "permission", "policy", "access", "admin", "route", "upload", "rbac", "role", "bootstrap", "seed"]) {
- if (lowered.includes(keyword)) {
- score += 3;
- }
- }
-
- for (const keyword of ["config", "schema", "plugin", "middleware", "api", "controller", "collection"]) {
- if (lowered.includes(keyword)) {
- score += 2;
- }
- }
-
+ if (/auth|permission|policy|access|role/.test(lowered)) score += 90;
+ if (/upload|storage|asset/.test(lowered)) score += 75;
+ if (/admin|route|controller|middleware|graphql|api/.test(lowered)) score += 60;
+ if (/config|bootstrap|seed|schema/.test(lowered)) score += 40;
return score;
}
function rankMirrorPath(filePath) {
const lowered = filePath.toLowerCase();
let score = rankPath(filePath);
-
- for (const keyword of ["service", "query", "database", "db", "resolver", "graphql"]) {
- if (lowered.includes(keyword)) {
- score += 2;
- }
- }
-
- if (/\.(ts|tsx|js|jsx|php|py)$/.test(lowered)) {
- score += 1;
- }
-
- if (/config|bootstrap|route|controller|service/.test(lowered)) {
- score += 2;
- }
-
+ if (/test|spec/.test(lowered)) score -= 30;
+ if (/users-permissions|authentication|graphql/.test(lowered)) score += 45;
return score;
}
-function calculateFreshnessBoost(isoValue) {
- if (!isoValue) {
- return 0;
- }
-
- const pushedAt = Date.parse(isoValue);
- if (!Number.isFinite(pushedAt)) {
+function calculateFreshnessBoost(dateValue) {
+ if (!dateValue) {
return 0;
}
-
- const ageInDays = (Date.now() - pushedAt) / (1000 * 60 * 60 * 24);
- if (ageInDays <= 30) {
- return 1800;
- }
- if (ageInDays <= 90) {
- return 900;
- }
- if (ageInDays <= 180) {
- return 300;
- }
+ const ageMs = Date.now() - new Date(dateValue).getTime();
+ const ageDays = ageMs / (1000 * 60 * 60 * 24);
+ if (ageDays <= 14) return 4000;
+ if (ageDays <= 45) return 2200;
+ if (ageDays <= 90) return 900;
return 0;
}
diff --git a/src/config/auditSkills.js b/src/config/auditSkills.js
index af4d463..c48d0d9 100644
--- a/src/config/auditSkills.js
+++ b/src/config/auditSkills.js
@@ -3,31 +3,61 @@ const AUDIT_SKILLS = [
id: "access-control",
name: "访问控制",
description: "关注对象级授权、公共角色、插件路由和后台访问边界。",
- reviewPrompt: "重点检查对象级访问控制、公共角色权限、管理接口与插件路由是否存在过宽暴露。"
+ reviewPrompt: "重点检查对象级访问控制、公共角色权限、管理接口与插件路由是否存在过宽暴露。只报告确实缺少权限校验的代码。"
},
{
id: "bootstrap-config",
name: "初始化与配置",
description: "关注初始化管理员、开发开关、默认凭据和危险默认值。",
- reviewPrompt: "重点检查初始化管理员、开发开关、默认凭据、演示密钥和 fail-open 配置。"
+ reviewPrompt: "重点检查初始化管理员、开发开关、默认凭据、演示密钥和 fail-open 配置。只报告确实风险。"
},
{
id: "upload-storage",
name: "上传与存储",
description: "关注上传链路、路径约束、公开目录和文件托管边界。",
- reviewPrompt: "重点检查上传处理、文件落盘、公开访问目录、文件类型和路径规范化控制。"
+ reviewPrompt: "重点检查上传处理中是否存在路径遍历、类型校验缺失、危险扩展名。只报告确实存在风险的代码。"
},
{
id: "query-safety",
name: "查询与注入",
description: "关注原始查询、模板拼接、动态筛选和持久层输入约束。",
- reviewPrompt: "重点检查原始查询、动态筛选、模板插值和持久层输入拼接风险。"
+ reviewPrompt: "重点检查原始查询是否直接拼接用户输入、动态字段是否缺少白名单。只报告有注入风险的代码。"
},
{
id: "secret-exposure",
name: "敏感信息",
description: "关注公开前端变量、配置文件中的密钥和占位凭据。",
- reviewPrompt: "重点检查公开变量、配置文件、环境变量和初始化脚本里的敏感信息暴露。"
+ reviewPrompt: "重点检查前端变量是否暴露敏感信息、是否存在硬编码密钥。只报告确实暴露的场景。"
+ },
+ {
+ id: "ssrf",
+ name: "SSRF",
+ description: "关注用户可控 URL 的网络请求。",
+ reviewPrompt: "检查是否存在用户输入控制 URL 的网络请求场景。只报告缺少 URL 校验的代码。"
+ },
+ {
+ id: "command-injection",
+ name: "命令注入",
+ description: "关注用户输入用于命令执行的场景。",
+ reviewPrompt: "检查 exec/spawn 等是否直接使用用户输入。只报告确实未过滤的命令执行代码。"
+ },
+ {
+ id: "path-traversal",
+ name: "路径穿越",
+ description: "关注文件操作中的路径穿越风险。",
+ reviewPrompt: "检查文件路径是否直接拼接用户输入。只报告缺少路径校验的代码。"
+ },
+ {
+ id: "xss",
+ name: "XSS",
+ description: "关注跨站脚本注入风险。",
+ reviewPrompt: "检查用户输入是否未经过滤输出到页面。只报告确实缺少转义的代码。"
+ },
+ {
+ id: "deserialization",
+ name: "反序列化",
+ description: "关注不安全的反序列化风险。",
+ reviewPrompt: "检查 eval/parse 等是否直接处理用户输入。只报告确实不安全的代码。"
}
];
diff --git a/src/services/fingerprintService.js b/src/services/fingerprintService.js
new file mode 100644
index 0000000..aea9625
--- /dev/null
+++ b/src/services/fingerprintService.js
@@ -0,0 +1,248 @@
+import { promises as fs } from "node:fs";
+import path from "node:path";
+
+const CMS_SIGNATURES = [
+ { id: "strapi", label: "Strapi", patterns: [/strapi/i, /users-permissions/i] },
+ { id: "directus", label: "Directus", patterns: [/directus/i] },
+ { id: "payload", label: "Payload CMS", patterns: [/payloadcms/i, /\bpayload\b/i] },
+ { id: "keystone", label: "Keystone", patterns: [/keystone/i] },
+ { id: "ghost", label: "Ghost", patterns: [/\bghost\b/i] },
+ { id: "wagtail", label: "Wagtail", patterns: [/wagtail/i] },
+ { id: "wordpress", label: "WordPress", patterns: [/wordpress/i, /wp-content/i, /wp-admin/i] },
+ { id: "joomla", label: "Joomla", patterns: [/joomla/i] },
+ { id: "drupal", label: "Drupal", patterns: [/drupal/i] }
+];
+
+const TECH_SIGNATURES = [
+ { id: "nextjs", label: "Next.js", patterns: [/\bnext\b/i, /next\/config/i] },
+ { id: "nestjs", label: "NestJS", patterns: [/\bnestjs\b/i, /@nestjs\//i] },
+ { id: "express", label: "Express", patterns: [/\bexpress\b/i] },
+ { id: "koa", label: "Koa", patterns: [/\bkoa\b/i] },
+ { id: "react", label: "React", patterns: [/\breact\b/i] },
+ { id: "vue", label: "Vue", patterns: [/\bvue\b/i] },
+ { id: "laravel", label: "Laravel", patterns: [/\blaravel\b/i] },
+ { id: "django", label: "Django", patterns: [/\bdjango\b/i] },
+ { id: "spring", label: "Spring", patterns: [/spring-boot/i, /\bspring\b/i] },
+ { id: "graphql", label: "GraphQL", patterns: [/\bgraphql\b/i] },
+ { id: "mysql", label: "MySQL", patterns: [/\bmysql\b/i] },
+ { id: "postgres", label: "PostgreSQL", patterns: [/postgres/i, /\bpg\b/i] },
+ { id: "redis", label: "Redis", patterns: [/\bredis\b/i] },
+ { id: "s3", label: "S3/Object Storage", patterns: [/\bs3\b/i, /minio/i, /r2/i] }
+];
+
+const CMS_TYPE_MAP = {
+ strapi: "headless",
+ directus: "headless",
+ payload: "headless",
+ keystone: "headless",
+ ghost: "blog",
+ wagtail: "enterprise",
+ wordpress: "blog",
+ joomla: "enterprise",
+ drupal: "enterprise"
+};
+
+const SAFE_HINTS = [
+ "仅用于本地资产清单匹配,不自动发起外部检索。",
+ "建议关注后台路径、登录页、公开 API 路径和常见静态资源命名。",
+ "如果需要外部资产搜索,请由人工在合规边界内自行执行。"
+];
+
+export function createFingerprintService({ downloadsDir }) {
+ return {
+ async listProjects() {
+ const projects = [];
+ const entries = await safeReadDir(downloadsDir);
+
+ for (const entry of entries) {
+ if (!entry.isDirectory()) {
+ continue;
+ }
+
+ const projectPath = path.join(downloadsDir, entry.name);
+ const fileCount = await countFiles(projectPath);
+ if (!fileCount) {
+ continue;
+ }
+
+ projects.push({
+ id: entry.name,
+ name: entry.name,
+ localPath: projectPath,
+ fileCount
+ });
+ }
+
+ return projects.sort((a, b) => b.fileCount - a.fileCount);
+ },
+
+ async analyzeProject(projectId) {
+ const projectPath = path.join(downloadsDir, projectId);
+ const files = await collectProjectFiles(projectPath);
+ const combinedText = files.map((file) => `${file.relativePath}\n${file.content}`).join("\n");
+ const cms = detectMatches(CMS_SIGNATURES, combinedText);
+ const technologies = detectMatches(TECH_SIGNATURES, combinedText);
+ const languages = collectLanguages(files);
+ const adminPaths = inferAdminPaths(files);
+ const apiPaths = inferApiPaths(files);
+ const cmsTypes = Array.from(new Set(cms.map((item) => CMS_TYPE_MAP[item.id]).filter(Boolean)));
+
+ return {
+ projectId,
+ projectPath,
+ fileCount: files.length,
+ cms,
+ cmsTypes,
+ technologies,
+ languages,
+ adminPaths,
+ apiPaths,
+ safeSearchHints: buildSafeSearchHints({ cms, technologies, adminPaths, apiPaths }),
+ notice: "本页只做本地源码指纹提取与本地资产清单匹配,不自动生成外部检索语句,也不自动发起互联网搜索。"
+ };
+ },
+
+ async matchAssets({ projectId, assetText }) {
+ const analysis = await this.analyzeProject(projectId);
+ const assets = String(assetText || "")
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean);
+
+ const tokens = [
+ ...analysis.cms.map((item) => item.label),
+ ...analysis.technologies.map((item) => item.label),
+ ...analysis.adminPaths,
+ ...analysis.apiPaths
+ ]
+ .map((item) => item.toLowerCase())
+ .filter(Boolean);
+
+ const matches = assets
+ .map((asset) => {
+ const lowered = asset.toLowerCase();
+ const hitTokens = tokens.filter((token) => lowered.includes(token.toLowerCase())).slice(0, 6);
+ return {
+ asset,
+ matched: hitTokens.length > 0,
+ hitTokens
+ };
+ })
+ .filter((item) => item.matched);
+
+ return {
+ projectId,
+ totalAssets: assets.length,
+ matchedAssets: matches.length,
+ matches: matches.slice(0, 100),
+ safeSummary: matches.length
+ ? `已在导入资产清单中匹配到 ${matches.length} 条疑似同技术栈记录。`
+ : "导入的资产清单里暂时没有匹配到明显同技术栈记录。"
+ };
+ }
+ };
+}
+
+async function safeReadDir(target) {
+ try {
+ return await fs.readdir(target, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+}
+
+async function countFiles(root) {
+ const files = await collectProjectFiles(root, { readContent: false });
+ return files.length;
+}
+
+async function collectProjectFiles(root, options = {}) {
+ const output = [];
+ await walk(root, root, output, options);
+ return output;
+}
+
+async function walk(root, current, output, { readContent = true } = {}) {
+ const entries = await safeReadDir(current);
+ for (const entry of entries) {
+ const target = path.join(current, entry.name);
+ if (entry.isDirectory()) {
+ await walk(root, target, output, { readContent });
+ continue;
+ }
+ if (!entry.isFile()) {
+ continue;
+ }
+ if (entry.name === "SAFE_SAMPLE.md") {
+ continue;
+ }
+
+ const relativePath = path.relative(root, target).replaceAll("\\", "/");
+ const item = { fullPath: target, relativePath };
+ if (readContent) {
+ try {
+ item.content = await fs.readFile(target, "utf8");
+ } catch {
+ item.content = "";
+ }
+ }
+ output.push(item);
+ }
+}
+
+function detectMatches(catalog, text) {
+ return catalog.filter((item) => item.patterns.some((pattern) => pattern.test(text)));
+}
+
+function collectLanguages(files) {
+ const languages = new Set();
+ for (const file of files) {
+ const ext = path.extname(file.relativePath).toLowerCase();
+ if (ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".jsx") languages.add("JavaScript / TypeScript");
+ if (ext === ".php") languages.add("PHP");
+ if (ext === ".py") languages.add("Python");
+ if (ext === ".java") languages.add("Java");
+ if (ext === ".cs") languages.add("C#");
+ if (ext === ".rb") languages.add("Ruby");
+ if (ext === ".go") languages.add("Go");
+ }
+ return Array.from(languages);
+}
+
+function inferAdminPaths(files) {
+ const candidates = new Set();
+ for (const file of files) {
+ const lowered = file.relativePath.toLowerCase();
+ if (/(admin|dashboard|backoffice|panel)/.test(lowered)) {
+ candidates.add(relativePathToHint(file.relativePath));
+ }
+ }
+ return Array.from(candidates).slice(0, 8);
+}
+
+function inferApiPaths(files) {
+ const candidates = new Set();
+ for (const file of files) {
+ const lowered = file.relativePath.toLowerCase();
+ if (/(api|graphql|content-api|rest)/.test(lowered)) {
+ candidates.add(relativePathToHint(file.relativePath));
+ }
+ }
+ return Array.from(candidates).slice(0, 8);
+}
+
+function relativePathToHint(relativePath) {
+ const cleaned = relativePath.replace(/^packages\//, "").replace(/^src\//, "");
+ const parts = cleaned.split("/");
+ return `/${parts.slice(0, Math.min(parts.length, 3)).join("/")}`;
+}
+
+function buildSafeSearchHints({ cms, technologies, adminPaths, apiPaths }) {
+ return [
+ ...SAFE_HINTS,
+ cms.length ? `识别到的 CMS 线索:${cms.map((item) => item.label).join("、")}` : "暂未识别到明显 CMS 框架名称。",
+ technologies.length ? `识别到的技术栈:${technologies.map((item) => item.label).join("、")}` : "暂未识别到明显技术栈标签。",
+ adminPaths.length ? `可人工关注的后台路径特征:${adminPaths.join("、")}` : "暂未提取到明显后台路径特征。",
+ apiPaths.length ? `可人工关注的接口路径特征:${apiPaths.join("、")}` : "暂未提取到明显接口路径特征。"
+ ];
+}
diff --git a/src/services/llmReviewService.js b/src/services/llmReviewService.js
index a205280..cc77abf 100644
--- a/src/services/llmReviewService.js
+++ b/src/services/llmReviewService.js
@@ -194,30 +194,64 @@ async function requestStructuredReview({ llmConfig, systemPrompt, userPrompt })
function buildSystemPrompt(selectedSkills) {
const skills = selectedSkills.map((skill) => `- ${skill.name}: ${skill.reviewPrompt}`).join("\n");
return [
- "你是一个防御性代码审计助手。",
- "只输出风险说明、证据、影响、修复建议和安全验证建议。",
- "不要提供利用步骤、payload、绕过思路、攻击链构造或 weaponization 细节。",
- "如果证据不足,就降低置信度或不要报出该问题。",
- "请只返回 JSON 对象,不要输出额外说明。",
- "关注的审计 Skill:",
+ "你是一个防御性代码审计助手,专注于识别真实的安全风险。",
+ "",
+ "## 核心原则",
+ "1. 只报告真实存在、可被利用的安全问题,不是误报",
+ "2. 如果代码中有防护措施(验证、过滤、转义、白名单),不要报告风险",
+ "3. 需要实际证据(漏洞代码模式)才能报告,不能猜测",
+ "4. 如果证据不足,降低置信度或不要报告",
+ "",
+ "## 输出要求",
+ "1. 只返回 JSON 对象,不要输出额外说明",
+ "2. 严重性等级:critical(可利用/高风险), high(条件成立时风险), medium(需要注意), low(低风险)",
+ "3. 置信度必须在 0.7 以上才能报告",
+ "",
+ "## 不报告的示例(误报)",
+ "- 有输入验证但报告 XSS:有 escapeHtml/sanitize 的代码",
+ "- 有参数化查询但报告 SQL 注入:使用了 prepared statement",
+ "- 有权限校验但报告越权:有 authorize/can/checkPermission",
+ "- 白名单路径但报告路径穿越:使用 path.join/normalize",
+ "",
+ "## 需要报告的示例(真阳性)",
+ "- 用户输入直接拼接到 SQL 查询中",
+ "- eval() 中使用用户输入",
+ "- 文件路径直接拼接用户输入",
+ "- JWT 密钥硬编码",
+ "- 管理员路由无认证",
+ "",
+ "## 审计 Skill:",
skills
].join("\n");
}
function buildUserPrompt({ project, selectedSkills, heuristicFindings, batch }) {
- const heuristicSummary = heuristicFindings.slice(0, 8).map((finding) => `- ${finding.title} @ ${finding.location}`).join("\n") || "- 当前规则层未提供额外提示";
+ const heuristicSummary = heuristicFindings.slice(0, 8).map((finding) => `- ${finding.title} @ ${finding.location} (置信度: ${finding.confidence})`).join("\n") || "- 当前规则层未发现明确问题";
const skills = selectedSkills.map((skill) => `${skill.id}: ${skill.description}`).join("\n");
const snippets = batch.map((file) => `FILE: ${file.relativePath}\n\`\`\`${file.language}\n${file.content}\n\`\`\``).join("\n\n");
return [
+ `## 项目信息`,
`项目名称:${project.name}`,
- `审计镜像路径:${project.localPath || path.join("workspace", "downloads", project.id)}`,
- `来源模式:${project.sourceType}`,
- `已启用 Skill:\n${skills}`,
- `规则层提示:\n${heuristicSummary}`,
- "请审阅下面的本地源码片段,输出不超过 3 条高置信度结果。",
- "严格返回如下 JSON:",
- '{ "findings": [ { "title": "", "severity": "low|medium|high", "confidence": 0.0, "location": "", "skillId": "", "evidence": "", "impact": "", "remediation": "", "safeValidation": "" } ] }',
+ `审计路径:${project.localPath || path.join("workspace", "downloads", project.id)}`,
+ "",
+ `## 已启用的审计 Skill:`,
+ skills,
+ "",
+ `## 规则层已发现的问题(供参考):`,
+ heuristicSummary,
+ "",
+ `## 任务`,
+ "请仔细审阅以下源码片段,只报告确实存在安全问题的真实漏洞。",
+ "对于每个发现:",
+ "1. 给出精确的问题位置(文件:行号)",
+ "2. 说明漏洞的具体代码模式",
+ "3. 确认没有防护措施才报告(检查代码中是否有 validate/sanitize/escape/authorize 等)",
+ "",
+ "## 输出格式(严格 JSON):",
+ '{ "findings": [ { "title": "问题标题", "severity": "critical|high|medium|low", "confidence": 0.7-1.0, "location": "文件路径:行号", "skillId": "skill id", "evidence": "具体漏洞代码", "impact": "影响说明", "remediation": "修复建议", "safeValidation": "验证建议" } ] }',
+ "",
+ `## 源码片段:`,
snippets
].join("\n\n");
}
@@ -345,10 +379,11 @@ function normalizeFindings(findings, selectedSkills) {
skillId: validSkillIds.has(finding.skillId) ? finding.skillId : selectedSkills[0]?.id || "access-control",
evidence: safeString(finding.evidence, "模型复核认为这里存在值得继续人工确认的实现迹象。"),
impact: safeString(finding.impact, "该实现如果在真实部署中成立,可能扩大管理面、数据面或配置暴露面。"),
- remediation: safeString(finding.remediation, "建议结合服务端收口、权限校验和配置默认值治理进行修复。"),
+ remediation: safeString(finding.remediation, "建议结合服务端收口、权限校验和默认值治理进行修复。"),
safeValidation: safeString(finding.safeValidation, "建议在本地或测试环境里补充代码走读与单元测试来确认边界。")
}))
- .filter((finding) => finding.confidence >= 0.55);
+ // 提高置信度阈值到 0.7 以减少误报
+ .filter((finding) => finding.confidence >= 0.7);
}
function dedupeFindings(findings) {
diff --git a/src/services/settingsStore.js b/src/services/settingsStore.js
index fdad729..a91ebba 100644
--- a/src/services/settingsStore.js
+++ b/src/services/settingsStore.js
@@ -13,6 +13,11 @@ const DEFAULT_SETTINGS = {
ownerFilter: "",
notes: ""
},
+ fofa: {
+ email: "",
+ apiKey: "",
+ notes: ""
+ },
updatedAt: null
};
@@ -34,6 +39,7 @@ export function createSettingsStore({ filePath }) {
...patch,
llm: { ...current.llm, ...(patch.llm || {}) },
github: { ...current.github, ...(patch.github || {}) },
+ fofa: { ...current.fofa, ...(patch.fofa || {}) },
updatedAt: new Date().toISOString()
});
await fs.mkdir(path.dirname(filePath), { recursive: true });
@@ -53,6 +59,10 @@ export function createSettingsStore({ filePath }) {
...current.github,
token: targets.includes("github") ? "" : current.github.token
},
+ fofa: {
+ ...current.fofa,
+ apiKey: targets.includes("fofa") ? "" : current.fofa.apiKey
+ },
updatedAt: new Date().toISOString()
});
await fs.mkdir(path.dirname(filePath), { recursive: true });
@@ -75,6 +85,11 @@ function normalize(value) {
ownerFilter: value?.github?.ownerFilter || "",
notes: value?.github?.notes || ""
},
+ fofa: {
+ email: value?.fofa?.email || "",
+ apiKey: value?.fofa?.apiKey || "",
+ notes: value?.fofa?.notes || ""
+ },
updatedAt: value?.updatedAt || null
};
}
diff --git a/src/store/taskStore.js b/src/store/taskStore.js
index e642959..a903bf4 100644
--- a/src/store/taskStore.js
+++ b/src/store/taskStore.js
@@ -1,9 +1,167 @@
-import crypto from "node:crypto";
+import crypto from "node:crypto";
+import Database from "better-sqlite3";
+import path from "node:path";
+import { promises as fs } from "node:fs";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const workspaceDir = path.join(__dirname, "..", "workspace");
+const dbPath = path.join(workspaceDir, "tasks.db");
+
+let db = null;
+
+function getDb() {
+ if (db) return db;
+
+ fs.mkdir(workspaceDir, { recursive: true }).catch(() => {});
+ db = new Database(dbPath);
+
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS tasks (
+ id TEXT PRIMARY KEY,
+ status TEXT NOT NULL DEFAULT 'queued',
+ phase TEXT NOT NULL DEFAULT 'queued',
+ message TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT,
+ source_data TEXT,
+ scout_result TEXT,
+ selected_project_ids TEXT,
+ audit_result TEXT,
+ report TEXT,
+ progress_data TEXT,
+ memory_snapshot TEXT,
+ memory_summary TEXT,
+ error TEXT
+ )
+ `);
+
+ return db;
+}
+
+function serializeTask(task) {
+ return {
+ id: task.id,
+ status: task.status,
+ phase: task.phase,
+ message: task.message,
+ created_at: task.createdAt,
+ updated_at: task.updatedAt || null,
+ source_data: JSON.stringify({
+ sourceType: task.sourceType,
+ query: task.query,
+ cmsType: task.cmsType,
+ industry: task.industry,
+ localRepoPaths: task.localRepoPaths,
+ minAdoption: task.minAdoption,
+ useMemory: task.useMemory,
+ selectedSkillIds: task.selectedSkillIds
+ }),
+ scout_result: task.scoutResult ? JSON.stringify(task.scoutResult) : null,
+ selected_project_ids: JSON.stringify(task.selectedProjectIds),
+ audit_result: task.auditResult ? JSON.stringify(task.auditResult) : null,
+ report: task.report,
+ progress_data: JSON.stringify(task.progress),
+ memory_snapshot: task.memorySnapshot ? JSON.stringify(task.memorySnapshot) : null,
+ memory_summary: task.memorySummary ? JSON.stringify(task.memorySummary) : null,
+ error: task.error
+ };
+}
+
+function deserializeTask(row) {
+ const sourceData = JSON.parse(row.source_data || "{}");
+ return {
+ id: row.id,
+ status: row.status,
+ phase: row.phase,
+ message: row.message,
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ sourceType: sourceData.sourceType,
+ query: sourceData.query,
+ cmsType: sourceData.cmsType,
+ industry: sourceData.industry,
+ localRepoPaths: sourceData.localRepoPaths || [],
+ minAdoption: sourceData.minAdoption,
+ useMemory: sourceData.useMemory,
+ selectedSkillIds: sourceData.selectedSkillIds || [],
+ scoutResult: row.scout_result ? JSON.parse(row.scout_result) : null,
+ selectedProjectIds: JSON.parse(row.selected_project_ids || "[]"),
+ auditResult: row.audit_result ? JSON.parse(row.audit_result) : null,
+ report: row.report,
+ progress: JSON.parse(row.progress_data || "{}"),
+ memorySnapshot: row.memory_snapshot ? JSON.parse(row.memory_snapshot) : null,
+ memorySummary: row.memory_summary ? JSON.parse(row.memory_summary) : null,
+ error: row.error
+ };
+}
+
+const taskListeners = new Map();
export function createTaskStore() {
- const tasks = new Map();
+ const memory = new Map();
+
+ try {
+ const db = getDb();
+ const rows = db.prepare("SELECT * FROM tasks ORDER BY created_at DESC").all();
+ for (const row of rows) {
+ const task = deserializeTask(row);
+ if (task.status === "running") {
+ task.status = "queued";
+ task.phase = "queued";
+ task.message = "Task recovered after server restart.";
+ }
+ memory.set(task.id, task);
+ }
+ } catch {
+ // ignore persistence errors
+ }
+
+ function persist(task) {
+ try {
+ const db = getDb();
+ const existing = db.prepare("SELECT 1 FROM tasks WHERE id = ?").get(task.id);
+ const data = serializeTask(task);
+
+ if (existing) {
+ const fields = Object.keys(data).filter(k => k !== "id").map(k => `${k} = @${k}`).join(", ");
+ db.prepare(`UPDATE tasks SET ${fields} WHERE id = @id`).run(data);
+ } else {
+ const cols = Object.keys(data).join(", ");
+ const vals = Object.keys(data).map(k => `@${k}`).join(", ");
+ db.prepare(`INSERT INTO tasks (${cols}) VALUES (${vals})`).run(data);
+ }
+ } catch {
+ // ignore persistence errors
+ }
+ }
+
+ function notifyListeners(task, event = "update") {
+ const listeners = taskListeners.get(task.id) || [];
+ for (const listener of listeners) {
+ try {
+ listener({ event, task: { id: task.id, status: task.status, phase: task.phase, message: task.message, progress: task.progress } });
+ } catch {
+ // ignore listener errors
+ }
+ }
+ }
return {
+ subscribe(id, callback) {
+ if (!taskListeners.has(id)) {
+ taskListeners.set(id, []);
+ }
+ taskListeners.get(id).push(callback);
+ return () => {
+ const list = taskListeners.get(id);
+ if (list) {
+ const idx = list.indexOf(callback);
+ if (idx >= 0) list.splice(idx, 1);
+ }
+ };
+ },
+
createTask(input = {}) {
const task = {
id: crypto.randomUUID(),
@@ -11,8 +169,11 @@ export function createTaskStore() {
phase: "queued",
message: "Task accepted.",
createdAt: new Date().toISOString(),
+ updatedAt: null,
sourceType: input.sourceType || "github",
query: input.query || 'topic:cms OR "headless cms" OR "content management system"',
+ cmsType: input.cmsType || "all",
+ industry: input.industry || "all",
localRepoPaths: Array.isArray(input.localRepoPaths) ? input.localRepoPaths : [],
minAdoption: Number(input.minAdoption || 100),
useMemory: input.useMemory !== false,
@@ -33,24 +194,28 @@ export function createTaskStore() {
memorySummary: null,
error: null
};
- tasks.set(task.id, task);
+ memory.set(task.id, task);
+ persist(task);
+ notifyListeners(task, "created");
return task;
},
listTasks() {
- return Array.from(tasks.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+ return Array.from(memory.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
},
getTask(id) {
- return tasks.get(id) || null;
+ return memory.get(id) || null;
},
updateTask(id, patch) {
- const task = tasks.get(id);
+ const task = memory.get(id);
if (!task) {
return null;
}
Object.assign(task, patch, { updatedAt: new Date().toISOString() });
+ persist(task);
+ notifyListeners(task, "update");
return task;
},
@@ -67,4 +232,4 @@ export function createTaskStore() {
});
}
};
-}
+}
\ No newline at end of file