progmune-runtime 3.7.11 → 3.7.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/dist/frameworks/hapi-detector.js +132 -0
- package/dist/frameworks/hapi-detector.test.js +60 -0
- package/dist/frameworks/index.js +7 -1
- package/dist/frameworks/koa-detector.js +128 -0
- package/dist/frameworks/koa-detector.test.js +57 -0
- package/dist/sdk.js +1 -1
- package/dist/trust/engine.js +122 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.7.12] — 2026-09-01
|
|
4
|
+
|
|
5
|
+
### Koa + Hapi 框架结构适配——10/13
|
|
6
|
+
|
|
7
|
+
- **Koa(第 9 个)**:`src/frameworks/koa-detector.ts`(代码串级,镜像 express/koa 中间件模式)——`KOA_ROUTE_NO_AUTH`:mutation 路由注册中间件链无认证名中间件,且文件内无认证 `app.use` 全局中间件;认证名按词表分类(auth/login/permission/jwt/verify/guard…);GET 读操作与认证入口路径豁免
|
|
8
|
+
- **Hapi(第 10 个)**:`src/frameworks/hapi-detector.ts`(路由配置模式)——`HAPI_ROUTE_NO_AUTH`:mutation 路由 options 无 auth 字段或显式 `auth: false`(显式公开 mutation 检出);`auth: 'strategy'` / `auth: { strategy: 'x' }` 两种形态均识别为受保护;`auth.strategy` 声明被记录
|
|
9
|
+
- **引擎接线**:collectKoaViolations / collectHapiViolations + overall `koaCoverage`/`hapiCoverage`(加性 best-effort);frameworks barrel 补全(11 个适配器模块)
|
|
10
|
+
- **验证**:15 个新单测(koa 7 + hapi 8,含显式 auth:false 与 strategy 对象形态);引擎冒烟:Koa 全局认证中间件 0 误报 APPROVED 90、Hapi 无 auth 路由检出 + login 豁免 APPROVED 87;全套件 **181/181**;Python 盲测 v1.2 64 零漂移
|
|
11
|
+
- **文档**:10/13 口径(README 双语/覆盖矩阵双语/CLAUDE.md/落地页 + Koa/Hapi 矩阵行)
|
|
12
|
+
- **边界(如实)**:文件级窗口(跨文件全局中间件不可见,与 Express 检测器同款);认证中间件按名词语汇识别;Hapi 路由窗口 500 字符(超长配置截断)
|
|
13
|
+
|
|
3
14
|
## [3.7.11] — 2026-09-01
|
|
4
15
|
|
|
5
16
|
### NestJS 补全——框架适配 8/13
|
package/README.md
CHANGED
|
@@ -108,7 +108,7 @@ Progmune is honest about what it can and cannot verify.
|
|
|
108
108
|
|
|
109
109
|
**Multi-language IR (registry-based).** TypeScript (ts-morph) and Python (AST) extractors are registered entries in one registry (`src/extract-project-ir.ts`); `extractProjectIR` merges every detected language into a single function IR shared by the agent loop, `execute()`, and the MCP server — the agent composes function-protocol chains across both languages. Adding a language (Go, Java, …) = register one extractor entry; callers don't change.
|
|
110
110
|
|
|
111
|
-
**Framework adapters:
|
|
111
|
+
**Framework adapters: 10/13.** Express ✅, tRPC ✅, FastAPI ✅, Django ✅ (structural route-level auth analysis — every mutation route checked for auth), Flask ✅ (route/before_request auth guard analysis), Fastify ✅ (route preHandler/hook auth analysis) and Next.js ✅ (App Router route-handler auth analysis), NestJS ✅ (decorator route parsing + global APP_GUARD recognition + @Public exemption), Koa ✅ (route/auth-middleware chain analysis) and Hapi ✅ (route-config auth strategy analysis) have dedicated detectors. Next.js version-aware governance also exists. Spring Boot/Go frameworks and a few TS frameworks remain — framework adaptation is the #1 product gap.
|
|
112
112
|
|
|
113
113
|
### What Progmune does NOT cover (honest boundaries)
|
|
114
114
|
|
|
@@ -234,7 +234,7 @@ High-impact contribution areas:
|
|
|
234
234
|
- **Runtime Pipeline:** Detect → Explain → Repair → Validate (L1–L4)
|
|
235
235
|
- **Trust Engine:** 4-dimension scoring with binary explainability gate
|
|
236
236
|
- **MCP Tools:** 19 — `progmune_trust_check`, `progmune_score`, `progmune_policy_check`, `progmune_certify`, and more
|
|
237
|
-
- **Framework Adapters:** Express ✅, tRPC ✅, FastAPI ✅, Django ✅, Flask ✅, Fastify ✅, Next.js ✅, NestJS ✅ (
|
|
237
|
+
- **Framework Adapters:** Express ✅, tRPC ✅, FastAPI ✅, Django ✅, Flask ✅, Fastify ✅, Next.js ✅, NestJS ✅ (10/13)
|
|
238
238
|
- **Knowledge Base:** 31 domains, 148 protocol rules, 22 detectors, 26 safeguards, PLSB 13/13 categories — plus 15 source-level detection rules (Python)
|
|
239
239
|
- **Corpus:** 2,500+ trajectories across 6+ repositories; blind benchmarks 100 (TS) + 90 (Python) projects; real-world validation on 4 application repos
|
|
240
240
|
- **Current focus:** Enterprise PoC validation + remaining framework-internal boundary FPs
|
package/README.zh-CN.md
CHANGED
|
@@ -108,7 +108,7 @@ Progmune 对能验证什么、不能验证什么保持诚实。
|
|
|
108
108
|
|
|
109
109
|
**多语言 IR(注册表式)。** TypeScript(ts-morph)与 Python(AST)提取器是同一注册表(`src/extract-project-ir.ts`)中的注册项;`extractProjectIR` 把检测到的所有语言合并为一份函数 IR,由 agent loop、`execute()` 与 MCP server 共享——agent 可在两种语言上编排函数协议链。新增语言(Go、Java……)= 注册一条提取器,调用方零改动。
|
|
110
110
|
|
|
111
|
-
**框架适配器:
|
|
111
|
+
**框架适配器:10/13。** Express ✅、tRPC ✅、FastAPI ✅、Django ✅(结构级路由认证分析——逐条检查写操作入口的认证)、Flask ✅(路由/before_request 认证守卫分析)、Fastify ✅(路由 preHandler/钩子认证分析)与 Next.js ✅(App Router 路由处理器认证分析)、NestJS ✅(装饰器路由解析 + 全局 APP_GUARD 守卫识别 + @Public 豁免)、Koa ✅(路由/认证中间件链分析)与 Hapi ✅(路由配置认证策略分析)有专用检测器;Next.js 另有版本感知治理。Spring Boot、Go 框架及少数 TS 框架待适配——框架适配是 #1 产品缺口。
|
|
112
112
|
|
|
113
113
|
### Progmune 不覆盖什么(诚实边界)
|
|
114
114
|
|
|
@@ -233,7 +233,7 @@ Progmune 建立在"**LLM 输出是统计表演而非推理**"这一前提上—
|
|
|
233
233
|
- **运行时管线:** 检测 → 解释 → 修复 → 验证(L1–L4)
|
|
234
234
|
- **信任引擎:** 四维评分 + 二元可解释性门
|
|
235
235
|
- **MCP 工具:** 19 个——`progmune_trust_check`、`progmune_score`、`progmune_policy_check`、`progmune_certify` 等
|
|
236
|
-
- **框架适配器:** Express ✅、tRPC ✅、FastAPI ✅、Django ✅、Flask ✅、Fastify ✅、Next.js ✅、NestJS ✅(
|
|
236
|
+
- **框架适配器:** Express ✅、tRPC ✅、FastAPI ✅、Django ✅、Flask ✅、Fastify ✅、Next.js ✅、NestJS ✅(10/13)
|
|
237
237
|
- **知识库:** 31 个域、148 条协议规则、22 个检测器、26 条防护规则、PLSB 13/13 类别——另加 15 条源码级检测规则(Python)
|
|
238
238
|
- **语料:** 6+ 仓库 2,500+ 轨迹;盲测基准 100(TS)+ 90(Python)个项目;4 个应用仓库真实验证
|
|
239
239
|
- **当前重点:** 企业 PoC 验证 + 剩余框架内部边界误报
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Hapi Framework Adapter — Protocol Detection for Hapi
|
|
4
|
+
*
|
|
5
|
+
* 第 10 个框架适配(TS/JS 第 6 个专用检测器,代码串级):
|
|
6
|
+
*
|
|
7
|
+
* server.auth.strategy('jwt', 'jwt', {...}) 认证策略声明
|
|
8
|
+
* server.route({ method: 'POST', path: '/x',
|
|
9
|
+
* options: { auth: 'jwt' } }) 路由级认证
|
|
10
|
+
*
|
|
11
|
+
* 规则:
|
|
12
|
+
* HAPI_ROUTE_NO_AUTH mutation 路由(method 含 POST/PUT/PATCH/
|
|
13
|
+
* DELETE)的 options 无 auth 字段,或显式
|
|
14
|
+
* auth: false——路由级 missing-auth / 显式公开
|
|
15
|
+
*
|
|
16
|
+
* 口径(如实):
|
|
17
|
+
* - get 读操作不检查;认证入口路径词汇豁免(login/regist/auth/token)
|
|
18
|
+
* - 策略名本身不作为认证证明——必须出现在路由 options.auth
|
|
19
|
+
* (auth: 'jwt' / auth: { strategy: 'jwt' } 均识别)
|
|
20
|
+
* - auth: 'try'(可选认证)视为受保护(非公开)
|
|
21
|
+
*/
|
|
22
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
23
|
+
if (k2 === undefined) k2 = k;
|
|
24
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
25
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
26
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
27
|
+
}
|
|
28
|
+
Object.defineProperty(o, k2, desc);
|
|
29
|
+
}) : (function(o, m, k, k2) {
|
|
30
|
+
if (k2 === undefined) k2 = k;
|
|
31
|
+
o[k2] = m[k];
|
|
32
|
+
}));
|
|
33
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
34
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
35
|
+
}) : function(o, v) {
|
|
36
|
+
o["default"] = v;
|
|
37
|
+
});
|
|
38
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
39
|
+
var ownKeys = function(o) {
|
|
40
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
41
|
+
var ar = [];
|
|
42
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
43
|
+
return ar;
|
|
44
|
+
};
|
|
45
|
+
return ownKeys(o);
|
|
46
|
+
};
|
|
47
|
+
return function (mod) {
|
|
48
|
+
if (mod && mod.__esModule) return mod;
|
|
49
|
+
var result = {};
|
|
50
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
51
|
+
__setModuleDefault(result, mod);
|
|
52
|
+
return result;
|
|
53
|
+
};
|
|
54
|
+
})();
|
|
55
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
56
|
+
exports.analyzeHapiApp = analyzeHapiApp;
|
|
57
|
+
exports.analyzeHapiFile = analyzeHapiFile;
|
|
58
|
+
const fs = __importStar(require("fs"));
|
|
59
|
+
const MUTATION_METHODS = new Set(["post", "put", "patch", "delete"]);
|
|
60
|
+
const AUTH_ENTRY_WORDS = [
|
|
61
|
+
"login", "signin", "sign_in", "regist", "signup", "sign_up",
|
|
62
|
+
"token", "auth", "health",
|
|
63
|
+
];
|
|
64
|
+
function isAuthEntryPath(pathName) {
|
|
65
|
+
const lower = pathName.toLowerCase();
|
|
66
|
+
return AUTH_ENTRY_WORDS.some((w) => lower.includes(w));
|
|
67
|
+
}
|
|
68
|
+
// ── Analysis(代码串级) ──
|
|
69
|
+
function analyzeHapiApp(code) {
|
|
70
|
+
const issues = [];
|
|
71
|
+
const routes = [];
|
|
72
|
+
const strategies = [];
|
|
73
|
+
const hasHapi = /@hapi\/hapi|\bHapi\.server\b|\bhapi\.server\b/.test(code);
|
|
74
|
+
if (!hasHapi) {
|
|
75
|
+
return { hasHapi: false, routes, strategies, issues };
|
|
76
|
+
}
|
|
77
|
+
// 认证策略声明:server.auth.strategy('name', ...)
|
|
78
|
+
const strategyRe = /\.auth\.strategy\s*\(\s*['"]([^'"]+)['"]/g;
|
|
79
|
+
let m;
|
|
80
|
+
while ((m = strategyRe.exec(code)) !== null) {
|
|
81
|
+
strategies.push(m[1]);
|
|
82
|
+
}
|
|
83
|
+
// 路由块:server.route({ ... }) —— 从 route( 向后截 500 字符窗口
|
|
84
|
+
const routeRe = /\.route\s*\(\s*\{/g;
|
|
85
|
+
while ((m = routeRe.exec(code)) !== null) {
|
|
86
|
+
const window = code.slice(m.index, m.index + 500);
|
|
87
|
+
const methodM = window.match(/method\s*:\s*['"]([^'"]+)['"]/);
|
|
88
|
+
const pathM = window.match(/path\s*:\s*['"]([^'"]+)['"]/);
|
|
89
|
+
const authM = window.match(/auth\s*:\s*(?:['"]([^'"]+)['"]|\{\s*strategy\s*:\s*['"]([^'"]+)['"]|\s*(false|true))/);
|
|
90
|
+
if (!methodM || !pathM)
|
|
91
|
+
continue;
|
|
92
|
+
const method = methodM[1].toLowerCase();
|
|
93
|
+
const pathName = pathM[1];
|
|
94
|
+
// auth: false → 显式公开;无 auth 字段 → authM null
|
|
95
|
+
const authOption = authM
|
|
96
|
+
? (authM[3] === "false" ? "false" : authM[1] || authM[2] || authM[3] || null)
|
|
97
|
+
: null;
|
|
98
|
+
routes.push({
|
|
99
|
+
method,
|
|
100
|
+
path: pathName,
|
|
101
|
+
authOption,
|
|
102
|
+
line: code.slice(0, m.index).split("\n").length,
|
|
103
|
+
});
|
|
104
|
+
if (!MUTATION_METHODS.has(method))
|
|
105
|
+
continue;
|
|
106
|
+
if (isAuthEntryPath(pathName))
|
|
107
|
+
continue;
|
|
108
|
+
// 无 auth 字段(authOption null 且非显式 false 已涵盖)或显式 false → 报
|
|
109
|
+
if (authOption === null || authOption === "false") {
|
|
110
|
+
issues.push({
|
|
111
|
+
severity: "medium",
|
|
112
|
+
rule: "HAPI_ROUTE_NO_AUTH",
|
|
113
|
+
message: authOption === "false"
|
|
114
|
+
? `Mutation route ${method.toUpperCase()} ${pathName} is explicitly ` +
|
|
115
|
+
`public (auth: false) — any caller can reach it.`
|
|
116
|
+
: `Mutation route ${method.toUpperCase()} ${pathName} has no auth ` +
|
|
117
|
+
`option in its route config — any caller can reach it.`,
|
|
118
|
+
route: `${method.toUpperCase()} ${pathName}`,
|
|
119
|
+
line: code.slice(0, m.index).split("\n").length,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { hasHapi: true, routes, strategies, issues };
|
|
124
|
+
}
|
|
125
|
+
function analyzeHapiFile(filePath) {
|
|
126
|
+
if (!fs.existsSync(filePath))
|
|
127
|
+
return null;
|
|
128
|
+
const code = fs.readFileSync(filePath, "utf-8");
|
|
129
|
+
if (!/@hapi\/hapi|@hapi\/hawk|\bHapi\.server\b/.test(code))
|
|
130
|
+
return null;
|
|
131
|
+
return analyzeHapiApp(code);
|
|
132
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* hapi-detector.test.ts — Hapi 框架适配器规则回归(纯函数,无文件 I/O)
|
|
5
|
+
*/
|
|
6
|
+
const vitest_1 = require("vitest");
|
|
7
|
+
const hapi_detector_1 = require("./hapi-detector");
|
|
8
|
+
const app = (routes, extra = "") => `
|
|
9
|
+
import Hapi from "@hapi/hapi";
|
|
10
|
+
const server = Hapi.server({ port: 3000 });
|
|
11
|
+
${extra}
|
|
12
|
+
${routes}
|
|
13
|
+
`;
|
|
14
|
+
(0, vitest_1.describe)("hapi-detector", () => {
|
|
15
|
+
(0, vitest_1.it)("R1:无 auth 字段的 mutation 路由 → HAPI_ROUTE_NO_AUTH", () => {
|
|
16
|
+
const { issues } = (0, hapi_detector_1.analyzeHapiApp)(app(`
|
|
17
|
+
server.route({ method: "POST", path: "/transfer", handler: () => "ok" });
|
|
18
|
+
`));
|
|
19
|
+
(0, vitest_1.expect)(issues.map((i) => i.rule)).toContain("HAPI_ROUTE_NO_AUTH");
|
|
20
|
+
});
|
|
21
|
+
(0, vitest_1.it)("R1:options.auth 策略引用保护不报", () => {
|
|
22
|
+
const { issues } = (0, hapi_detector_1.analyzeHapiApp)(app(`
|
|
23
|
+
server.route({ method: "POST", path: "/transfer", options: { auth: "jwt" }, handler: () => "ok" });
|
|
24
|
+
`));
|
|
25
|
+
(0, vitest_1.expect)(issues).toHaveLength(0);
|
|
26
|
+
});
|
|
27
|
+
(0, vitest_1.it)("R1:auth 对象形态(strategy 字段)保护不报", () => {
|
|
28
|
+
const { issues } = (0, hapi_detector_1.analyzeHapiApp)(app(`
|
|
29
|
+
server.route({ method: "PUT", path: "/update", options: { auth: { strategy: "jwt" } }, handler: () => "ok" });
|
|
30
|
+
`));
|
|
31
|
+
(0, vitest_1.expect)(issues).toHaveLength(0);
|
|
32
|
+
});
|
|
33
|
+
(0, vitest_1.it)("R1:显式 auth: false → 报(显式公开 mutation)", () => {
|
|
34
|
+
const { issues } = (0, hapi_detector_1.analyzeHapiApp)(app(`
|
|
35
|
+
server.route({ method: "POST", path: "/transfer", options: { auth: false }, handler: () => "ok" });
|
|
36
|
+
`));
|
|
37
|
+
(0, vitest_1.expect)(issues.map((i) => i.rule)).toContain("HAPI_ROUTE_NO_AUTH");
|
|
38
|
+
});
|
|
39
|
+
(0, vitest_1.it)("R1:GET 读操作不报", () => {
|
|
40
|
+
const { issues } = (0, hapi_detector_1.analyzeHapiApp)(app(`
|
|
41
|
+
server.route({ method: "GET", path: "/articles", handler: () => [] });
|
|
42
|
+
`));
|
|
43
|
+
(0, vitest_1.expect)(issues).toHaveLength(0);
|
|
44
|
+
});
|
|
45
|
+
(0, vitest_1.it)("R1 豁免:login 认证入口路径不报", () => {
|
|
46
|
+
const { issues } = (0, hapi_detector_1.analyzeHapiApp)(app(`
|
|
47
|
+
server.route({ method: "POST", path: "/login", handler: () => "token" });
|
|
48
|
+
`));
|
|
49
|
+
(0, vitest_1.expect)(issues).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
(0, vitest_1.it)("策略声明被记录(auth.strategy 提取)", () => {
|
|
52
|
+
const { strategies } = (0, hapi_detector_1.analyzeHapiApp)(app(`server.route({ method: "GET", path: "/me", options: { auth: "jwt" }, handler: () => "me" });`, `server.auth.strategy("jwt", "jwt", { keys: ["secret"] });`));
|
|
53
|
+
(0, vitest_1.expect)(strategies).toContain("jwt");
|
|
54
|
+
});
|
|
55
|
+
(0, vitest_1.it)("非 Hapi 代码不产生任何问题", () => {
|
|
56
|
+
const { hasHapi, issues } = (0, hapi_detector_1.analyzeHapiApp)(`import express from "express"; const app = express(); app.post("/x", h);`);
|
|
57
|
+
(0, vitest_1.expect)(hasHapi).toBe(false);
|
|
58
|
+
(0, vitest_1.expect)(issues).toHaveLength(0);
|
|
59
|
+
});
|
|
60
|
+
});
|
package/dist/frameworks/index.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Next.js (App Router) — 7 dedicated detectors.
|
|
13
13
|
*/
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
-
exports.generateVersionAwarenessReport = exports.checkFileRename = exports.checkFrameworkConventions = exports.detectFrameworks = exports.analyzeNestJSFile = exports.analyzeNestJSProject = exports.readNextMiddleware = exports.analyzeNextApp = exports.analyzeFastifyFile = exports.analyzeFastifyApp = exports.analyzeFlaskStructure = exports.analyzeDjangoStructure = exports.analyzeFastapiStructure = exports.formatExpressReport = exports.classifyMiddleware = exports.extractGlobalMiddleware = exports.extractRoutes = exports.detectExpressApp = exports.analyzeExpressProject = exports.analyzeExpressFile = exports.analyzeExpressApp = void 0;
|
|
15
|
+
exports.generateVersionAwarenessReport = exports.checkFileRename = exports.checkFrameworkConventions = exports.detectFrameworks = exports.analyzeNestJSFile = exports.analyzeNestJSProject = exports.analyzeHapiFile = exports.analyzeHapiApp = exports.analyzeKoaFile = exports.analyzeKoaApp = exports.readNextMiddleware = exports.analyzeNextApp = exports.analyzeFastifyFile = exports.analyzeFastifyApp = exports.analyzeFlaskStructure = exports.analyzeDjangoStructure = exports.analyzeFastapiStructure = exports.formatExpressReport = exports.classifyMiddleware = exports.extractGlobalMiddleware = exports.extractRoutes = exports.detectExpressApp = exports.analyzeExpressProject = exports.analyzeExpressFile = exports.analyzeExpressApp = void 0;
|
|
16
16
|
var express_detector_1 = require("./express-detector");
|
|
17
17
|
Object.defineProperty(exports, "analyzeExpressApp", { enumerable: true, get: function () { return express_detector_1.analyzeExpressApp; } });
|
|
18
18
|
Object.defineProperty(exports, "analyzeExpressFile", { enumerable: true, get: function () { return express_detector_1.analyzeExpressFile; } });
|
|
@@ -34,6 +34,12 @@ Object.defineProperty(exports, "analyzeFastifyFile", { enumerable: true, get: fu
|
|
|
34
34
|
var nextjs_detector_1 = require("./nextjs-detector");
|
|
35
35
|
Object.defineProperty(exports, "analyzeNextApp", { enumerable: true, get: function () { return nextjs_detector_1.analyzeNextApp; } });
|
|
36
36
|
Object.defineProperty(exports, "readNextMiddleware", { enumerable: true, get: function () { return nextjs_detector_1.readNextMiddleware; } });
|
|
37
|
+
var koa_detector_1 = require("./koa-detector");
|
|
38
|
+
Object.defineProperty(exports, "analyzeKoaApp", { enumerable: true, get: function () { return koa_detector_1.analyzeKoaApp; } });
|
|
39
|
+
Object.defineProperty(exports, "analyzeKoaFile", { enumerable: true, get: function () { return koa_detector_1.analyzeKoaFile; } });
|
|
40
|
+
var hapi_detector_1 = require("./hapi-detector");
|
|
41
|
+
Object.defineProperty(exports, "analyzeHapiApp", { enumerable: true, get: function () { return hapi_detector_1.analyzeHapiApp; } });
|
|
42
|
+
Object.defineProperty(exports, "analyzeHapiFile", { enumerable: true, get: function () { return hapi_detector_1.analyzeHapiFile; } });
|
|
37
43
|
var nestjs_detector_1 = require("./nestjs-detector");
|
|
38
44
|
Object.defineProperty(exports, "analyzeNestJSProject", { enumerable: true, get: function () { return nestjs_detector_1.analyzeNestJSProject; } });
|
|
39
45
|
Object.defineProperty(exports, "analyzeNestJSFile", { enumerable: true, get: function () { return nestjs_detector_1.analyzeNestJSFile; } });
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Koa Framework Adapter — Protocol Detection for Koa
|
|
4
|
+
*
|
|
5
|
+
* 第 9 个框架适配(TS/JS 第 5 个专用检测器,代码串级镜像 express-detector):
|
|
6
|
+
*
|
|
7
|
+
* app.use(authMiddleware) 全局认证中间件
|
|
8
|
+
* router.post('/x', authMW, handler) 路由级认证中间件链
|
|
9
|
+
*
|
|
10
|
+
* 规则:
|
|
11
|
+
* KOA_ROUTE_NO_AUTH mutation 路由注册(post/put/patch/delete/del)
|
|
12
|
+
* 中间件链里没有认证名中间件,且文件内无认证
|
|
13
|
+
* 全局 app.use——路由级 missing-auth
|
|
14
|
+
*
|
|
15
|
+
* 口径(如实):
|
|
16
|
+
* - get 读操作不检查;认证入口路径词汇豁免(login/regist/auth/token)
|
|
17
|
+
* - 认证中间件按名字词表识别(auth/login/permission/token/session/
|
|
18
|
+
* jwt/verify/guard);自定义认证名不含词表漏判(保守方向=漏报)
|
|
19
|
+
* - 文件级窗口(与 Express 检测器同款):跨文件注册的全局中间件不可见
|
|
20
|
+
*/
|
|
21
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
24
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
25
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
26
|
+
}
|
|
27
|
+
Object.defineProperty(o, k2, desc);
|
|
28
|
+
}) : (function(o, m, k, k2) {
|
|
29
|
+
if (k2 === undefined) k2 = k;
|
|
30
|
+
o[k2] = m[k];
|
|
31
|
+
}));
|
|
32
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
33
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
34
|
+
}) : function(o, v) {
|
|
35
|
+
o["default"] = v;
|
|
36
|
+
});
|
|
37
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
38
|
+
var ownKeys = function(o) {
|
|
39
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
40
|
+
var ar = [];
|
|
41
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
42
|
+
return ar;
|
|
43
|
+
};
|
|
44
|
+
return ownKeys(o);
|
|
45
|
+
};
|
|
46
|
+
return function (mod) {
|
|
47
|
+
if (mod && mod.__esModule) return mod;
|
|
48
|
+
var result = {};
|
|
49
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
50
|
+
__setModuleDefault(result, mod);
|
|
51
|
+
return result;
|
|
52
|
+
};
|
|
53
|
+
})();
|
|
54
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
55
|
+
exports.analyzeKoaApp = analyzeKoaApp;
|
|
56
|
+
exports.analyzeKoaFile = analyzeKoaFile;
|
|
57
|
+
const fs = __importStar(require("fs"));
|
|
58
|
+
const MUTATION_METHODS = new Set(["post", "put", "patch", "delete", "del"]);
|
|
59
|
+
const AUTH_ENTRY_WORDS = [
|
|
60
|
+
"login", "signin", "sign_in", "regist", "signup", "sign_up",
|
|
61
|
+
"token", "auth", "health",
|
|
62
|
+
];
|
|
63
|
+
const AUTH_FN_WORDS = [
|
|
64
|
+
"auth", "login", "permission", "token", "credential", "session",
|
|
65
|
+
"jwt", "verify", "guard", "protect", "passport",
|
|
66
|
+
];
|
|
67
|
+
function isAuthEntryPath(pathName) {
|
|
68
|
+
const lower = pathName.toLowerCase();
|
|
69
|
+
return AUTH_ENTRY_WORDS.some((w) => lower.includes(w));
|
|
70
|
+
}
|
|
71
|
+
function isAuthFnName(name) {
|
|
72
|
+
const lower = name.toLowerCase();
|
|
73
|
+
return AUTH_FN_WORDS.some((w) => lower.includes(w));
|
|
74
|
+
}
|
|
75
|
+
// ── Analysis(代码串级) ──
|
|
76
|
+
function analyzeKoaApp(code) {
|
|
77
|
+
const issues = [];
|
|
78
|
+
const routes = [];
|
|
79
|
+
const authGlobalMiddleware = [];
|
|
80
|
+
const hasKoa = /\bKoa\b|\bkoa\b/.test(code);
|
|
81
|
+
if (!hasKoa) {
|
|
82
|
+
return { hasKoa: false, routes, authGlobalMiddleware, issues };
|
|
83
|
+
}
|
|
84
|
+
// 全局认证中间件:app.use(authFn)
|
|
85
|
+
const useRe = /\.use\s*\(\s*([A-Za-z_$][\w$]*)\s*\)/g;
|
|
86
|
+
let m;
|
|
87
|
+
while ((m = useRe.exec(code)) !== null) {
|
|
88
|
+
if (isAuthFnName(m[1]))
|
|
89
|
+
authGlobalMiddleware.push(m[1]);
|
|
90
|
+
}
|
|
91
|
+
// 路由注册:router.post('/x', mw1, mw2, handler) / .del()
|
|
92
|
+
const routeRe = /\.(get|post|put|patch|delete|del)\s*\(\s*['"]([^'"]+)['"]/g;
|
|
93
|
+
while ((m = routeRe.exec(code)) !== null) {
|
|
94
|
+
const method = m[1].toLowerCase() === "del" ? "delete" : m[1].toLowerCase();
|
|
95
|
+
const pathName = m[2];
|
|
96
|
+
// 从路径串向后截 300 字符窗口,收集中间件名(认证名判定)
|
|
97
|
+
const windowStart = m.index + m[0].length;
|
|
98
|
+
const window = code.slice(windowStart, windowStart + 300);
|
|
99
|
+
const mwNames = window.match(/[A-Za-z_$][\w$]*/g) || [];
|
|
100
|
+
const hasAuthMw = mwNames.some((name) => isAuthFnName(name));
|
|
101
|
+
routes.push({
|
|
102
|
+
method,
|
|
103
|
+
path: pathName,
|
|
104
|
+
protected: hasAuthMw,
|
|
105
|
+
line: code.slice(0, m.index).split("\n").length,
|
|
106
|
+
});
|
|
107
|
+
if (MUTATION_METHODS.has(method) && !hasAuthMw
|
|
108
|
+
&& authGlobalMiddleware.length === 0 && !isAuthEntryPath(pathName)) {
|
|
109
|
+
issues.push({
|
|
110
|
+
severity: "medium",
|
|
111
|
+
rule: "KOA_ROUTE_NO_AUTH",
|
|
112
|
+
message: `Route ${method.toUpperCase()} ${pathName} has no auth middleware ` +
|
|
113
|
+
`and the app registers no auth middleware — any caller can reach it.`,
|
|
114
|
+
route: `${method.toUpperCase()} ${pathName}`,
|
|
115
|
+
line: code.slice(0, m.index).split("\n").length,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { hasKoa: true, routes, authGlobalMiddleware, issues };
|
|
120
|
+
}
|
|
121
|
+
function analyzeKoaFile(filePath) {
|
|
122
|
+
if (!fs.existsSync(filePath))
|
|
123
|
+
return null;
|
|
124
|
+
const code = fs.readFileSync(filePath, "utf-8");
|
|
125
|
+
if (!/from\s+['"]koa|require\(['"]koa/.test(code))
|
|
126
|
+
return null;
|
|
127
|
+
return analyzeKoaApp(code);
|
|
128
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* koa-detector.test.ts — Koa 框架适配器规则回归(纯函数,无文件 I/O)
|
|
5
|
+
*/
|
|
6
|
+
const vitest_1 = require("vitest");
|
|
7
|
+
const koa_detector_1 = require("./koa-detector");
|
|
8
|
+
const app = (routes, extra = "") => `
|
|
9
|
+
import Koa from "koa";
|
|
10
|
+
import Router from "@koa/router";
|
|
11
|
+
const app = new Koa();
|
|
12
|
+
const router = new Router();
|
|
13
|
+
${extra}
|
|
14
|
+
${routes}
|
|
15
|
+
app.use(router.routes());
|
|
16
|
+
`;
|
|
17
|
+
(0, vitest_1.describe)("koa-detector", () => {
|
|
18
|
+
(0, vitest_1.it)("R1:无认证中间件的 mutation 路由 → KOA_ROUTE_NO_AUTH", () => {
|
|
19
|
+
const { issues } = (0, koa_detector_1.analyzeKoaApp)(app(`
|
|
20
|
+
router.post("/transfer", async (ctx) => { ctx.body = "ok"; });
|
|
21
|
+
`));
|
|
22
|
+
(0, vitest_1.expect)(issues.map((i) => i.rule)).toContain("KOA_ROUTE_NO_AUTH");
|
|
23
|
+
});
|
|
24
|
+
(0, vitest_1.it)("R1:路由级认证中间件保护不报", () => {
|
|
25
|
+
const { issues } = (0, koa_detector_1.analyzeKoaApp)(app(`
|
|
26
|
+
router.post("/transfer", authenticate, async (ctx) => { ctx.body = "ok"; });
|
|
27
|
+
`));
|
|
28
|
+
(0, vitest_1.expect)(issues).toHaveLength(0);
|
|
29
|
+
});
|
|
30
|
+
(0, vitest_1.it)("R1:全局 app.use 认证中间件保护不报", () => {
|
|
31
|
+
const { issues } = (0, koa_detector_1.analyzeKoaApp)(app(`router.post("/transfer", async (ctx) => { ctx.body = "ok"; });`, `app.use(authenticate);`));
|
|
32
|
+
(0, vitest_1.expect)(issues).toHaveLength(0);
|
|
33
|
+
});
|
|
34
|
+
(0, vitest_1.it)("R1:非认证中间件(日志)不视为保护", () => {
|
|
35
|
+
const { issues } = (0, koa_detector_1.analyzeKoaApp)(app(`
|
|
36
|
+
router.post("/transfer", logger, async (ctx) => { ctx.body = "ok"; });
|
|
37
|
+
`));
|
|
38
|
+
(0, vitest_1.expect)(issues.map((i) => i.rule)).toContain("KOA_ROUTE_NO_AUTH");
|
|
39
|
+
});
|
|
40
|
+
(0, vitest_1.it)("R1:GET 读操作不报", () => {
|
|
41
|
+
const { issues } = (0, koa_detector_1.analyzeKoaApp)(app(`
|
|
42
|
+
router.get("/articles", async (ctx) => { ctx.body = []; });
|
|
43
|
+
`));
|
|
44
|
+
(0, vitest_1.expect)(issues).toHaveLength(0);
|
|
45
|
+
});
|
|
46
|
+
(0, vitest_1.it)("R1 豁免:login 认证入口路径不报", () => {
|
|
47
|
+
const { issues } = (0, koa_detector_1.analyzeKoaApp)(app(`
|
|
48
|
+
router.post("/login", async (ctx) => { ctx.body = "token"; });
|
|
49
|
+
`));
|
|
50
|
+
(0, vitest_1.expect)(issues).toHaveLength(0);
|
|
51
|
+
});
|
|
52
|
+
(0, vitest_1.it)("非 Koa 代码不产生任何问题", () => {
|
|
53
|
+
const { hasKoa, issues } = (0, koa_detector_1.analyzeKoaApp)(`import express from "express"; const app = express(); app.post("/x", h);`);
|
|
54
|
+
(0, vitest_1.expect)(hasKoa).toBe(false);
|
|
55
|
+
(0, vitest_1.expect)(issues).toHaveLength(0);
|
|
56
|
+
});
|
|
57
|
+
});
|
package/dist/sdk.js
CHANGED
|
@@ -20,7 +20,7 @@ const risk_model_1 = require("./risk-model");
|
|
|
20
20
|
const protocol_knowledge_1 = require("./protocol-knowledge");
|
|
21
21
|
const evidence_repository_1 = require("./evidence-repository");
|
|
22
22
|
/** Runtime version — stable public identifier. Internal layers evolve underneath. */
|
|
23
|
-
exports.RUNTIME_VERSION = "3.7.
|
|
23
|
+
exports.RUNTIME_VERSION = "3.7.12";
|
|
24
24
|
function verify(filePath) {
|
|
25
25
|
const cert = (0, certify_1.certify)(filePath);
|
|
26
26
|
const kb = (0, protocol_knowledge_1.buildKnowledgeBase)();
|
package/dist/trust/engine.js
CHANGED
|
@@ -67,6 +67,8 @@ const django_detector_1 = require("../frameworks/django-detector");
|
|
|
67
67
|
const flask_detector_1 = require("../frameworks/flask-detector");
|
|
68
68
|
const fastify_detector_1 = require("../frameworks/fastify-detector");
|
|
69
69
|
const nextjs_detector_1 = require("../frameworks/nextjs-detector");
|
|
70
|
+
const koa_detector_1 = require("../frameworks/koa-detector");
|
|
71
|
+
const hapi_detector_1 = require("../frameworks/hapi-detector");
|
|
70
72
|
const ssg_bridge_1 = require("./ssg-bridge");
|
|
71
73
|
const call_sequence_1 = require("../call-sequence");
|
|
72
74
|
const extract_ir_1 = require("../extract-ir");
|
|
@@ -102,6 +104,8 @@ async function evaluateTrust(ctx) {
|
|
|
102
104
|
const flaskResult = collectFlaskViolations(ctx);
|
|
103
105
|
const fastifyResult = collectFastifyViolations(ctx);
|
|
104
106
|
const nextjsResult = collectNextjsViolations(ctx);
|
|
107
|
+
const koaResult = collectKoaViolations(ctx);
|
|
108
|
+
const hapiResult = collectHapiViolations(ctx);
|
|
105
109
|
const coverageData = collectVerificationCoverage(ctx);
|
|
106
110
|
const governanceDefects = collectGovernanceDefects(ctx);
|
|
107
111
|
// ═══════════════════════════════════════
|
|
@@ -124,6 +128,8 @@ async function evaluateTrust(ctx) {
|
|
|
124
128
|
...flaskResult.violations,
|
|
125
129
|
...fastifyResult.violations,
|
|
126
130
|
...nextjsResult.violations,
|
|
131
|
+
...koaResult.violations,
|
|
132
|
+
...hapiResult.violations,
|
|
127
133
|
];
|
|
128
134
|
// ═══════════════════════════════════════
|
|
129
135
|
// PHASE 3: SCORE
|
|
@@ -288,6 +294,24 @@ async function evaluateTrust(ctx) {
|
|
|
288
294
|
issuesFound: nextjsResult.violations.length,
|
|
289
295
|
}
|
|
290
296
|
: undefined,
|
|
297
|
+
/** Koa framework adapter coverage — route/auth-middleware analysis */
|
|
298
|
+
koaCoverage: koaResult.coverage.routes > 0
|
|
299
|
+
? {
|
|
300
|
+
appsDetected: koaResult.coverage.apps,
|
|
301
|
+
totalRoutes: koaResult.coverage.routes,
|
|
302
|
+
filesScanned: koaResult.coverage.filesScanned,
|
|
303
|
+
issuesFound: koaResult.violations.length,
|
|
304
|
+
}
|
|
305
|
+
: undefined,
|
|
306
|
+
/** Hapi framework adapter coverage — route-config auth analysis */
|
|
307
|
+
hapiCoverage: hapiResult.coverage.routes > 0
|
|
308
|
+
? {
|
|
309
|
+
appsDetected: hapiResult.coverage.apps,
|
|
310
|
+
totalRoutes: hapiResult.coverage.routes,
|
|
311
|
+
filesScanned: hapiResult.coverage.filesScanned,
|
|
312
|
+
issuesFound: hapiResult.violations.length,
|
|
313
|
+
}
|
|
314
|
+
: undefined,
|
|
291
315
|
},
|
|
292
316
|
dimensions: {
|
|
293
317
|
policyCompliance: {
|
|
@@ -426,6 +450,104 @@ function mapPolicyViolation(rv, filePath, _enterprisePolicy) {
|
|
|
426
450
|
* Collect Express-specific security violations from the framework detector.
|
|
427
451
|
* Maps ExpressSecurityIssue[] → TrustViolation[].
|
|
428
452
|
*/
|
|
453
|
+
function collectKoaViolations(ctx) {
|
|
454
|
+
const violations = [];
|
|
455
|
+
const coverage = { apps: 0, routes: 0, filesScanned: 0 };
|
|
456
|
+
try {
|
|
457
|
+
const fs = require("fs");
|
|
458
|
+
const candidateDirs = ["src", "server", "app", "api", "routes", "lib"];
|
|
459
|
+
const extensions = languageToExtensions(ctx.language);
|
|
460
|
+
for (const dir of candidateDirs) {
|
|
461
|
+
const dirPath = path.join(ctx.projectPath, dir);
|
|
462
|
+
if (!fs.existsSync(dirPath))
|
|
463
|
+
continue;
|
|
464
|
+
let files;
|
|
465
|
+
try {
|
|
466
|
+
files = walkDir(dirPath, extensions, 100);
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
for (const file of files) {
|
|
472
|
+
if (/\.(test|spec)\.(ts|tsx|js|jsx)$/.test(file))
|
|
473
|
+
continue;
|
|
474
|
+
coverage.filesScanned++;
|
|
475
|
+
try {
|
|
476
|
+
const analysis = (0, koa_detector_1.analyzeKoaFile)(file);
|
|
477
|
+
if (!analysis || !analysis.hasKoa)
|
|
478
|
+
continue;
|
|
479
|
+
coverage.apps++;
|
|
480
|
+
coverage.routes += analysis.routes.length;
|
|
481
|
+
for (const issue of analysis.issues) {
|
|
482
|
+
violations.push({
|
|
483
|
+
severity: issue.severity === "low" ? "low" : issue.severity,
|
|
484
|
+
rule_id: issue.rule,
|
|
485
|
+
file: path.relative(ctx.projectPath, file),
|
|
486
|
+
function: "unknown",
|
|
487
|
+
message: issue.message,
|
|
488
|
+
evidence: issue.route || "",
|
|
489
|
+
why: `Framework structural analysis: ${issue.message}`,
|
|
490
|
+
fix: `Add an auth middleware to the route registration, or register an auth middleware globally with app.use.`,
|
|
491
|
+
policy_ref: "framework-safety.koa",
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
catch { /* skip unreadable files */ }
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
catch { /* best-effort */ }
|
|
500
|
+
return { violations, coverage };
|
|
501
|
+
}
|
|
502
|
+
function collectHapiViolations(ctx) {
|
|
503
|
+
const violations = [];
|
|
504
|
+
const coverage = { apps: 0, routes: 0, filesScanned: 0 };
|
|
505
|
+
try {
|
|
506
|
+
const fs = require("fs");
|
|
507
|
+
const candidateDirs = ["src", "server", "app", "api", "routes", "lib"];
|
|
508
|
+
const extensions = languageToExtensions(ctx.language);
|
|
509
|
+
for (const dir of candidateDirs) {
|
|
510
|
+
const dirPath = path.join(ctx.projectPath, dir);
|
|
511
|
+
if (!fs.existsSync(dirPath))
|
|
512
|
+
continue;
|
|
513
|
+
let files;
|
|
514
|
+
try {
|
|
515
|
+
files = walkDir(dirPath, extensions, 100);
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
for (const file of files) {
|
|
521
|
+
if (/\.(test|spec)\.(ts|tsx|js|jsx)$/.test(file))
|
|
522
|
+
continue;
|
|
523
|
+
coverage.filesScanned++;
|
|
524
|
+
try {
|
|
525
|
+
const analysis = (0, hapi_detector_1.analyzeHapiFile)(file);
|
|
526
|
+
if (!analysis || !analysis.hasHapi)
|
|
527
|
+
continue;
|
|
528
|
+
coverage.apps++;
|
|
529
|
+
coverage.routes += analysis.routes.length;
|
|
530
|
+
for (const issue of analysis.issues) {
|
|
531
|
+
violations.push({
|
|
532
|
+
severity: issue.severity === "low" ? "low" : issue.severity,
|
|
533
|
+
rule_id: issue.rule,
|
|
534
|
+
file: path.relative(ctx.projectPath, file),
|
|
535
|
+
function: "unknown",
|
|
536
|
+
message: issue.message,
|
|
537
|
+
evidence: issue.route || "",
|
|
538
|
+
why: `Framework structural analysis: ${issue.message}`,
|
|
539
|
+
fix: `Add an auth strategy reference to the route options (auth: "<strategy>"), or remove the explicit auth: false.`,
|
|
540
|
+
policy_ref: "framework-safety.hapi",
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
catch { /* skip unreadable files */ }
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
catch { /* best-effort */ }
|
|
549
|
+
return { violations, coverage };
|
|
550
|
+
}
|
|
429
551
|
function collectNextjsViolations(ctx) {
|
|
430
552
|
const violations = [];
|
|
431
553
|
const coverage = { apps: 0, routes: 0, filesScanned: 0 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "progmune-runtime",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.12",
|
|
4
4
|
"description": "Progmune — AI Trust Decision Engine. Verify AI-generated code before it reaches production. Outputs APPROVED / NEEDS_REVIEW / BLOCKED with evidence.",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist/",
|