@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/cli.js
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
import { writeFile as writeFile4 } from "fs/promises";
|
|
5
5
|
import { realpathSync } from "fs";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
|
-
import { dirname as
|
|
7
|
+
import { dirname as dirname6, join as join13, resolve as resolve11 } from "path";
|
|
8
8
|
import { Command, CommanderError } from "commander";
|
|
9
9
|
|
|
10
10
|
// package.json
|
|
11
11
|
var package_default = {
|
|
12
12
|
name: "@anhcodo13/lattice",
|
|
13
|
-
version: "0.
|
|
13
|
+
version: "0.5.0",
|
|
14
14
|
private: false,
|
|
15
15
|
packageManager: "pnpm@10.30.3",
|
|
16
16
|
type: "module",
|
|
@@ -1159,7 +1159,9 @@ async function analyzeProject(projectRoot, options = {}) {
|
|
|
1159
1159
|
}
|
|
1160
1160
|
|
|
1161
1161
|
// src/governance/check.ts
|
|
1162
|
-
import {
|
|
1162
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
1163
|
+
import { resolve as resolve5, relative as relative4, sep as sep3, join as join8, dirname as dirname3 } from "path";
|
|
1164
|
+
import fastGlob3 from "fast-glob";
|
|
1163
1165
|
import { satisfies, subset, valid, validRange } from "semver";
|
|
1164
1166
|
|
|
1165
1167
|
// src/governance/discover.ts
|
|
@@ -1343,11 +1345,36 @@ async function readResolution(app, workspaceRoot, name) {
|
|
|
1343
1345
|
}
|
|
1344
1346
|
|
|
1345
1347
|
// src/governance/check.ts
|
|
1348
|
+
var scanIgnore = ["**/node_modules/**", "**/.git/**", "**/.next/**", "**/dist/**", "**/build/**", "**/out/**", "**/coverage/**", "**/.turbo/**", "**/.vercel/**", "**/.astro/**", "**/.nklattice-report/**", "**/.nklattice-governance/**", "**/*.d.ts"];
|
|
1349
|
+
async function importedManagedPackages(app, managed) {
|
|
1350
|
+
if (Object.keys(managed).length === 0) return /* @__PURE__ */ new Set();
|
|
1351
|
+
const nestedManifests = await fastGlob3("**/package.json", { cwd: app.directory, onlyFiles: true, dot: true, followSymbolicLinks: false, ignore: scanIgnore });
|
|
1352
|
+
const nestedIgnore = nestedManifests.filter((path) => path !== "package.json").map((path) => `${dirname3(path)}/**`);
|
|
1353
|
+
const files = await fastGlob3("**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", {
|
|
1354
|
+
cwd: app.directory,
|
|
1355
|
+
onlyFiles: true,
|
|
1356
|
+
dot: true,
|
|
1357
|
+
followSymbolicLinks: false,
|
|
1358
|
+
ignore: [...scanIgnore, ...nestedIgnore]
|
|
1359
|
+
});
|
|
1360
|
+
const found = /* @__PURE__ */ new Set();
|
|
1361
|
+
await Promise.all(files.map(async (path) => {
|
|
1362
|
+
const module = parseModule(path, await readFile7(join8(app.directory, path), "utf8"));
|
|
1363
|
+
for (const imported of module.imports) {
|
|
1364
|
+
for (const name of Object.keys(managed)) {
|
|
1365
|
+
if (imported.specifier === name || imported.specifier.startsWith(`${name}/`)) found.add(name);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
}));
|
|
1369
|
+
return found;
|
|
1370
|
+
}
|
|
1346
1371
|
async function checkApp(app, workspaceRoot, policy) {
|
|
1347
1372
|
const requirements = policy.profiles[app.profile];
|
|
1348
1373
|
if (!requirements) throw new Error(`Policy does not define profile ${app.profile}`);
|
|
1349
1374
|
const findings2 = [];
|
|
1350
|
-
|
|
1375
|
+
const imported = await importedManagedPackages(app, policy.managedPackages ?? {});
|
|
1376
|
+
const managed = Object.fromEntries(Object.entries(policy.managedPackages ?? {}).filter(([packageName]) => app.dependencies[packageName] !== void 0 || imported.has(packageName)));
|
|
1377
|
+
for (const [packageName, expected] of Object.entries({ ...requirements, ...managed })) {
|
|
1351
1378
|
const declared = app.dependencies[packageName];
|
|
1352
1379
|
const resolution = declared === void 0 ? { specifier: void 0, version: void 0 } : await readResolution(app, workspaceRoot, packageName);
|
|
1353
1380
|
const reasons = [];
|
|
@@ -1397,12 +1424,12 @@ async function checkGovernance(options) {
|
|
|
1397
1424
|
|
|
1398
1425
|
// src/governance/assess.ts
|
|
1399
1426
|
import { mkdir as mkdir3, writeFile as writeFile2 } from "fs/promises";
|
|
1400
|
-
import { basename as basename2, join as
|
|
1427
|
+
import { basename as basename2, join as join11, relative as relative7, resolve as resolve9, sep as sep6 } from "path";
|
|
1401
1428
|
|
|
1402
1429
|
// src/governance/inventory.ts
|
|
1403
1430
|
import { createHash } from "crypto";
|
|
1404
|
-
import { readFile as
|
|
1405
|
-
import { isAbsolute, join as
|
|
1431
|
+
import { readFile as readFile8, realpath } from "fs/promises";
|
|
1432
|
+
import { isAbsolute, join as join9, relative as relative5, resolve as resolve6, sep as sep4 } from "path";
|
|
1406
1433
|
var profiles2 = /* @__PURE__ */ new Set(["next", "vite-react", "react"]);
|
|
1407
1434
|
async function loadInventory(source, scanRoot) {
|
|
1408
1435
|
const absolute = resolve6(source);
|
|
@@ -1410,7 +1437,7 @@ async function loadInventory(source, scanRoot) {
|
|
|
1410
1437
|
const inventoryPath = await realpath(absolute);
|
|
1411
1438
|
const inventoryRelative = relative5(root, inventoryPath);
|
|
1412
1439
|
if (inventoryRelative === ".." || inventoryRelative.startsWith(`..${sep4}`)) throw new Error("Inventory must be inside scan root");
|
|
1413
|
-
const raw = await
|
|
1440
|
+
const raw = await readFile8(inventoryPath, "utf8");
|
|
1414
1441
|
let value;
|
|
1415
1442
|
try {
|
|
1416
1443
|
value = JSON.parse(raw);
|
|
@@ -1435,7 +1462,7 @@ async function loadInventory(source, scanRoot) {
|
|
|
1435
1462
|
if (seen.has(path)) throw new Error(`Duplicate inventory app ${path}`);
|
|
1436
1463
|
seen.add(path);
|
|
1437
1464
|
try {
|
|
1438
|
-
const actual = await realpath(
|
|
1465
|
+
const actual = await realpath(join9(root, path));
|
|
1439
1466
|
const rel = relative5(root, actual);
|
|
1440
1467
|
if (rel === ".." || rel.startsWith(`..${sep4}`)) throw new Error(`App path escapes scan root: ${path}`);
|
|
1441
1468
|
} catch (error) {
|
|
@@ -1461,8 +1488,8 @@ function compareInventory(inventory, discovered, scanRoot) {
|
|
|
1461
1488
|
import { createHash as createHash2 } from "crypto";
|
|
1462
1489
|
import { execFile } from "child_process";
|
|
1463
1490
|
import { createReadStream } from "fs";
|
|
1464
|
-
import { lstat, readFile as
|
|
1465
|
-
import { dirname as
|
|
1491
|
+
import { lstat, readFile as readFile9, realpath as realpath2 } from "fs/promises";
|
|
1492
|
+
import { dirname as dirname4, join as join10, relative as relative6, resolve as resolve7, sep as sep5 } from "path";
|
|
1466
1493
|
import { promisify } from "util";
|
|
1467
1494
|
import fg from "fast-glob";
|
|
1468
1495
|
var execFileAsync = promisify(execFile);
|
|
@@ -1494,7 +1521,11 @@ function validateApp(value) {
|
|
|
1494
1521
|
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;
|
|
1495
1522
|
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;
|
|
1496
1523
|
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;
|
|
1497
|
-
if (!base(compatibility2) || !Array.isArray(compatibility2.projects) || !compatibility2.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;
|
|
1524
|
+
if (!base(compatibility2) || !Array.isArray(compatibility2.projects) || !compatibility2.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;
|
|
1525
|
+
if (value.accessibility !== void 0) {
|
|
1526
|
+
const accessibility = value.accessibility;
|
|
1527
|
+
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;
|
|
1528
|
+
}
|
|
1498
1529
|
return true;
|
|
1499
1530
|
}
|
|
1500
1531
|
async function hashFile(hash, path) {
|
|
@@ -1539,7 +1570,7 @@ async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
|
|
|
1539
1570
|
hash.update("\0");
|
|
1540
1571
|
}
|
|
1541
1572
|
let ancestor = root;
|
|
1542
|
-
while (ancestor === workspace || ancestor !==
|
|
1573
|
+
while (ancestor === workspace || ancestor !== dirname4(ancestor) && !relative6(workspace, ancestor).startsWith("..")) {
|
|
1543
1574
|
for (const name of [
|
|
1544
1575
|
"package.json",
|
|
1545
1576
|
"package-lock.json",
|
|
@@ -1564,7 +1595,7 @@ async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
|
|
|
1564
1595
|
"semgrep.yml",
|
|
1565
1596
|
"semgrep.yaml"
|
|
1566
1597
|
]) {
|
|
1567
|
-
const path =
|
|
1598
|
+
const path = join10(ancestor, name);
|
|
1568
1599
|
try {
|
|
1569
1600
|
const stat2 = await lstat(path);
|
|
1570
1601
|
if (stat2.isSymbolicLink()) throw new Error(`Symlinked scanner configuration is unsupported: ${path}`);
|
|
@@ -1578,7 +1609,7 @@ async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
|
|
|
1578
1609
|
}
|
|
1579
1610
|
}
|
|
1580
1611
|
if (ancestor === workspace) break;
|
|
1581
|
-
ancestor =
|
|
1612
|
+
ancestor = dirname4(ancestor);
|
|
1582
1613
|
}
|
|
1583
1614
|
return hash.digest("hex");
|
|
1584
1615
|
}
|
|
@@ -1595,7 +1626,7 @@ async function loadScanEvidence(path, scanRoot, workspaceRoot, policySha256, max
|
|
|
1595
1626
|
if (actualRelative !== ".." && !actualRelative.startsWith(`..${sep5}`)) throw new Error("Evidence file resolves inside the scan root");
|
|
1596
1627
|
let parsed;
|
|
1597
1628
|
try {
|
|
1598
|
-
parsed = JSON.parse(await
|
|
1629
|
+
parsed = JSON.parse(await readFile9(source, "utf8"));
|
|
1599
1630
|
} catch {
|
|
1600
1631
|
throw new Error("Evidence is not valid JSON");
|
|
1601
1632
|
}
|
|
@@ -1616,10 +1647,28 @@ async function validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha25
|
|
|
1616
1647
|
return parsed;
|
|
1617
1648
|
}
|
|
1618
1649
|
var incomplete = (issues, tool, version2, scanned) => ({ status: "incomplete", issues, tool, version: version2, scanned });
|
|
1650
|
+
function numericVersion(value) {
|
|
1651
|
+
if (!value || !/^(0|[1-9]\d*)(?:\.(0|[1-9]\d*)){0,3}$/.test(value)) return void 0;
|
|
1652
|
+
const parts = value.split(".").map(Number);
|
|
1653
|
+
return parts.every(Number.isSafeInteger) ? parts : void 0;
|
|
1654
|
+
}
|
|
1655
|
+
function belowVersion(actual, minimum) {
|
|
1656
|
+
for (let index = 0; index < Math.max(actual.length, minimum.length); index++) {
|
|
1657
|
+
if ((actual[index] ?? 0) < (minimum[index] ?? 0)) return true;
|
|
1658
|
+
if ((actual[index] ?? 0) > (minimum[index] ?? 0)) return false;
|
|
1659
|
+
}
|
|
1660
|
+
return false;
|
|
1661
|
+
}
|
|
1619
1662
|
function evaluateAppEvidence(app, policy) {
|
|
1620
1663
|
if (!app) {
|
|
1621
1664
|
const missing = incomplete(["No fresh scan evidence for this app"]);
|
|
1622
|
-
return {
|
|
1665
|
+
return {
|
|
1666
|
+
dependencySecurity: { ...missing },
|
|
1667
|
+
codeQuality: { ...missing },
|
|
1668
|
+
codeSecurity: { ...missing },
|
|
1669
|
+
compatibility: { ...missing },
|
|
1670
|
+
...policy.accessibility ? { accessibility: { ...missing } } : {}
|
|
1671
|
+
};
|
|
1623
1672
|
}
|
|
1624
1673
|
const dependency = app.dependencySecurity;
|
|
1625
1674
|
let dependencySecurity;
|
|
@@ -1709,6 +1758,28 @@ function evaluateAppEvidence(app, policy) {
|
|
|
1709
1758
|
const invalidEngines = compatibility2.projects.filter((project) => expectedEngines[project.name] && project.engine !== expectedEngines[project.name]);
|
|
1710
1759
|
const invalidDevices = compatibility2.projects.filter((project) => (project.name === "android-chrome" || project.name === "iphone-safari") && (project.mobile !== true || !project.device));
|
|
1711
1760
|
const missingViewports = compatibility2.projects.filter((project) => new Set(project.viewportWidths ?? []).size < 2);
|
|
1761
|
+
const versionEvidenceMissing = [];
|
|
1762
|
+
const versionFailures = [];
|
|
1763
|
+
for (const [name, minimum] of Object.entries(policy.compatibility.minimumTestedBrowserVersions ?? {})) {
|
|
1764
|
+
const project = compatibility2.projects.find((item) => item.name === name);
|
|
1765
|
+
if (!project) continue;
|
|
1766
|
+
const actual = numericVersion(project.browserVersion);
|
|
1767
|
+
if (!actual) versionEvidenceMissing.push(`${name} did not record a valid measured browser version`);
|
|
1768
|
+
else if (belowVersion(actual, numericVersion(minimum))) {
|
|
1769
|
+
versionFailures.push(`${name} measured browser version ${project.browserVersion} is below minimum tested browser version ${minimum}`);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
for (const [name, minimum] of Object.entries(policy.compatibility.minimumEmulatedOsVersions ?? {})) {
|
|
1773
|
+
const project = compatibility2.projects.find((item) => item.name === name);
|
|
1774
|
+
if (!project) continue;
|
|
1775
|
+
const expectedOs = name === "android-chrome" ? "Android" : "iOS";
|
|
1776
|
+
const actual = numericVersion(project.emulatedOsVersion);
|
|
1777
|
+
if (project.emulatedOs !== expectedOs || !actual) {
|
|
1778
|
+
versionEvidenceMissing.push(`${name} did not record a valid emulated ${expectedOs} version`);
|
|
1779
|
+
} else if (belowVersion(actual, numericVersion(minimum))) {
|
|
1780
|
+
versionFailures.push(`${name} emulated ${expectedOs} version ${project.emulatedOsVersion} is below minimum emulated ${expectedOs} version ${minimum}`);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1712
1783
|
const compatibilityMissing = [
|
|
1713
1784
|
...!compatibility2.complete ? ["Compatibility test run did not complete", ...compatibility2.errors] : [],
|
|
1714
1785
|
...missingProjects.length ? [`Missing Playwright projects: ${missingProjects.join(", ")}`] : [],
|
|
@@ -1716,27 +1787,82 @@ function evaluateAppEvidence(app, policy) {
|
|
|
1716
1787
|
...missingBrowserVersions.map((project) => `${project.name} did not record its browser engine version`),
|
|
1717
1788
|
...invalidEngines.map((project) => `${project.name} used ${project.engine ?? "an unverified engine"} instead of ${expectedEngines[project.name]}`),
|
|
1718
1789
|
...invalidDevices.map((project) => `${project.name} did not verify mobile emulation settings`),
|
|
1719
|
-
...missingViewports.map((project) => `${project.name} did not test two viewport widths`)
|
|
1790
|
+
...missingViewports.map((project) => `${project.name} did not test two viewport widths`),
|
|
1791
|
+
...versionEvidenceMissing
|
|
1720
1792
|
];
|
|
1721
1793
|
const failedProjects = compatibility2.projects.filter((project) => project.failed > 0);
|
|
1722
|
-
const
|
|
1723
|
-
|
|
1724
|
-
|
|
1794
|
+
const testFailures = failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`);
|
|
1795
|
+
const matrix = compatibility2.projects.map((project) => ({
|
|
1796
|
+
...project,
|
|
1797
|
+
minimumTestedBrowserVersion: policy.compatibility.minimumTestedBrowserVersions?.[project.name],
|
|
1798
|
+
minimumEmulatedOsVersion: policy.compatibility.minimumEmulatedOsVersions?.[project.name]
|
|
1799
|
+
}));
|
|
1800
|
+
const compatibilityDomain = compatibilityMissing.length ? { ...incomplete([...compatibilityMissing, ...testFailures, ...versionFailures], compatibility2.tool, compatibility2.version), matrix } : {
|
|
1801
|
+
status: testFailures.length || versionFailures.length ? "fail" : "pass",
|
|
1802
|
+
issues: [...testFailures, ...versionFailures],
|
|
1725
1803
|
tool: compatibility2.tool,
|
|
1726
1804
|
version: compatibility2.version,
|
|
1727
1805
|
scanned: compatibility2.projects.reduce((count, project) => count + project.passed + project.failed, 0),
|
|
1728
|
-
matrix
|
|
1806
|
+
matrix
|
|
1807
|
+
};
|
|
1808
|
+
let accessibilityDomain;
|
|
1809
|
+
if (policy.accessibility) {
|
|
1810
|
+
const rules = policy.accessibility;
|
|
1811
|
+
const result = app.accessibility;
|
|
1812
|
+
if (!result) accessibilityDomain = incomplete(["Accessibility scan evidence is missing"]);
|
|
1813
|
+
else {
|
|
1814
|
+
const missing = [...!result.complete || result.errors.length ? ["Accessibility scan did not complete", ...result.errors] : []];
|
|
1815
|
+
for (const project of rules.requiredProjects) {
|
|
1816
|
+
const scans = result.scans.filter((scan) => scan.project === project);
|
|
1817
|
+
if (scans.length < rules.minimumScansPerProject) missing.push(`${project} has fewer than ${rules.minimumScansPerProject} accessibility scans`);
|
|
1818
|
+
}
|
|
1819
|
+
for (const scan of result.scans) {
|
|
1820
|
+
if (scan.checks < rules.minimumChecksPerScan) missing.push(`${scan.project} ${scan.route} checked fewer than ${rules.minimumChecksPerScan} axe rules`);
|
|
1821
|
+
if (rules.requiredTags.some((tag) => !scan.tags.includes(tag))) missing.push(`${scan.project} ${scan.route} omitted required WCAG tags`);
|
|
1822
|
+
const measuredVersion2 = numericVersion(scan.axeVersion);
|
|
1823
|
+
if (!measuredVersion2) missing.push(`${scan.project} ${scan.route} did not record a valid axe-core version`);
|
|
1824
|
+
else if (belowVersion(measuredVersion2, numericVersion(rules.minimumAxeVersion))) {
|
|
1825
|
+
missing.push(`${scan.project} ${scan.route} used axe-core ${scan.axeVersion} below ${rules.minimumAxeVersion}`);
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
const findings2 = result.scans.flatMap((scan) => scan.violations);
|
|
1829
|
+
const needsReview = result.scans.reduce((sum, scan) => sum + scan.needsReview, 0);
|
|
1830
|
+
if (needsReview > rules.maximumNeedsReview) missing.push(`${needsReview} axe checks need manual review (maximum ${rules.maximumNeedsReview})`);
|
|
1831
|
+
const failures = findings2.length > rules.maximumViolations ? [`${findings2.length} accessibility violations exceed ${rules.maximumViolations}`] : [];
|
|
1832
|
+
const details = {
|
|
1833
|
+
tool: result.tool,
|
|
1834
|
+
version: result.version,
|
|
1835
|
+
scanned: result.scans.length,
|
|
1836
|
+
findings: findings2,
|
|
1837
|
+
metrics: { scans: result.scans.length, axeChecks: result.scans.reduce((sum, scan) => sum + scan.checks, 0), needsReview },
|
|
1838
|
+
routes: result.scans.map((scan) => ({
|
|
1839
|
+
project: scan.project,
|
|
1840
|
+
route: scan.route,
|
|
1841
|
+
axeVersion: scan.axeVersion,
|
|
1842
|
+
checks: scan.checks,
|
|
1843
|
+
needsReview: scan.needsReview
|
|
1844
|
+
}))
|
|
1845
|
+
};
|
|
1846
|
+
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 };
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
return {
|
|
1850
|
+
dependencySecurity,
|
|
1851
|
+
codeQuality,
|
|
1852
|
+
codeSecurity,
|
|
1853
|
+
compatibility: compatibilityDomain,
|
|
1854
|
+
...accessibilityDomain ? { accessibility: accessibilityDomain } : {}
|
|
1729
1855
|
};
|
|
1730
|
-
return { dependencySecurity, codeQuality, codeSecurity, compatibility: compatibilityDomain };
|
|
1731
1856
|
}
|
|
1732
1857
|
|
|
1733
1858
|
// src/governance/policy.ts
|
|
1734
1859
|
import { createHash as createHash3 } from "crypto";
|
|
1735
|
-
import { readFile as
|
|
1860
|
+
import { readFile as readFile10 } from "fs/promises";
|
|
1736
1861
|
import { resolve as resolve8 } from "path";
|
|
1737
1862
|
import { valid as valid2 } from "semver";
|
|
1738
1863
|
var exactVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
1739
1864
|
var minimumVersion = /^>=(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
1865
|
+
var measuredVersion = /^(0|[1-9]\d*)(?:\.(0|[1-9]\d*)){0,3}$/;
|
|
1740
1866
|
function record3(value) {
|
|
1741
1867
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1742
1868
|
}
|
|
@@ -1805,6 +1931,51 @@ function parseAssessmentPolicy(value) {
|
|
|
1805
1931
|
if (!Array.isArray(projects) || projects.length === 0 || !projects.every((item) => typeof item === "string" && item.length > 0) || new Set(projects).size !== projects.length) {
|
|
1806
1932
|
throw new Error("assessment.evidence.compatibility.requiredProjects must be a unique nonempty string array");
|
|
1807
1933
|
}
|
|
1934
|
+
const versionFloors = (key) => {
|
|
1935
|
+
const raw = compatibility2[key];
|
|
1936
|
+
if (raw === void 0) return void 0;
|
|
1937
|
+
if (!record3(raw)) throw new Error(`assessment.evidence.compatibility.${key} must be an object`);
|
|
1938
|
+
for (const [project, version2] of Object.entries(raw)) {
|
|
1939
|
+
if (!projects.includes(project)) throw new Error(`${key} references unknown required project ${project}`);
|
|
1940
|
+
if (key === "minimumEmulatedOsVersions" && project !== "android-chrome" && project !== "iphone-safari") {
|
|
1941
|
+
throw new Error(`${key} requires an Android Chrome or iPhone Safari project`);
|
|
1942
|
+
}
|
|
1943
|
+
if (typeof version2 !== "string" || !measuredVersion.test(version2) || !version2.split(".").map(Number).every(Number.isSafeInteger)) {
|
|
1944
|
+
throw new Error(`${key}.${project} must be a numeric version with one to four components`);
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
return raw;
|
|
1948
|
+
};
|
|
1949
|
+
const minimumTestedBrowserVersions = versionFloors("minimumTestedBrowserVersions");
|
|
1950
|
+
const minimumEmulatedOsVersions = versionFloors("minimumEmulatedOsVersions");
|
|
1951
|
+
let accessibility;
|
|
1952
|
+
if (gates.accessibility !== void 0) {
|
|
1953
|
+
if (!record3(gates.accessibility)) throw new Error("assessment.evidence.accessibility must be an object");
|
|
1954
|
+
const source = gates.accessibility;
|
|
1955
|
+
const requiredProjects = source.requiredProjects;
|
|
1956
|
+
if (!Array.isArray(requiredProjects) || requiredProjects.length === 0 || !requiredProjects.every((project) => typeof project === "string" && projects.includes(project)) || new Set(requiredProjects).size !== requiredProjects.length) {
|
|
1957
|
+
throw new Error("assessment.evidence.accessibility projects must be unique members of compatibility.requiredProjects");
|
|
1958
|
+
}
|
|
1959
|
+
const requiredTags = source.requiredTags;
|
|
1960
|
+
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) {
|
|
1961
|
+
throw new Error("assessment.evidence.accessibility.requiredTags must list unique supported WCAG A/AA tags");
|
|
1962
|
+
}
|
|
1963
|
+
if (typeof source.minimumAxeVersion !== "string" || !minimumVersion.test(`>=${source.minimumAxeVersion}`) || valid2(source.minimumAxeVersion) === null) {
|
|
1964
|
+
throw new Error("assessment.evidence.accessibility.minimumAxeVersion must be an exact stable version");
|
|
1965
|
+
}
|
|
1966
|
+
accessibility = {
|
|
1967
|
+
requiredProjects,
|
|
1968
|
+
requiredTags,
|
|
1969
|
+
minimumScansPerProject: nonnegative2(source, "minimumScansPerProject", "assessment.evidence.accessibility"),
|
|
1970
|
+
minimumChecksPerScan: nonnegative2(source, "minimumChecksPerScan", "assessment.evidence.accessibility"),
|
|
1971
|
+
minimumAxeVersion: source.minimumAxeVersion,
|
|
1972
|
+
maximumViolations: nonnegative2(source, "maximumViolations", "assessment.evidence.accessibility"),
|
|
1973
|
+
maximumNeedsReview: nonnegative2(source, "maximumNeedsReview", "assessment.evidence.accessibility")
|
|
1974
|
+
};
|
|
1975
|
+
if (accessibility.minimumScansPerProject === 0 || accessibility.minimumChecksPerScan === 0) {
|
|
1976
|
+
throw new Error("assessment.evidence.accessibility minimum scans and checks must be positive");
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1808
1979
|
evidence = {
|
|
1809
1980
|
maximumAgeHours: gates.maximumAgeHours,
|
|
1810
1981
|
dependencySecurity: { maximumHigh: nonnegative2(dependencySecurity, "maximumHigh", "assessment.evidence.dependencySecurity"), maximumCritical: nonnegative2(dependencySecurity, "maximumCritical", "assessment.evidence.dependencySecurity") },
|
|
@@ -1815,7 +1986,13 @@ function parseAssessmentPolicy(value) {
|
|
|
1815
1986
|
requireTests: codeQuality.requireTests
|
|
1816
1987
|
},
|
|
1817
1988
|
codeSecurity: { maximumHigh: nonnegative2(codeSecurity, "maximumHigh", "assessment.evidence.codeSecurity"), maximumCritical: nonnegative2(codeSecurity, "maximumCritical", "assessment.evidence.codeSecurity"), maximumSecrets: nonnegative2(codeSecurity, "maximumSecrets", "assessment.evidence.codeSecurity") },
|
|
1818
|
-
compatibility: {
|
|
1989
|
+
compatibility: {
|
|
1990
|
+
requiredProjects: projects,
|
|
1991
|
+
minimumTestsPerProject: nonnegative2(compatibility2, "minimumTestsPerProject", "assessment.evidence.compatibility"),
|
|
1992
|
+
...minimumTestedBrowserVersions ? { minimumTestedBrowserVersions } : {},
|
|
1993
|
+
...minimumEmulatedOsVersions ? { minimumEmulatedOsVersions } : {}
|
|
1994
|
+
},
|
|
1995
|
+
...accessibility ? { accessibility } : {}
|
|
1819
1996
|
};
|
|
1820
1997
|
}
|
|
1821
1998
|
return { architecture: {
|
|
@@ -1855,10 +2032,25 @@ function parsePolicy(raw, source) {
|
|
|
1855
2032
|
}
|
|
1856
2033
|
profiles3[name] = versions;
|
|
1857
2034
|
}
|
|
2035
|
+
let managedPackages;
|
|
2036
|
+
if (parsed.managedPackages !== void 0) {
|
|
2037
|
+
if (!record3(parsed.managedPackages)) throw new Error("Policy managedPackages must be an object");
|
|
2038
|
+
managedPackages = {};
|
|
2039
|
+
for (const [packageName, version2] of Object.entries(parsed.managedPackages)) {
|
|
2040
|
+
if (typeof version2 !== "string" || !minimumVersion.test(version2) || valid2(version2.slice(2)) === null) {
|
|
2041
|
+
throw new Error(`Policy minimum version (>=X.Y.Z) required for managed package ${packageName}`);
|
|
2042
|
+
}
|
|
2043
|
+
if (Object.values(profiles3).some((profile) => packageName in profile)) {
|
|
2044
|
+
throw new Error(`Package ${packageName} appears in both profiles and managedPackages`);
|
|
2045
|
+
}
|
|
2046
|
+
managedPackages[packageName] = version2;
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
1858
2049
|
return {
|
|
1859
2050
|
schemaVersion: 2,
|
|
1860
2051
|
policyVersion: parsed.policyVersion,
|
|
1861
2052
|
profiles: profiles3,
|
|
2053
|
+
...managedPackages ? { managedPackages } : {},
|
|
1862
2054
|
...parsed.assessment === void 0 ? {} : { assessment: parseAssessmentPolicy(parsed.assessment) },
|
|
1863
2055
|
source,
|
|
1864
2056
|
sha256: createHash3("sha256").update(raw).digest("hex")
|
|
@@ -1878,7 +2070,7 @@ async function loadPolicy(source) {
|
|
|
1878
2070
|
}
|
|
1879
2071
|
return parsePolicy(await response.text(), source);
|
|
1880
2072
|
}
|
|
1881
|
-
return parsePolicy(await
|
|
2073
|
+
return parsePolicy(await readFile10(resolve8(source), "utf8"), source);
|
|
1882
2074
|
}
|
|
1883
2075
|
|
|
1884
2076
|
// src/governance/assess.ts
|
|
@@ -1908,7 +2100,8 @@ function renderAssessmentHtml(report) {
|
|
|
1908
2100
|
dependencySecurity: "Dependency security",
|
|
1909
2101
|
codeQuality: "Code quality",
|
|
1910
2102
|
codeSecurity: "Code security",
|
|
1911
|
-
compatibility: "Browser compatibility"
|
|
2103
|
+
compatibility: "Browser compatibility",
|
|
2104
|
+
accessibility: "Accessibility"
|
|
1912
2105
|
};
|
|
1913
2106
|
const domainNames = report.apps.length ? Object.keys(report.apps[0].domains) : [];
|
|
1914
2107
|
const overview = domainNames.map((name) => {
|
|
@@ -1923,8 +2116,9 @@ function renderAssessmentHtml(report) {
|
|
|
1923
2116
|
const identity = evidence.tool ? `<p><small>${escapeHtml2(evidence.tool)} ${escapeHtml2(evidence.version ?? "")} \xB7 scanned ${escapeHtml2(evidence.scanned ?? "unknown")}</small></p>` : "";
|
|
1924
2117
|
const metrics = evidence.metrics ? `<p><small>${Object.entries(evidence.metrics).map(([key, value]) => `${escapeHtml2(key)}: ${escapeHtml2(value)}`).join(" \xB7 ")}</small></p>` : "";
|
|
1925
2118
|
const findings3 = evidence.findings?.length ? `<details><summary>${evidence.findings.length} finding(s)</summary><ul>${evidence.findings.map((item) => `<li><strong>${escapeHtml2(item.severity)}</strong> ${escapeHtml2(item.id)} \xB7 <code>${escapeHtml2(item.subject)}</code> \u2014 ${escapeHtml2(item.message)}${item.remediation ? ` \xB7 ${escapeHtml2(item.remediation)}` : ""}</li>`).join("")}</ul></details>` : "";
|
|
1926
|
-
const matrix = evidence.matrix?.length ? `<div class="scroll"><table><thead><tr><th>Project</th><th>Passed</th><th>Failed</th><th>Skipped</th><th>
|
|
1927
|
-
|
|
2119
|
+
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">${escapeHtml2(item.name)}</th><td>${item.passed}</td><td>${item.failed}</td><td>${item.skipped}</td><td>${escapeHtml2(item.browserVersion ?? "Not recorded")} / ${escapeHtml2(item.minimumTestedBrowserVersion ?? "Not set")}</td><td>${escapeHtml2(item.device ?? "None")} / ${escapeHtml2(item.minimumEmulatedOsVersion ?? "Not set")}</td><td>${escapeHtml2(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>` : "";
|
|
2120
|
+
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">${escapeHtml2(item.project)}</th><td><code>${escapeHtml2(item.route)}</code></td><td>${escapeHtml2(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>` : "";
|
|
2121
|
+
return `<tr><th scope="row">${escapeHtml2(labels[name] ?? name)}</th><td class="${domain.status}">${escapeHtml2(domain.status)}</td><td>${identity}${metrics}${domain.issues.length ? `<ul>${domain.issues.map((issue) => `<li>${escapeHtml2(issue)}</li>`).join("")}</ul>` : "No issues"}${findings3}${matrix}${routes}</td></tr>`;
|
|
1928
2122
|
}).join("");
|
|
1929
2123
|
const architecture = app.domains.architecture;
|
|
1930
2124
|
const categoryRows = architecture.scoreBreakdown?.categories.map((category) => `<tr><th scope="row">${escapeHtml2(category.id)}</th><td>${escapeHtml2(category.weight)}%</td><td>${escapeHtml2(category.score)}</td><td>${escapeHtml2(category.findingCount)}</td></tr>`).join("") ?? "";
|
|
@@ -1943,8 +2137,8 @@ async function assessGovernance(options) {
|
|
|
1943
2137
|
if (scanRelative === ".." || scanRelative.startsWith(`..${sep6}`)) throw new Error("Scan root must be inside workspace root");
|
|
1944
2138
|
if (!options.policy.assessment) throw new Error("Policy assessment.architecture is required");
|
|
1945
2139
|
const assessment = parseAssessmentPolicy(options.policy.assessment);
|
|
1946
|
-
const outputDirectory = resolve9(options.outputDirectory ??
|
|
1947
|
-
const inventorySource = resolve9(options.inventory ??
|
|
2140
|
+
const outputDirectory = resolve9(options.outputDirectory ?? join11(scanRoot, ".nklattice-governance"));
|
|
2141
|
+
const inventorySource = resolve9(options.inventory ?? join11(scanRoot, ".nklattice-apps.json"));
|
|
1948
2142
|
let inventory;
|
|
1949
2143
|
const issues = [];
|
|
1950
2144
|
try {
|
|
@@ -2043,25 +2237,48 @@ async function assessGovernance(options) {
|
|
|
2043
2237
|
...evidence ? { evidence: { source: options.evidence ? resolve9(options.evidence) : "in-process collector", generatedAt: evidence.generatedAt, commit: evidence.commit, inputSha256: evidence.inputSha256 } } : {},
|
|
2044
2238
|
inventory: { source: inventorySource, sha256: inventory?.sha256, expectedApps: inventory?.apps.length ?? 0, discoveredApps: apps.length },
|
|
2045
2239
|
scope: {
|
|
2046
|
-
domains: [
|
|
2047
|
-
|
|
2240
|
+
domains: [
|
|
2241
|
+
"explicit app inventory",
|
|
2242
|
+
"dependency minimums",
|
|
2243
|
+
...options.policy.managedPackages ? ["conditional managed-library minimums"] : [],
|
|
2244
|
+
"source architecture",
|
|
2245
|
+
...assessment.evidence ? [
|
|
2246
|
+
"known dependency advisories",
|
|
2247
|
+
"source quality",
|
|
2248
|
+
"source security",
|
|
2249
|
+
"browser and mobile emulation",
|
|
2250
|
+
"tested browser and emulated OS version floors",
|
|
2251
|
+
...assessment.evidence.accessibility ? ["automated accessibility"] : []
|
|
2252
|
+
] : []
|
|
2253
|
+
],
|
|
2254
|
+
notEvaluated: assessment.evidence ? [
|
|
2255
|
+
"licenses",
|
|
2256
|
+
"physical Android/iOS devices",
|
|
2257
|
+
"older browser-version support",
|
|
2258
|
+
"runtime performance",
|
|
2259
|
+
...assessment.evidence.accessibility ? ["manual accessibility review and unvisited routes"] : ["accessibility"],
|
|
2260
|
+
"cryptographic evidence attestation",
|
|
2261
|
+
"branch protection",
|
|
2262
|
+
"repository access controls",
|
|
2263
|
+
"cross-repository rollout status"
|
|
2264
|
+
] : notEvaluated
|
|
2048
2265
|
},
|
|
2049
2266
|
issues,
|
|
2050
2267
|
apps: results
|
|
2051
2268
|
};
|
|
2052
2269
|
await mkdir3(outputDirectory, { recursive: true });
|
|
2053
|
-
await writeFile2(
|
|
2270
|
+
await writeFile2(join11(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
|
|
2054
2271
|
`);
|
|
2055
|
-
await writeFile2(
|
|
2272
|
+
await writeFile2(join11(outputDirectory, "report.html"), renderAssessmentHtml(report));
|
|
2056
2273
|
return report;
|
|
2057
2274
|
}
|
|
2058
2275
|
|
|
2059
2276
|
// src/governance/collect.ts
|
|
2060
2277
|
import { execFile as execFile2 } from "child_process";
|
|
2061
2278
|
import { createHash as createHash4 } from "crypto";
|
|
2062
|
-
import { mkdtemp, mkdir as mkdir4, readFile as
|
|
2279
|
+
import { mkdtemp, mkdir as mkdir4, readFile as readFile11, rm, writeFile as writeFile3 } from "fs/promises";
|
|
2063
2280
|
import { tmpdir } from "os";
|
|
2064
|
-
import { dirname as
|
|
2281
|
+
import { dirname as dirname5, join as join12, relative as relative8, resolve as resolve10, sep as sep7 } from "path";
|
|
2065
2282
|
import { promisify as promisify2 } from "util";
|
|
2066
2283
|
import fg2 from "fast-glob";
|
|
2067
2284
|
import YAML2 from "yaml";
|
|
@@ -2088,14 +2305,14 @@ function inside(parent, child) {
|
|
|
2088
2305
|
async function binary(name, app, workspaceRoot) {
|
|
2089
2306
|
let directory = app;
|
|
2090
2307
|
while (inside(workspaceRoot, directory)) {
|
|
2091
|
-
const candidate =
|
|
2308
|
+
const candidate = join12(directory, "node_modules", ".bin", name);
|
|
2092
2309
|
try {
|
|
2093
|
-
await
|
|
2310
|
+
await readFile11(candidate);
|
|
2094
2311
|
return candidate;
|
|
2095
2312
|
} catch {
|
|
2096
2313
|
}
|
|
2097
2314
|
if (directory === workspaceRoot) break;
|
|
2098
|
-
directory =
|
|
2315
|
+
directory = dirname5(directory);
|
|
2099
2316
|
}
|
|
2100
2317
|
return name;
|
|
2101
2318
|
}
|
|
@@ -2154,7 +2371,7 @@ async function dependencyAudit(app, workspaceRoot, cache) {
|
|
|
2154
2371
|
}
|
|
2155
2372
|
const cached = cache.get(lock.path);
|
|
2156
2373
|
if (cached) return cached;
|
|
2157
|
-
const raw = await
|
|
2374
|
+
const raw = await readFile11(lock.path, "utf8");
|
|
2158
2375
|
const parsedLock = lock.kind === "npm" ? json(raw) : YAML2.parse(raw);
|
|
2159
2376
|
const packages = obj(parsedLock) && obj(parsedLock.packages) ? Object.keys(parsedLock.packages).filter((key) => key !== "").length : 0;
|
|
2160
2377
|
const executable = lock.kind === "npm" ? "npm" : "pnpm";
|
|
@@ -2263,7 +2480,7 @@ async function typecheck(app, workspaceRoot) {
|
|
|
2263
2480
|
const file = await binary("tsc", app.directory, workspaceRoot);
|
|
2264
2481
|
const toolVersion = await version(file, app.directory);
|
|
2265
2482
|
if (!applicable) return { tool: "TypeScript", version: toolVersion, applicable: false, complete: true, passed: true, errors: [] };
|
|
2266
|
-
const config = await
|
|
2483
|
+
const config = await readFile11(join12(app.directory, "tsconfig.json"), "utf8").catch(() => "");
|
|
2267
2484
|
if (!config) return {
|
|
2268
2485
|
tool: "TypeScript",
|
|
2269
2486
|
version: toolVersion,
|
|
@@ -2285,8 +2502,8 @@ async function typecheck(app, workspaceRoot) {
|
|
|
2285
2502
|
async function testAndCoverage(app, workspaceRoot, temp) {
|
|
2286
2503
|
const file = await binary("vitest", app.directory, workspaceRoot);
|
|
2287
2504
|
const toolVersion = await version(file, app.directory);
|
|
2288
|
-
const resultPath =
|
|
2289
|
-
const coverageDirectory =
|
|
2505
|
+
const resultPath = join12(temp, "vitest.json");
|
|
2506
|
+
const coverageDirectory = join12(temp, "coverage");
|
|
2290
2507
|
const result = await run(file, [
|
|
2291
2508
|
"run",
|
|
2292
2509
|
"--reporter=json",
|
|
@@ -2297,9 +2514,9 @@ async function testAndCoverage(app, workspaceRoot, temp) {
|
|
|
2297
2514
|
"--coverage.reportsDirectory",
|
|
2298
2515
|
coverageDirectory
|
|
2299
2516
|
], app.directory);
|
|
2300
|
-
const output = await
|
|
2517
|
+
const output = await readFile11(resultPath, "utf8").catch(() => "");
|
|
2301
2518
|
const parsed = json(output);
|
|
2302
|
-
const summary = json(await
|
|
2519
|
+
const summary = json(await readFile11(join12(coverageDirectory, "coverage-summary.json"), "utf8").catch(() => ""));
|
|
2303
2520
|
const tests = obj(parsed) && typeof parsed.numPassedTests === "number" && typeof parsed.numFailedTests === "number";
|
|
2304
2521
|
const lineCoverage = obj(summary) && obj(summary.total) && obj(summary.total.lines) ? summary.total.lines.pct : void 0;
|
|
2305
2522
|
const expectedFiles = await productionSources(app.directory);
|
|
@@ -2330,7 +2547,7 @@ async function testAndCoverage(app, workspaceRoot, temp) {
|
|
|
2330
2547
|
async function sast(app, temp) {
|
|
2331
2548
|
const file = "semgrep";
|
|
2332
2549
|
const toolVersion = await version(file, app.directory);
|
|
2333
|
-
const outputPath =
|
|
2550
|
+
const outputPath = join12(temp, "semgrep.json");
|
|
2334
2551
|
const result = await run(file, [
|
|
2335
2552
|
"scan",
|
|
2336
2553
|
"--config",
|
|
@@ -2349,7 +2566,7 @@ async function sast(app, temp) {
|
|
|
2349
2566
|
outputPath,
|
|
2350
2567
|
app.directory
|
|
2351
2568
|
], app.directory);
|
|
2352
|
-
const parsed = json(await
|
|
2569
|
+
const parsed = json(await readFile11(outputPath, "utf8").catch(() => result.stdout));
|
|
2353
2570
|
const expectedSources = await sourcePaths(app.directory);
|
|
2354
2571
|
if (!obj(parsed) || !Array.isArray(parsed.results) || result.error) {
|
|
2355
2572
|
return {
|
|
@@ -2385,8 +2602,8 @@ async function sast(app, temp) {
|
|
|
2385
2602
|
async function secrets(app, temp) {
|
|
2386
2603
|
const file = "gitleaks";
|
|
2387
2604
|
const toolVersion = await version(file, app.directory);
|
|
2388
|
-
const outputPath =
|
|
2389
|
-
const configPath =
|
|
2605
|
+
const outputPath = join12(temp, "gitleaks.json");
|
|
2606
|
+
const configPath = join12(temp, "gitleaks.toml");
|
|
2390
2607
|
await writeFile3(configPath, '[extend]\nuseDefault = true\n\n[allowlist]\npaths = ["(^|/)(node_modules|\\\\.next|dist|build|coverage|test-results|playwright-report)/"]\n');
|
|
2391
2608
|
const result = await run(file, [
|
|
2392
2609
|
"dir",
|
|
@@ -2400,7 +2617,7 @@ async function secrets(app, temp) {
|
|
|
2400
2617
|
"--redact",
|
|
2401
2618
|
"--no-banner"
|
|
2402
2619
|
], app.directory);
|
|
2403
|
-
const parsed = json(await
|
|
2620
|
+
const parsed = json(await readFile11(outputPath, "utf8").catch(() => ""));
|
|
2404
2621
|
const count = await sourceCount(app.directory);
|
|
2405
2622
|
if (!Array.isArray(parsed) || result.error) {
|
|
2406
2623
|
return {
|
|
@@ -2462,12 +2679,59 @@ function playwrightProjects(value) {
|
|
|
2462
2679
|
for (const suite of value.suites) visit(suite);
|
|
2463
2680
|
return [...totals.values()].map((project) => ({ ...project, viewportWidths: [...project.viewportWidths].sort((a, b) => a - b) }));
|
|
2464
2681
|
}
|
|
2682
|
+
function parsePlaywrightAccessibility(value) {
|
|
2683
|
+
const scans = [];
|
|
2684
|
+
const errors2 = [];
|
|
2685
|
+
const visit = (suite) => {
|
|
2686
|
+
if (!obj(suite)) return;
|
|
2687
|
+
for (const spec of Array.isArray(suite.specs) ? suite.specs : []) {
|
|
2688
|
+
if (!obj(spec) || !Array.isArray(spec.tests)) continue;
|
|
2689
|
+
for (const test of spec.tests) {
|
|
2690
|
+
if (!obj(test) || !str(test.projectName)) continue;
|
|
2691
|
+
for (const annotation of Array.isArray(test.annotations) ? test.annotations : []) {
|
|
2692
|
+
if (!obj(annotation) || annotation.type !== "lattice-axe") continue;
|
|
2693
|
+
const measured = json(str(annotation.description));
|
|
2694
|
+
const validFindings = obj(measured) && Array.isArray(measured.violations) && measured.violations.every((item) => obj(item) && str(item.id) && str(item.help) && str(item.selector) && ["minor", "moderate", "serious", "critical"].includes(str(item.impact)));
|
|
2695
|
+
if (!obj(measured) || !/^\/[^?#]*$/.test(str(measured.route)) || !/^\d+\.\d+\.\d+$/.test(str(measured.axeVersion)) || !Array.isArray(measured.tags) || measured.tags.length === 0 || !measured.tags.every((item) => typeof item === "string" && item.length > 0) || new Set(measured.tags).size !== measured.tags.length || !Number.isSafeInteger(measured.checks) || measured.checks <= 0 || !Number.isSafeInteger(measured.needsReview) || measured.needsReview < 0 || !validFindings) {
|
|
2696
|
+
errors2.push(`Malformed axe accessibility annotation for ${test.projectName}`);
|
|
2697
|
+
continue;
|
|
2698
|
+
}
|
|
2699
|
+
scans.push({
|
|
2700
|
+
project: str(test.projectName),
|
|
2701
|
+
route: str(measured.route),
|
|
2702
|
+
axeVersion: str(measured.axeVersion),
|
|
2703
|
+
tags: measured.tags,
|
|
2704
|
+
checks: measured.checks,
|
|
2705
|
+
needsReview: measured.needsReview,
|
|
2706
|
+
violations: measured.violations.map((item) => ({
|
|
2707
|
+
id: str(item.id),
|
|
2708
|
+
severity: str(item.impact),
|
|
2709
|
+
subject: `${measured.route} ${item.selector}`,
|
|
2710
|
+
message: str(item.help)
|
|
2711
|
+
}))
|
|
2712
|
+
});
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
for (const child of Array.isArray(suite.suites) ? suite.suites : []) visit(child);
|
|
2717
|
+
};
|
|
2718
|
+
if (obj(value) && Array.isArray(value.suites)) for (const suite of value.suites) visit(suite);
|
|
2719
|
+
if (scans.length === 0 && errors2.length === 0) errors2.push("No axe accessibility annotations were recorded");
|
|
2720
|
+
const versions = new Set(scans.map((scan) => scan.axeVersion));
|
|
2721
|
+
return {
|
|
2722
|
+
tool: "axe-core via Playwright",
|
|
2723
|
+
version: versions.size === 1 ? scans[0].axeVersion : "mixed or unavailable",
|
|
2724
|
+
complete: errors2.length === 0 && scans.length > 0,
|
|
2725
|
+
scans,
|
|
2726
|
+
errors: errors2
|
|
2727
|
+
};
|
|
2728
|
+
}
|
|
2465
2729
|
async function compatibility(app, workspaceRoot, temp) {
|
|
2466
2730
|
const file = await binary("playwright", app.directory, workspaceRoot);
|
|
2467
2731
|
const toolVersion = await version(file, app.directory);
|
|
2468
|
-
const outputPath =
|
|
2469
|
-
const configPath =
|
|
2470
|
-
const reporterPath =
|
|
2732
|
+
const outputPath = join12(temp, "playwright.json");
|
|
2733
|
+
const configPath = join12(temp, "playwright-projects.json");
|
|
2734
|
+
const reporterPath = join12(temp, "lattice-playwright-reporter.cjs");
|
|
2471
2735
|
await writeFile3(reporterPath, 'module.exports=class { onBegin(config) { require("node:fs").writeFileSync(process.env.LATTICE_PLAYWRIGHT_CONFIG_OUT, JSON.stringify(config.projects.map(p => ({ name: p.name, engine: p.use.browserName || p.use.defaultBrowserType, mobile: p.use.isMobile === true, touch: p.use.hasTouch === true, userAgent: p.use.userAgent || "" })))); } };\n');
|
|
2472
2736
|
const result = await run(
|
|
2473
2737
|
file,
|
|
@@ -2475,9 +2739,9 @@ async function compatibility(app, workspaceRoot, temp) {
|
|
|
2475
2739
|
app.directory,
|
|
2476
2740
|
{ ...process.env, PLAYWRIGHT_JSON_OUTPUT_FILE: outputPath, LATTICE_PLAYWRIGHT_CONFIG_OUT: configPath }
|
|
2477
2741
|
);
|
|
2478
|
-
const parsed = json(await
|
|
2742
|
+
const parsed = json(await readFile11(outputPath, "utf8").catch(() => result.stdout));
|
|
2479
2743
|
const projects = playwrightProjects(parsed);
|
|
2480
|
-
const config = json(await
|
|
2744
|
+
const config = json(await readFile11(configPath, "utf8").catch(() => ""));
|
|
2481
2745
|
const configurations = Array.isArray(config) && config.every(obj) ? config : [];
|
|
2482
2746
|
for (const project of projects) {
|
|
2483
2747
|
const configured = configurations.find((item) => item.name === project.name);
|
|
@@ -2487,17 +2751,28 @@ async function compatibility(app, workspaceRoot, temp) {
|
|
|
2487
2751
|
const agent = str(configured.userAgent);
|
|
2488
2752
|
const android = agent.match(/Android\s+([\d.]+)/i);
|
|
2489
2753
|
const iphone = agent.match(/iPhone OS\s+([\d_]+)/i);
|
|
2490
|
-
if (project.name === "android-chrome" && android && project.mobile)
|
|
2491
|
-
|
|
2754
|
+
if (project.name === "android-chrome" && android && project.mobile) {
|
|
2755
|
+
project.emulatedOs = "Android";
|
|
2756
|
+
project.emulatedOsVersion = android[1];
|
|
2757
|
+
project.device = `Android ${android[1]} UA emulation`;
|
|
2758
|
+
}
|
|
2759
|
+
if (project.name === "iphone-safari" && iphone && project.mobile) {
|
|
2760
|
+
project.emulatedOs = "iOS";
|
|
2761
|
+
project.emulatedOsVersion = iphone[1].replaceAll("_", ".");
|
|
2762
|
+
project.device = `iOS ${project.emulatedOsVersion} UA emulation`;
|
|
2763
|
+
}
|
|
2492
2764
|
}
|
|
2493
2765
|
const reportedErrors = obj(parsed) && Array.isArray(parsed.errors) ? parsed.errors : [];
|
|
2494
2766
|
const infrastructureFailure = reportedErrors.length > 0 || configurations.length === 0 || typeof result.stderr === "string" && /browser executable.*doesn't exist|failed to launch browser|install playwright browsers/i.test(result.stderr);
|
|
2495
2767
|
return {
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2768
|
+
compatibility: {
|
|
2769
|
+
tool: "Playwright",
|
|
2770
|
+
version: toolVersion,
|
|
2771
|
+
complete: Boolean(parsed) && !result.error && !infrastructureFailure && projects.length > 0,
|
|
2772
|
+
projects,
|
|
2773
|
+
errors: infrastructureFailure ? ["Playwright browser infrastructure or configuration failed"] : projects.length ? [] : [result.error ?? "Playwright JSON results are unavailable; configure required browser and mobile projects"]
|
|
2774
|
+
},
|
|
2775
|
+
accessibility: parsePlaywrightAccessibility(parsed)
|
|
2501
2776
|
};
|
|
2502
2777
|
}
|
|
2503
2778
|
async function collectGovernanceEvidence(options) {
|
|
@@ -2519,7 +2794,7 @@ async function collectGovernanceEvidence(options) {
|
|
|
2519
2794
|
const auditCache = /* @__PURE__ */ new Map();
|
|
2520
2795
|
const scans = [];
|
|
2521
2796
|
for (const app of apps) {
|
|
2522
|
-
const temp = await mkdtemp(
|
|
2797
|
+
const temp = await mkdtemp(join12(tmpdir(), "lattice-scanners-"));
|
|
2523
2798
|
try {
|
|
2524
2799
|
const dependencySecurity = await dependencyAudit(app, workspaceRoot, auditCache);
|
|
2525
2800
|
await assertStableInputs("after dependency audit");
|
|
@@ -2541,7 +2816,7 @@ async function collectGovernanceEvidence(options) {
|
|
|
2541
2816
|
dependencySecurity,
|
|
2542
2817
|
codeQuality: { lint: qualityLint, typecheck: qualityTypecheck, ...qualityTests },
|
|
2543
2818
|
codeSecurity: { sast: securitySast, secrets: securitySecrets },
|
|
2544
|
-
|
|
2819
|
+
...compatibilityResult
|
|
2545
2820
|
});
|
|
2546
2821
|
} finally {
|
|
2547
2822
|
await rm(temp, { recursive: true, force: true });
|
|
@@ -2556,7 +2831,7 @@ async function collectGovernanceEvidence(options) {
|
|
|
2556
2831
|
commit,
|
|
2557
2832
|
apps: scans
|
|
2558
2833
|
};
|
|
2559
|
-
await mkdir4(
|
|
2834
|
+
await mkdir4(dirname5(destination), { recursive: true });
|
|
2560
2835
|
await writeFile3(destination, `${JSON.stringify(report, null, 2)}
|
|
2561
2836
|
`, { flag: "w" });
|
|
2562
2837
|
return report;
|
|
@@ -2586,11 +2861,11 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
2586
2861
|
program.exitOverride();
|
|
2587
2862
|
program.command("analyze [path]").description("Analyze local architecture and write offline reports").option("--output <directory>", "Output directory").action(async (path, flags) => {
|
|
2588
2863
|
const root = resolve11(path ?? ".");
|
|
2589
|
-
const destination = resolve11(flags.output ??
|
|
2864
|
+
const destination = resolve11(flags.output ?? join13(root, ".nklattice-report"));
|
|
2590
2865
|
const report = await analyzeProject(root, { outputDirectory: destination });
|
|
2591
2866
|
output.out(`NKLattice analyzed ${report.graph.modules.length} modules. Score ${report.score.score} (${report.score.grade}); ${report.findings.length} findings.
|
|
2592
2867
|
`);
|
|
2593
|
-
output.out(`Reports: ${
|
|
2868
|
+
output.out(`Reports: ${join13(destination, "report.html")} and ${join13(destination, "analysis.json")}
|
|
2594
2869
|
`);
|
|
2595
2870
|
});
|
|
2596
2871
|
program.command("doctor [path]").description("Check whether a directory contains supported source files").action(async (path) => {
|
|
@@ -2600,7 +2875,7 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
2600
2875
|
`);
|
|
2601
2876
|
});
|
|
2602
2877
|
program.command("init [path]").description("Create a project configuration file").action(async (path) => {
|
|
2603
|
-
const destination =
|
|
2878
|
+
const destination = join13(resolve11(path ?? "."), ".nklattice.json");
|
|
2604
2879
|
await writeFile4(destination, '{\n "analysis": {},\n "policies": []\n}\n', { flag: "wx" });
|
|
2605
2880
|
output.out(`Created ${destination}
|
|
2606
2881
|
`);
|
|
@@ -2613,7 +2888,7 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
2613
2888
|
expectedApps = Number(flags.expectApps);
|
|
2614
2889
|
if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
|
|
2615
2890
|
}
|
|
2616
|
-
const policyPath = flags.policy ?? resolve11(
|
|
2891
|
+
const policyPath = flags.policy ?? resolve11(dirname6(fileURLToPath(import.meta.url)), "../governance/policy.json");
|
|
2617
2892
|
const policy = await loadPolicy(policyPath);
|
|
2618
2893
|
const result = await checkGovernance({
|
|
2619
2894
|
scanRoot: path ?? ".",
|
|
@@ -2647,7 +2922,7 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
2647
2922
|
if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
|
|
2648
2923
|
}
|
|
2649
2924
|
const scanRoot = resolve11(path ?? ".");
|
|
2650
|
-
const policyPath = flags.policy ?? resolve11(
|
|
2925
|
+
const policyPath = flags.policy ?? resolve11(dirname6(fileURLToPath(import.meta.url)), "../governance/policy.json");
|
|
2651
2926
|
const report = await assessGovernance({
|
|
2652
2927
|
scanRoot,
|
|
2653
2928
|
workspaceRoot: flags.workspaceRoot,
|
|
@@ -2670,14 +2945,14 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
2670
2945
|
for (const [name, domain] of Object.entries(app.domains)) for (const issue of domain.issues) output.out(` ${name}: ${issue}
|
|
2671
2946
|
`);
|
|
2672
2947
|
}
|
|
2673
|
-
output.out(`Reports: ${resolve11(flags.output ??
|
|
2948
|
+
output.out(`Reports: ${resolve11(flags.output ?? join13(scanRoot, ".nklattice-governance"))}
|
|
2674
2949
|
`);
|
|
2675
2950
|
}
|
|
2676
2951
|
exitCode = report.status === "pass" ? 0 : report.status === "fail" ? 7 : 3;
|
|
2677
2952
|
});
|
|
2678
2953
|
governance.command("collect [path]").description("Run project scanners and write fresh machine-readable evidence").requiredOption("--evidence <file>", "Evidence JSON outside the scan root").option("--policy <source>", "Local file or HTTPS policy URL").option("--workspace-root <path>", "Repository root containing a shared lockfile").action(async (path, flags) => {
|
|
2679
2954
|
const scanRoot = resolve11(path ?? ".");
|
|
2680
|
-
const policyPath = flags.policy ?? resolve11(
|
|
2955
|
+
const policyPath = flags.policy ?? resolve11(dirname6(fileURLToPath(import.meta.url)), "../governance/policy.json");
|
|
2681
2956
|
const result = await collectGovernanceEvidence({ scanRoot, workspaceRoot: flags.workspaceRoot, evidence: flags.evidence, policy: await loadPolicy(policyPath) });
|
|
2682
2957
|
output.out(`Collected scanner evidence for ${result.apps.length} app(s) at ${resolve11(flags.evidence)}.
|
|
2683
2958
|
`);
|
|
@@ -2690,7 +2965,7 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
2690
2965
|
if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
|
|
2691
2966
|
}
|
|
2692
2967
|
const scanRoot = resolve11(path ?? ".");
|
|
2693
|
-
const policyPath = flags.policy ?? resolve11(
|
|
2968
|
+
const policyPath = flags.policy ?? resolve11(dirname6(fileURLToPath(import.meta.url)), "../governance/policy.json");
|
|
2694
2969
|
const policy = await loadPolicy(policyPath);
|
|
2695
2970
|
const collected = await collectGovernanceEvidence({ scanRoot, workspaceRoot: flags.workspaceRoot, evidence: flags.evidence, policy });
|
|
2696
2971
|
const report = await assessGovernance({
|
|
@@ -2716,7 +2991,7 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
2716
2991
|
for (const [name, domain] of Object.entries(app.domains)) for (const issue of domain.issues) output.out(` ${name}: ${issue}
|
|
2717
2992
|
`);
|
|
2718
2993
|
}
|
|
2719
|
-
output.out(`Reports: ${resolve11(flags.output ??
|
|
2994
|
+
output.out(`Reports: ${resolve11(flags.output ?? join13(scanRoot, ".nklattice-governance"))}
|
|
2720
2995
|
`);
|
|
2721
2996
|
}
|
|
2722
2997
|
exitCode = report.status === "pass" ? 0 : report.status === "fail" ? 7 : 3;
|