@anhcodo13/lattice 0.3.0 → 0.5.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/README.md +23 -29
- package/dist/cli.js +1278 -93
- package/dist/index.d.ts +177 -0
- package/dist/index.js +767 -193
- package/governance/policy.json +52 -9
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -101,6 +101,7 @@ function parseAnalysisConfig(value) {
|
|
|
101
101
|
// src/governance/policy.ts
|
|
102
102
|
var exactVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
103
103
|
var minimumVersion = /^>=(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
104
|
+
var measuredVersion = /^(0|[1-9]\d*)(?:\.(0|[1-9]\d*)){0,3}$/;
|
|
104
105
|
function record2(value) {
|
|
105
106
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
106
107
|
}
|
|
@@ -139,12 +140,106 @@ function parseAssessmentPolicy(value) {
|
|
|
139
140
|
for (const id of architecture.blockedRuleIds) {
|
|
140
141
|
if (!known.has(id)) throw new Error(`Unknown blocked rule ID ${id}`);
|
|
141
142
|
}
|
|
143
|
+
let evidence;
|
|
144
|
+
if (value.evidence !== void 0) {
|
|
145
|
+
if (!record2(value.evidence)) throw new Error("assessment.evidence must be an object");
|
|
146
|
+
const gates = value.evidence;
|
|
147
|
+
const nonnegative2 = (source, key, label) => {
|
|
148
|
+
const result = source[key];
|
|
149
|
+
if (!Number.isSafeInteger(result) || result < 0) throw new Error(`${label}.${key} must be a nonnegative integer`);
|
|
150
|
+
return result;
|
|
151
|
+
};
|
|
152
|
+
if (!Number.isFinite(gates.maximumAgeHours) || gates.maximumAgeHours <= 0 || gates.maximumAgeHours > 168) {
|
|
153
|
+
throw new Error("assessment.evidence.maximumAgeHours must be greater than 0 and at most 168");
|
|
154
|
+
}
|
|
155
|
+
for (const domain of ["dependencySecurity", "codeQuality", "codeSecurity", "compatibility"]) {
|
|
156
|
+
if (!record2(gates[domain])) throw new Error(`assessment.evidence.${domain} is required`);
|
|
157
|
+
}
|
|
158
|
+
const dependencySecurity = gates.dependencySecurity;
|
|
159
|
+
const codeQuality = gates.codeQuality;
|
|
160
|
+
const codeSecurity = gates.codeSecurity;
|
|
161
|
+
const compatibility = gates.compatibility;
|
|
162
|
+
const minimumLineCoverage = codeQuality.minimumLineCoverage;
|
|
163
|
+
if (typeof minimumLineCoverage !== "number" || !Number.isFinite(minimumLineCoverage) || minimumLineCoverage < 0 || minimumLineCoverage > 100) {
|
|
164
|
+
throw new Error("assessment.evidence.codeQuality.minimumLineCoverage must be 0\u2013100");
|
|
165
|
+
}
|
|
166
|
+
if (typeof codeQuality.requireTypecheck !== "boolean" || typeof codeQuality.requireTests !== "boolean") {
|
|
167
|
+
throw new Error("assessment.evidence.codeQuality requires boolean requireTypecheck and requireTests");
|
|
168
|
+
}
|
|
169
|
+
const projects = compatibility.requiredProjects;
|
|
170
|
+
if (!Array.isArray(projects) || projects.length === 0 || !projects.every((item) => typeof item === "string" && item.length > 0) || new Set(projects).size !== projects.length) {
|
|
171
|
+
throw new Error("assessment.evidence.compatibility.requiredProjects must be a unique nonempty string array");
|
|
172
|
+
}
|
|
173
|
+
const versionFloors = (key) => {
|
|
174
|
+
const raw = compatibility[key];
|
|
175
|
+
if (raw === void 0) return void 0;
|
|
176
|
+
if (!record2(raw)) throw new Error(`assessment.evidence.compatibility.${key} must be an object`);
|
|
177
|
+
for (const [project, version] of Object.entries(raw)) {
|
|
178
|
+
if (!projects.includes(project)) throw new Error(`${key} references unknown required project ${project}`);
|
|
179
|
+
if (key === "minimumEmulatedOsVersions" && project !== "android-chrome" && project !== "iphone-safari") {
|
|
180
|
+
throw new Error(`${key} requires an Android Chrome or iPhone Safari project`);
|
|
181
|
+
}
|
|
182
|
+
if (typeof version !== "string" || !measuredVersion.test(version) || !version.split(".").map(Number).every(Number.isSafeInteger)) {
|
|
183
|
+
throw new Error(`${key}.${project} must be a numeric version with one to four components`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return raw;
|
|
187
|
+
};
|
|
188
|
+
const minimumTestedBrowserVersions = versionFloors("minimumTestedBrowserVersions");
|
|
189
|
+
const minimumEmulatedOsVersions = versionFloors("minimumEmulatedOsVersions");
|
|
190
|
+
let accessibility;
|
|
191
|
+
if (gates.accessibility !== void 0) {
|
|
192
|
+
if (!record2(gates.accessibility)) throw new Error("assessment.evidence.accessibility must be an object");
|
|
193
|
+
const source = gates.accessibility;
|
|
194
|
+
const requiredProjects = source.requiredProjects;
|
|
195
|
+
if (!Array.isArray(requiredProjects) || requiredProjects.length === 0 || !requiredProjects.every((project) => typeof project === "string" && projects.includes(project)) || new Set(requiredProjects).size !== requiredProjects.length) {
|
|
196
|
+
throw new Error("assessment.evidence.accessibility projects must be unique members of compatibility.requiredProjects");
|
|
197
|
+
}
|
|
198
|
+
const requiredTags = source.requiredTags;
|
|
199
|
+
if (!Array.isArray(requiredTags) || requiredTags.length === 0 || !requiredTags.every((tag) => typeof tag === "string" && /^wcag(?:2|21|22)(?:a|aa)$/.test(tag)) || new Set(requiredTags).size !== requiredTags.length) {
|
|
200
|
+
throw new Error("assessment.evidence.accessibility.requiredTags must list unique supported WCAG A/AA tags");
|
|
201
|
+
}
|
|
202
|
+
if (typeof source.minimumAxeVersion !== "string" || !minimumVersion.test(`>=${source.minimumAxeVersion}`) || valid(source.minimumAxeVersion) === null) {
|
|
203
|
+
throw new Error("assessment.evidence.accessibility.minimumAxeVersion must be an exact stable version");
|
|
204
|
+
}
|
|
205
|
+
accessibility = {
|
|
206
|
+
requiredProjects,
|
|
207
|
+
requiredTags,
|
|
208
|
+
minimumScansPerProject: nonnegative2(source, "minimumScansPerProject", "assessment.evidence.accessibility"),
|
|
209
|
+
minimumChecksPerScan: nonnegative2(source, "minimumChecksPerScan", "assessment.evidence.accessibility"),
|
|
210
|
+
minimumAxeVersion: source.minimumAxeVersion,
|
|
211
|
+
maximumViolations: nonnegative2(source, "maximumViolations", "assessment.evidence.accessibility"),
|
|
212
|
+
maximumNeedsReview: nonnegative2(source, "maximumNeedsReview", "assessment.evidence.accessibility")
|
|
213
|
+
};
|
|
214
|
+
if (accessibility.minimumScansPerProject === 0 || accessibility.minimumChecksPerScan === 0) {
|
|
215
|
+
throw new Error("assessment.evidence.accessibility minimum scans and checks must be positive");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
evidence = {
|
|
219
|
+
maximumAgeHours: gates.maximumAgeHours,
|
|
220
|
+
dependencySecurity: { maximumHigh: nonnegative2(dependencySecurity, "maximumHigh", "assessment.evidence.dependencySecurity"), maximumCritical: nonnegative2(dependencySecurity, "maximumCritical", "assessment.evidence.dependencySecurity") },
|
|
221
|
+
codeQuality: {
|
|
222
|
+
maximumLintErrors: nonnegative2(codeQuality, "maximumLintErrors", "assessment.evidence.codeQuality"),
|
|
223
|
+
minimumLineCoverage,
|
|
224
|
+
requireTypecheck: codeQuality.requireTypecheck,
|
|
225
|
+
requireTests: codeQuality.requireTests
|
|
226
|
+
},
|
|
227
|
+
codeSecurity: { maximumHigh: nonnegative2(codeSecurity, "maximumHigh", "assessment.evidence.codeSecurity"), maximumCritical: nonnegative2(codeSecurity, "maximumCritical", "assessment.evidence.codeSecurity"), maximumSecrets: nonnegative2(codeSecurity, "maximumSecrets", "assessment.evidence.codeSecurity") },
|
|
228
|
+
compatibility: {
|
|
229
|
+
requiredProjects: projects,
|
|
230
|
+
minimumTestsPerProject: nonnegative2(compatibility, "minimumTestsPerProject", "assessment.evidence.compatibility"),
|
|
231
|
+
...minimumTestedBrowserVersions ? { minimumTestedBrowserVersions } : {},
|
|
232
|
+
...minimumEmulatedOsVersions ? { minimumEmulatedOsVersions } : {}
|
|
233
|
+
},
|
|
234
|
+
...accessibility ? { accessibility } : {}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
142
237
|
return { architecture: {
|
|
143
238
|
minimumScore: architecture.minimumScore,
|
|
144
239
|
maximumErrors: architecture.maximumErrors,
|
|
145
240
|
blockedRuleIds: architecture.blockedRuleIds,
|
|
146
241
|
analysis
|
|
147
|
-
} };
|
|
242
|
+
}, ...evidence ? { evidence } : {} };
|
|
148
243
|
}
|
|
149
244
|
function parsePolicy(raw, source) {
|
|
150
245
|
let parsed;
|
|
@@ -176,10 +271,25 @@ function parsePolicy(raw, source) {
|
|
|
176
271
|
}
|
|
177
272
|
profiles3[name] = versions;
|
|
178
273
|
}
|
|
274
|
+
let managedPackages;
|
|
275
|
+
if (parsed.managedPackages !== void 0) {
|
|
276
|
+
if (!record2(parsed.managedPackages)) throw new Error("Policy managedPackages must be an object");
|
|
277
|
+
managedPackages = {};
|
|
278
|
+
for (const [packageName, version] of Object.entries(parsed.managedPackages)) {
|
|
279
|
+
if (typeof version !== "string" || !minimumVersion.test(version) || valid(version.slice(2)) === null) {
|
|
280
|
+
throw new Error(`Policy minimum version (>=X.Y.Z) required for managed package ${packageName}`);
|
|
281
|
+
}
|
|
282
|
+
if (Object.values(profiles3).some((profile) => packageName in profile)) {
|
|
283
|
+
throw new Error(`Package ${packageName} appears in both profiles and managedPackages`);
|
|
284
|
+
}
|
|
285
|
+
managedPackages[packageName] = version;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
179
288
|
return {
|
|
180
289
|
schemaVersion: 2,
|
|
181
290
|
policyVersion: parsed.policyVersion,
|
|
182
291
|
profiles: profiles3,
|
|
292
|
+
...managedPackages ? { managedPackages } : {},
|
|
183
293
|
...parsed.assessment === void 0 ? {} : { assessment: parseAssessmentPolicy(parsed.assessment) },
|
|
184
294
|
source,
|
|
185
295
|
sha256: createHash("sha256").update(raw).digest("hex")
|
|
@@ -203,9 +313,145 @@ async function loadPolicy(source) {
|
|
|
203
313
|
}
|
|
204
314
|
|
|
205
315
|
// src/governance/check.ts
|
|
206
|
-
import {
|
|
316
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
317
|
+
import { resolve as resolve3, relative as relative3, sep as sep2, join as join4, dirname as dirname2 } from "path";
|
|
318
|
+
import fastGlob2 from "fast-glob";
|
|
207
319
|
import { satisfies, subset, valid as valid2, validRange } from "semver";
|
|
208
320
|
|
|
321
|
+
// src/analysis/parse.ts
|
|
322
|
+
import ts from "typescript";
|
|
323
|
+
function hasModifier(node, kind) {
|
|
324
|
+
return ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) === true;
|
|
325
|
+
}
|
|
326
|
+
function containsJsx(node) {
|
|
327
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) return true;
|
|
328
|
+
return ts.forEachChild(node, containsJsx) === true;
|
|
329
|
+
}
|
|
330
|
+
function importNames(clause) {
|
|
331
|
+
if (!clause) return [];
|
|
332
|
+
const names = [];
|
|
333
|
+
if (clause.name) names.push("default");
|
|
334
|
+
if (clause.namedBindings) {
|
|
335
|
+
if (ts.isNamespaceImport(clause.namedBindings)) names.push("*");
|
|
336
|
+
else for (const item of clause.namedBindings.elements) names.push(item.propertyName?.text ?? item.name.text);
|
|
337
|
+
}
|
|
338
|
+
return names;
|
|
339
|
+
}
|
|
340
|
+
function sourceKind(path) {
|
|
341
|
+
if (path.endsWith(".tsx")) return ts.ScriptKind.TSX;
|
|
342
|
+
if (path.endsWith(".jsx")) return ts.ScriptKind.JSX;
|
|
343
|
+
if (path.endsWith(".js") || path.endsWith(".mjs") || path.endsWith(".cjs")) return ts.ScriptKind.JS;
|
|
344
|
+
return ts.ScriptKind.TS;
|
|
345
|
+
}
|
|
346
|
+
function parseModule(path, contents) {
|
|
347
|
+
const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
|
|
348
|
+
const imports = [];
|
|
349
|
+
const exports = [];
|
|
350
|
+
const typeExports = [];
|
|
351
|
+
let jsxDepth = 0;
|
|
352
|
+
let complexity = 1;
|
|
353
|
+
let componentCount = 0;
|
|
354
|
+
for (const node of source.statements) {
|
|
355
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
356
|
+
imports.push({
|
|
357
|
+
specifier: node.moduleSpecifier.text,
|
|
358
|
+
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
359
|
+
importedNames: importNames(node.importClause),
|
|
360
|
+
isTypeOnly: node.importClause?.isTypeOnly ?? false
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
if (ts.isExportDeclaration(node)) {
|
|
364
|
+
if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
365
|
+
imports.push({
|
|
366
|
+
specifier: node.moduleSpecifier.text,
|
|
367
|
+
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
368
|
+
importedNames: node.exportClause && ts.isNamedExports(node.exportClause) ? node.exportClause.elements.map((item) => item.propertyName?.text ?? item.name.text) : ["*"],
|
|
369
|
+
isTypeOnly: node.isTypeOnly
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
if (node.exportClause && ts.isNamedExports(node.exportClause)) {
|
|
373
|
+
exports.push(...node.exportClause.elements.map((item) => item.name.text));
|
|
374
|
+
if (node.isTypeOnly) typeExports.push(...node.exportClause.elements.map((item) => item.name.text));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (hasModifier(node, ts.SyntaxKind.ExportKeyword)) {
|
|
378
|
+
if (hasModifier(node, ts.SyntaxKind.DefaultKeyword)) exports.push("default");
|
|
379
|
+
else if (ts.isVariableStatement(node)) {
|
|
380
|
+
for (const declaration of node.declarationList.declarations) {
|
|
381
|
+
if (ts.isIdentifier(declaration.name)) exports.push(declaration.name.text);
|
|
382
|
+
}
|
|
383
|
+
} else if ("name" in node && node.name && ts.isIdentifier(node.name)) {
|
|
384
|
+
const name = node.name.text;
|
|
385
|
+
exports.push(name);
|
|
386
|
+
if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) typeExports.push(name);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (ts.isFunctionDeclaration(node) && node.name && /^[A-Z]/.test(node.name.text) && containsJsx(node)) {
|
|
390
|
+
componentCount++;
|
|
391
|
+
}
|
|
392
|
+
if (ts.isVariableStatement(node)) {
|
|
393
|
+
for (const declaration of node.declarationList.declarations) {
|
|
394
|
+
if (ts.isIdentifier(declaration.name) && /^[A-Z]/.test(declaration.name.text) && declaration.initializer && containsJsx(declaration.initializer)) {
|
|
395
|
+
componentCount++;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function visit(node, depth) {
|
|
401
|
+
if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
|
|
402
|
+
const argument = node.arguments[0];
|
|
403
|
+
if (argument && ts.isStringLiteralLike(argument)) {
|
|
404
|
+
imports.push({
|
|
405
|
+
specifier: argument.text,
|
|
406
|
+
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
407
|
+
importedNames: ["*"],
|
|
408
|
+
isTypeOnly: false
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const isJsx = ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node);
|
|
413
|
+
const nextDepth = depth + (isJsx ? 1 : 0);
|
|
414
|
+
jsxDepth = Math.max(jsxDepth, nextDepth);
|
|
415
|
+
if (ts.isIfStatement(node) || ts.isForStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node) || ts.isWhileStatement(node) || ts.isDoStatement(node) || ts.isCatchClause(node) || ts.isConditionalExpression(node) || ts.isCaseBlock(node)) complexity++;
|
|
416
|
+
if (ts.isBinaryExpression(node) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(node.operatorToken.kind)) {
|
|
417
|
+
complexity++;
|
|
418
|
+
}
|
|
419
|
+
ts.forEachChild(node, (child) => visit(child, nextDepth));
|
|
420
|
+
}
|
|
421
|
+
visit(source, 0);
|
|
422
|
+
return {
|
|
423
|
+
path,
|
|
424
|
+
imports,
|
|
425
|
+
exports: [...new Set(exports)].sort(),
|
|
426
|
+
typeExports: [...new Set(typeExports)].sort(),
|
|
427
|
+
lines: contents.split(/\r?\n/).length,
|
|
428
|
+
jsxDepth,
|
|
429
|
+
componentCount,
|
|
430
|
+
complexity,
|
|
431
|
+
isRoute: /(^|\/)(app|pages)\/.+(page|layout|route|index)\.[cm]?[jt]sx?$/.test(path) || /(^|\/)app\/(page|layout|route)\.[cm]?[jt]sx?$/.test(path)
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
function moduleParseDiagnostics(path, contents) {
|
|
435
|
+
const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
|
|
436
|
+
const diagnostics = source.parseDiagnostics ?? [];
|
|
437
|
+
const messages = diagnostics.map((diagnostic) => {
|
|
438
|
+
const line = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0).line + 1;
|
|
439
|
+
return `${path}:${line}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, " ")}`;
|
|
440
|
+
});
|
|
441
|
+
function visit(node) {
|
|
442
|
+
if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
|
|
443
|
+
const argument = node.arguments[0];
|
|
444
|
+
if (!argument || !ts.isStringLiteralLike(argument)) {
|
|
445
|
+
const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
|
|
446
|
+
messages.push(`${path}:${line}: nonliteral dynamic import or require cannot be analyzed`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
ts.forEachChild(node, visit);
|
|
450
|
+
}
|
|
451
|
+
visit(source);
|
|
452
|
+
return messages;
|
|
453
|
+
}
|
|
454
|
+
|
|
209
455
|
// src/governance/discover.ts
|
|
210
456
|
import { readFile as readFile3 } from "fs/promises";
|
|
211
457
|
import { join as join2, relative } from "path";
|
|
@@ -387,11 +633,36 @@ async function readResolution(app, workspaceRoot, name) {
|
|
|
387
633
|
}
|
|
388
634
|
|
|
389
635
|
// src/governance/check.ts
|
|
636
|
+
var scanIgnore = ["**/node_modules/**", "**/.git/**", "**/.next/**", "**/dist/**", "**/build/**", "**/out/**", "**/coverage/**", "**/.turbo/**", "**/.vercel/**", "**/.astro/**", "**/.nklattice-report/**", "**/.nklattice-governance/**", "**/*.d.ts"];
|
|
637
|
+
async function importedManagedPackages(app, managed) {
|
|
638
|
+
if (Object.keys(managed).length === 0) return /* @__PURE__ */ new Set();
|
|
639
|
+
const nestedManifests = await fastGlob2("**/package.json", { cwd: app.directory, onlyFiles: true, dot: true, followSymbolicLinks: false, ignore: scanIgnore });
|
|
640
|
+
const nestedIgnore = nestedManifests.filter((path) => path !== "package.json").map((path) => `${dirname2(path)}/**`);
|
|
641
|
+
const files = await fastGlob2("**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", {
|
|
642
|
+
cwd: app.directory,
|
|
643
|
+
onlyFiles: true,
|
|
644
|
+
dot: true,
|
|
645
|
+
followSymbolicLinks: false,
|
|
646
|
+
ignore: [...scanIgnore, ...nestedIgnore]
|
|
647
|
+
});
|
|
648
|
+
const found = /* @__PURE__ */ new Set();
|
|
649
|
+
await Promise.all(files.map(async (path) => {
|
|
650
|
+
const module = parseModule(path, await readFile5(join4(app.directory, path), "utf8"));
|
|
651
|
+
for (const imported of module.imports) {
|
|
652
|
+
for (const name of Object.keys(managed)) {
|
|
653
|
+
if (imported.specifier === name || imported.specifier.startsWith(`${name}/`)) found.add(name);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}));
|
|
657
|
+
return found;
|
|
658
|
+
}
|
|
390
659
|
async function checkApp(app, workspaceRoot, policy) {
|
|
391
660
|
const requirements = policy.profiles[app.profile];
|
|
392
661
|
if (!requirements) throw new Error(`Policy does not define profile ${app.profile}`);
|
|
393
|
-
const
|
|
394
|
-
|
|
662
|
+
const findings2 = [];
|
|
663
|
+
const imported = await importedManagedPackages(app, policy.managedPackages ?? {});
|
|
664
|
+
const managed = Object.fromEntries(Object.entries(policy.managedPackages ?? {}).filter(([packageName]) => app.dependencies[packageName] !== void 0 || imported.has(packageName)));
|
|
665
|
+
for (const [packageName, expected] of Object.entries({ ...requirements, ...managed })) {
|
|
395
666
|
const declared = app.dependencies[packageName];
|
|
396
667
|
const resolution = declared === void 0 ? { specifier: void 0, version: void 0 } : await readResolution(app, workspaceRoot, packageName);
|
|
397
668
|
const reasons = [];
|
|
@@ -408,7 +679,7 @@ async function checkApp(app, workspaceRoot, policy) {
|
|
|
408
679
|
reasons.push("lockfile resolution outside manifest range");
|
|
409
680
|
}
|
|
410
681
|
}
|
|
411
|
-
if (reasons.length)
|
|
682
|
+
if (reasons.length) findings2.push({
|
|
412
683
|
app: app.relativePath,
|
|
413
684
|
packageName,
|
|
414
685
|
expected,
|
|
@@ -418,7 +689,7 @@ async function checkApp(app, workspaceRoot, policy) {
|
|
|
418
689
|
reason: reasons.join("; ")
|
|
419
690
|
});
|
|
420
691
|
}
|
|
421
|
-
return
|
|
692
|
+
return findings2;
|
|
422
693
|
}
|
|
423
694
|
async function checkGovernance(options) {
|
|
424
695
|
const scanRoot = resolve3(options.scanRoot);
|
|
@@ -429,10 +700,10 @@ async function checkGovernance(options) {
|
|
|
429
700
|
}
|
|
430
701
|
const apps = await discoverApps(scanRoot, workspaceRoot);
|
|
431
702
|
if (apps.length === 0) throw new Error("No managed applications found");
|
|
432
|
-
const
|
|
703
|
+
const findings2 = (await Promise.all(apps.map((app) => checkApp(app, workspaceRoot, options.policy)))).flat();
|
|
433
704
|
return {
|
|
434
705
|
appsChecked: apps.length,
|
|
435
|
-
findings,
|
|
706
|
+
findings: findings2,
|
|
436
707
|
policyVersion: options.policy.policyVersion,
|
|
437
708
|
policySource: options.policy.source,
|
|
438
709
|
policySha256: options.policy.sha256
|
|
@@ -441,7 +712,7 @@ async function checkGovernance(options) {
|
|
|
441
712
|
|
|
442
713
|
// src/governance/assess.ts
|
|
443
714
|
import { mkdir, writeFile } from "fs/promises";
|
|
444
|
-
import { join as
|
|
715
|
+
import { basename, join as join9, relative as relative7, resolve as resolve8, sep as sep6 } from "path";
|
|
445
716
|
|
|
446
717
|
// src/analysis/graph.ts
|
|
447
718
|
function buildGraph(_modules) {
|
|
@@ -494,12 +765,12 @@ function findCycles(graph) {
|
|
|
494
765
|
// src/analysis/rules.ts
|
|
495
766
|
import { matchesGlob } from "path";
|
|
496
767
|
function evaluateRules(graph, config) {
|
|
497
|
-
const
|
|
768
|
+
const findings2 = [];
|
|
498
769
|
const outgoing = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.from === module.path && !edge.isTypeOnly)]));
|
|
499
770
|
const incoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path && !edge.isTypeOnly)]));
|
|
500
771
|
const allIncoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path)]));
|
|
501
772
|
for (const cycle of findCycles(graph)) {
|
|
502
|
-
|
|
773
|
+
findings2.push({
|
|
503
774
|
ruleId: "module-cycle",
|
|
504
775
|
category: "architecture",
|
|
505
776
|
severity: "error",
|
|
@@ -516,7 +787,7 @@ function evaluateRules(graph, config) {
|
|
|
516
787
|
for (const imported of module.imports) {
|
|
517
788
|
if (imported.isTypeOnly) continue;
|
|
518
789
|
if (seen.has(imported.specifier)) {
|
|
519
|
-
|
|
790
|
+
findings2.push({
|
|
520
791
|
ruleId: "duplicate-import",
|
|
521
792
|
category: "imports",
|
|
522
793
|
severity: "warning",
|
|
@@ -529,7 +800,7 @@ function evaluateRules(graph, config) {
|
|
|
529
800
|
seen.add(imported.specifier);
|
|
530
801
|
}
|
|
531
802
|
if (out.length > config.thresholds.maxFanOut) {
|
|
532
|
-
|
|
803
|
+
findings2.push({
|
|
533
804
|
ruleId: "high-fan-out",
|
|
534
805
|
category: "architecture",
|
|
535
806
|
severity: "warning",
|
|
@@ -540,7 +811,7 @@ function evaluateRules(graph, config) {
|
|
|
540
811
|
});
|
|
541
812
|
}
|
|
542
813
|
if (into.length > config.thresholds.maxFanIn) {
|
|
543
|
-
|
|
814
|
+
findings2.push({
|
|
544
815
|
ruleId: "high-fan-in",
|
|
545
816
|
category: "architecture",
|
|
546
817
|
severity: "warning",
|
|
@@ -551,7 +822,7 @@ function evaluateRules(graph, config) {
|
|
|
551
822
|
});
|
|
552
823
|
}
|
|
553
824
|
if (into.length === 0 && out.length === 0 && graph.modules.length > 1 && !module.isRoute) {
|
|
554
|
-
|
|
825
|
+
findings2.push({
|
|
555
826
|
ruleId: "orphan-module",
|
|
556
827
|
category: "architecture",
|
|
557
828
|
severity: "info",
|
|
@@ -567,7 +838,7 @@ function evaluateRules(graph, config) {
|
|
|
567
838
|
for (const name of module.exports) {
|
|
568
839
|
if (name === "*" || module.typeExports.includes(name)) continue;
|
|
569
840
|
if (!usedNames.has(name)) {
|
|
570
|
-
|
|
841
|
+
findings2.push({
|
|
571
842
|
ruleId: "unused-export",
|
|
572
843
|
category: "imports",
|
|
573
844
|
severity: "info",
|
|
@@ -581,7 +852,7 @@ function evaluateRules(graph, config) {
|
|
|
581
852
|
}
|
|
582
853
|
}
|
|
583
854
|
if (module.componentCount > 0 && module.lines > config.thresholds.maxComponentLines) {
|
|
584
|
-
|
|
855
|
+
findings2.push({
|
|
585
856
|
ruleId: "large-component",
|
|
586
857
|
category: "components",
|
|
587
858
|
severity: "warning",
|
|
@@ -592,7 +863,7 @@ function evaluateRules(graph, config) {
|
|
|
592
863
|
});
|
|
593
864
|
}
|
|
594
865
|
if (module.jsxDepth > config.thresholds.maxJsxDepth) {
|
|
595
|
-
|
|
866
|
+
findings2.push({
|
|
596
867
|
ruleId: "deep-jsx",
|
|
597
868
|
category: "components",
|
|
598
869
|
severity: "warning",
|
|
@@ -603,7 +874,7 @@ function evaluateRules(graph, config) {
|
|
|
603
874
|
});
|
|
604
875
|
}
|
|
605
876
|
if (module.complexity > config.thresholds.maxComplexity) {
|
|
606
|
-
|
|
877
|
+
findings2.push({
|
|
607
878
|
ruleId: "high-complexity",
|
|
608
879
|
category: "complexity",
|
|
609
880
|
severity: "warning",
|
|
@@ -619,7 +890,7 @@ function evaluateRules(graph, config) {
|
|
|
619
890
|
const allowed = policy.allow?.some((pattern) => matchesGlob(edge.to, pattern));
|
|
620
891
|
const disallowed = policy.disallow?.some((pattern) => matchesGlob(edge.to, pattern));
|
|
621
892
|
if (policy.allow && !allowed || policy.disallow && disallowed) {
|
|
622
|
-
|
|
893
|
+
findings2.push({
|
|
623
894
|
ruleId: policy.id,
|
|
624
895
|
category: "architecture",
|
|
625
896
|
severity: policy.severity,
|
|
@@ -646,7 +917,7 @@ function evaluateRules(graph, config) {
|
|
|
646
917
|
return false;
|
|
647
918
|
};
|
|
648
919
|
if (visit(module.path, /* @__PURE__ */ new Set([module.path]), 0)) {
|
|
649
|
-
|
|
920
|
+
findings2.push({
|
|
650
921
|
ruleId: "deep-dependency-chain",
|
|
651
922
|
category: "architecture",
|
|
652
923
|
severity: "warning",
|
|
@@ -657,157 +928,23 @@ function evaluateRules(graph, config) {
|
|
|
657
928
|
});
|
|
658
929
|
}
|
|
659
930
|
}
|
|
660
|
-
return
|
|
931
|
+
return findings2.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.ruleId.localeCompare(b.ruleId));
|
|
661
932
|
}
|
|
662
933
|
|
|
663
934
|
// src/analysis/scan.ts
|
|
664
|
-
import { readFile as
|
|
665
|
-
import { join as
|
|
666
|
-
import
|
|
667
|
-
|
|
668
|
-
// src/analysis/parse.ts
|
|
669
|
-
import ts from "typescript";
|
|
670
|
-
function hasModifier(node, kind) {
|
|
671
|
-
return ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) === true;
|
|
672
|
-
}
|
|
673
|
-
function containsJsx(node) {
|
|
674
|
-
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) return true;
|
|
675
|
-
return ts.forEachChild(node, containsJsx) === true;
|
|
676
|
-
}
|
|
677
|
-
function importNames(clause) {
|
|
678
|
-
if (!clause) return [];
|
|
679
|
-
const names = [];
|
|
680
|
-
if (clause.name) names.push("default");
|
|
681
|
-
if (clause.namedBindings) {
|
|
682
|
-
if (ts.isNamespaceImport(clause.namedBindings)) names.push("*");
|
|
683
|
-
else for (const item of clause.namedBindings.elements) names.push(item.propertyName?.text ?? item.name.text);
|
|
684
|
-
}
|
|
685
|
-
return names;
|
|
686
|
-
}
|
|
687
|
-
function sourceKind(path) {
|
|
688
|
-
if (path.endsWith(".tsx")) return ts.ScriptKind.TSX;
|
|
689
|
-
if (path.endsWith(".jsx")) return ts.ScriptKind.JSX;
|
|
690
|
-
if (path.endsWith(".js") || path.endsWith(".mjs") || path.endsWith(".cjs")) return ts.ScriptKind.JS;
|
|
691
|
-
return ts.ScriptKind.TS;
|
|
692
|
-
}
|
|
693
|
-
function parseModule(path, contents) {
|
|
694
|
-
const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
|
|
695
|
-
const imports = [];
|
|
696
|
-
const exports = [];
|
|
697
|
-
const typeExports = [];
|
|
698
|
-
let jsxDepth = 0;
|
|
699
|
-
let complexity = 1;
|
|
700
|
-
let componentCount = 0;
|
|
701
|
-
for (const node of source.statements) {
|
|
702
|
-
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
703
|
-
imports.push({
|
|
704
|
-
specifier: node.moduleSpecifier.text,
|
|
705
|
-
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
706
|
-
importedNames: importNames(node.importClause),
|
|
707
|
-
isTypeOnly: node.importClause?.isTypeOnly ?? false
|
|
708
|
-
});
|
|
709
|
-
}
|
|
710
|
-
if (ts.isExportDeclaration(node)) {
|
|
711
|
-
if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
712
|
-
imports.push({
|
|
713
|
-
specifier: node.moduleSpecifier.text,
|
|
714
|
-
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
715
|
-
importedNames: node.exportClause && ts.isNamedExports(node.exportClause) ? node.exportClause.elements.map((item) => item.propertyName?.text ?? item.name.text) : ["*"],
|
|
716
|
-
isTypeOnly: node.isTypeOnly
|
|
717
|
-
});
|
|
718
|
-
}
|
|
719
|
-
if (node.exportClause && ts.isNamedExports(node.exportClause)) {
|
|
720
|
-
exports.push(...node.exportClause.elements.map((item) => item.name.text));
|
|
721
|
-
if (node.isTypeOnly) typeExports.push(...node.exportClause.elements.map((item) => item.name.text));
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
if (hasModifier(node, ts.SyntaxKind.ExportKeyword)) {
|
|
725
|
-
if (hasModifier(node, ts.SyntaxKind.DefaultKeyword)) exports.push("default");
|
|
726
|
-
else if (ts.isVariableStatement(node)) {
|
|
727
|
-
for (const declaration of node.declarationList.declarations) {
|
|
728
|
-
if (ts.isIdentifier(declaration.name)) exports.push(declaration.name.text);
|
|
729
|
-
}
|
|
730
|
-
} else if ("name" in node && node.name && ts.isIdentifier(node.name)) {
|
|
731
|
-
const name = node.name.text;
|
|
732
|
-
exports.push(name);
|
|
733
|
-
if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) typeExports.push(name);
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
if (ts.isFunctionDeclaration(node) && node.name && /^[A-Z]/.test(node.name.text) && containsJsx(node)) {
|
|
737
|
-
componentCount++;
|
|
738
|
-
}
|
|
739
|
-
if (ts.isVariableStatement(node)) {
|
|
740
|
-
for (const declaration of node.declarationList.declarations) {
|
|
741
|
-
if (ts.isIdentifier(declaration.name) && /^[A-Z]/.test(declaration.name.text) && declaration.initializer && containsJsx(declaration.initializer)) {
|
|
742
|
-
componentCount++;
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
function visit(node, depth) {
|
|
748
|
-
if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
|
|
749
|
-
const argument = node.arguments[0];
|
|
750
|
-
if (argument && ts.isStringLiteralLike(argument)) {
|
|
751
|
-
imports.push({
|
|
752
|
-
specifier: argument.text,
|
|
753
|
-
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
754
|
-
importedNames: ["*"],
|
|
755
|
-
isTypeOnly: false
|
|
756
|
-
});
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
const isJsx = ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node);
|
|
760
|
-
const nextDepth = depth + (isJsx ? 1 : 0);
|
|
761
|
-
jsxDepth = Math.max(jsxDepth, nextDepth);
|
|
762
|
-
if (ts.isIfStatement(node) || ts.isForStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node) || ts.isWhileStatement(node) || ts.isDoStatement(node) || ts.isCatchClause(node) || ts.isConditionalExpression(node) || ts.isCaseBlock(node)) complexity++;
|
|
763
|
-
if (ts.isBinaryExpression(node) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(node.operatorToken.kind)) {
|
|
764
|
-
complexity++;
|
|
765
|
-
}
|
|
766
|
-
ts.forEachChild(node, (child) => visit(child, nextDepth));
|
|
767
|
-
}
|
|
768
|
-
visit(source, 0);
|
|
769
|
-
return {
|
|
770
|
-
path,
|
|
771
|
-
imports,
|
|
772
|
-
exports: [...new Set(exports)].sort(),
|
|
773
|
-
typeExports: [...new Set(typeExports)].sort(),
|
|
774
|
-
lines: contents.split(/\r?\n/).length,
|
|
775
|
-
jsxDepth,
|
|
776
|
-
componentCount,
|
|
777
|
-
complexity,
|
|
778
|
-
isRoute: /(^|\/)(app|pages)\/.+(page|layout|route|index)\.[cm]?[jt]sx?$/.test(path) || /(^|\/)app\/(page|layout|route)\.[cm]?[jt]sx?$/.test(path)
|
|
779
|
-
};
|
|
780
|
-
}
|
|
781
|
-
function moduleParseDiagnostics(path, contents) {
|
|
782
|
-
const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
|
|
783
|
-
const diagnostics = source.parseDiagnostics ?? [];
|
|
784
|
-
const messages = diagnostics.map((diagnostic) => {
|
|
785
|
-
const line = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0).line + 1;
|
|
786
|
-
return `${path}:${line}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, " ")}`;
|
|
787
|
-
});
|
|
788
|
-
function visit(node) {
|
|
789
|
-
if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
|
|
790
|
-
const argument = node.arguments[0];
|
|
791
|
-
if (!argument || !ts.isStringLiteralLike(argument)) {
|
|
792
|
-
const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
|
|
793
|
-
messages.push(`${path}:${line}: nonliteral dynamic import or require cannot be analyzed`);
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
ts.forEachChild(node, visit);
|
|
797
|
-
}
|
|
798
|
-
visit(source);
|
|
799
|
-
return messages;
|
|
800
|
-
}
|
|
935
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
936
|
+
import { join as join6, resolve as resolve5 } from "path";
|
|
937
|
+
import fastGlob3 from "fast-glob";
|
|
801
938
|
|
|
802
939
|
// src/analysis/resolve.ts
|
|
803
|
-
import { readFile as
|
|
804
|
-
import { dirname as
|
|
940
|
+
import { readFile as readFile6, stat } from "fs/promises";
|
|
941
|
+
import { dirname as dirname3, extname, join as join5, relative as relative4, resolve as resolve4, sep as sep3 } from "path";
|
|
805
942
|
import ts2 from "typescript";
|
|
806
943
|
async function compilerOptions(root) {
|
|
807
|
-
const path =
|
|
944
|
+
const path = join5(root, "tsconfig.json");
|
|
808
945
|
let content;
|
|
809
946
|
try {
|
|
810
|
-
content = await
|
|
947
|
+
content = await readFile6(path, "utf8");
|
|
811
948
|
} catch (error) {
|
|
812
949
|
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return { options: {}, diagnostics: [] };
|
|
813
950
|
throw error;
|
|
@@ -884,7 +1021,7 @@ async function viteAliasDiagnostics(root, options, aliases) {
|
|
|
884
1021
|
var visit = visit2;
|
|
885
1022
|
let sourceText;
|
|
886
1023
|
try {
|
|
887
|
-
sourceText = await
|
|
1024
|
+
sourceText = await readFile6(join5(root, file), "utf8");
|
|
888
1025
|
} catch (error) {
|
|
889
1026
|
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") continue;
|
|
890
1027
|
throw error;
|
|
@@ -915,12 +1052,12 @@ async function resolveImportsDetailed(root, modules, aliases = {}) {
|
|
|
915
1052
|
diagnostics.push(...await viteAliasDiagnostics(root, options, aliases));
|
|
916
1053
|
const files = new Set(modules.map((module) => module.path));
|
|
917
1054
|
for (const module of modules) {
|
|
918
|
-
const from =
|
|
1055
|
+
const from = join5(root, module.path);
|
|
919
1056
|
for (const imported of module.imports) {
|
|
920
1057
|
const specifier = imported.specifier.split("?")[0];
|
|
921
1058
|
const matchingAlias = Object.keys(aliases).sort((a, b) => b.length - a.length).find((prefix) => specifier.startsWith(prefix));
|
|
922
1059
|
const aliasTarget = matchingAlias ? resolve4(root, aliases[matchingAlias], specifier.slice(matchingAlias.length)) : void 0;
|
|
923
|
-
const candidate = aliasTarget ? relative4(
|
|
1060
|
+
const candidate = aliasTarget ? relative4(dirname3(from), aliasTarget).replaceAll("\\", "/") : specifier;
|
|
924
1061
|
const lookup = aliasTarget && !candidate.startsWith(".") ? `./${candidate}` : candidate;
|
|
925
1062
|
const result = ts2.resolveModuleName(lookup, from, options, ts2.sys).resolvedModule;
|
|
926
1063
|
const localAlias = Object.keys(options.paths ?? {}).some((pattern) => {
|
|
@@ -929,7 +1066,7 @@ async function resolveImportsDetailed(root, modules, aliases = {}) {
|
|
|
929
1066
|
});
|
|
930
1067
|
const isLocal = specifier.startsWith(".") || localAlias || aliasTarget !== void 0 || /^[~@#]\//.test(specifier);
|
|
931
1068
|
if (!result) {
|
|
932
|
-
const assetPath = aliasTarget ?? (specifier.startsWith(".") ? resolve4(
|
|
1069
|
+
const assetPath = aliasTarget ?? (specifier.startsWith(".") ? resolve4(dirname3(from), specifier) : void 0);
|
|
933
1070
|
const assetRelative = assetPath ? relative4(root, assetPath) : void 0;
|
|
934
1071
|
if (assetRelative && (assetRelative === ".." || assetRelative.startsWith(`..${sep3}`))) {
|
|
935
1072
|
diagnostics.push(`${module.path}:${imported.line}: local import escapes app root ${imported.specifier}`);
|
|
@@ -955,7 +1092,7 @@ async function resolveImportsDetailed(root, modules, aliases = {}) {
|
|
|
955
1092
|
// src/analysis/scan.ts
|
|
956
1093
|
async function scanProjectDetailed(projectRoot, aliases = {}) {
|
|
957
1094
|
const root = resolve5(projectRoot);
|
|
958
|
-
const files = await
|
|
1095
|
+
const files = await fastGlob3("**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", {
|
|
959
1096
|
cwd: root,
|
|
960
1097
|
onlyFiles: true,
|
|
961
1098
|
unique: true,
|
|
@@ -965,7 +1102,7 @@ async function scanProjectDetailed(projectRoot, aliases = {}) {
|
|
|
965
1102
|
});
|
|
966
1103
|
const parsed = await Promise.all(files.sort().map(async (path) => {
|
|
967
1104
|
const normalized = path.replaceAll("\\", "/");
|
|
968
|
-
const contents = await
|
|
1105
|
+
const contents = await readFile7(join6(root, path), "utf8");
|
|
969
1106
|
return { module: parseModule(normalized, contents), diagnostics: moduleParseDiagnostics(normalized, contents) };
|
|
970
1107
|
}));
|
|
971
1108
|
const modules = parsed.map((item) => item.module);
|
|
@@ -986,10 +1123,10 @@ function grade(score) {
|
|
|
986
1123
|
if (score >= 55) return "D";
|
|
987
1124
|
return "F";
|
|
988
1125
|
}
|
|
989
|
-
function scoreFindings(
|
|
1126
|
+
function scoreFindings(findings2, config) {
|
|
990
1127
|
const penalties = { error: 18, warning: 7, info: 2 };
|
|
991
1128
|
const categories = Object.entries(config.weights).map(([id, weight]) => {
|
|
992
|
-
const categoryFindings =
|
|
1129
|
+
const categoryFindings = findings2.filter((finding) => finding.category === id).sort((a, b) => a.ruleId.localeCompare(b.ruleId) || a.file.localeCompare(b.file));
|
|
993
1130
|
const occurrences = /* @__PURE__ */ new Map();
|
|
994
1131
|
let deduction = 0;
|
|
995
1132
|
for (const finding of categoryFindings) {
|
|
@@ -1001,16 +1138,16 @@ function scoreFindings(findings, config) {
|
|
|
1001
1138
|
return { id, weight, score: round(100 - deduction), deduction: round(deduction), findingCount: categoryFindings.length };
|
|
1002
1139
|
});
|
|
1003
1140
|
const weightedDeduction = round(categories.reduce((sum, category) => sum + category.deduction * category.weight / 100, 0));
|
|
1004
|
-
const maintenanceAdjustment = round(Math.min(5, Math.max(0,
|
|
1005
|
-
const criticalAdjustment = round(Math.min(10,
|
|
1141
|
+
const maintenanceAdjustment = round(Math.min(5, Math.max(0, findings2.length - 10) * 0.15));
|
|
1142
|
+
const criticalAdjustment = round(Math.min(10, findings2.filter((finding) => finding.severity === "error").length * 1.5));
|
|
1006
1143
|
const score = round(Math.max(0, Math.min(100, 100 - weightedDeduction - maintenanceAdjustment - criticalAdjustment)));
|
|
1007
1144
|
return { score, grade: grade(score), weightedDeduction, maintenanceAdjustment, criticalAdjustment, categories };
|
|
1008
1145
|
}
|
|
1009
1146
|
|
|
1010
1147
|
// src/governance/inventory.ts
|
|
1011
1148
|
import { createHash as createHash2 } from "crypto";
|
|
1012
|
-
import { readFile as
|
|
1013
|
-
import { isAbsolute, join as
|
|
1149
|
+
import { readFile as readFile8, realpath } from "fs/promises";
|
|
1150
|
+
import { isAbsolute, join as join7, relative as relative5, resolve as resolve6, sep as sep4 } from "path";
|
|
1014
1151
|
var profiles2 = /* @__PURE__ */ new Set(["next", "vite-react", "react"]);
|
|
1015
1152
|
async function loadInventory(source, scanRoot) {
|
|
1016
1153
|
const absolute = resolve6(source);
|
|
@@ -1018,7 +1155,7 @@ async function loadInventory(source, scanRoot) {
|
|
|
1018
1155
|
const inventoryPath = await realpath(absolute);
|
|
1019
1156
|
const inventoryRelative = relative5(root, inventoryPath);
|
|
1020
1157
|
if (inventoryRelative === ".." || inventoryRelative.startsWith(`..${sep4}`)) throw new Error("Inventory must be inside scan root");
|
|
1021
|
-
const raw = await
|
|
1158
|
+
const raw = await readFile8(inventoryPath, "utf8");
|
|
1022
1159
|
let value;
|
|
1023
1160
|
try {
|
|
1024
1161
|
value = JSON.parse(raw);
|
|
@@ -1043,7 +1180,7 @@ async function loadInventory(source, scanRoot) {
|
|
|
1043
1180
|
if (seen.has(path)) throw new Error(`Duplicate inventory app ${path}`);
|
|
1044
1181
|
seen.add(path);
|
|
1045
1182
|
try {
|
|
1046
|
-
const actual = await realpath(
|
|
1183
|
+
const actual = await realpath(join7(root, path));
|
|
1047
1184
|
const rel = relative5(root, actual);
|
|
1048
1185
|
if (rel === ".." || rel.startsWith(`..${sep4}`)) throw new Error(`App path escapes scan root: ${path}`);
|
|
1049
1186
|
} catch (error) {
|
|
@@ -1065,6 +1202,377 @@ function compareInventory(inventory, discovered, scanRoot) {
|
|
|
1065
1202
|
return issues;
|
|
1066
1203
|
}
|
|
1067
1204
|
|
|
1205
|
+
// src/governance/evidence.ts
|
|
1206
|
+
import { createHash as createHash3 } from "crypto";
|
|
1207
|
+
import { execFile } from "child_process";
|
|
1208
|
+
import { createReadStream } from "fs";
|
|
1209
|
+
import { lstat, readFile as readFile9, realpath as realpath2 } from "fs/promises";
|
|
1210
|
+
import { dirname as dirname4, join as join8, relative as relative6, resolve as resolve7, sep as sep5 } from "path";
|
|
1211
|
+
import { promisify } from "util";
|
|
1212
|
+
import fg from "fast-glob";
|
|
1213
|
+
var execFileAsync = promisify(execFile);
|
|
1214
|
+
function object(value) {
|
|
1215
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1216
|
+
}
|
|
1217
|
+
function string(value) {
|
|
1218
|
+
return typeof value === "string" && value.length > 0;
|
|
1219
|
+
}
|
|
1220
|
+
function nonnegative(value) {
|
|
1221
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
1222
|
+
}
|
|
1223
|
+
function errors(value) {
|
|
1224
|
+
return Array.isArray(value) && value.every(string);
|
|
1225
|
+
}
|
|
1226
|
+
function findings(value) {
|
|
1227
|
+
return Array.isArray(value) && value.every((item) => object(item) && string(item.id) && string(item.severity) && string(item.subject) && string(item.message) && (item.remediation === void 0 || string(item.remediation)));
|
|
1228
|
+
}
|
|
1229
|
+
function base(value) {
|
|
1230
|
+
return object(value) && string(value.tool) && string(value.version) && typeof value.complete === "boolean" && errors(value.errors);
|
|
1231
|
+
}
|
|
1232
|
+
function validateApp(value) {
|
|
1233
|
+
if (!object(value) || !string(value.path) || !string(value.profile)) return false;
|
|
1234
|
+
const dependency = value.dependencySecurity;
|
|
1235
|
+
const quality = value.codeQuality;
|
|
1236
|
+
const security = value.codeSecurity;
|
|
1237
|
+
const compatibility = value.compatibility;
|
|
1238
|
+
if (!base(dependency) || !nonnegative(dependency.packagesScanned) || !findings(dependency.advisories)) return false;
|
|
1239
|
+
if (!object(quality) || !object(quality.lint) || !string(quality.lint.tool) || !string(quality.lint.version) || typeof quality.lint.complete !== "boolean" || !base(quality.typecheck) || !base(quality.tests) || !base(quality.coverage)) return false;
|
|
1240
|
+
if (!nonnegative(quality.lint.filesScanned) || !nonnegative(quality.lint.errorCount) || !nonnegative(quality.lint.warnings) || !errors(quality.lint.errorsDetail) || typeof quality.typecheck.passed !== "boolean" || quality.typecheck.applicable !== void 0 && typeof quality.typecheck.applicable !== "boolean" || !nonnegative(quality.tests.passed) || !nonnegative(quality.tests.failed) || typeof quality.coverage.linesPercent !== "number" || !Number.isFinite(quality.coverage.linesPercent) || quality.coverage.linesPercent < 0 || quality.coverage.linesPercent > 100) return false;
|
|
1241
|
+
if (!object(security) || !base(security.sast) || !base(security.secrets) || !nonnegative(security.sast.filesScanned) || !nonnegative(security.secrets.filesScanned) || !findings(security.sast.findings) || !findings(security.secrets.findings)) return false;
|
|
1242
|
+
if (!base(compatibility) || !Array.isArray(compatibility.projects) || !compatibility.projects.every((project) => object(project) && string(project.name) && nonnegative(project.passed) && nonnegative(project.failed) && nonnegative(project.skipped) && (project.browserVersion === void 0 || string(project.browserVersion)) && (project.engine === void 0 || string(project.engine)) && (project.mobile === void 0 || typeof project.mobile === "boolean") && (project.device === void 0 || string(project.device)) && (project.emulatedOs === void 0 || string(project.emulatedOs)) && (project.emulatedOsVersion === void 0 || string(project.emulatedOsVersion)) && (project.viewportWidths === void 0 || Array.isArray(project.viewportWidths) && project.viewportWidths.every(nonnegative)))) return false;
|
|
1243
|
+
if (value.accessibility !== void 0) {
|
|
1244
|
+
const accessibility = value.accessibility;
|
|
1245
|
+
if (!base(accessibility) || !Array.isArray(accessibility.scans) || !accessibility.scans.every((scan) => object(scan) && string(scan.project) && string(scan.route) && /^\/[^?#]*$/.test(scan.route) && string(scan.axeVersion) && /^\d+\.\d+\.\d+$/.test(scan.axeVersion) && Array.isArray(scan.tags) && scan.tags.length > 0 && scan.tags.every(string) && new Set(scan.tags).size === scan.tags.length && nonnegative(scan.checks) && scan.checks > 0 && nonnegative(scan.needsReview) && findings(scan.violations))) return false;
|
|
1246
|
+
}
|
|
1247
|
+
return true;
|
|
1248
|
+
}
|
|
1249
|
+
async function hashFile(hash, path) {
|
|
1250
|
+
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
|
1251
|
+
}
|
|
1252
|
+
async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
|
|
1253
|
+
const root = resolve7(scanRoot);
|
|
1254
|
+
const workspace = resolve7(workspaceRoot);
|
|
1255
|
+
const scanRelative = relative6(workspace, root);
|
|
1256
|
+
if (scanRelative === ".." || scanRelative.startsWith(`..${sep5}`)) throw new Error("Scan root must be inside workspace root");
|
|
1257
|
+
const paths = await fg("**/*", {
|
|
1258
|
+
cwd: root,
|
|
1259
|
+
dot: true,
|
|
1260
|
+
onlyFiles: true,
|
|
1261
|
+
followSymbolicLinks: false,
|
|
1262
|
+
ignore: [
|
|
1263
|
+
"**/.git/**",
|
|
1264
|
+
"**/node_modules/**",
|
|
1265
|
+
"**/dist/**",
|
|
1266
|
+
"**/build/**",
|
|
1267
|
+
"**/.next/**",
|
|
1268
|
+
"**/coverage/**",
|
|
1269
|
+
"**/playwright-report/**",
|
|
1270
|
+
"**/test-results/**",
|
|
1271
|
+
"**/.nklattice-governance/**",
|
|
1272
|
+
"**/.nklattice-report/**",
|
|
1273
|
+
"**/AGENTS.md",
|
|
1274
|
+
"**/*.tsbuildinfo",
|
|
1275
|
+
"**/next-env.d.ts"
|
|
1276
|
+
]
|
|
1277
|
+
});
|
|
1278
|
+
paths.sort();
|
|
1279
|
+
const hash = createHash3("sha256");
|
|
1280
|
+
for (const path of paths) {
|
|
1281
|
+
const absolute = resolve7(root, path);
|
|
1282
|
+
const normalized = relative6(root, absolute);
|
|
1283
|
+
if (normalized === ".." || normalized.startsWith(`..${sep5}`)) throw new Error("Input path escapes scan root");
|
|
1284
|
+
if (!(await lstat(absolute)).isFile()) continue;
|
|
1285
|
+
hash.update(path.replaceAll("\\", "/"));
|
|
1286
|
+
hash.update("\0");
|
|
1287
|
+
await hashFile(hash, absolute);
|
|
1288
|
+
hash.update("\0");
|
|
1289
|
+
}
|
|
1290
|
+
let ancestor = root;
|
|
1291
|
+
while (ancestor === workspace || ancestor !== dirname4(ancestor) && !relative6(workspace, ancestor).startsWith("..")) {
|
|
1292
|
+
for (const name of [
|
|
1293
|
+
"package.json",
|
|
1294
|
+
"package-lock.json",
|
|
1295
|
+
"pnpm-lock.yaml",
|
|
1296
|
+
"pnpm-workspace.yaml",
|
|
1297
|
+
"tsconfig.json",
|
|
1298
|
+
"eslint.config.js",
|
|
1299
|
+
"eslint.config.mjs",
|
|
1300
|
+
"eslint.config.cjs",
|
|
1301
|
+
"eslint.config.ts",
|
|
1302
|
+
"eslint.config.mts",
|
|
1303
|
+
"vitest.config.js",
|
|
1304
|
+
"vitest.config.mjs",
|
|
1305
|
+
"vitest.config.ts",
|
|
1306
|
+
"vitest.config.mts",
|
|
1307
|
+
"playwright.config.js",
|
|
1308
|
+
"playwright.config.mjs",
|
|
1309
|
+
"playwright.config.ts",
|
|
1310
|
+
"playwright.config.mts",
|
|
1311
|
+
".semgrep.yml",
|
|
1312
|
+
".semgrep.yaml",
|
|
1313
|
+
"semgrep.yml",
|
|
1314
|
+
"semgrep.yaml"
|
|
1315
|
+
]) {
|
|
1316
|
+
const path = join8(ancestor, name);
|
|
1317
|
+
try {
|
|
1318
|
+
const stat2 = await lstat(path);
|
|
1319
|
+
if (stat2.isSymbolicLink()) throw new Error(`Symlinked scanner configuration is unsupported: ${path}`);
|
|
1320
|
+
if (!stat2.isFile()) continue;
|
|
1321
|
+
hash.update(relative6(workspace, path).replaceAll("\\", "/"));
|
|
1322
|
+
hash.update("\0");
|
|
1323
|
+
await hashFile(hash, path);
|
|
1324
|
+
hash.update("\0");
|
|
1325
|
+
} catch (error) {
|
|
1326
|
+
if (error.code !== "ENOENT") throw error;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
if (ancestor === workspace) break;
|
|
1330
|
+
ancestor = dirname4(ancestor);
|
|
1331
|
+
}
|
|
1332
|
+
return hash.digest("hex");
|
|
1333
|
+
}
|
|
1334
|
+
async function loadScanEvidence(path, scanRoot, workspaceRoot, policySha256, maximumAgeHours) {
|
|
1335
|
+
const source = resolve7(path);
|
|
1336
|
+
const stat2 = await lstat(source);
|
|
1337
|
+
if (!stat2.isFile() || stat2.isSymbolicLink() || stat2.size > 1e7) throw new Error("Evidence must be a regular JSON file under 10 MB");
|
|
1338
|
+
const root = resolve7(scanRoot);
|
|
1339
|
+
const relativePath = relative6(root, source);
|
|
1340
|
+
if (relativePath !== ".." && !relativePath.startsWith(`..${sep5}`)) throw new Error("Evidence file must be outside the scan root");
|
|
1341
|
+
const actualRoot = await realpath2(root);
|
|
1342
|
+
const actualSource = await realpath2(source);
|
|
1343
|
+
const actualRelative = relative6(actualRoot, actualSource);
|
|
1344
|
+
if (actualRelative !== ".." && !actualRelative.startsWith(`..${sep5}`)) throw new Error("Evidence file resolves inside the scan root");
|
|
1345
|
+
let parsed;
|
|
1346
|
+
try {
|
|
1347
|
+
parsed = JSON.parse(await readFile9(source, "utf8"));
|
|
1348
|
+
} catch {
|
|
1349
|
+
throw new Error("Evidence is not valid JSON");
|
|
1350
|
+
}
|
|
1351
|
+
return validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha256, maximumAgeHours);
|
|
1352
|
+
}
|
|
1353
|
+
async function validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha256, maximumAgeHours) {
|
|
1354
|
+
if (!object(parsed) || parsed.schemaVersion !== 1 || !string(parsed.generatedAt) || !string(parsed.policySha256) || !string(parsed.inputSha256) || !string(parsed.commit) || !/^[0-9a-f]{40}$/.test(parsed.commit) || !Array.isArray(parsed.apps) || !parsed.apps.every(validateApp)) throw new Error("Evidence schema is invalid or incomplete");
|
|
1355
|
+
if (parsed.policySha256 !== policySha256) throw new Error("Evidence was generated for another policy");
|
|
1356
|
+
const generatedAt = Date.parse(parsed.generatedAt);
|
|
1357
|
+
if (!Number.isFinite(generatedAt) || generatedAt > Date.now() + 6e4 || Date.now() - generatedAt > maximumAgeHours * 36e5) {
|
|
1358
|
+
throw new Error("Evidence is stale or has an invalid timestamp");
|
|
1359
|
+
}
|
|
1360
|
+
if (parsed.inputSha256 !== await hashAssessmentInputs(scanRoot, workspaceRoot)) throw new Error("Evidence inputs changed after scanning");
|
|
1361
|
+
const current = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: workspaceRoot })).stdout.trim();
|
|
1362
|
+
if (!/^[0-9a-f]{40}$/.test(current) || parsed.commit !== current) throw new Error("Evidence commit differs from the checked-out commit");
|
|
1363
|
+
const appPaths = parsed.apps.map((app) => app.path);
|
|
1364
|
+
if (new Set(appPaths).size !== appPaths.length) throw new Error("Evidence contains duplicate app paths");
|
|
1365
|
+
return parsed;
|
|
1366
|
+
}
|
|
1367
|
+
var incomplete = (issues, tool, version, scanned) => ({ status: "incomplete", issues, tool, version, scanned });
|
|
1368
|
+
function numericVersion(value) {
|
|
1369
|
+
if (!value || !/^(0|[1-9]\d*)(?:\.(0|[1-9]\d*)){0,3}$/.test(value)) return void 0;
|
|
1370
|
+
const parts = value.split(".").map(Number);
|
|
1371
|
+
return parts.every(Number.isSafeInteger) ? parts : void 0;
|
|
1372
|
+
}
|
|
1373
|
+
function belowVersion(actual, minimum) {
|
|
1374
|
+
for (let index = 0; index < Math.max(actual.length, minimum.length); index++) {
|
|
1375
|
+
if ((actual[index] ?? 0) < (minimum[index] ?? 0)) return true;
|
|
1376
|
+
if ((actual[index] ?? 0) > (minimum[index] ?? 0)) return false;
|
|
1377
|
+
}
|
|
1378
|
+
return false;
|
|
1379
|
+
}
|
|
1380
|
+
function evaluateAppEvidence(app, policy) {
|
|
1381
|
+
if (!app) {
|
|
1382
|
+
const missing = incomplete(["No fresh scan evidence for this app"]);
|
|
1383
|
+
return {
|
|
1384
|
+
dependencySecurity: { ...missing },
|
|
1385
|
+
codeQuality: { ...missing },
|
|
1386
|
+
codeSecurity: { ...missing },
|
|
1387
|
+
compatibility: { ...missing },
|
|
1388
|
+
...policy.accessibility ? { accessibility: { ...missing } } : {}
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
const dependency = app.dependencySecurity;
|
|
1392
|
+
let dependencySecurity;
|
|
1393
|
+
if (!dependency.complete || dependency.packagesScanned === 0 || dependency.errors.length) {
|
|
1394
|
+
dependencySecurity = { ...incomplete(["Dependency advisory scan did not cover the lockfile", ...dependency.errors], dependency.tool, dependency.version, dependency.packagesScanned), findings: dependency.advisories };
|
|
1395
|
+
} else {
|
|
1396
|
+
const high2 = dependency.advisories.filter((item) => item.severity.toLowerCase() === "high").length;
|
|
1397
|
+
const critical2 = dependency.advisories.filter((item) => item.severity.toLowerCase() === "critical").length;
|
|
1398
|
+
const unknown = dependency.advisories.filter((item) => !["low", "moderate", "medium", "high", "critical"].includes(item.severity.toLowerCase()));
|
|
1399
|
+
dependencySecurity = unknown.length ? { ...incomplete([`${unknown.length} advisories have unknown severity`], dependency.tool, dependency.version, dependency.packagesScanned), findings: dependency.advisories } : {
|
|
1400
|
+
status: high2 > policy.dependencySecurity.maximumHigh || critical2 > policy.dependencySecurity.maximumCritical ? "fail" : "pass",
|
|
1401
|
+
issues: [
|
|
1402
|
+
...high2 > policy.dependencySecurity.maximumHigh ? [`${high2} high advisories exceed ${policy.dependencySecurity.maximumHigh}`] : [],
|
|
1403
|
+
...critical2 > policy.dependencySecurity.maximumCritical ? [`${critical2} critical advisories exceed ${policy.dependencySecurity.maximumCritical}`] : []
|
|
1404
|
+
],
|
|
1405
|
+
tool: dependency.tool,
|
|
1406
|
+
version: dependency.version,
|
|
1407
|
+
scanned: dependency.packagesScanned,
|
|
1408
|
+
findings: dependency.advisories
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
const quality = app.codeQuality;
|
|
1412
|
+
const qualityMissing = [
|
|
1413
|
+
...!quality.lint.complete || quality.lint.filesScanned === 0 ? ["ESLint did not scan source files", ...quality.lint.errorsDetail] : [],
|
|
1414
|
+
...policy.codeQuality.requireTypecheck && !quality.typecheck.complete ? ["TypeScript typecheck did not complete", ...quality.typecheck.errors] : [],
|
|
1415
|
+
...policy.codeQuality.requireTests && (!quality.tests.complete || quality.tests.passed + quality.tests.failed === 0) ? ["Tests did not run", ...quality.tests.errors] : [],
|
|
1416
|
+
...!quality.coverage.complete ? ["Line coverage is unavailable", ...quality.coverage.errors] : []
|
|
1417
|
+
];
|
|
1418
|
+
const qualityFailures = [
|
|
1419
|
+
...quality.lint.errorCount > policy.codeQuality.maximumLintErrors ? [`${quality.lint.errorCount} ESLint errors exceed ${policy.codeQuality.maximumLintErrors}`] : [],
|
|
1420
|
+
...policy.codeQuality.requireTypecheck && !quality.typecheck.passed ? ["TypeScript typecheck failed"] : [],
|
|
1421
|
+
...quality.tests.failed > 0 ? [`${quality.tests.failed} tests failed`] : [],
|
|
1422
|
+
...quality.coverage.linesPercent < policy.codeQuality.minimumLineCoverage ? [`Line coverage ${quality.coverage.linesPercent}% is below ${policy.codeQuality.minimumLineCoverage}%`] : []
|
|
1423
|
+
];
|
|
1424
|
+
const qualityVersion = `ESLint ${quality.lint.version} \xB7 TypeScript ${quality.typecheck.version} \xB7 Vitest ${quality.tests.version}`;
|
|
1425
|
+
const qualityMetrics = {
|
|
1426
|
+
lintErrors: quality.lint.errorCount,
|
|
1427
|
+
lintWarnings: quality.lint.warnings,
|
|
1428
|
+
typecheck: quality.typecheck.applicable === false ? "not applicable" : quality.typecheck.passed ? "pass" : "fail",
|
|
1429
|
+
testsPassed: quality.tests.passed,
|
|
1430
|
+
testsFailed: quality.tests.failed,
|
|
1431
|
+
lineCoveragePercent: quality.coverage.linesPercent
|
|
1432
|
+
};
|
|
1433
|
+
const codeQuality = qualityMissing.length ? { ...incomplete([...qualityMissing, ...qualityFailures], "ESLint / TypeScript / tests", qualityVersion, quality.lint.filesScanned), metrics: qualityMetrics } : {
|
|
1434
|
+
status: qualityFailures.length ? "fail" : "pass",
|
|
1435
|
+
issues: qualityFailures,
|
|
1436
|
+
tool: "ESLint / TypeScript / tests",
|
|
1437
|
+
version: qualityVersion,
|
|
1438
|
+
scanned: quality.lint.filesScanned,
|
|
1439
|
+
metrics: qualityMetrics
|
|
1440
|
+
};
|
|
1441
|
+
const security = app.codeSecurity;
|
|
1442
|
+
const securityMissing = [
|
|
1443
|
+
...!security.sast.complete || security.sast.filesScanned === 0 ? ["SAST did not scan source files", ...security.sast.errors] : [],
|
|
1444
|
+
...!security.secrets.complete || security.secrets.filesScanned === 0 ? ["Secret scanner did not scan source files", ...security.secrets.errors] : []
|
|
1445
|
+
];
|
|
1446
|
+
const high = security.sast.findings.filter((item) => item.severity.toLowerCase() === "high").length;
|
|
1447
|
+
const critical = security.sast.findings.filter((item) => item.severity.toLowerCase() === "critical").length;
|
|
1448
|
+
const unknownSecurity = security.sast.findings.filter((item) => !["low", "moderate", "medium", "high", "critical"].includes(item.severity.toLowerCase()));
|
|
1449
|
+
if (unknownSecurity.length) securityMissing.push(`${unknownSecurity.length} SAST findings have unknown severity`);
|
|
1450
|
+
const securityFailures = [
|
|
1451
|
+
...high > policy.codeSecurity.maximumHigh ? [`${high} high SAST findings exceed ${policy.codeSecurity.maximumHigh}`] : [],
|
|
1452
|
+
...critical > policy.codeSecurity.maximumCritical ? [`${critical} critical SAST findings exceed ${policy.codeSecurity.maximumCritical}`] : [],
|
|
1453
|
+
...security.secrets.findings.length > policy.codeSecurity.maximumSecrets ? [`${security.secrets.findings.length} detected secrets exceed ${policy.codeSecurity.maximumSecrets}`] : []
|
|
1454
|
+
];
|
|
1455
|
+
const securityVersion = `Semgrep ${security.sast.version} \xB7 Gitleaks ${security.secrets.version}`;
|
|
1456
|
+
const codeSecurity = securityMissing.length ? { ...incomplete([...securityMissing, ...securityFailures], "Semgrep / Gitleaks", securityVersion, security.sast.filesScanned), findings: [...security.sast.findings, ...security.secrets.findings] } : {
|
|
1457
|
+
status: securityFailures.length ? "fail" : "pass",
|
|
1458
|
+
issues: securityFailures,
|
|
1459
|
+
tool: "Semgrep / Gitleaks",
|
|
1460
|
+
version: securityVersion,
|
|
1461
|
+
scanned: security.sast.filesScanned,
|
|
1462
|
+
findings: [...security.sast.findings, ...security.secrets.findings]
|
|
1463
|
+
};
|
|
1464
|
+
const compatibility = app.compatibility;
|
|
1465
|
+
const projectNames = new Set(compatibility.projects.map((project) => project.name));
|
|
1466
|
+
const missingProjects = policy.compatibility.requiredProjects.filter((name) => !projectNames.has(name));
|
|
1467
|
+
const incompleteProjects = compatibility.projects.filter((project) => project.passed + project.failed < policy.compatibility.minimumTestsPerProject);
|
|
1468
|
+
const missingBrowserVersions = compatibility.projects.filter((project) => !project.browserVersion);
|
|
1469
|
+
const expectedEngines = {
|
|
1470
|
+
"chromium-desktop": "chromium",
|
|
1471
|
+
"firefox-desktop": "firefox",
|
|
1472
|
+
"webkit-desktop": "webkit",
|
|
1473
|
+
"android-chrome": "chromium",
|
|
1474
|
+
"iphone-safari": "webkit"
|
|
1475
|
+
};
|
|
1476
|
+
const invalidEngines = compatibility.projects.filter((project) => expectedEngines[project.name] && project.engine !== expectedEngines[project.name]);
|
|
1477
|
+
const invalidDevices = compatibility.projects.filter((project) => (project.name === "android-chrome" || project.name === "iphone-safari") && (project.mobile !== true || !project.device));
|
|
1478
|
+
const missingViewports = compatibility.projects.filter((project) => new Set(project.viewportWidths ?? []).size < 2);
|
|
1479
|
+
const versionEvidenceMissing = [];
|
|
1480
|
+
const versionFailures = [];
|
|
1481
|
+
for (const [name, minimum] of Object.entries(policy.compatibility.minimumTestedBrowserVersions ?? {})) {
|
|
1482
|
+
const project = compatibility.projects.find((item) => item.name === name);
|
|
1483
|
+
if (!project) continue;
|
|
1484
|
+
const actual = numericVersion(project.browserVersion);
|
|
1485
|
+
if (!actual) versionEvidenceMissing.push(`${name} did not record a valid measured browser version`);
|
|
1486
|
+
else if (belowVersion(actual, numericVersion(minimum))) {
|
|
1487
|
+
versionFailures.push(`${name} measured browser version ${project.browserVersion} is below minimum tested browser version ${minimum}`);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
for (const [name, minimum] of Object.entries(policy.compatibility.minimumEmulatedOsVersions ?? {})) {
|
|
1491
|
+
const project = compatibility.projects.find((item) => item.name === name);
|
|
1492
|
+
if (!project) continue;
|
|
1493
|
+
const expectedOs = name === "android-chrome" ? "Android" : "iOS";
|
|
1494
|
+
const actual = numericVersion(project.emulatedOsVersion);
|
|
1495
|
+
if (project.emulatedOs !== expectedOs || !actual) {
|
|
1496
|
+
versionEvidenceMissing.push(`${name} did not record a valid emulated ${expectedOs} version`);
|
|
1497
|
+
} else if (belowVersion(actual, numericVersion(minimum))) {
|
|
1498
|
+
versionFailures.push(`${name} emulated ${expectedOs} version ${project.emulatedOsVersion} is below minimum emulated ${expectedOs} version ${minimum}`);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
const compatibilityMissing = [
|
|
1502
|
+
...!compatibility.complete ? ["Compatibility test run did not complete", ...compatibility.errors] : [],
|
|
1503
|
+
...missingProjects.length ? [`Missing Playwright projects: ${missingProjects.join(", ")}`] : [],
|
|
1504
|
+
...incompleteProjects.map((project) => `${project.name} ran fewer than ${policy.compatibility.minimumTestsPerProject} tests`),
|
|
1505
|
+
...missingBrowserVersions.map((project) => `${project.name} did not record its browser engine version`),
|
|
1506
|
+
...invalidEngines.map((project) => `${project.name} used ${project.engine ?? "an unverified engine"} instead of ${expectedEngines[project.name]}`),
|
|
1507
|
+
...invalidDevices.map((project) => `${project.name} did not verify mobile emulation settings`),
|
|
1508
|
+
...missingViewports.map((project) => `${project.name} did not test two viewport widths`),
|
|
1509
|
+
...versionEvidenceMissing
|
|
1510
|
+
];
|
|
1511
|
+
const failedProjects = compatibility.projects.filter((project) => project.failed > 0);
|
|
1512
|
+
const testFailures = failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`);
|
|
1513
|
+
const matrix = compatibility.projects.map((project) => ({
|
|
1514
|
+
...project,
|
|
1515
|
+
minimumTestedBrowserVersion: policy.compatibility.minimumTestedBrowserVersions?.[project.name],
|
|
1516
|
+
minimumEmulatedOsVersion: policy.compatibility.minimumEmulatedOsVersions?.[project.name]
|
|
1517
|
+
}));
|
|
1518
|
+
const compatibilityDomain = compatibilityMissing.length ? { ...incomplete([...compatibilityMissing, ...testFailures, ...versionFailures], compatibility.tool, compatibility.version), matrix } : {
|
|
1519
|
+
status: testFailures.length || versionFailures.length ? "fail" : "pass",
|
|
1520
|
+
issues: [...testFailures, ...versionFailures],
|
|
1521
|
+
tool: compatibility.tool,
|
|
1522
|
+
version: compatibility.version,
|
|
1523
|
+
scanned: compatibility.projects.reduce((count, project) => count + project.passed + project.failed, 0),
|
|
1524
|
+
matrix
|
|
1525
|
+
};
|
|
1526
|
+
let accessibilityDomain;
|
|
1527
|
+
if (policy.accessibility) {
|
|
1528
|
+
const rules = policy.accessibility;
|
|
1529
|
+
const result = app.accessibility;
|
|
1530
|
+
if (!result) accessibilityDomain = incomplete(["Accessibility scan evidence is missing"]);
|
|
1531
|
+
else {
|
|
1532
|
+
const missing = [...!result.complete || result.errors.length ? ["Accessibility scan did not complete", ...result.errors] : []];
|
|
1533
|
+
for (const project of rules.requiredProjects) {
|
|
1534
|
+
const scans = result.scans.filter((scan) => scan.project === project);
|
|
1535
|
+
if (scans.length < rules.minimumScansPerProject) missing.push(`${project} has fewer than ${rules.minimumScansPerProject} accessibility scans`);
|
|
1536
|
+
}
|
|
1537
|
+
for (const scan of result.scans) {
|
|
1538
|
+
if (scan.checks < rules.minimumChecksPerScan) missing.push(`${scan.project} ${scan.route} checked fewer than ${rules.minimumChecksPerScan} axe rules`);
|
|
1539
|
+
if (rules.requiredTags.some((tag) => !scan.tags.includes(tag))) missing.push(`${scan.project} ${scan.route} omitted required WCAG tags`);
|
|
1540
|
+
const measuredVersion2 = numericVersion(scan.axeVersion);
|
|
1541
|
+
if (!measuredVersion2) missing.push(`${scan.project} ${scan.route} did not record a valid axe-core version`);
|
|
1542
|
+
else if (belowVersion(measuredVersion2, numericVersion(rules.minimumAxeVersion))) {
|
|
1543
|
+
missing.push(`${scan.project} ${scan.route} used axe-core ${scan.axeVersion} below ${rules.minimumAxeVersion}`);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
const findings2 = result.scans.flatMap((scan) => scan.violations);
|
|
1547
|
+
const needsReview = result.scans.reduce((sum, scan) => sum + scan.needsReview, 0);
|
|
1548
|
+
if (needsReview > rules.maximumNeedsReview) missing.push(`${needsReview} axe checks need manual review (maximum ${rules.maximumNeedsReview})`);
|
|
1549
|
+
const failures = findings2.length > rules.maximumViolations ? [`${findings2.length} accessibility violations exceed ${rules.maximumViolations}`] : [];
|
|
1550
|
+
const details = {
|
|
1551
|
+
tool: result.tool,
|
|
1552
|
+
version: result.version,
|
|
1553
|
+
scanned: result.scans.length,
|
|
1554
|
+
findings: findings2,
|
|
1555
|
+
metrics: { scans: result.scans.length, axeChecks: result.scans.reduce((sum, scan) => sum + scan.checks, 0), needsReview },
|
|
1556
|
+
routes: result.scans.map((scan) => ({
|
|
1557
|
+
project: scan.project,
|
|
1558
|
+
route: scan.route,
|
|
1559
|
+
axeVersion: scan.axeVersion,
|
|
1560
|
+
checks: scan.checks,
|
|
1561
|
+
needsReview: scan.needsReview
|
|
1562
|
+
}))
|
|
1563
|
+
};
|
|
1564
|
+
accessibilityDomain = failures.length ? { status: "fail", issues: [...failures, ...missing], ...details } : missing.length ? { ...incomplete(missing, result.tool, result.version, result.scans.length), ...details } : { status: "pass", issues: [], ...details };
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
return {
|
|
1568
|
+
dependencySecurity,
|
|
1569
|
+
codeQuality,
|
|
1570
|
+
codeSecurity,
|
|
1571
|
+
compatibility: compatibilityDomain,
|
|
1572
|
+
...accessibilityDomain ? { accessibility: accessibilityDomain } : {}
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1068
1576
|
// src/governance/assess.ts
|
|
1069
1577
|
var notEvaluated = [
|
|
1070
1578
|
"vulnerability databases",
|
|
@@ -1085,27 +1593,52 @@ function escapeHtml(value) {
|
|
|
1085
1593
|
return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
1086
1594
|
}
|
|
1087
1595
|
function renderAssessmentHtml(report) {
|
|
1596
|
+
const labels = {
|
|
1597
|
+
inventory: "App inventory",
|
|
1598
|
+
dependencies: "Dependency policy",
|
|
1599
|
+
architecture: "Architecture",
|
|
1600
|
+
dependencySecurity: "Dependency security",
|
|
1601
|
+
codeQuality: "Code quality",
|
|
1602
|
+
codeSecurity: "Code security",
|
|
1603
|
+
compatibility: "Browser compatibility",
|
|
1604
|
+
accessibility: "Accessibility"
|
|
1605
|
+
};
|
|
1606
|
+
const domainNames = report.apps.length ? Object.keys(report.apps[0].domains) : [];
|
|
1607
|
+
const overview = domainNames.map((name) => {
|
|
1608
|
+
const statuses = report.apps.map((app) => app.domains[name]?.status ?? "incomplete");
|
|
1609
|
+
const status = statusOf(statuses);
|
|
1610
|
+
const passed = statuses.filter((item) => item === "pass").length;
|
|
1611
|
+
return `<li><span>${escapeHtml(labels[name] ?? name)}</span><strong class="${status}">${escapeHtml(status)}</strong><small>${passed}/${report.apps.length} apps passed</small></li>`;
|
|
1612
|
+
}).join("");
|
|
1088
1613
|
const apps = report.apps.map((app) => {
|
|
1089
|
-
const domains = Object.entries(app.domains).map(([name, domain]) =>
|
|
1614
|
+
const domains = Object.entries(app.domains).map(([name, domain]) => {
|
|
1615
|
+
const evidence = domain;
|
|
1616
|
+
const identity = evidence.tool ? `<p><small>${escapeHtml(evidence.tool)} ${escapeHtml(evidence.version ?? "")} \xB7 scanned ${escapeHtml(evidence.scanned ?? "unknown")}</small></p>` : "";
|
|
1617
|
+
const metrics = evidence.metrics ? `<p><small>${Object.entries(evidence.metrics).map(([key, value]) => `${escapeHtml(key)}: ${escapeHtml(value)}`).join(" \xB7 ")}</small></p>` : "";
|
|
1618
|
+
const findings3 = evidence.findings?.length ? `<details><summary>${evidence.findings.length} finding(s)</summary><ul>${evidence.findings.map((item) => `<li><strong>${escapeHtml(item.severity)}</strong> ${escapeHtml(item.id)} \xB7 <code>${escapeHtml(item.subject)}</code> \u2014 ${escapeHtml(item.message)}${item.remediation ? ` \xB7 ${escapeHtml(item.remediation)}` : ""}</li>`).join("")}</ul></details>` : "";
|
|
1619
|
+
const matrix = evidence.matrix?.length ? `<div class="scroll"><table><thead><tr><th>Project</th><th>Passed</th><th>Failed</th><th>Skipped</th><th>Measured browser / minimum test environment</th><th>Mobile UA emulation / minimum</th><th>Widths</th></tr></thead><tbody>${evidence.matrix.map((item) => `<tr><th scope="row">${escapeHtml(item.name)}</th><td>${item.passed}</td><td>${item.failed}</td><td>${item.skipped}</td><td>${escapeHtml(item.browserVersion ?? "Not recorded")} / ${escapeHtml(item.minimumTestedBrowserVersion ?? "Not set")}</td><td>${escapeHtml(item.device ?? "None")} / ${escapeHtml(item.minimumEmulatedOsVersion ?? "Not set")}</td><td>${escapeHtml(item.viewportWidths?.join(", ") ?? "Not recorded")}</td></tr>`).join("")}</tbody></table><p><small>Version floors check the tested environment. Mobile OS values come from user-agent emulation, not physical devices or a promise of support for older versions.</small></p></div>` : "";
|
|
1620
|
+
const routes = evidence.routes?.length ? `<div class="scroll"><table><thead><tr><th>Project</th><th>Scanned route</th><th>axe-core</th><th>Checks</th><th>Needs review</th></tr></thead><tbody>${evidence.routes.map((item) => `<tr><th scope="row">${escapeHtml(item.project)}</th><td><code>${escapeHtml(item.route)}</code></td><td>${escapeHtml(item.axeVersion)}</td><td>${item.checks}</td><td>${item.needsReview}</td></tr>`).join("")}</tbody></table><p><small>Only these visited routes were scanned automatically. Manual accessibility review remains outside this result.</small></p></div>` : "";
|
|
1621
|
+
return `<tr><th scope="row">${escapeHtml(labels[name] ?? name)}</th><td class="${domain.status}">${escapeHtml(domain.status)}</td><td>${identity}${metrics}${domain.issues.length ? `<ul>${domain.issues.map((issue) => `<li>${escapeHtml(issue)}</li>`).join("")}</ul>` : "No issues"}${findings3}${matrix}${routes}</td></tr>`;
|
|
1622
|
+
}).join("");
|
|
1090
1623
|
const architecture = app.domains.architecture;
|
|
1091
1624
|
const categoryRows = architecture.scoreBreakdown?.categories.map((category) => `<tr><th scope="row">${escapeHtml(category.id)}</th><td>${escapeHtml(category.weight)}%</td><td>${escapeHtml(category.score)}</td><td>${escapeHtml(category.findingCount)}</td></tr>`).join("") ?? "";
|
|
1092
|
-
const
|
|
1093
|
-
return `<section class="app"><h2>${escapeHtml(app.path)} <span class="${app.status}">${escapeHtml(app.status)}</span></h2><p>Profile: ${escapeHtml(app.profile)} \xB7 Source files scanned: ${architecture.filesScanned} \xB7 Local import edges: ${architecture.localEdges} \xB7 Architecture score: ${architecture.score ?? "unavailable"} \xB7 Findings: ${architecture.findings.length}</p><div class="scroll"><table><thead><tr><th>Domain</th><th>Status</th><th>Evidence</th></tr></thead><tbody>${domains}</tbody></table></div>${categoryRows ? `<details><summary>Score breakdown</summary><div class="scroll"><table><thead><tr><th>Category</th><th>Weight</th><th>Score</th><th>Findings</th></tr></thead><tbody>${categoryRows}</tbody></table></div></details>` : ""}${
|
|
1625
|
+
const findings2 = architecture.findings.map((finding) => `<tr><td>${escapeHtml(finding.severity)}</td><td>${escapeHtml(finding.ruleId)}</td><td><code>${escapeHtml(finding.file)}:${finding.line}</code></td><td>${escapeHtml(finding.evidence)}</td></tr>`).join("");
|
|
1626
|
+
return `<section class="app"><h2>${escapeHtml(app.path)} <span class="${app.status}">${escapeHtml(app.status)}</span></h2><p>Profile: ${escapeHtml(app.profile)} \xB7 Source files scanned: ${architecture.filesScanned} \xB7 Local import edges: ${architecture.localEdges} \xB7 Architecture score: ${architecture.score ?? "unavailable"} \xB7 Findings: ${architecture.findings.length}</p><div class="scroll"><table><thead><tr><th>Domain</th><th>Status</th><th>Evidence</th></tr></thead><tbody>${domains}</tbody></table></div>${categoryRows ? `<details><summary>Score breakdown</summary><div class="scroll"><table><thead><tr><th>Category</th><th>Weight</th><th>Score</th><th>Findings</th></tr></thead><tbody>${categoryRows}</tbody></table></div></details>` : ""}${findings2 ? `<details><summary>Architecture findings (${architecture.findings.length})</summary><div class="scroll"><table><thead><tr><th>Severity</th><th>Rule</th><th>Location</th><th>Evidence</th></tr></thead><tbody>${findings2}</tbody></table></div></details>` : ""}</section>`;
|
|
1094
1627
|
}).join("");
|
|
1095
1628
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>NKLattice governance assessment</title><style>
|
|
1096
|
-
:root{color-scheme:light;font:16px/1.5 system-ui,sans-serif;background:#
|
|
1097
|
-
</style></head><body><header><small>NKLATTICE \xB7 CONFIGURED POLICY ASSESSMENT</small><h1>Project governance <span class="${report.status}">${escapeHtml(report.status)}</span></h1><p>${report.apps.length} discovered app(s) \xB7 ${report.inventory.expectedApps} expected app(s)</p></header><section class="meta"><h2>Evidence identity</h2><p>Policy ${escapeHtml(report.policy.version)} \xB7 <code>${escapeHtml(report.policy.sha256)}</code></p><p>Policy source: <code>${escapeHtml(report.policy.source)}</code></p><p>Inventory <code>${escapeHtml(report.inventory.sha256 ?? "unavailable")}</code></p><p>Inventory source: <code>${escapeHtml(report.inventory.source)}</code></p>${report.issues.length ? `<ul>${report.issues.map((issue) => `<li>${escapeHtml(issue)}</li>`).join("")}</ul>` : ""}</section>${apps}<section class="meta"><h2>Assessment scope</h2><p>Evaluated: ${report.scope.domains.map(escapeHtml).join(", ")}.</p><p>Not evaluated: ${report.scope.notEvaluated.map(escapeHtml).join(", ")}.</p></section></body></html>
|
|
1629
|
+
:root{color-scheme:light;font:16px/1.5 system-ui,sans-serif;background:#f3f6f4;color:#1b2925}body{max-width:1160px;margin:auto;padding:2rem 1rem}header,.app,.meta,.overview{background:white;border:1px solid #d9e1dc;border-radius:12px;padding:1.4rem;margin:0 0 1.2rem}header{background:#13231d;color:#f5f8f4;padding:2rem}header small{color:#9de2bc;font-weight:700;letter-spacing:.12em}header p{color:#c8d6ce}h1{font-size:clamp(2rem,5vw,3.5rem);line-height:1.08;margin:.3rem 0 1rem}h2{font-size:1.35rem;margin:0 0 .75rem}p{margin:.5rem 0}code{overflow-wrap:anywhere}.pass{color:#146c43;font-weight:700}.fail{color:#b3242b;font-weight:700}.incomplete{color:#8a5500;font-weight:700}header .pass{color:#9de2bc}.scroll{overflow-x:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;vertical-align:top;border-bottom:1px solid #d9e1dc;padding:.8rem}th:first-child{width:12rem}ul{padding-left:1.2rem;margin:.1rem 0}li+li{margin-top:.3rem}small{color:#50645c}details{border-top:1px solid #d9e1dc;margin-top:1rem;padding-top:1rem}summary{cursor:pointer;font-weight:650}details[open] summary{margin-bottom:.6rem}.domain-overview{list-style:none;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.65rem;padding:0;margin:1rem 0 0}.domain-overview li{border:1px solid #d9e1dc;background:#f8faf8;border-radius:8px;padding:.9rem;display:grid;grid-template-columns:1fr auto;gap:.35rem}.domain-overview li small{grid-column:1/-1}.domain-overview li+li{margin:0}
|
|
1630
|
+
@media(max-width:800px){.domain-overview{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:480px){.domain-overview{grid-template-columns:1fr}}</style></head><body><header><small>NKLATTICE \xB7 CONFIGURED POLICY ASSESSMENT</small><h1>Project governance <span class="${report.status}">${escapeHtml(report.status)}</span></h1><p>${report.apps.length} discovered app(s) \xB7 ${report.inventory.expectedApps} expected app(s) \xB7 policy ${escapeHtml(report.policy.version)}</p></header><section class="overview"><h2>Assessment by domain</h2><p>Each status is based on the checked apps and fresh scanner evidence. Open an app below for findings and browser versions.</p><ul class="domain-overview">${overview}</ul></section><section class="meta"><h2>Evidence identity</h2><p>Policy ${escapeHtml(report.policy.version)} \xB7 <code>${escapeHtml(report.policy.sha256)}</code></p><p>Policy source: <code>${escapeHtml(report.policy.source)}</code></p><p>Inventory <code>${escapeHtml(report.inventory.sha256 ?? "unavailable")}</code></p><p>Inventory source: <code>${escapeHtml(basename(report.inventory.source))}</code></p>${report.evidence ? `<p>Scanners: ${escapeHtml(report.evidence.generatedAt)} \xB7 commit <code>${escapeHtml(report.evidence.commit)}</code> \xB7 input <code>${escapeHtml(report.evidence.inputSha256)}</code></p>` : "<p>Scanner evidence unavailable.</p>"}${report.issues.length ? `<ul>${report.issues.map((issue) => `<li>${escapeHtml(issue)}</li>`).join("")}</ul>` : ""}</section>${apps}<section class="meta"><h2>Assessment scope</h2><p>Evaluated: ${report.scope.domains.map(escapeHtml).join(", ")}.</p><p>Not evaluated: ${report.scope.notEvaluated.map(escapeHtml).join(", ")}.</p></section></body></html>
|
|
1098
1631
|
`;
|
|
1099
1632
|
}
|
|
1100
1633
|
async function assessGovernance(options) {
|
|
1101
|
-
const scanRoot =
|
|
1102
|
-
const workspaceRoot =
|
|
1103
|
-
const scanRelative =
|
|
1104
|
-
if (scanRelative === ".." || scanRelative.startsWith(`..${
|
|
1634
|
+
const scanRoot = resolve8(options.scanRoot);
|
|
1635
|
+
const workspaceRoot = resolve8(options.workspaceRoot ?? scanRoot);
|
|
1636
|
+
const scanRelative = relative7(workspaceRoot, scanRoot);
|
|
1637
|
+
if (scanRelative === ".." || scanRelative.startsWith(`..${sep6}`)) throw new Error("Scan root must be inside workspace root");
|
|
1105
1638
|
if (!options.policy.assessment) throw new Error("Policy assessment.architecture is required");
|
|
1106
1639
|
const assessment = parseAssessmentPolicy(options.policy.assessment);
|
|
1107
|
-
const outputDirectory =
|
|
1108
|
-
const inventorySource =
|
|
1640
|
+
const outputDirectory = resolve8(options.outputDirectory ?? join9(scanRoot, ".nklattice-governance"));
|
|
1641
|
+
const inventorySource = resolve8(options.inventory ?? join9(scanRoot, ".nklattice-apps.json"));
|
|
1109
1642
|
let inventory;
|
|
1110
1643
|
const issues = [];
|
|
1111
1644
|
try {
|
|
@@ -1124,9 +1657,22 @@ async function assessGovernance(options) {
|
|
|
1124
1657
|
if (options.expectedApps !== void 0 && apps.length !== options.expectedApps) {
|
|
1125
1658
|
issues.push(`Expected ${options.expectedApps} managed apps; discovered ${apps.length}`);
|
|
1126
1659
|
}
|
|
1660
|
+
let evidence;
|
|
1661
|
+
if (assessment.evidence) {
|
|
1662
|
+
if (!options.evidence && !options.trustedEvidence) issues.push("Scan evidence is required by the policy; run governance collect and pass --evidence");
|
|
1663
|
+
else try {
|
|
1664
|
+
evidence = options.trustedEvidence ? await validateScanEvidence(options.trustedEvidence, scanRoot, workspaceRoot, options.policy.sha256, assessment.evidence.maximumAgeHours) : await loadScanEvidence(options.evidence, scanRoot, workspaceRoot, options.policy.sha256, assessment.evidence.maximumAgeHours);
|
|
1665
|
+
} catch (error) {
|
|
1666
|
+
issues.push(`Scan evidence: ${error instanceof Error ? error.message : String(error)}`);
|
|
1667
|
+
}
|
|
1668
|
+
if (evidence) {
|
|
1669
|
+
const discovered = new Set(apps.map((app) => relative7(scanRoot, app.directory).replaceAll("\\", "/") || "."));
|
|
1670
|
+
for (const item of evidence.apps) if (!discovered.has(item.path)) issues.push(`Scan evidence contains unrecognized app ${item.path}`);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1127
1673
|
const results = [];
|
|
1128
1674
|
for (const app of apps) {
|
|
1129
|
-
const appPath =
|
|
1675
|
+
const appPath = relative7(scanRoot, app.directory).replaceAll("\\", "/") || ".";
|
|
1130
1676
|
const inventoryIssues = issues.filter((issue) => issue.includes(appPath) || issue.startsWith("Inventory:") || issue.startsWith("Discovery:"));
|
|
1131
1677
|
const inventoryDomain = { status: inventoryIssues.length ? "incomplete" : "pass", issues: inventoryIssues };
|
|
1132
1678
|
const dependencyDomain = { status: "pass", issues: [], findings: [] };
|
|
@@ -1179,7 +1725,8 @@ async function assessGovernance(options) {
|
|
|
1179
1725
|
architectureDomain.status = "incomplete";
|
|
1180
1726
|
architectureDomain.issues.push(error instanceof Error ? error.message : String(error));
|
|
1181
1727
|
}
|
|
1182
|
-
const
|
|
1728
|
+
const extra = assessment.evidence ? evaluateAppEvidence(evidence?.apps.find((item) => item.path === appPath && item.profile === app.profile), assessment.evidence) : {};
|
|
1729
|
+
const domains = { inventory: inventoryDomain, dependencies: dependencyDomain, architecture: architectureDomain, ...extra };
|
|
1183
1730
|
results.push({ path: appPath, profile: app.profile, status: statusOf(Object.values(domains).map((domain) => domain.status)), domains });
|
|
1184
1731
|
}
|
|
1185
1732
|
const report = {
|
|
@@ -1187,15 +1734,42 @@ async function assessGovernance(options) {
|
|
|
1187
1734
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1188
1735
|
status: issues.length ? "incomplete" : statusOf(results.map((app) => app.status)),
|
|
1189
1736
|
policy: { version: options.policy.policyVersion, source: options.policy.source, sha256: options.policy.sha256 },
|
|
1737
|
+
...evidence ? { evidence: { source: options.evidence ? resolve8(options.evidence) : "in-process collector", generatedAt: evidence.generatedAt, commit: evidence.commit, inputSha256: evidence.inputSha256 } } : {},
|
|
1190
1738
|
inventory: { source: inventorySource, sha256: inventory?.sha256, expectedApps: inventory?.apps.length ?? 0, discoveredApps: apps.length },
|
|
1191
|
-
scope: {
|
|
1739
|
+
scope: {
|
|
1740
|
+
domains: [
|
|
1741
|
+
"explicit app inventory",
|
|
1742
|
+
"dependency minimums",
|
|
1743
|
+
...options.policy.managedPackages ? ["conditional managed-library minimums"] : [],
|
|
1744
|
+
"source architecture",
|
|
1745
|
+
...assessment.evidence ? [
|
|
1746
|
+
"known dependency advisories",
|
|
1747
|
+
"source quality",
|
|
1748
|
+
"source security",
|
|
1749
|
+
"browser and mobile emulation",
|
|
1750
|
+
"tested browser and emulated OS version floors",
|
|
1751
|
+
...assessment.evidence.accessibility ? ["automated accessibility"] : []
|
|
1752
|
+
] : []
|
|
1753
|
+
],
|
|
1754
|
+
notEvaluated: assessment.evidence ? [
|
|
1755
|
+
"licenses",
|
|
1756
|
+
"physical Android/iOS devices",
|
|
1757
|
+
"older browser-version support",
|
|
1758
|
+
"runtime performance",
|
|
1759
|
+
...assessment.evidence.accessibility ? ["manual accessibility review and unvisited routes"] : ["accessibility"],
|
|
1760
|
+
"cryptographic evidence attestation",
|
|
1761
|
+
"branch protection",
|
|
1762
|
+
"repository access controls",
|
|
1763
|
+
"cross-repository rollout status"
|
|
1764
|
+
] : notEvaluated
|
|
1765
|
+
},
|
|
1192
1766
|
issues,
|
|
1193
1767
|
apps: results
|
|
1194
1768
|
};
|
|
1195
1769
|
await mkdir(outputDirectory, { recursive: true });
|
|
1196
|
-
await writeFile(
|
|
1770
|
+
await writeFile(join9(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
|
|
1197
1771
|
`);
|
|
1198
|
-
await writeFile(
|
|
1772
|
+
await writeFile(join9(outputDirectory, "report.html"), renderAssessmentHtml(report));
|
|
1199
1773
|
return report;
|
|
1200
1774
|
}
|
|
1201
1775
|
export {
|