@enwithai/product-check 0.1.0 → 0.3.0
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/ATTRIBUTION.md +6 -6
- package/CHANGELOG.md +66 -0
- package/NOTICE.md +5 -3
- package/README.md +34 -19
- package/SECURITY.md +5 -1
- package/dashboard/assurance.css +71 -0
- package/dashboard/assurance.js +21 -0
- package/dashboard/dashboard.css +6 -0
- package/dist/assurance/adapters.d.ts +32 -0
- package/dist/assurance/adapters.js +76 -0
- package/dist/assurance/approvals.d.ts +34 -0
- package/dist/assurance/approvals.js +133 -0
- package/dist/assurance/cli.d.ts +13 -0
- package/dist/assurance/cli.js +863 -0
- package/dist/assurance/dashboard.d.ts +18 -0
- package/dist/assurance/dashboard.js +267 -0
- package/dist/assurance/diff.d.ts +20 -0
- package/dist/assurance/diff.js +99 -0
- package/dist/assurance/evidence-log.d.ts +20 -0
- package/dist/assurance/evidence-log.js +100 -0
- package/dist/assurance/fix-packets.d.ts +12 -0
- package/dist/assurance/fix-packets.js +178 -0
- package/dist/assurance/gate.d.ts +37 -0
- package/dist/assurance/gate.js +324 -0
- package/dist/assurance/lanes.d.ts +61 -0
- package/dist/assurance/lanes.js +380 -0
- package/dist/assurance/policy.d.ts +10 -0
- package/dist/assurance/policy.js +85 -0
- package/dist/assurance/proof.d.ts +35 -0
- package/dist/assurance/proof.js +146 -0
- package/dist/assurance/register.d.ts +41 -0
- package/dist/assurance/register.js +480 -0
- package/dist/assurance/render-md.d.ts +17 -0
- package/dist/assurance/render-md.js +212 -0
- package/dist/assurance/repo-state.d.ts +15 -0
- package/dist/assurance/repo-state.js +79 -0
- package/dist/assurance/review.d.ts +52 -0
- package/dist/assurance/review.js +408 -0
- package/dist/assurance/risk.d.ts +21 -0
- package/dist/assurance/risk.js +184 -0
- package/dist/assurance/types.d.ts +573 -0
- package/dist/assurance/types.js +6 -0
- package/dist/assurance/util.d.ts +24 -0
- package/dist/assurance/util.js +89 -0
- package/dist/assurance/verification.d.ts +30 -0
- package/dist/assurance/verification.js +212 -0
- package/dist/assurance/verify.d.ts +26 -0
- package/dist/assurance/verify.js +304 -0
- package/dist/blueprint/build.d.ts +3 -1
- package/dist/blueprint/build.js +41 -5
- package/dist/cli/run.js +103 -5
- package/dist/contract/contract.d.ts +4 -0
- package/dist/contract/contract.js +16 -0
- package/dist/dashboard/render.d.ts +5 -0
- package/dist/dashboard/render.js +107 -3
- package/dist/discovery/pops.d.ts +2 -0
- package/dist/discovery/pops.js +7 -5
- package/dist/exec/process.d.ts +27 -0
- package/dist/exec/process.js +175 -0
- package/dist/importers/operational.d.ts +56 -0
- package/dist/importers/operational.js +217 -0
- package/dist/importers/reports.d.ts +24 -0
- package/dist/importers/reports.js +37 -1
- package/dist/inspectors/source/surface.d.ts +30 -0
- package/dist/inspectors/source/surface.js +529 -0
- package/dist/parse/yaml.d.ts +4 -0
- package/dist/parse/yaml.js +39 -0
- package/dist/reporting/markdown.d.ts +1 -0
- package/dist/reporting/markdown.js +18 -1
- package/dist/reporting/spec-markdown.d.ts +9 -0
- package/dist/reporting/spec-markdown.js +144 -0
- package/dist/run.d.ts +2 -0
- package/dist/run.js +52 -2
- package/dist/schema/registry.js +10 -0
- package/dist/scoring/score.js +1 -1
- package/dist/spec/build.d.ts +28 -0
- package/dist/spec/build.js +278 -0
- package/dist/types.d.ts +78 -2
- package/dist/types.js +6 -1
- package/dist/validation/catalog.d.ts +1 -1
- package/dist/validation/catalog.js +125 -2
- package/dist/validation/context.d.ts +6 -1
- package/dist/validation/import-evaluators.js +37 -3
- package/dist/validation/integrity.js +4 -0
- package/dist/validation/operational-evaluators.d.ts +3 -0
- package/dist/validation/operational-evaluators.js +153 -0
- package/dist/validation/source-evaluators.js +1 -1
- package/package.json +4 -1
- package/schemas/approval.schema.json +98 -0
- package/schemas/as-built-spec.schema.json +356 -0
- package/schemas/assurance-common.schema.json +291 -0
- package/schemas/assurance-policy.schema.json +185 -0
- package/schemas/blueprint.schema.json +3 -0
- package/schemas/change-review.schema.json +711 -0
- package/schemas/closure-register.schema.json +499 -0
- package/schemas/evidence-record.schema.json +94 -0
- package/schemas/fix-packet.schema.json +272 -0
- package/schemas/release-gate.schema.json +308 -0
- package/schemas/reviewer-findings.schema.json +49 -0
- package/schemas/scan-contract.schema.json +20 -0
- package/schemas/scorecard.schema.json +1 -1
- package/schemas/verification-run.schema.json +199 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { SECRET_RULES } from "../validation/source-evaluators.js";
|
|
2
|
+
const CODE = /\.(c|m)?(j|t)sx?$|\.(py|rb|go|java|kt|php|cs|rs|vue|svelte)$/i;
|
|
3
|
+
const TEST = /(^|\/)(__tests__|tests?|spec|e2e)\/|\.(test|spec)\.[cm]?[jt]sx?$|_test\.(py|go)$|(^|\/)test_[^/]+\.py$/i;
|
|
4
|
+
const MIGRATION = /(^|\/)(migrations?|migrate|alembic\/versions)\/|\.sql$/i;
|
|
5
|
+
const CLIENT = /(^|\/)(app|pages|components|src\/(components|pages|views|client|ui))\/|\.(jsx|tsx|vue|svelte)$|(^|\/)public\//i;
|
|
6
|
+
const isCode = (p) => CODE.test(p);
|
|
7
|
+
const isProdCode = (p) => CODE.test(p) && !TEST.test(p) && !/(^|\/)(fixtures?|mocks?|__mocks__|examples?|scripts|seed)\//i.test(p);
|
|
8
|
+
const isTest = (p) => TEST.test(p);
|
|
9
|
+
const re = (r) => (t) => r.exec(t)?.[0] ?? null;
|
|
10
|
+
/** Line rules: evaluated on each added line (and on whole files when re-checking a fix). */
|
|
11
|
+
export const LINE_RULES = [
|
|
12
|
+
{
|
|
13
|
+
id: "VC-TR-SECRET",
|
|
14
|
+
lane: "trust-security",
|
|
15
|
+
title: "Secret-like credential added to the code",
|
|
16
|
+
severity: "P0",
|
|
17
|
+
pillars: ["trust"],
|
|
18
|
+
epistemic: "fact",
|
|
19
|
+
confidence: "HIGH",
|
|
20
|
+
expected: "Credentials come from the runtime environment or a secret manager, never from source.",
|
|
21
|
+
impact: "Anyone with repository or bundle access can use the credential.",
|
|
22
|
+
files: (p) => !/\.(lock|lockb|sum)$|package-lock\.json$/.test(p),
|
|
23
|
+
match: (t) => {
|
|
24
|
+
for (const rule of SECRET_RULES) {
|
|
25
|
+
rule.re.lastIndex = 0;
|
|
26
|
+
for (const m of t.matchAll(rule.re)) {
|
|
27
|
+
const sev = rule.check ? rule.check(m[0]) : rule.severity;
|
|
28
|
+
if (sev === "P0" || sev === "P1")
|
|
29
|
+
return `${rule.name}:${m[0]}`;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "VC-TR-CLIENT-ENV",
|
|
37
|
+
lane: "trust-security",
|
|
38
|
+
title: "Privileged environment variable exposed to the browser bundle",
|
|
39
|
+
severity: "P0",
|
|
40
|
+
pillars: ["trust"],
|
|
41
|
+
epistemic: "fact",
|
|
42
|
+
confidence: "HIGH",
|
|
43
|
+
expected: "Only public, non-privileged values use client-exposed prefixes (NEXT_PUBLIC_, VITE_, REACT_APP_, PUBLIC_).",
|
|
44
|
+
impact: "The privileged value ships to every visitor's browser.",
|
|
45
|
+
files: (p) => isCode(p) || /(^|\/)\.env/.test(p),
|
|
46
|
+
match: re(/\b(?:NEXT_PUBLIC|VITE|REACT_APP|PUBLIC|EXPO_PUBLIC)_[A-Z0-9_]*(?:SECRET|SERVICE_ROLE|PRIVATE|PASSWORD|ADMIN)[A-Z0-9_]*\b/),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "VC-TR-TLS-DISABLED",
|
|
50
|
+
lane: "trust-security",
|
|
51
|
+
title: "TLS certificate verification disabled",
|
|
52
|
+
severity: "P1",
|
|
53
|
+
pillars: ["trust"],
|
|
54
|
+
epistemic: "fact",
|
|
55
|
+
confidence: "HIGH",
|
|
56
|
+
expected: "TLS verification stays on outside of tests.",
|
|
57
|
+
impact: "Traffic can be intercepted and altered.",
|
|
58
|
+
files: isProdCode,
|
|
59
|
+
match: re(/rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['"]?0|verify\s*=\s*False/),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "VC-TR-CORS-WILDCARD",
|
|
63
|
+
lane: "trust-security",
|
|
64
|
+
title: "Wildcard CORS with credentials or on an API",
|
|
65
|
+
severity: "P2",
|
|
66
|
+
pillars: ["trust"],
|
|
67
|
+
epistemic: "inference",
|
|
68
|
+
confidence: "MEDIUM",
|
|
69
|
+
expected: "CORS allows only known origins for credentialed APIs.",
|
|
70
|
+
impact: "Other sites may read API responses on behalf of signed-in users.",
|
|
71
|
+
files: isProdCode,
|
|
72
|
+
match: re(/Access-Control-Allow-Origin['"]?\s*[,:]\s*['"]\*['"]|origin\s*:\s*['"]\*['"]|cors\(\s*\)/),
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "VC-TR-SQL-CONCAT",
|
|
76
|
+
lane: "trust-security",
|
|
77
|
+
title: "SQL built from interpolated input",
|
|
78
|
+
severity: "P1",
|
|
79
|
+
pillars: ["trust", "technology"],
|
|
80
|
+
epistemic: "hypothesis",
|
|
81
|
+
confidence: "MEDIUM",
|
|
82
|
+
expected: "Queries use parameters or a query builder.",
|
|
83
|
+
impact: "Possible SQL injection.",
|
|
84
|
+
files: isProdCode,
|
|
85
|
+
match: re(/\b(query|execute|raw|\$queryRawUnsafe|exec)\s*\(\s*[`'"](?:SELECT|INSERT|UPDATE|DELETE)\b[^`'"]*(\$\{|['"]\s*\+)/i),
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "VC-TR-DYNAMIC-EVAL",
|
|
89
|
+
lane: "trust-security",
|
|
90
|
+
title: "Dynamic code or shell execution",
|
|
91
|
+
severity: "P1",
|
|
92
|
+
pillars: ["trust"],
|
|
93
|
+
epistemic: "inference",
|
|
94
|
+
confidence: "MEDIUM",
|
|
95
|
+
expected: "No eval/new Function/shell execution of runtime data.",
|
|
96
|
+
impact: "Remote code execution if any input reaches it.",
|
|
97
|
+
files: isProdCode,
|
|
98
|
+
match: re(/\beval\s*\(|new\s+Function\s*\(|\bexecSync?\s*\(\s*`[^`]*\$\{|\bos\.system\s*\(|subprocess\.\w+\([^)]*shell\s*=\s*True/),
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: "VC-TR-AI-TOOL-EXEC",
|
|
102
|
+
lane: "trust-security",
|
|
103
|
+
title: "Model output reaches a shell, eval or database without an allowlist",
|
|
104
|
+
severity: "P1",
|
|
105
|
+
pillars: ["trust"],
|
|
106
|
+
epistemic: "hypothesis",
|
|
107
|
+
confidence: "LOW",
|
|
108
|
+
expected: "Tool calls from a model are validated against an allowlist and schema before execution.",
|
|
109
|
+
impact: "Prompt injection can trigger consequential actions.",
|
|
110
|
+
files: isProdCode,
|
|
111
|
+
match: re(/(tool_calls|function_call|toolCall|completion|choices\[0\]|\.content\b)[^;\n]*(exec|eval|spawn|query|\$queryRaw)\s*\(|(exec|eval|spawn)\s*\([^)]*(tool_calls|completion|choices\[0\]|toolCall)/),
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: "VC-CR-STUB",
|
|
115
|
+
lane: "correctness",
|
|
116
|
+
title: "Unimplemented stub or placeholder in production code",
|
|
117
|
+
severity: "P2",
|
|
118
|
+
pillars: ["technology", "customer"],
|
|
119
|
+
epistemic: "fact",
|
|
120
|
+
confidence: "HIGH",
|
|
121
|
+
expected: "Shipped code paths are implemented or explicitly disabled behind a flag.",
|
|
122
|
+
impact: "The feature silently does nothing or fails at runtime.",
|
|
123
|
+
files: isProdCode,
|
|
124
|
+
match: re(/throw new Error\(\s*['"`](not implemented|todo|unimplemented)[^'"`]*['"`]\s*\)|raise NotImplementedError|\/\/\s*TODO:?\s*implement|#\s*TODO:?\s*implement/i),
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: "VC-CR-MOCK-DATA",
|
|
128
|
+
lane: "correctness",
|
|
129
|
+
title: "Mock or sample data wired into production code",
|
|
130
|
+
severity: "P2",
|
|
131
|
+
pillars: ["customer", "technology"],
|
|
132
|
+
epistemic: "inference",
|
|
133
|
+
confidence: "MEDIUM",
|
|
134
|
+
expected: "Production code reads real data sources; fixtures stay in tests.",
|
|
135
|
+
impact: "Users see fabricated data presented as real.",
|
|
136
|
+
files: isProdCode,
|
|
137
|
+
match: re(/from\s+['"][^'"]*(\/|^)(mocks?|__mocks__|fixtures?|fake-?data|sample-?data|dummy-?data)[/'".]|\b(const|let|var)\s+(mock|fake|dummy)[A-Z_]\w*\s*=\s*\[/),
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
id: "VC-CR-SWALLOWED-ERROR",
|
|
141
|
+
lane: "correctness",
|
|
142
|
+
title: "Error swallowed silently",
|
|
143
|
+
severity: "P3",
|
|
144
|
+
pillars: ["technology"],
|
|
145
|
+
epistemic: "fact",
|
|
146
|
+
confidence: "HIGH",
|
|
147
|
+
expected: "Errors are handled, logged or rethrown.",
|
|
148
|
+
impact: "Failures disappear; users see silent fallbacks.",
|
|
149
|
+
files: isProdCode,
|
|
150
|
+
match: re(/catch\s*(\([^)]*\))?\s*\{\s*\}|except[^:]*:\s*pass\b|\.catch\(\s*\(\s*\)\s*=>\s*(\{\s*\}|null|undefined)\s*\)/),
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: "VC-CR-FOCUSED-TEST",
|
|
154
|
+
lane: "correctness",
|
|
155
|
+
title: "Focused or skipped test",
|
|
156
|
+
severity: "P2",
|
|
157
|
+
pillars: ["technology"],
|
|
158
|
+
epistemic: "fact",
|
|
159
|
+
confidence: "HIGH",
|
|
160
|
+
expected: "The full suite runs in CI; nothing is focused or skipped without a tracked reason.",
|
|
161
|
+
impact: "Tests silently stop protecting behavior.",
|
|
162
|
+
files: isTest,
|
|
163
|
+
match: re(/\b(describe|it|test)\.(only|skip)\s*\(|\b(xit|xdescribe|fit|fdescribe)\s*\(|@pytest\.mark\.skip\b/),
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
id: "VC-OP-NONIDEMPOTENT",
|
|
167
|
+
lane: "operations",
|
|
168
|
+
title: "Consequential call without an idempotency key",
|
|
169
|
+
severity: "P2",
|
|
170
|
+
pillars: ["trust", "technology"],
|
|
171
|
+
epistemic: "hypothesis",
|
|
172
|
+
confidence: "LOW",
|
|
173
|
+
expected: "Payments, emails and other consequential actions carry an idempotency key so retries do not repeat them.",
|
|
174
|
+
impact: "Retries or background jobs can charge or message people twice.",
|
|
175
|
+
files: isProdCode,
|
|
176
|
+
match: (t) => /\b(paymentIntents|charges|refunds|transfers|payouts)\.create\s*\(|\b(sendEmail|sendMail|messages\.create|sms\.send)\s*\(/.test(t) && !/idempotenc/i.test(t)
|
|
177
|
+
? (/\b\w+\.create\s*\(|\bsend\w*\s*\(/.exec(t)?.[0] ?? "call")
|
|
178
|
+
: null,
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: "VC-UX-IMG-ALT",
|
|
182
|
+
lane: "experience",
|
|
183
|
+
title: "Image without alternative text",
|
|
184
|
+
severity: "P3",
|
|
185
|
+
pillars: ["customer"],
|
|
186
|
+
epistemic: "fact",
|
|
187
|
+
confidence: "HIGH",
|
|
188
|
+
expected: "Images carry alt text (empty alt for decoration).",
|
|
189
|
+
impact: "Screen-reader users miss the content.",
|
|
190
|
+
files: (p) => /\.(jsx|tsx|html|vue|svelte)$/.test(p),
|
|
191
|
+
match: (t) => /<img\b(?![^>]*\balt\s*=)[^>]*>/i.exec(t)?.[0] ?? null,
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: "VC-UX-POSITIVE-TABINDEX",
|
|
195
|
+
lane: "experience",
|
|
196
|
+
title: "Positive tabindex breaks keyboard order",
|
|
197
|
+
severity: "P3",
|
|
198
|
+
pillars: ["customer"],
|
|
199
|
+
epistemic: "fact",
|
|
200
|
+
confidence: "HIGH",
|
|
201
|
+
expected: "tabindex is 0 or -1.",
|
|
202
|
+
impact: "Keyboard users meet a confusing focus order.",
|
|
203
|
+
files: (p) => /\.(jsx|tsx|html|vue|svelte)$/.test(p),
|
|
204
|
+
match: re(/tab[iI]ndex\s*=\s*\{?\s*['"]?[1-9]/),
|
|
205
|
+
},
|
|
206
|
+
];
|
|
207
|
+
const AUTH_CALL = /\b(requireAuth|requireUser|isAuthenticated|ensureAuthenticated|authenticate|authorize|checkPermission|requireRole|getServerSession|verifyToken|jwt\.verify|withAuth|login_required|permission_required|@PreAuthorize|currentUser)\b/;
|
|
208
|
+
const ID_LOOKUP = /\b(findUnique|findById|findOne|findFirst|get_object_or_404|objects\.get|\.get\()\s*\(?[^)]*\b(req\.params|params\.|request\.args|req\.query|ctx\.params|\bid\b)/;
|
|
209
|
+
const OWNER_FILTER = /\b(userId|user_id|ownerId|owner_id|tenantId|tenant_id|orgId|org_id|accountId|account_id|session\.user|req\.user|currentUser|request\.user)\b/;
|
|
210
|
+
const normalize = (s) => s.replace(/\s+/g, " ").replace(/\d+/g, "#").trim().slice(0, 160);
|
|
211
|
+
/** Runs the line rules over a set of lines of one file. */
|
|
212
|
+
export function lineHits(path, lines) {
|
|
213
|
+
const hits = [];
|
|
214
|
+
for (const rule of LINE_RULES) {
|
|
215
|
+
if (!rule.files(path))
|
|
216
|
+
continue;
|
|
217
|
+
for (const l of lines) {
|
|
218
|
+
const m = rule.match(l.text, path);
|
|
219
|
+
if (m === null)
|
|
220
|
+
continue;
|
|
221
|
+
hits.push({
|
|
222
|
+
rule_id: rule.id,
|
|
223
|
+
lane: rule.lane,
|
|
224
|
+
title: rule.title,
|
|
225
|
+
severity: rule.severity,
|
|
226
|
+
pillars: rule.pillars,
|
|
227
|
+
epistemic: rule.epistemic,
|
|
228
|
+
confidence: rule.confidence,
|
|
229
|
+
path,
|
|
230
|
+
line: l.line,
|
|
231
|
+
signature: normalize(m),
|
|
232
|
+
expected: rule.expected,
|
|
233
|
+
actual: `${path}${l.line !== null ? `:${l.line}` : ""} adds: ${normalize(l.text)}`,
|
|
234
|
+
impact: rule.impact,
|
|
235
|
+
excerpt: l.text.trim().slice(0, 200),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return hits;
|
|
240
|
+
}
|
|
241
|
+
/** File-level rules that need the whole change of a file (removed auth, ownership filters, migrations, tests). */
|
|
242
|
+
export function fileHits(file, headText) {
|
|
243
|
+
const hits = [];
|
|
244
|
+
const added = file.lines.filter((l) => l.kind === "add");
|
|
245
|
+
const removed = file.lines.filter((l) => l.kind === "del");
|
|
246
|
+
const push = (h) => {
|
|
247
|
+
hits.push({ ...h, path: file.path });
|
|
248
|
+
};
|
|
249
|
+
if (isProdCode(file.path)) {
|
|
250
|
+
const IMPORT = /^\s*(import\b|export\s+\{|from\s+\S+\s+import\b|const\s+\{[^}]*\}\s*=\s*require\()/;
|
|
251
|
+
const stillThere = (t) => {
|
|
252
|
+
const token = AUTH_CALL.exec(t)?.[0];
|
|
253
|
+
return (headText !== null &&
|
|
254
|
+
token !== undefined &&
|
|
255
|
+
new RegExp(`\\b${token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\(`).test(headText));
|
|
256
|
+
};
|
|
257
|
+
const removedAuth = removed.filter((l) => AUTH_CALL.test(l.text) && !IMPORT.test(l.text) && !stillThere(l.text));
|
|
258
|
+
const addedAuth = added.some((l) => AUTH_CALL.test(l.text) && !IMPORT.test(l.text));
|
|
259
|
+
if (removedAuth.length > 0 && !addedAuth) {
|
|
260
|
+
const first = removedAuth[0];
|
|
261
|
+
push({
|
|
262
|
+
rule_id: "VC-TR-AUTHZ-REMOVED",
|
|
263
|
+
lane: "trust-security",
|
|
264
|
+
title: "Authentication or authorization check removed",
|
|
265
|
+
severity: "P1",
|
|
266
|
+
pillars: ["trust"],
|
|
267
|
+
epistemic: "fact",
|
|
268
|
+
confidence: "HIGH",
|
|
269
|
+
line: null,
|
|
270
|
+
signature: normalize(AUTH_CALL.exec(first.text)?.[0] ?? "auth"),
|
|
271
|
+
expected: "Every protected route keeps an authentication and authorization check.",
|
|
272
|
+
actual: `${file.path} removes ${removedAuth.length} line(s) with an auth check (old line ${first.line}: ${normalize(first.text)}) and adds none.`,
|
|
273
|
+
impact: "Protected data or actions may become reachable without signing in.",
|
|
274
|
+
excerpt: first.text.trim().slice(0, 200),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
const lookups = added.filter((l) => ID_LOOKUP.test(l.text));
|
|
278
|
+
if (lookups.length > 0) {
|
|
279
|
+
const context = headText ?? added.map((l) => l.text).join("\n");
|
|
280
|
+
if (!OWNER_FILTER.test(context)) {
|
|
281
|
+
const first = lookups[0];
|
|
282
|
+
push({
|
|
283
|
+
rule_id: "VC-TR-OBJECT-AUTHZ",
|
|
284
|
+
lane: "trust-security",
|
|
285
|
+
title: "Object fetched by request ID without an ownership or tenant filter",
|
|
286
|
+
severity: "P1",
|
|
287
|
+
pillars: ["trust"],
|
|
288
|
+
epistemic: "hypothesis",
|
|
289
|
+
confidence: "MEDIUM",
|
|
290
|
+
line: first.line,
|
|
291
|
+
signature: normalize(ID_LOOKUP.exec(first.text)?.[0] ?? "lookup"),
|
|
292
|
+
expected: "Objects looked up by a client-supplied ID are scoped to the signed-in user or tenant.",
|
|
293
|
+
actual: `${file.path}:${first.line} looks up a record by a request ID; no user/tenant filter appears in the file.`,
|
|
294
|
+
impact: "Any signed-in user may read or change other users' records (IDOR / cross-tenant leakage).",
|
|
295
|
+
excerpt: first.text.trim().slice(0, 200),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (MIGRATION.test(file.path) && file.status === "added") {
|
|
301
|
+
const text = headText ?? added.map((l) => l.text).join("\n");
|
|
302
|
+
if (/\b(ALTER|DROP|CREATE|RENAME)\b/i.test(text) &&
|
|
303
|
+
!/\b(down|rollback|revert|reverse)\b|--\s*\+migrate\s+Down/i.test(text)) {
|
|
304
|
+
push({
|
|
305
|
+
rule_id: "VC-OP-MIGRATION-NO-DOWN",
|
|
306
|
+
lane: "operations",
|
|
307
|
+
title: "Schema migration without a reversible path",
|
|
308
|
+
severity: "P2",
|
|
309
|
+
pillars: ["technology", "trust"],
|
|
310
|
+
epistemic: "inference",
|
|
311
|
+
confidence: "MEDIUM",
|
|
312
|
+
line: null,
|
|
313
|
+
signature: "migration-without-down",
|
|
314
|
+
expected: "Each migration has a tested down/rollback path or a documented forward-only recovery plan.",
|
|
315
|
+
actual: `${file.path} changes the schema and declares no down/rollback step.`,
|
|
316
|
+
impact: "A failed release cannot be rolled back without data loss.",
|
|
317
|
+
excerpt: text
|
|
318
|
+
.split("\n")
|
|
319
|
+
.find((l) => /\b(ALTER|DROP|CREATE|RENAME)\b/i.test(l))
|
|
320
|
+
?.trim()
|
|
321
|
+
.slice(0, 200) ?? "",
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (isTest(file.path) && added.length > 0) {
|
|
326
|
+
const text = headText ?? added.map((l) => l.text).join("\n");
|
|
327
|
+
const assertions = text.split("\n").filter((l) => /\b(expect|assert)\w*\s*\(|\bassert\b/.test(l));
|
|
328
|
+
const mockOnly = assertions.length > 0 &&
|
|
329
|
+
assertions.every((l) => /(mock|spy|stub|fake|toHaveBeenCalled|called_once|assert_called)/i.test(l) ||
|
|
330
|
+
/expect\(\s*(true|1)\s*\)|assert\.ok\(\s*true\s*\)|assert\s+True\b/.test(l));
|
|
331
|
+
if (mockOnly) {
|
|
332
|
+
push({
|
|
333
|
+
rule_id: "VC-CR-MOCK-ONLY-TEST",
|
|
334
|
+
lane: "correctness",
|
|
335
|
+
title: "Test only asserts mocks or constants",
|
|
336
|
+
severity: "P2",
|
|
337
|
+
pillars: ["technology"],
|
|
338
|
+
epistemic: "hypothesis",
|
|
339
|
+
confidence: "MEDIUM",
|
|
340
|
+
line: null,
|
|
341
|
+
signature: "mock-only-assertions",
|
|
342
|
+
expected: "Tests assert observable behavior of the real code under test.",
|
|
343
|
+
actual: `All ${assertions.length} assertion(s) in ${file.path} check mocks, spies or constants.`,
|
|
344
|
+
impact: "The suite passes while the real behavior is unverified.",
|
|
345
|
+
excerpt: (assertions[0] ?? "").trim().slice(0, 200),
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return hits;
|
|
350
|
+
}
|
|
351
|
+
/** Change-level rule: production code changed but no test changed. */
|
|
352
|
+
export function changeHits(files) {
|
|
353
|
+
const prod = files.filter((f) => isProdCode(f.path) && f.status !== "deleted" && f.additions > 0);
|
|
354
|
+
const tests = files.filter((f) => isTest(f.path));
|
|
355
|
+
if (prod.length === 0 || tests.length > 0)
|
|
356
|
+
return [];
|
|
357
|
+
return [
|
|
358
|
+
{
|
|
359
|
+
rule_id: "VC-CR-NO-TESTS",
|
|
360
|
+
lane: "correctness",
|
|
361
|
+
title: "Behavior changed without any test change",
|
|
362
|
+
severity: "P3",
|
|
363
|
+
pillars: ["technology"],
|
|
364
|
+
epistemic: "inference",
|
|
365
|
+
confidence: "MEDIUM",
|
|
366
|
+
path: prod[0].path,
|
|
367
|
+
line: null,
|
|
368
|
+
signature: "no-test-change",
|
|
369
|
+
expected: "Behavior changes come with a test that would fail without them.",
|
|
370
|
+
actual: `${prod.length} production file(s) changed (${prod
|
|
371
|
+
.map((f) => f.path)
|
|
372
|
+
.slice(0, 5)
|
|
373
|
+
.join(", ")}) and no test file changed.`,
|
|
374
|
+
impact: "Regressions in the changed behavior go unnoticed.",
|
|
375
|
+
excerpt: "",
|
|
376
|
+
},
|
|
377
|
+
];
|
|
378
|
+
}
|
|
379
|
+
/** True when a path is client-reachable code (used to raise secret findings' certainty). */
|
|
380
|
+
export const isClientPath = (p) => CLIENT.test(p);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type AssurancePolicy } from "./types.js";
|
|
2
|
+
/** Defaults: no checks declared, nobody may approve, HIGH needs two humans, three review cycles. */
|
|
3
|
+
export declare function defaultPolicy(): AssurancePolicy;
|
|
4
|
+
/** Applies defaults, validates against the schema and checks every approver key parses. */
|
|
5
|
+
export declare function parsePolicy(raw: unknown, label: string): AssurancePolicy;
|
|
6
|
+
/** Loads a policy file, or the defaults when no path is given. */
|
|
7
|
+
export declare function loadPolicy(path: string | undefined): {
|
|
8
|
+
policy: AssurancePolicy;
|
|
9
|
+
digest: string | null;
|
|
10
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 EnWithAI Technologies
|
|
3
|
+
/**
|
|
4
|
+
* The assurance policy: required checks, approvers and their public keys, risk routing, review convergence and
|
|
5
|
+
* release settings. It belongs to the operator and is passed explicitly with --policy; Product Check never reads a
|
|
6
|
+
* policy from the scanned repository implicitly. Omitted fields take the defaults below.
|
|
7
|
+
*/
|
|
8
|
+
import { createPublicKey } from "node:crypto";
|
|
9
|
+
import { usageError } from "../errors.js";
|
|
10
|
+
import { formatErrors, validateAgainst } from "../schema/registry.js";
|
|
11
|
+
import { ASSURANCE_SCHEMA_VERSION } from "./types.js";
|
|
12
|
+
import { digestOf, readJson } from "./util.js";
|
|
13
|
+
/** Defaults: no checks declared, nobody may approve, HIGH needs two humans, three review cycles. */
|
|
14
|
+
export function defaultPolicy() {
|
|
15
|
+
return {
|
|
16
|
+
schema_version: ASSURANCE_SCHEMA_VERSION,
|
|
17
|
+
kind: "assurance-policy",
|
|
18
|
+
policy_id: "default",
|
|
19
|
+
required_checks: [],
|
|
20
|
+
approvers: [],
|
|
21
|
+
risk: {
|
|
22
|
+
protected_paths: [],
|
|
23
|
+
high_risk_paths: [],
|
|
24
|
+
low_risk_paths: [],
|
|
25
|
+
trusted_authors: [],
|
|
26
|
+
allow_low_risk_waiver: false,
|
|
27
|
+
approvals: { LOW: 1, MEDIUM: 1, HIGH: 2 },
|
|
28
|
+
},
|
|
29
|
+
review: { max_cycles: 3, fail_closed_unverified_p0: true },
|
|
30
|
+
release: { rollback_plan: null, staged_rollout: null },
|
|
31
|
+
exec: {
|
|
32
|
+
env_allowlist: ["PATH", "HOME", "LANG", "TMPDIR", "TEMP", "TMP", "SYSTEMROOT", "COMSPEC", "PATHEXT"],
|
|
33
|
+
max_output_bytes: 4 * 1024 * 1024,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
38
|
+
function merge(base, over) {
|
|
39
|
+
if (!isObj(base) || !isObj(over))
|
|
40
|
+
return over === undefined ? base : over;
|
|
41
|
+
const out = { ...base };
|
|
42
|
+
for (const [k, v] of Object.entries(over))
|
|
43
|
+
out[k] = k in base ? merge(base[k], v) : v;
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
/** Applies defaults, validates against the schema and checks every approver key parses. */
|
|
47
|
+
export function parsePolicy(raw, label) {
|
|
48
|
+
if (!isObj(raw))
|
|
49
|
+
throw usageError(`Policy ${label} must be a JSON object.`);
|
|
50
|
+
const merged = merge(defaultPolicy(), raw);
|
|
51
|
+
merged.required_checks = merged.required_checks.map((c) => ({
|
|
52
|
+
...c,
|
|
53
|
+
cwd: c.cwd ?? ".",
|
|
54
|
+
timeout_ms: c.timeout_ms ?? 600_000,
|
|
55
|
+
source: "policy",
|
|
56
|
+
}));
|
|
57
|
+
const errors = validateAgainst("assurance-policy.schema.json", merged);
|
|
58
|
+
if (errors.length > 0)
|
|
59
|
+
throw usageError(`Invalid assurance policy ${label}:\n${formatErrors(errors)}`);
|
|
60
|
+
const ids = new Set();
|
|
61
|
+
for (const a of merged.approvers) {
|
|
62
|
+
if (ids.has(a.id))
|
|
63
|
+
throw usageError(`Policy ${label}: approver ${a.id} is declared twice.`);
|
|
64
|
+
ids.add(a.id);
|
|
65
|
+
try {
|
|
66
|
+
const key = createPublicKey(a.public_key);
|
|
67
|
+
if (key.asymmetricKeyType !== "ed25519")
|
|
68
|
+
throw new Error("not ed25519");
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw usageError(`Policy ${label}: approver ${a.id} has no valid Ed25519 public key (SPKI PEM).`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const checkIds = merged.required_checks.map((c) => c.id);
|
|
75
|
+
if (new Set(checkIds).size !== checkIds.length)
|
|
76
|
+
throw usageError(`Policy ${label}: required_checks ids must be unique.`);
|
|
77
|
+
return merged;
|
|
78
|
+
}
|
|
79
|
+
/** Loads a policy file, or the defaults when no path is given. */
|
|
80
|
+
export function loadPolicy(path) {
|
|
81
|
+
if (path === undefined)
|
|
82
|
+
return { policy: defaultPolicy(), digest: null };
|
|
83
|
+
const policy = parsePolicy(readJson(path), path);
|
|
84
|
+
return { policy, digest: digestOf(policy) };
|
|
85
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type ExecPermission } from "../exec/process.js";
|
|
2
|
+
import type { EvidenceLog } from "./evidence-log.js";
|
|
3
|
+
import { type AssurancePolicy, type CommandExecution, type CommandSpec, type EnvironmentRecord, type VerificationCheckResult, type VerificationRun } from "./types.js";
|
|
4
|
+
/** Scripts a repository declares that could serve as checks (suggestions only). */
|
|
5
|
+
export declare function detectCheckSuggestions(repo: string): {
|
|
6
|
+
id: string;
|
|
7
|
+
argv: string[];
|
|
8
|
+
purpose: CommandSpec["purpose"];
|
|
9
|
+
}[];
|
|
10
|
+
/** Runs commands, records each execution in the evidence log, returns executions and checks. */
|
|
11
|
+
export declare function runCommands(specs: CommandSpec[], role: VerificationCheckResult["role"], ctx: {
|
|
12
|
+
repo: string;
|
|
13
|
+
permission: ExecPermission;
|
|
14
|
+
policy: AssurancePolicy;
|
|
15
|
+
env: EnvironmentRecord;
|
|
16
|
+
sha: string | null;
|
|
17
|
+
log: EvidenceLog;
|
|
18
|
+
now: () => Date;
|
|
19
|
+
}): {
|
|
20
|
+
executions: CommandExecution[];
|
|
21
|
+
checks: VerificationCheckResult[];
|
|
22
|
+
};
|
|
23
|
+
/** Overall result from checks: any FAIL → FAIL; anything not run → INCONCLUSIVE; else PASS. */
|
|
24
|
+
export declare function overall(checks: VerificationCheckResult[]): VerificationRun["result"];
|
|
25
|
+
/** VC-P1: executes the policy's required checks at the current commit. */
|
|
26
|
+
export declare function authorProof(input: {
|
|
27
|
+
repo: string;
|
|
28
|
+
policy: AssurancePolicy;
|
|
29
|
+
permission: ExecPermission;
|
|
30
|
+
env: EnvironmentRecord;
|
|
31
|
+
sha: string | null;
|
|
32
|
+
clean: boolean | null;
|
|
33
|
+
log: EvidenceLog;
|
|
34
|
+
now: () => Date;
|
|
35
|
+
}): VerificationRun;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 EnWithAI Technologies
|
|
3
|
+
/**
|
|
4
|
+
* Author-side proof (VC-P1) and the shared command runner for closure verification (VC-P5).
|
|
5
|
+
*
|
|
6
|
+
* Commands come only from the operator's policy (required_checks), from a fix record (reproduction commands the
|
|
7
|
+
* implementer declared) or from the regression corpus. Product Check never invents a command: detected scripts are
|
|
8
|
+
* printed as suggestions to put in the policy, never run. Nothing runs without --allow-exec on that invocation;
|
|
9
|
+
* without it every command is recorded as NOT_TESTED with the reason, and the run is INCONCLUSIVE.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { execCommand } from "../exec/process.js";
|
|
14
|
+
import { stableId } from "../util/hash.js";
|
|
15
|
+
import { ASSURANCE_SCHEMA_VERSION, } from "./types.js";
|
|
16
|
+
/** Scripts a repository declares that could serve as checks (suggestions only). */
|
|
17
|
+
export function detectCheckSuggestions(repo) {
|
|
18
|
+
const out = [];
|
|
19
|
+
const pkg = join(repo, "package.json");
|
|
20
|
+
if (existsSync(pkg)) {
|
|
21
|
+
try {
|
|
22
|
+
const scripts = JSON.parse(readFileSync(pkg, "utf8")).scripts ?? {};
|
|
23
|
+
const map = [
|
|
24
|
+
["typecheck", "typecheck"],
|
|
25
|
+
["lint", "lint"],
|
|
26
|
+
["test", "test"],
|
|
27
|
+
["build", "build"],
|
|
28
|
+
];
|
|
29
|
+
for (const [name, purpose] of map)
|
|
30
|
+
if (typeof scripts[name] === "string")
|
|
31
|
+
out.push({ id: name, argv: ["npm", "run", name], purpose });
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* malformed manifest: no suggestions */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (existsSync(join(repo, "pyproject.toml")) || existsSync(join(repo, "pytest.ini")))
|
|
38
|
+
out.push({ id: "pytest", argv: ["python", "-m", "pytest", "-q"], purpose: "test" });
|
|
39
|
+
if (existsSync(join(repo, "go.mod")))
|
|
40
|
+
out.push({ id: "go-test", argv: ["go", "test", "./..."], purpose: "test" });
|
|
41
|
+
if (existsSync(join(repo, "Cargo.toml")))
|
|
42
|
+
out.push({ id: "cargo-test", argv: ["cargo", "test"], purpose: "test" });
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/** Runs commands, records each execution in the evidence log, returns executions and checks. */
|
|
46
|
+
export function runCommands(specs, role, ctx) {
|
|
47
|
+
const executions = [];
|
|
48
|
+
const checks = [];
|
|
49
|
+
for (const spec of specs) {
|
|
50
|
+
const ex = execCommand({
|
|
51
|
+
spec,
|
|
52
|
+
repo: ctx.repo,
|
|
53
|
+
permission: ctx.permission,
|
|
54
|
+
envAllowlist: ctx.policy.exec.env_allowlist,
|
|
55
|
+
maxOutputBytes: ctx.policy.exec.max_output_bytes,
|
|
56
|
+
environmentId: ctx.env.environment_id,
|
|
57
|
+
commitSha: ctx.sha,
|
|
58
|
+
now: ctx.now,
|
|
59
|
+
});
|
|
60
|
+
ex.evidence_id = ctx.log.append({
|
|
61
|
+
type: "command-execution",
|
|
62
|
+
subject: spec.id,
|
|
63
|
+
commit_sha: ctx.sha,
|
|
64
|
+
environment_id: ctx.env.environment_id,
|
|
65
|
+
summary: `${spec.argv.join(" ")} → ${ex.status}${ex.exit_code !== null ? ` (exit ${ex.exit_code})` : ""}${ex.reason ? `: ${ex.reason}` : ""}`,
|
|
66
|
+
locator: spec.cwd,
|
|
67
|
+
line: null,
|
|
68
|
+
payload: {
|
|
69
|
+
execution_id: ex.execution_id,
|
|
70
|
+
argv: ex.argv,
|
|
71
|
+
exit_code: ex.exit_code,
|
|
72
|
+
duration_ms: ex.duration_ms,
|
|
73
|
+
stdout_digest: ex.stdout_digest,
|
|
74
|
+
stderr_digest: ex.stderr_digest,
|
|
75
|
+
excerpt: ex.excerpt,
|
|
76
|
+
status: ex.status,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
executions.push(ex);
|
|
80
|
+
checks.push({
|
|
81
|
+
check_id: `${role}:${spec.id}`,
|
|
82
|
+
kind: "command",
|
|
83
|
+
role,
|
|
84
|
+
status: ex.status,
|
|
85
|
+
detail: ex.status === "PASS"
|
|
86
|
+
? `${spec.argv.join(" ")} exited 0 in ${ex.duration_ms} ms.`
|
|
87
|
+
: (ex.reason ?? `${spec.argv.join(" ")} exited ${ex.exit_code ?? "?"}.`),
|
|
88
|
+
execution_id: ex.execution_id,
|
|
89
|
+
evidence_ids: [ex.evidence_id],
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return { executions, checks };
|
|
93
|
+
}
|
|
94
|
+
/** Overall result from checks: any FAIL → FAIL; anything not run → INCONCLUSIVE; else PASS. */
|
|
95
|
+
export function overall(checks) {
|
|
96
|
+
if (checks.some((c) => c.status === "FAIL" || c.status === "PARTIAL"))
|
|
97
|
+
return "FAIL";
|
|
98
|
+
if (checks.length === 0 || checks.some((c) => c.status !== "PASS" && c.status !== "NOT_APPLICABLE"))
|
|
99
|
+
return "INCONCLUSIVE";
|
|
100
|
+
return "PASS";
|
|
101
|
+
}
|
|
102
|
+
/** VC-P1: executes the policy's required checks at the current commit. */
|
|
103
|
+
export function authorProof(input) {
|
|
104
|
+
const started = input.now().toISOString();
|
|
105
|
+
const { executions, checks } = runCommands(input.policy.required_checks, "regression", { ...input });
|
|
106
|
+
const reasons = [];
|
|
107
|
+
const limitations = [];
|
|
108
|
+
if (input.policy.required_checks.length === 0)
|
|
109
|
+
reasons.push("The policy declares no required checks; nothing can be claimed as green.");
|
|
110
|
+
if (!input.permission.allowed)
|
|
111
|
+
reasons.push(input.permission.reason);
|
|
112
|
+
if (input.clean === false)
|
|
113
|
+
limitations.push("The working tree has uncommitted changes; results describe the worktree, not exactly the commit.");
|
|
114
|
+
let result = overall(checks);
|
|
115
|
+
if (input.clean === false && result === "PASS") {
|
|
116
|
+
result = "INCONCLUSIVE";
|
|
117
|
+
reasons.push("Checks passed on a dirty worktree; commit and rerun to bind the proof to an exact commit.");
|
|
118
|
+
}
|
|
119
|
+
const failed = checks.filter((c) => c.status === "FAIL");
|
|
120
|
+
if (failed.length > 0)
|
|
121
|
+
reasons.push(`Failed: ${failed.map((c) => c.check_id).join(", ")}.`);
|
|
122
|
+
if (result === "PASS")
|
|
123
|
+
reasons.push(`All ${checks.length} required check(s) passed at ${input.sha?.slice(0, 12) ?? "?"}.`);
|
|
124
|
+
return {
|
|
125
|
+
schema_version: ASSURANCE_SCHEMA_VERSION,
|
|
126
|
+
kind: "verification-run",
|
|
127
|
+
verification_id: stableId("PR", [input.sha, started, ...executions.map((e) => e.execution_id)]),
|
|
128
|
+
purpose: "author-proof",
|
|
129
|
+
finding_ids: [],
|
|
130
|
+
fix_id: null,
|
|
131
|
+
exact_sha: input.sha,
|
|
132
|
+
worktree_clean: input.clean,
|
|
133
|
+
environment: input.env,
|
|
134
|
+
started_at: started,
|
|
135
|
+
finished_at: input.now().toISOString(),
|
|
136
|
+
checks,
|
|
137
|
+
executions,
|
|
138
|
+
before_evidence_ids: [],
|
|
139
|
+
after_evidence_ids: executions.map((e) => e.evidence_id).filter((x) => x !== null),
|
|
140
|
+
after_results: null,
|
|
141
|
+
result,
|
|
142
|
+
reasons,
|
|
143
|
+
limitations,
|
|
144
|
+
actor: { id: "product-check", type: "system" },
|
|
145
|
+
};
|
|
146
|
+
}
|