@anhcodo13/lattice 0.4.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 +16 -16
- package/dist/cli.js +350 -75
- package/dist/index.d.ts +47 -22
- package/dist/index.js +395 -178
- package/governance/policy.json +23 -1
- 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
|
}
|
|
@@ -169,6 +170,51 @@ function parseAssessmentPolicy(value) {
|
|
|
169
170
|
if (!Array.isArray(projects) || projects.length === 0 || !projects.every((item) => typeof item === "string" && item.length > 0) || new Set(projects).size !== projects.length) {
|
|
170
171
|
throw new Error("assessment.evidence.compatibility.requiredProjects must be a unique nonempty string array");
|
|
171
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
|
+
}
|
|
172
218
|
evidence = {
|
|
173
219
|
maximumAgeHours: gates.maximumAgeHours,
|
|
174
220
|
dependencySecurity: { maximumHigh: nonnegative2(dependencySecurity, "maximumHigh", "assessment.evidence.dependencySecurity"), maximumCritical: nonnegative2(dependencySecurity, "maximumCritical", "assessment.evidence.dependencySecurity") },
|
|
@@ -179,7 +225,13 @@ function parseAssessmentPolicy(value) {
|
|
|
179
225
|
requireTests: codeQuality.requireTests
|
|
180
226
|
},
|
|
181
227
|
codeSecurity: { maximumHigh: nonnegative2(codeSecurity, "maximumHigh", "assessment.evidence.codeSecurity"), maximumCritical: nonnegative2(codeSecurity, "maximumCritical", "assessment.evidence.codeSecurity"), maximumSecrets: nonnegative2(codeSecurity, "maximumSecrets", "assessment.evidence.codeSecurity") },
|
|
182
|
-
compatibility: {
|
|
228
|
+
compatibility: {
|
|
229
|
+
requiredProjects: projects,
|
|
230
|
+
minimumTestsPerProject: nonnegative2(compatibility, "minimumTestsPerProject", "assessment.evidence.compatibility"),
|
|
231
|
+
...minimumTestedBrowserVersions ? { minimumTestedBrowserVersions } : {},
|
|
232
|
+
...minimumEmulatedOsVersions ? { minimumEmulatedOsVersions } : {}
|
|
233
|
+
},
|
|
234
|
+
...accessibility ? { accessibility } : {}
|
|
183
235
|
};
|
|
184
236
|
}
|
|
185
237
|
return { architecture: {
|
|
@@ -219,10 +271,25 @@ function parsePolicy(raw, source) {
|
|
|
219
271
|
}
|
|
220
272
|
profiles3[name] = versions;
|
|
221
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
|
+
}
|
|
222
288
|
return {
|
|
223
289
|
schemaVersion: 2,
|
|
224
290
|
policyVersion: parsed.policyVersion,
|
|
225
291
|
profiles: profiles3,
|
|
292
|
+
...managedPackages ? { managedPackages } : {},
|
|
226
293
|
...parsed.assessment === void 0 ? {} : { assessment: parseAssessmentPolicy(parsed.assessment) },
|
|
227
294
|
source,
|
|
228
295
|
sha256: createHash("sha256").update(raw).digest("hex")
|
|
@@ -246,9 +313,145 @@ async function loadPolicy(source) {
|
|
|
246
313
|
}
|
|
247
314
|
|
|
248
315
|
// src/governance/check.ts
|
|
249
|
-
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";
|
|
250
319
|
import { satisfies, subset, valid as valid2, validRange } from "semver";
|
|
251
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
|
+
|
|
252
455
|
// src/governance/discover.ts
|
|
253
456
|
import { readFile as readFile3 } from "fs/promises";
|
|
254
457
|
import { join as join2, relative } from "path";
|
|
@@ -430,11 +633,36 @@ async function readResolution(app, workspaceRoot, name) {
|
|
|
430
633
|
}
|
|
431
634
|
|
|
432
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
|
+
}
|
|
433
659
|
async function checkApp(app, workspaceRoot, policy) {
|
|
434
660
|
const requirements = policy.profiles[app.profile];
|
|
435
661
|
if (!requirements) throw new Error(`Policy does not define profile ${app.profile}`);
|
|
436
662
|
const findings2 = [];
|
|
437
|
-
|
|
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 })) {
|
|
438
666
|
const declared = app.dependencies[packageName];
|
|
439
667
|
const resolution = declared === void 0 ? { specifier: void 0, version: void 0 } : await readResolution(app, workspaceRoot, packageName);
|
|
440
668
|
const reasons = [];
|
|
@@ -484,7 +712,7 @@ async function checkGovernance(options) {
|
|
|
484
712
|
|
|
485
713
|
// src/governance/assess.ts
|
|
486
714
|
import { mkdir, writeFile } from "fs/promises";
|
|
487
|
-
import { basename, join as
|
|
715
|
+
import { basename, join as join9, relative as relative7, resolve as resolve8, sep as sep6 } from "path";
|
|
488
716
|
|
|
489
717
|
// src/analysis/graph.ts
|
|
490
718
|
function buildGraph(_modules) {
|
|
@@ -704,153 +932,19 @@ function evaluateRules(graph, config) {
|
|
|
704
932
|
}
|
|
705
933
|
|
|
706
934
|
// src/analysis/scan.ts
|
|
707
|
-
import { readFile as
|
|
708
|
-
import { join as
|
|
709
|
-
import
|
|
710
|
-
|
|
711
|
-
// src/analysis/parse.ts
|
|
712
|
-
import ts from "typescript";
|
|
713
|
-
function hasModifier(node, kind) {
|
|
714
|
-
return ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) === true;
|
|
715
|
-
}
|
|
716
|
-
function containsJsx(node) {
|
|
717
|
-
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) return true;
|
|
718
|
-
return ts.forEachChild(node, containsJsx) === true;
|
|
719
|
-
}
|
|
720
|
-
function importNames(clause) {
|
|
721
|
-
if (!clause) return [];
|
|
722
|
-
const names = [];
|
|
723
|
-
if (clause.name) names.push("default");
|
|
724
|
-
if (clause.namedBindings) {
|
|
725
|
-
if (ts.isNamespaceImport(clause.namedBindings)) names.push("*");
|
|
726
|
-
else for (const item of clause.namedBindings.elements) names.push(item.propertyName?.text ?? item.name.text);
|
|
727
|
-
}
|
|
728
|
-
return names;
|
|
729
|
-
}
|
|
730
|
-
function sourceKind(path) {
|
|
731
|
-
if (path.endsWith(".tsx")) return ts.ScriptKind.TSX;
|
|
732
|
-
if (path.endsWith(".jsx")) return ts.ScriptKind.JSX;
|
|
733
|
-
if (path.endsWith(".js") || path.endsWith(".mjs") || path.endsWith(".cjs")) return ts.ScriptKind.JS;
|
|
734
|
-
return ts.ScriptKind.TS;
|
|
735
|
-
}
|
|
736
|
-
function parseModule(path, contents) {
|
|
737
|
-
const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
|
|
738
|
-
const imports = [];
|
|
739
|
-
const exports = [];
|
|
740
|
-
const typeExports = [];
|
|
741
|
-
let jsxDepth = 0;
|
|
742
|
-
let complexity = 1;
|
|
743
|
-
let componentCount = 0;
|
|
744
|
-
for (const node of source.statements) {
|
|
745
|
-
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
746
|
-
imports.push({
|
|
747
|
-
specifier: node.moduleSpecifier.text,
|
|
748
|
-
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
749
|
-
importedNames: importNames(node.importClause),
|
|
750
|
-
isTypeOnly: node.importClause?.isTypeOnly ?? false
|
|
751
|
-
});
|
|
752
|
-
}
|
|
753
|
-
if (ts.isExportDeclaration(node)) {
|
|
754
|
-
if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
755
|
-
imports.push({
|
|
756
|
-
specifier: node.moduleSpecifier.text,
|
|
757
|
-
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
758
|
-
importedNames: node.exportClause && ts.isNamedExports(node.exportClause) ? node.exportClause.elements.map((item) => item.propertyName?.text ?? item.name.text) : ["*"],
|
|
759
|
-
isTypeOnly: node.isTypeOnly
|
|
760
|
-
});
|
|
761
|
-
}
|
|
762
|
-
if (node.exportClause && ts.isNamedExports(node.exportClause)) {
|
|
763
|
-
exports.push(...node.exportClause.elements.map((item) => item.name.text));
|
|
764
|
-
if (node.isTypeOnly) typeExports.push(...node.exportClause.elements.map((item) => item.name.text));
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
if (hasModifier(node, ts.SyntaxKind.ExportKeyword)) {
|
|
768
|
-
if (hasModifier(node, ts.SyntaxKind.DefaultKeyword)) exports.push("default");
|
|
769
|
-
else if (ts.isVariableStatement(node)) {
|
|
770
|
-
for (const declaration of node.declarationList.declarations) {
|
|
771
|
-
if (ts.isIdentifier(declaration.name)) exports.push(declaration.name.text);
|
|
772
|
-
}
|
|
773
|
-
} else if ("name" in node && node.name && ts.isIdentifier(node.name)) {
|
|
774
|
-
const name = node.name.text;
|
|
775
|
-
exports.push(name);
|
|
776
|
-
if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) typeExports.push(name);
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
if (ts.isFunctionDeclaration(node) && node.name && /^[A-Z]/.test(node.name.text) && containsJsx(node)) {
|
|
780
|
-
componentCount++;
|
|
781
|
-
}
|
|
782
|
-
if (ts.isVariableStatement(node)) {
|
|
783
|
-
for (const declaration of node.declarationList.declarations) {
|
|
784
|
-
if (ts.isIdentifier(declaration.name) && /^[A-Z]/.test(declaration.name.text) && declaration.initializer && containsJsx(declaration.initializer)) {
|
|
785
|
-
componentCount++;
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
function visit(node, depth) {
|
|
791
|
-
if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
|
|
792
|
-
const argument = node.arguments[0];
|
|
793
|
-
if (argument && ts.isStringLiteralLike(argument)) {
|
|
794
|
-
imports.push({
|
|
795
|
-
specifier: argument.text,
|
|
796
|
-
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
797
|
-
importedNames: ["*"],
|
|
798
|
-
isTypeOnly: false
|
|
799
|
-
});
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
const isJsx = ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node);
|
|
803
|
-
const nextDepth = depth + (isJsx ? 1 : 0);
|
|
804
|
-
jsxDepth = Math.max(jsxDepth, nextDepth);
|
|
805
|
-
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++;
|
|
806
|
-
if (ts.isBinaryExpression(node) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(node.operatorToken.kind)) {
|
|
807
|
-
complexity++;
|
|
808
|
-
}
|
|
809
|
-
ts.forEachChild(node, (child) => visit(child, nextDepth));
|
|
810
|
-
}
|
|
811
|
-
visit(source, 0);
|
|
812
|
-
return {
|
|
813
|
-
path,
|
|
814
|
-
imports,
|
|
815
|
-
exports: [...new Set(exports)].sort(),
|
|
816
|
-
typeExports: [...new Set(typeExports)].sort(),
|
|
817
|
-
lines: contents.split(/\r?\n/).length,
|
|
818
|
-
jsxDepth,
|
|
819
|
-
componentCount,
|
|
820
|
-
complexity,
|
|
821
|
-
isRoute: /(^|\/)(app|pages)\/.+(page|layout|route|index)\.[cm]?[jt]sx?$/.test(path) || /(^|\/)app\/(page|layout|route)\.[cm]?[jt]sx?$/.test(path)
|
|
822
|
-
};
|
|
823
|
-
}
|
|
824
|
-
function moduleParseDiagnostics(path, contents) {
|
|
825
|
-
const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
|
|
826
|
-
const diagnostics = source.parseDiagnostics ?? [];
|
|
827
|
-
const messages = diagnostics.map((diagnostic) => {
|
|
828
|
-
const line = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0).line + 1;
|
|
829
|
-
return `${path}:${line}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, " ")}`;
|
|
830
|
-
});
|
|
831
|
-
function visit(node) {
|
|
832
|
-
if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
|
|
833
|
-
const argument = node.arguments[0];
|
|
834
|
-
if (!argument || !ts.isStringLiteralLike(argument)) {
|
|
835
|
-
const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
|
|
836
|
-
messages.push(`${path}:${line}: nonliteral dynamic import or require cannot be analyzed`);
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
ts.forEachChild(node, visit);
|
|
840
|
-
}
|
|
841
|
-
visit(source);
|
|
842
|
-
return messages;
|
|
843
|
-
}
|
|
935
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
936
|
+
import { join as join6, resolve as resolve5 } from "path";
|
|
937
|
+
import fastGlob3 from "fast-glob";
|
|
844
938
|
|
|
845
939
|
// src/analysis/resolve.ts
|
|
846
|
-
import { readFile as
|
|
847
|
-
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";
|
|
848
942
|
import ts2 from "typescript";
|
|
849
943
|
async function compilerOptions(root) {
|
|
850
|
-
const path =
|
|
944
|
+
const path = join5(root, "tsconfig.json");
|
|
851
945
|
let content;
|
|
852
946
|
try {
|
|
853
|
-
content = await
|
|
947
|
+
content = await readFile6(path, "utf8");
|
|
854
948
|
} catch (error) {
|
|
855
949
|
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return { options: {}, diagnostics: [] };
|
|
856
950
|
throw error;
|
|
@@ -927,7 +1021,7 @@ async function viteAliasDiagnostics(root, options, aliases) {
|
|
|
927
1021
|
var visit = visit2;
|
|
928
1022
|
let sourceText;
|
|
929
1023
|
try {
|
|
930
|
-
sourceText = await
|
|
1024
|
+
sourceText = await readFile6(join5(root, file), "utf8");
|
|
931
1025
|
} catch (error) {
|
|
932
1026
|
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") continue;
|
|
933
1027
|
throw error;
|
|
@@ -958,12 +1052,12 @@ async function resolveImportsDetailed(root, modules, aliases = {}) {
|
|
|
958
1052
|
diagnostics.push(...await viteAliasDiagnostics(root, options, aliases));
|
|
959
1053
|
const files = new Set(modules.map((module) => module.path));
|
|
960
1054
|
for (const module of modules) {
|
|
961
|
-
const from =
|
|
1055
|
+
const from = join5(root, module.path);
|
|
962
1056
|
for (const imported of module.imports) {
|
|
963
1057
|
const specifier = imported.specifier.split("?")[0];
|
|
964
1058
|
const matchingAlias = Object.keys(aliases).sort((a, b) => b.length - a.length).find((prefix) => specifier.startsWith(prefix));
|
|
965
1059
|
const aliasTarget = matchingAlias ? resolve4(root, aliases[matchingAlias], specifier.slice(matchingAlias.length)) : void 0;
|
|
966
|
-
const candidate = aliasTarget ? relative4(
|
|
1060
|
+
const candidate = aliasTarget ? relative4(dirname3(from), aliasTarget).replaceAll("\\", "/") : specifier;
|
|
967
1061
|
const lookup = aliasTarget && !candidate.startsWith(".") ? `./${candidate}` : candidate;
|
|
968
1062
|
const result = ts2.resolveModuleName(lookup, from, options, ts2.sys).resolvedModule;
|
|
969
1063
|
const localAlias = Object.keys(options.paths ?? {}).some((pattern) => {
|
|
@@ -972,7 +1066,7 @@ async function resolveImportsDetailed(root, modules, aliases = {}) {
|
|
|
972
1066
|
});
|
|
973
1067
|
const isLocal = specifier.startsWith(".") || localAlias || aliasTarget !== void 0 || /^[~@#]\//.test(specifier);
|
|
974
1068
|
if (!result) {
|
|
975
|
-
const assetPath = aliasTarget ?? (specifier.startsWith(".") ? resolve4(
|
|
1069
|
+
const assetPath = aliasTarget ?? (specifier.startsWith(".") ? resolve4(dirname3(from), specifier) : void 0);
|
|
976
1070
|
const assetRelative = assetPath ? relative4(root, assetPath) : void 0;
|
|
977
1071
|
if (assetRelative && (assetRelative === ".." || assetRelative.startsWith(`..${sep3}`))) {
|
|
978
1072
|
diagnostics.push(`${module.path}:${imported.line}: local import escapes app root ${imported.specifier}`);
|
|
@@ -998,7 +1092,7 @@ async function resolveImportsDetailed(root, modules, aliases = {}) {
|
|
|
998
1092
|
// src/analysis/scan.ts
|
|
999
1093
|
async function scanProjectDetailed(projectRoot, aliases = {}) {
|
|
1000
1094
|
const root = resolve5(projectRoot);
|
|
1001
|
-
const files = await
|
|
1095
|
+
const files = await fastGlob3("**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", {
|
|
1002
1096
|
cwd: root,
|
|
1003
1097
|
onlyFiles: true,
|
|
1004
1098
|
unique: true,
|
|
@@ -1008,7 +1102,7 @@ async function scanProjectDetailed(projectRoot, aliases = {}) {
|
|
|
1008
1102
|
});
|
|
1009
1103
|
const parsed = await Promise.all(files.sort().map(async (path) => {
|
|
1010
1104
|
const normalized = path.replaceAll("\\", "/");
|
|
1011
|
-
const contents = await
|
|
1105
|
+
const contents = await readFile7(join6(root, path), "utf8");
|
|
1012
1106
|
return { module: parseModule(normalized, contents), diagnostics: moduleParseDiagnostics(normalized, contents) };
|
|
1013
1107
|
}));
|
|
1014
1108
|
const modules = parsed.map((item) => item.module);
|
|
@@ -1052,8 +1146,8 @@ function scoreFindings(findings2, config) {
|
|
|
1052
1146
|
|
|
1053
1147
|
// src/governance/inventory.ts
|
|
1054
1148
|
import { createHash as createHash2 } from "crypto";
|
|
1055
|
-
import { readFile as
|
|
1056
|
-
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";
|
|
1057
1151
|
var profiles2 = /* @__PURE__ */ new Set(["next", "vite-react", "react"]);
|
|
1058
1152
|
async function loadInventory(source, scanRoot) {
|
|
1059
1153
|
const absolute = resolve6(source);
|
|
@@ -1061,7 +1155,7 @@ async function loadInventory(source, scanRoot) {
|
|
|
1061
1155
|
const inventoryPath = await realpath(absolute);
|
|
1062
1156
|
const inventoryRelative = relative5(root, inventoryPath);
|
|
1063
1157
|
if (inventoryRelative === ".." || inventoryRelative.startsWith(`..${sep4}`)) throw new Error("Inventory must be inside scan root");
|
|
1064
|
-
const raw = await
|
|
1158
|
+
const raw = await readFile8(inventoryPath, "utf8");
|
|
1065
1159
|
let value;
|
|
1066
1160
|
try {
|
|
1067
1161
|
value = JSON.parse(raw);
|
|
@@ -1086,7 +1180,7 @@ async function loadInventory(source, scanRoot) {
|
|
|
1086
1180
|
if (seen.has(path)) throw new Error(`Duplicate inventory app ${path}`);
|
|
1087
1181
|
seen.add(path);
|
|
1088
1182
|
try {
|
|
1089
|
-
const actual = await realpath(
|
|
1183
|
+
const actual = await realpath(join7(root, path));
|
|
1090
1184
|
const rel = relative5(root, actual);
|
|
1091
1185
|
if (rel === ".." || rel.startsWith(`..${sep4}`)) throw new Error(`App path escapes scan root: ${path}`);
|
|
1092
1186
|
} catch (error) {
|
|
@@ -1112,8 +1206,8 @@ function compareInventory(inventory, discovered, scanRoot) {
|
|
|
1112
1206
|
import { createHash as createHash3 } from "crypto";
|
|
1113
1207
|
import { execFile } from "child_process";
|
|
1114
1208
|
import { createReadStream } from "fs";
|
|
1115
|
-
import { lstat, readFile as
|
|
1116
|
-
import { dirname as
|
|
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";
|
|
1117
1211
|
import { promisify } from "util";
|
|
1118
1212
|
import fg from "fast-glob";
|
|
1119
1213
|
var execFileAsync = promisify(execFile);
|
|
@@ -1145,7 +1239,11 @@ function validateApp(value) {
|
|
|
1145
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;
|
|
1146
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;
|
|
1147
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;
|
|
1148
|
-
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.viewportWidths === void 0 || Array.isArray(project.viewportWidths) && project.viewportWidths.every(nonnegative)))) 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
|
+
}
|
|
1149
1247
|
return true;
|
|
1150
1248
|
}
|
|
1151
1249
|
async function hashFile(hash, path) {
|
|
@@ -1190,7 +1288,7 @@ async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
|
|
|
1190
1288
|
hash.update("\0");
|
|
1191
1289
|
}
|
|
1192
1290
|
let ancestor = root;
|
|
1193
|
-
while (ancestor === workspace || ancestor !==
|
|
1291
|
+
while (ancestor === workspace || ancestor !== dirname4(ancestor) && !relative6(workspace, ancestor).startsWith("..")) {
|
|
1194
1292
|
for (const name of [
|
|
1195
1293
|
"package.json",
|
|
1196
1294
|
"package-lock.json",
|
|
@@ -1215,7 +1313,7 @@ async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
|
|
|
1215
1313
|
"semgrep.yml",
|
|
1216
1314
|
"semgrep.yaml"
|
|
1217
1315
|
]) {
|
|
1218
|
-
const path =
|
|
1316
|
+
const path = join8(ancestor, name);
|
|
1219
1317
|
try {
|
|
1220
1318
|
const stat2 = await lstat(path);
|
|
1221
1319
|
if (stat2.isSymbolicLink()) throw new Error(`Symlinked scanner configuration is unsupported: ${path}`);
|
|
@@ -1229,7 +1327,7 @@ async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
|
|
|
1229
1327
|
}
|
|
1230
1328
|
}
|
|
1231
1329
|
if (ancestor === workspace) break;
|
|
1232
|
-
ancestor =
|
|
1330
|
+
ancestor = dirname4(ancestor);
|
|
1233
1331
|
}
|
|
1234
1332
|
return hash.digest("hex");
|
|
1235
1333
|
}
|
|
@@ -1246,7 +1344,7 @@ async function loadScanEvidence(path, scanRoot, workspaceRoot, policySha256, max
|
|
|
1246
1344
|
if (actualRelative !== ".." && !actualRelative.startsWith(`..${sep5}`)) throw new Error("Evidence file resolves inside the scan root");
|
|
1247
1345
|
let parsed;
|
|
1248
1346
|
try {
|
|
1249
|
-
parsed = JSON.parse(await
|
|
1347
|
+
parsed = JSON.parse(await readFile9(source, "utf8"));
|
|
1250
1348
|
} catch {
|
|
1251
1349
|
throw new Error("Evidence is not valid JSON");
|
|
1252
1350
|
}
|
|
@@ -1267,10 +1365,28 @@ async function validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha25
|
|
|
1267
1365
|
return parsed;
|
|
1268
1366
|
}
|
|
1269
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
|
+
}
|
|
1270
1380
|
function evaluateAppEvidence(app, policy) {
|
|
1271
1381
|
if (!app) {
|
|
1272
1382
|
const missing = incomplete(["No fresh scan evidence for this app"]);
|
|
1273
|
-
return {
|
|
1383
|
+
return {
|
|
1384
|
+
dependencySecurity: { ...missing },
|
|
1385
|
+
codeQuality: { ...missing },
|
|
1386
|
+
codeSecurity: { ...missing },
|
|
1387
|
+
compatibility: { ...missing },
|
|
1388
|
+
...policy.accessibility ? { accessibility: { ...missing } } : {}
|
|
1389
|
+
};
|
|
1274
1390
|
}
|
|
1275
1391
|
const dependency = app.dependencySecurity;
|
|
1276
1392
|
let dependencySecurity;
|
|
@@ -1360,6 +1476,28 @@ function evaluateAppEvidence(app, policy) {
|
|
|
1360
1476
|
const invalidEngines = compatibility.projects.filter((project) => expectedEngines[project.name] && project.engine !== expectedEngines[project.name]);
|
|
1361
1477
|
const invalidDevices = compatibility.projects.filter((project) => (project.name === "android-chrome" || project.name === "iphone-safari") && (project.mobile !== true || !project.device));
|
|
1362
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
|
+
}
|
|
1363
1501
|
const compatibilityMissing = [
|
|
1364
1502
|
...!compatibility.complete ? ["Compatibility test run did not complete", ...compatibility.errors] : [],
|
|
1365
1503
|
...missingProjects.length ? [`Missing Playwright projects: ${missingProjects.join(", ")}`] : [],
|
|
@@ -1367,18 +1505,72 @@ function evaluateAppEvidence(app, policy) {
|
|
|
1367
1505
|
...missingBrowserVersions.map((project) => `${project.name} did not record its browser engine version`),
|
|
1368
1506
|
...invalidEngines.map((project) => `${project.name} used ${project.engine ?? "an unverified engine"} instead of ${expectedEngines[project.name]}`),
|
|
1369
1507
|
...invalidDevices.map((project) => `${project.name} did not verify mobile emulation settings`),
|
|
1370
|
-
...missingViewports.map((project) => `${project.name} did not test two viewport widths`)
|
|
1508
|
+
...missingViewports.map((project) => `${project.name} did not test two viewport widths`),
|
|
1509
|
+
...versionEvidenceMissing
|
|
1371
1510
|
];
|
|
1372
1511
|
const failedProjects = compatibility.projects.filter((project) => project.failed > 0);
|
|
1373
|
-
const
|
|
1374
|
-
|
|
1375
|
-
|
|
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],
|
|
1376
1521
|
tool: compatibility.tool,
|
|
1377
1522
|
version: compatibility.version,
|
|
1378
1523
|
scanned: compatibility.projects.reduce((count, project) => count + project.passed + project.failed, 0),
|
|
1379
|
-
matrix
|
|
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 } : {}
|
|
1380
1573
|
};
|
|
1381
|
-
return { dependencySecurity, codeQuality, codeSecurity, compatibility: compatibilityDomain };
|
|
1382
1574
|
}
|
|
1383
1575
|
|
|
1384
1576
|
// src/governance/assess.ts
|
|
@@ -1408,7 +1600,8 @@ function renderAssessmentHtml(report) {
|
|
|
1408
1600
|
dependencySecurity: "Dependency security",
|
|
1409
1601
|
codeQuality: "Code quality",
|
|
1410
1602
|
codeSecurity: "Code security",
|
|
1411
|
-
compatibility: "Browser compatibility"
|
|
1603
|
+
compatibility: "Browser compatibility",
|
|
1604
|
+
accessibility: "Accessibility"
|
|
1412
1605
|
};
|
|
1413
1606
|
const domainNames = report.apps.length ? Object.keys(report.apps[0].domains) : [];
|
|
1414
1607
|
const overview = domainNames.map((name) => {
|
|
@@ -1423,8 +1616,9 @@ function renderAssessmentHtml(report) {
|
|
|
1423
1616
|
const identity = evidence.tool ? `<p><small>${escapeHtml(evidence.tool)} ${escapeHtml(evidence.version ?? "")} \xB7 scanned ${escapeHtml(evidence.scanned ?? "unknown")}</small></p>` : "";
|
|
1424
1617
|
const metrics = evidence.metrics ? `<p><small>${Object.entries(evidence.metrics).map(([key, value]) => `${escapeHtml(key)}: ${escapeHtml(value)}`).join(" \xB7 ")}</small></p>` : "";
|
|
1425
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>` : "";
|
|
1426
|
-
const matrix = evidence.matrix?.length ? `<div class="scroll"><table><thead><tr><th>Project</th><th>Passed</th><th>Failed</th><th>Skipped</th><th>
|
|
1427
|
-
|
|
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>`;
|
|
1428
1622
|
}).join("");
|
|
1429
1623
|
const architecture = app.domains.architecture;
|
|
1430
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("") ?? "";
|
|
@@ -1443,8 +1637,8 @@ async function assessGovernance(options) {
|
|
|
1443
1637
|
if (scanRelative === ".." || scanRelative.startsWith(`..${sep6}`)) throw new Error("Scan root must be inside workspace root");
|
|
1444
1638
|
if (!options.policy.assessment) throw new Error("Policy assessment.architecture is required");
|
|
1445
1639
|
const assessment = parseAssessmentPolicy(options.policy.assessment);
|
|
1446
|
-
const outputDirectory = resolve8(options.outputDirectory ??
|
|
1447
|
-
const inventorySource = resolve8(options.inventory ??
|
|
1640
|
+
const outputDirectory = resolve8(options.outputDirectory ?? join9(scanRoot, ".nklattice-governance"));
|
|
1641
|
+
const inventorySource = resolve8(options.inventory ?? join9(scanRoot, ".nklattice-apps.json"));
|
|
1448
1642
|
let inventory;
|
|
1449
1643
|
const issues = [];
|
|
1450
1644
|
try {
|
|
@@ -1543,16 +1737,39 @@ async function assessGovernance(options) {
|
|
|
1543
1737
|
...evidence ? { evidence: { source: options.evidence ? resolve8(options.evidence) : "in-process collector", generatedAt: evidence.generatedAt, commit: evidence.commit, inputSha256: evidence.inputSha256 } } : {},
|
|
1544
1738
|
inventory: { source: inventorySource, sha256: inventory?.sha256, expectedApps: inventory?.apps.length ?? 0, discoveredApps: apps.length },
|
|
1545
1739
|
scope: {
|
|
1546
|
-
domains: [
|
|
1547
|
-
|
|
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
|
|
1548
1765
|
},
|
|
1549
1766
|
issues,
|
|
1550
1767
|
apps: results
|
|
1551
1768
|
};
|
|
1552
1769
|
await mkdir(outputDirectory, { recursive: true });
|
|
1553
|
-
await writeFile(
|
|
1770
|
+
await writeFile(join9(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
|
|
1554
1771
|
`);
|
|
1555
|
-
await writeFile(
|
|
1772
|
+
await writeFile(join9(outputDirectory, "report.html"), renderAssessmentHtml(report));
|
|
1556
1773
|
return report;
|
|
1557
1774
|
}
|
|
1558
1775
|
export {
|