@anhcodo13/lattice 0.3.0 → 0.4.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 +22 -28
- package/dist/cli.js +997 -87
- package/dist/index.d.ts +152 -0
- package/dist/index.js +397 -40
- package/governance/policy.json +30 -9
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { writeFile as
|
|
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 dirname5, join as join12, 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.4.0",
|
|
14
14
|
private: false,
|
|
15
15
|
packageManager: "pnpm@10.30.3",
|
|
16
16
|
type: "module",
|
|
@@ -223,28 +223,28 @@ async function exists(path) {
|
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
225
|
async function archivePrevious(outputDirectory) {
|
|
226
|
-
const
|
|
226
|
+
const json2 = join2(outputDirectory, "analysis.json");
|
|
227
227
|
const html = join2(outputDirectory, "report.html");
|
|
228
|
-
if (!await exists(
|
|
228
|
+
if (!await exists(json2) && !await exists(html)) return;
|
|
229
229
|
const name = `${(/* @__PURE__ */ new Date()).toISOString().replaceAll(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
|
|
230
230
|
const destination = join2(outputDirectory, "history", name);
|
|
231
231
|
await mkdir(destination, { recursive: true });
|
|
232
|
-
if (await exists(
|
|
232
|
+
if (await exists(json2)) await rename(json2, join2(destination, "analysis.json"));
|
|
233
233
|
if (await exists(html)) await rename(html, join2(destination, "report.html"));
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
// src/analysis/recommend.ts
|
|
237
|
-
function recommendActions(
|
|
237
|
+
function recommendActions(findings2) {
|
|
238
238
|
const points = { error: 5, warning: 2, info: 1 };
|
|
239
239
|
const grouped = /* @__PURE__ */ new Map();
|
|
240
|
-
for (const finding of
|
|
240
|
+
for (const finding of findings2) grouped.set(finding.file, [...grouped.get(finding.file) ?? [], finding]);
|
|
241
241
|
const ranked = [...grouped.entries()].map(([file, items]) => ({ file, items, priority: items.reduce((sum, item) => sum + points[item.severity], 0) })).sort((a, b) => b.priority - a.priority || a.file.localeCompare(b.file));
|
|
242
242
|
const hotspots = ranked.slice(0, 8).map(({ file, items, priority }) => ({
|
|
243
243
|
file,
|
|
244
244
|
findingCount: items.length,
|
|
245
245
|
estimatedRecovery: `Estimate: up to ${Math.min(15, priority)} points if these findings are resolved`
|
|
246
246
|
}));
|
|
247
|
-
const quickWins =
|
|
247
|
+
const quickWins = findings2.filter((finding) => finding.severity === "info" || finding.ruleId === "duplicate-import").slice(0, 8).map((finding) => ({ file: finding.file, ruleId: finding.ruleId, recommendation: finding.recommendation }));
|
|
248
248
|
const roadmap = ranked.slice(0, 8).map(({ file, items, priority }) => ({
|
|
249
249
|
file,
|
|
250
250
|
priority,
|
|
@@ -610,12 +610,12 @@ function renderHtml(report) {
|
|
|
610
610
|
// src/analysis/rules.ts
|
|
611
611
|
import { matchesGlob } from "path";
|
|
612
612
|
function evaluateRules(graph, config) {
|
|
613
|
-
const
|
|
613
|
+
const findings2 = [];
|
|
614
614
|
const outgoing = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.from === module.path && !edge.isTypeOnly)]));
|
|
615
615
|
const incoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path && !edge.isTypeOnly)]));
|
|
616
616
|
const allIncoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path)]));
|
|
617
617
|
for (const cycle of findCycles(graph)) {
|
|
618
|
-
|
|
618
|
+
findings2.push({
|
|
619
619
|
ruleId: "module-cycle",
|
|
620
620
|
category: "architecture",
|
|
621
621
|
severity: "error",
|
|
@@ -632,7 +632,7 @@ function evaluateRules(graph, config) {
|
|
|
632
632
|
for (const imported of module.imports) {
|
|
633
633
|
if (imported.isTypeOnly) continue;
|
|
634
634
|
if (seen.has(imported.specifier)) {
|
|
635
|
-
|
|
635
|
+
findings2.push({
|
|
636
636
|
ruleId: "duplicate-import",
|
|
637
637
|
category: "imports",
|
|
638
638
|
severity: "warning",
|
|
@@ -645,7 +645,7 @@ function evaluateRules(graph, config) {
|
|
|
645
645
|
seen.add(imported.specifier);
|
|
646
646
|
}
|
|
647
647
|
if (out.length > config.thresholds.maxFanOut) {
|
|
648
|
-
|
|
648
|
+
findings2.push({
|
|
649
649
|
ruleId: "high-fan-out",
|
|
650
650
|
category: "architecture",
|
|
651
651
|
severity: "warning",
|
|
@@ -656,7 +656,7 @@ function evaluateRules(graph, config) {
|
|
|
656
656
|
});
|
|
657
657
|
}
|
|
658
658
|
if (into.length > config.thresholds.maxFanIn) {
|
|
659
|
-
|
|
659
|
+
findings2.push({
|
|
660
660
|
ruleId: "high-fan-in",
|
|
661
661
|
category: "architecture",
|
|
662
662
|
severity: "warning",
|
|
@@ -667,7 +667,7 @@ function evaluateRules(graph, config) {
|
|
|
667
667
|
});
|
|
668
668
|
}
|
|
669
669
|
if (into.length === 0 && out.length === 0 && graph.modules.length > 1 && !module.isRoute) {
|
|
670
|
-
|
|
670
|
+
findings2.push({
|
|
671
671
|
ruleId: "orphan-module",
|
|
672
672
|
category: "architecture",
|
|
673
673
|
severity: "info",
|
|
@@ -683,7 +683,7 @@ function evaluateRules(graph, config) {
|
|
|
683
683
|
for (const name of module.exports) {
|
|
684
684
|
if (name === "*" || module.typeExports.includes(name)) continue;
|
|
685
685
|
if (!usedNames.has(name)) {
|
|
686
|
-
|
|
686
|
+
findings2.push({
|
|
687
687
|
ruleId: "unused-export",
|
|
688
688
|
category: "imports",
|
|
689
689
|
severity: "info",
|
|
@@ -697,7 +697,7 @@ function evaluateRules(graph, config) {
|
|
|
697
697
|
}
|
|
698
698
|
}
|
|
699
699
|
if (module.componentCount > 0 && module.lines > config.thresholds.maxComponentLines) {
|
|
700
|
-
|
|
700
|
+
findings2.push({
|
|
701
701
|
ruleId: "large-component",
|
|
702
702
|
category: "components",
|
|
703
703
|
severity: "warning",
|
|
@@ -708,7 +708,7 @@ function evaluateRules(graph, config) {
|
|
|
708
708
|
});
|
|
709
709
|
}
|
|
710
710
|
if (module.jsxDepth > config.thresholds.maxJsxDepth) {
|
|
711
|
-
|
|
711
|
+
findings2.push({
|
|
712
712
|
ruleId: "deep-jsx",
|
|
713
713
|
category: "components",
|
|
714
714
|
severity: "warning",
|
|
@@ -719,7 +719,7 @@ function evaluateRules(graph, config) {
|
|
|
719
719
|
});
|
|
720
720
|
}
|
|
721
721
|
if (module.complexity > config.thresholds.maxComplexity) {
|
|
722
|
-
|
|
722
|
+
findings2.push({
|
|
723
723
|
ruleId: "high-complexity",
|
|
724
724
|
category: "complexity",
|
|
725
725
|
severity: "warning",
|
|
@@ -735,7 +735,7 @@ function evaluateRules(graph, config) {
|
|
|
735
735
|
const allowed = policy.allow?.some((pattern) => matchesGlob(edge.to, pattern));
|
|
736
736
|
const disallowed = policy.disallow?.some((pattern) => matchesGlob(edge.to, pattern));
|
|
737
737
|
if (policy.allow && !allowed || policy.disallow && disallowed) {
|
|
738
|
-
|
|
738
|
+
findings2.push({
|
|
739
739
|
ruleId: policy.id,
|
|
740
740
|
category: "architecture",
|
|
741
741
|
severity: policy.severity,
|
|
@@ -762,7 +762,7 @@ function evaluateRules(graph, config) {
|
|
|
762
762
|
return false;
|
|
763
763
|
};
|
|
764
764
|
if (visit(module.path, /* @__PURE__ */ new Set([module.path]), 0)) {
|
|
765
|
-
|
|
765
|
+
findings2.push({
|
|
766
766
|
ruleId: "deep-dependency-chain",
|
|
767
767
|
category: "architecture",
|
|
768
768
|
severity: "warning",
|
|
@@ -773,7 +773,7 @@ function evaluateRules(graph, config) {
|
|
|
773
773
|
});
|
|
774
774
|
}
|
|
775
775
|
}
|
|
776
|
-
return
|
|
776
|
+
return findings2.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.ruleId.localeCompare(b.ruleId));
|
|
777
777
|
}
|
|
778
778
|
|
|
779
779
|
// src/analysis/scan.ts
|
|
@@ -929,9 +929,9 @@ async function compilerOptions(root) {
|
|
|
929
929
|
throw error;
|
|
930
930
|
}
|
|
931
931
|
try {
|
|
932
|
-
const
|
|
933
|
-
if (
|
|
934
|
-
const parsed = ts2.parseJsonConfigFileContent(
|
|
932
|
+
const json2 = ts2.parseConfigFileTextToJson(path, content);
|
|
933
|
+
if (json2.error) return { options: {}, diagnostics: [`tsconfig.json: ${ts2.flattenDiagnosticMessageText(json2.error.messageText, " ")}`] };
|
|
934
|
+
const parsed = ts2.parseJsonConfigFileContent(json2.config, ts2.sys, root);
|
|
935
935
|
return { options: parsed.options, diagnostics: parsed.errors.map((error) => `tsconfig.json: ${ts2.flattenDiagnosticMessageText(error.messageText, " ")}`) };
|
|
936
936
|
} catch (error) {
|
|
937
937
|
return { options: {}, diagnostics: [`tsconfig.json: ${error instanceof Error ? error.message : String(error)}`] };
|
|
@@ -1105,10 +1105,10 @@ function grade(score) {
|
|
|
1105
1105
|
if (score >= 55) return "D";
|
|
1106
1106
|
return "F";
|
|
1107
1107
|
}
|
|
1108
|
-
function scoreFindings(
|
|
1108
|
+
function scoreFindings(findings2, config) {
|
|
1109
1109
|
const penalties = { error: 18, warning: 7, info: 2 };
|
|
1110
1110
|
const categories = Object.entries(config.weights).map(([id, weight]) => {
|
|
1111
|
-
const categoryFindings =
|
|
1111
|
+
const categoryFindings = findings2.filter((finding) => finding.category === id).sort((a, b) => a.ruleId.localeCompare(b.ruleId) || a.file.localeCompare(b.file));
|
|
1112
1112
|
const occurrences = /* @__PURE__ */ new Map();
|
|
1113
1113
|
let deduction = 0;
|
|
1114
1114
|
for (const finding of categoryFindings) {
|
|
@@ -1120,8 +1120,8 @@ function scoreFindings(findings, config) {
|
|
|
1120
1120
|
return { id, weight, score: round(100 - deduction), deduction: round(deduction), findingCount: categoryFindings.length };
|
|
1121
1121
|
});
|
|
1122
1122
|
const weightedDeduction = round(categories.reduce((sum, category) => sum + category.deduction * category.weight / 100, 0));
|
|
1123
|
-
const maintenanceAdjustment = round(Math.min(5, Math.max(0,
|
|
1124
|
-
const criticalAdjustment = round(Math.min(10,
|
|
1123
|
+
const maintenanceAdjustment = round(Math.min(5, Math.max(0, findings2.length - 10) * 0.15));
|
|
1124
|
+
const criticalAdjustment = round(Math.min(10, findings2.filter((finding) => finding.severity === "error").length * 1.5));
|
|
1125
1125
|
const score = round(Math.max(0, Math.min(100, 100 - weightedDeduction - maintenanceAdjustment - criticalAdjustment)));
|
|
1126
1126
|
return { score, grade: grade(score), weightedDeduction, maintenanceAdjustment, criticalAdjustment, categories };
|
|
1127
1127
|
}
|
|
@@ -1133,7 +1133,7 @@ async function analyzeProject(projectRoot, options = {}) {
|
|
|
1133
1133
|
const modules = await scanProject(root, config.aliases);
|
|
1134
1134
|
if (modules.length === 0) throw new Error("No supported source files found");
|
|
1135
1135
|
const graph = buildGraph(modules);
|
|
1136
|
-
const
|
|
1136
|
+
const findings2 = evaluateRules(graph, config);
|
|
1137
1137
|
let name = basename(root);
|
|
1138
1138
|
try {
|
|
1139
1139
|
const manifest = JSON.parse(await readFile4(join5(root, "package.json"), "utf8"));
|
|
@@ -1145,9 +1145,9 @@ async function analyzeProject(projectRoot, options = {}) {
|
|
|
1145
1145
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1146
1146
|
project: { name },
|
|
1147
1147
|
graph,
|
|
1148
|
-
findings,
|
|
1149
|
-
score: scoreFindings(
|
|
1150
|
-
actions: recommendActions(
|
|
1148
|
+
findings: findings2,
|
|
1149
|
+
score: scoreFindings(findings2, config),
|
|
1150
|
+
actions: recommendActions(findings2)
|
|
1151
1151
|
};
|
|
1152
1152
|
const outputDirectory = resolve3(options.outputDirectory ?? join5(root, ".nklattice-report"));
|
|
1153
1153
|
await mkdir2(outputDirectory, { recursive: true });
|
|
@@ -1176,12 +1176,12 @@ function dependencyMap(manifest) {
|
|
|
1176
1176
|
const entries = manifest[section2];
|
|
1177
1177
|
if (entries === void 0) continue;
|
|
1178
1178
|
if (!isRecord(entries)) throw new Error(`Invalid ${section2} in package.json`);
|
|
1179
|
-
for (const [name,
|
|
1180
|
-
if (typeof
|
|
1181
|
-
if (result[name] !== void 0 && result[name] !==
|
|
1179
|
+
for (const [name, version2] of Object.entries(entries)) {
|
|
1180
|
+
if (typeof version2 !== "string") throw new Error(`Invalid version for ${name}`);
|
|
1181
|
+
if (result[name] !== void 0 && result[name] !== version2) {
|
|
1182
1182
|
throw new Error(`Conflicting declarations for ${name}`);
|
|
1183
1183
|
}
|
|
1184
|
-
result[name] =
|
|
1184
|
+
result[name] = version2;
|
|
1185
1185
|
}
|
|
1186
1186
|
}
|
|
1187
1187
|
return result;
|
|
@@ -1324,17 +1324,17 @@ function readPnpm(raw, root, app, name) {
|
|
|
1324
1324
|
if (!record2(entry) || typeof entry.specifier !== "string" || typeof entry.version !== "string") {
|
|
1325
1325
|
throw new Error(`Malformed pnpm lockfile dependency for ${name}`);
|
|
1326
1326
|
}
|
|
1327
|
-
const
|
|
1328
|
-
if (!
|
|
1329
|
-
const packageKeys = [`${name}@${
|
|
1327
|
+
const version2 = entry.version.match(/^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/)?.[1];
|
|
1328
|
+
if (!version2) throw new Error(`Unsupported pnpm resolution for ${name}`);
|
|
1329
|
+
const packageKeys = [`${name}@${version2}`, `/${name}@${version2}`];
|
|
1330
1330
|
if (!packageKeys.some((key) => record2(lock.packages) && Object.hasOwn(lock.packages, key))) {
|
|
1331
|
-
throw new Error(`Missing pnpm package entry for ${name}@${
|
|
1331
|
+
throw new Error(`Missing pnpm package entry for ${name}@${version2}`);
|
|
1332
1332
|
}
|
|
1333
1333
|
const snapshotKeys = [`${name}@${entry.version}`, `/${name}@${entry.version}`, ...packageKeys];
|
|
1334
1334
|
if (!snapshotKeys.some((key) => Object.hasOwn(lock.snapshots, key))) {
|
|
1335
1335
|
throw new Error(`Missing pnpm snapshot for ${name}@${entry.version}`);
|
|
1336
1336
|
}
|
|
1337
|
-
return { specifier: entry.specifier, version };
|
|
1337
|
+
return { specifier: entry.specifier, version: version2 };
|
|
1338
1338
|
}
|
|
1339
1339
|
async function readResolution(app, workspaceRoot, name) {
|
|
1340
1340
|
const lockfile = await findLockfile(app.directory, workspaceRoot);
|
|
@@ -1346,7 +1346,7 @@ async function readResolution(app, workspaceRoot, name) {
|
|
|
1346
1346
|
async function checkApp(app, workspaceRoot, policy) {
|
|
1347
1347
|
const requirements = policy.profiles[app.profile];
|
|
1348
1348
|
if (!requirements) throw new Error(`Policy does not define profile ${app.profile}`);
|
|
1349
|
-
const
|
|
1349
|
+
const findings2 = [];
|
|
1350
1350
|
for (const [packageName, expected] of Object.entries(requirements)) {
|
|
1351
1351
|
const declared = app.dependencies[packageName];
|
|
1352
1352
|
const resolution = declared === void 0 ? { specifier: void 0, version: void 0 } : await readResolution(app, workspaceRoot, packageName);
|
|
@@ -1364,7 +1364,7 @@ async function checkApp(app, workspaceRoot, policy) {
|
|
|
1364
1364
|
reasons.push("lockfile resolution outside manifest range");
|
|
1365
1365
|
}
|
|
1366
1366
|
}
|
|
1367
|
-
if (reasons.length)
|
|
1367
|
+
if (reasons.length) findings2.push({
|
|
1368
1368
|
app: app.relativePath,
|
|
1369
1369
|
packageName,
|
|
1370
1370
|
expected,
|
|
@@ -1374,7 +1374,7 @@ async function checkApp(app, workspaceRoot, policy) {
|
|
|
1374
1374
|
reason: reasons.join("; ")
|
|
1375
1375
|
});
|
|
1376
1376
|
}
|
|
1377
|
-
return
|
|
1377
|
+
return findings2;
|
|
1378
1378
|
}
|
|
1379
1379
|
async function checkGovernance(options) {
|
|
1380
1380
|
const scanRoot = resolve5(options.scanRoot);
|
|
@@ -1385,10 +1385,10 @@ async function checkGovernance(options) {
|
|
|
1385
1385
|
}
|
|
1386
1386
|
const apps = await discoverApps(scanRoot, workspaceRoot);
|
|
1387
1387
|
if (apps.length === 0) throw new Error("No managed applications found");
|
|
1388
|
-
const
|
|
1388
|
+
const findings2 = (await Promise.all(apps.map((app) => checkApp(app, workspaceRoot, options.policy)))).flat();
|
|
1389
1389
|
return {
|
|
1390
1390
|
appsChecked: apps.length,
|
|
1391
|
-
findings,
|
|
1391
|
+
findings: findings2,
|
|
1392
1392
|
policyVersion: options.policy.policyVersion,
|
|
1393
1393
|
policySource: options.policy.source,
|
|
1394
1394
|
policySha256: options.policy.sha256
|
|
@@ -1397,7 +1397,7 @@ async function checkGovernance(options) {
|
|
|
1397
1397
|
|
|
1398
1398
|
// src/governance/assess.ts
|
|
1399
1399
|
import { mkdir as mkdir3, writeFile as writeFile2 } from "fs/promises";
|
|
1400
|
-
import { join as
|
|
1400
|
+
import { basename as basename2, join as join10, relative as relative7, resolve as resolve9, sep as sep6 } from "path";
|
|
1401
1401
|
|
|
1402
1402
|
// src/governance/inventory.ts
|
|
1403
1403
|
import { createHash } from "crypto";
|
|
@@ -1457,10 +1457,283 @@ function compareInventory(inventory, discovered, scanRoot) {
|
|
|
1457
1457
|
return issues;
|
|
1458
1458
|
}
|
|
1459
1459
|
|
|
1460
|
-
// src/governance/
|
|
1460
|
+
// src/governance/evidence.ts
|
|
1461
1461
|
import { createHash as createHash2 } from "crypto";
|
|
1462
|
-
import {
|
|
1463
|
-
import {
|
|
1462
|
+
import { execFile } from "child_process";
|
|
1463
|
+
import { createReadStream } from "fs";
|
|
1464
|
+
import { lstat, readFile as readFile8, realpath as realpath2 } from "fs/promises";
|
|
1465
|
+
import { dirname as dirname3, join as join9, relative as relative6, resolve as resolve7, sep as sep5 } from "path";
|
|
1466
|
+
import { promisify } from "util";
|
|
1467
|
+
import fg from "fast-glob";
|
|
1468
|
+
var execFileAsync = promisify(execFile);
|
|
1469
|
+
function object(value) {
|
|
1470
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1471
|
+
}
|
|
1472
|
+
function string(value) {
|
|
1473
|
+
return typeof value === "string" && value.length > 0;
|
|
1474
|
+
}
|
|
1475
|
+
function nonnegative(value) {
|
|
1476
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
1477
|
+
}
|
|
1478
|
+
function errors(value) {
|
|
1479
|
+
return Array.isArray(value) && value.every(string);
|
|
1480
|
+
}
|
|
1481
|
+
function findings(value) {
|
|
1482
|
+
return Array.isArray(value) && value.every((item) => object(item) && string(item.id) && string(item.severity) && string(item.subject) && string(item.message) && (item.remediation === void 0 || string(item.remediation)));
|
|
1483
|
+
}
|
|
1484
|
+
function base(value) {
|
|
1485
|
+
return object(value) && string(value.tool) && string(value.version) && typeof value.complete === "boolean" && errors(value.errors);
|
|
1486
|
+
}
|
|
1487
|
+
function validateApp(value) {
|
|
1488
|
+
if (!object(value) || !string(value.path) || !string(value.profile)) return false;
|
|
1489
|
+
const dependency = value.dependencySecurity;
|
|
1490
|
+
const quality = value.codeQuality;
|
|
1491
|
+
const security = value.codeSecurity;
|
|
1492
|
+
const compatibility2 = value.compatibility;
|
|
1493
|
+
if (!base(dependency) || !nonnegative(dependency.packagesScanned) || !findings(dependency.advisories)) return false;
|
|
1494
|
+
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
|
+
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
|
+
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;
|
|
1498
|
+
return true;
|
|
1499
|
+
}
|
|
1500
|
+
async function hashFile(hash, path) {
|
|
1501
|
+
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
|
1502
|
+
}
|
|
1503
|
+
async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
|
|
1504
|
+
const root = resolve7(scanRoot);
|
|
1505
|
+
const workspace = resolve7(workspaceRoot);
|
|
1506
|
+
const scanRelative = relative6(workspace, root);
|
|
1507
|
+
if (scanRelative === ".." || scanRelative.startsWith(`..${sep5}`)) throw new Error("Scan root must be inside workspace root");
|
|
1508
|
+
const paths = await fg("**/*", {
|
|
1509
|
+
cwd: root,
|
|
1510
|
+
dot: true,
|
|
1511
|
+
onlyFiles: true,
|
|
1512
|
+
followSymbolicLinks: false,
|
|
1513
|
+
ignore: [
|
|
1514
|
+
"**/.git/**",
|
|
1515
|
+
"**/node_modules/**",
|
|
1516
|
+
"**/dist/**",
|
|
1517
|
+
"**/build/**",
|
|
1518
|
+
"**/.next/**",
|
|
1519
|
+
"**/coverage/**",
|
|
1520
|
+
"**/playwright-report/**",
|
|
1521
|
+
"**/test-results/**",
|
|
1522
|
+
"**/.nklattice-governance/**",
|
|
1523
|
+
"**/.nklattice-report/**",
|
|
1524
|
+
"**/AGENTS.md",
|
|
1525
|
+
"**/*.tsbuildinfo",
|
|
1526
|
+
"**/next-env.d.ts"
|
|
1527
|
+
]
|
|
1528
|
+
});
|
|
1529
|
+
paths.sort();
|
|
1530
|
+
const hash = createHash2("sha256");
|
|
1531
|
+
for (const path of paths) {
|
|
1532
|
+
const absolute = resolve7(root, path);
|
|
1533
|
+
const normalized = relative6(root, absolute);
|
|
1534
|
+
if (normalized === ".." || normalized.startsWith(`..${sep5}`)) throw new Error("Input path escapes scan root");
|
|
1535
|
+
if (!(await lstat(absolute)).isFile()) continue;
|
|
1536
|
+
hash.update(path.replaceAll("\\", "/"));
|
|
1537
|
+
hash.update("\0");
|
|
1538
|
+
await hashFile(hash, absolute);
|
|
1539
|
+
hash.update("\0");
|
|
1540
|
+
}
|
|
1541
|
+
let ancestor = root;
|
|
1542
|
+
while (ancestor === workspace || ancestor !== dirname3(ancestor) && !relative6(workspace, ancestor).startsWith("..")) {
|
|
1543
|
+
for (const name of [
|
|
1544
|
+
"package.json",
|
|
1545
|
+
"package-lock.json",
|
|
1546
|
+
"pnpm-lock.yaml",
|
|
1547
|
+
"pnpm-workspace.yaml",
|
|
1548
|
+
"tsconfig.json",
|
|
1549
|
+
"eslint.config.js",
|
|
1550
|
+
"eslint.config.mjs",
|
|
1551
|
+
"eslint.config.cjs",
|
|
1552
|
+
"eslint.config.ts",
|
|
1553
|
+
"eslint.config.mts",
|
|
1554
|
+
"vitest.config.js",
|
|
1555
|
+
"vitest.config.mjs",
|
|
1556
|
+
"vitest.config.ts",
|
|
1557
|
+
"vitest.config.mts",
|
|
1558
|
+
"playwright.config.js",
|
|
1559
|
+
"playwright.config.mjs",
|
|
1560
|
+
"playwright.config.ts",
|
|
1561
|
+
"playwright.config.mts",
|
|
1562
|
+
".semgrep.yml",
|
|
1563
|
+
".semgrep.yaml",
|
|
1564
|
+
"semgrep.yml",
|
|
1565
|
+
"semgrep.yaml"
|
|
1566
|
+
]) {
|
|
1567
|
+
const path = join9(ancestor, name);
|
|
1568
|
+
try {
|
|
1569
|
+
const stat2 = await lstat(path);
|
|
1570
|
+
if (stat2.isSymbolicLink()) throw new Error(`Symlinked scanner configuration is unsupported: ${path}`);
|
|
1571
|
+
if (!stat2.isFile()) continue;
|
|
1572
|
+
hash.update(relative6(workspace, path).replaceAll("\\", "/"));
|
|
1573
|
+
hash.update("\0");
|
|
1574
|
+
await hashFile(hash, path);
|
|
1575
|
+
hash.update("\0");
|
|
1576
|
+
} catch (error) {
|
|
1577
|
+
if (error.code !== "ENOENT") throw error;
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
if (ancestor === workspace) break;
|
|
1581
|
+
ancestor = dirname3(ancestor);
|
|
1582
|
+
}
|
|
1583
|
+
return hash.digest("hex");
|
|
1584
|
+
}
|
|
1585
|
+
async function loadScanEvidence(path, scanRoot, workspaceRoot, policySha256, maximumAgeHours) {
|
|
1586
|
+
const source = resolve7(path);
|
|
1587
|
+
const stat2 = await lstat(source);
|
|
1588
|
+
if (!stat2.isFile() || stat2.isSymbolicLink() || stat2.size > 1e7) throw new Error("Evidence must be a regular JSON file under 10 MB");
|
|
1589
|
+
const root = resolve7(scanRoot);
|
|
1590
|
+
const relativePath = relative6(root, source);
|
|
1591
|
+
if (relativePath !== ".." && !relativePath.startsWith(`..${sep5}`)) throw new Error("Evidence file must be outside the scan root");
|
|
1592
|
+
const actualRoot = await realpath2(root);
|
|
1593
|
+
const actualSource = await realpath2(source);
|
|
1594
|
+
const actualRelative = relative6(actualRoot, actualSource);
|
|
1595
|
+
if (actualRelative !== ".." && !actualRelative.startsWith(`..${sep5}`)) throw new Error("Evidence file resolves inside the scan root");
|
|
1596
|
+
let parsed;
|
|
1597
|
+
try {
|
|
1598
|
+
parsed = JSON.parse(await readFile8(source, "utf8"));
|
|
1599
|
+
} catch {
|
|
1600
|
+
throw new Error("Evidence is not valid JSON");
|
|
1601
|
+
}
|
|
1602
|
+
return validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha256, maximumAgeHours);
|
|
1603
|
+
}
|
|
1604
|
+
async function validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha256, maximumAgeHours) {
|
|
1605
|
+
if (!object(parsed) || parsed.schemaVersion !== 1 || !string(parsed.generatedAt) || !string(parsed.policySha256) || !string(parsed.inputSha256) || !string(parsed.commit) || !/^[0-9a-f]{40}$/.test(parsed.commit) || !Array.isArray(parsed.apps) || !parsed.apps.every(validateApp)) throw new Error("Evidence schema is invalid or incomplete");
|
|
1606
|
+
if (parsed.policySha256 !== policySha256) throw new Error("Evidence was generated for another policy");
|
|
1607
|
+
const generatedAt = Date.parse(parsed.generatedAt);
|
|
1608
|
+
if (!Number.isFinite(generatedAt) || generatedAt > Date.now() + 6e4 || Date.now() - generatedAt > maximumAgeHours * 36e5) {
|
|
1609
|
+
throw new Error("Evidence is stale or has an invalid timestamp");
|
|
1610
|
+
}
|
|
1611
|
+
if (parsed.inputSha256 !== await hashAssessmentInputs(scanRoot, workspaceRoot)) throw new Error("Evidence inputs changed after scanning");
|
|
1612
|
+
const current = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: workspaceRoot })).stdout.trim();
|
|
1613
|
+
if (!/^[0-9a-f]{40}$/.test(current) || parsed.commit !== current) throw new Error("Evidence commit differs from the checked-out commit");
|
|
1614
|
+
const appPaths = parsed.apps.map((app) => app.path);
|
|
1615
|
+
if (new Set(appPaths).size !== appPaths.length) throw new Error("Evidence contains duplicate app paths");
|
|
1616
|
+
return parsed;
|
|
1617
|
+
}
|
|
1618
|
+
var incomplete = (issues, tool, version2, scanned) => ({ status: "incomplete", issues, tool, version: version2, scanned });
|
|
1619
|
+
function evaluateAppEvidence(app, policy) {
|
|
1620
|
+
if (!app) {
|
|
1621
|
+
const missing = incomplete(["No fresh scan evidence for this app"]);
|
|
1622
|
+
return { dependencySecurity: { ...missing }, codeQuality: { ...missing }, codeSecurity: { ...missing }, compatibility: { ...missing } };
|
|
1623
|
+
}
|
|
1624
|
+
const dependency = app.dependencySecurity;
|
|
1625
|
+
let dependencySecurity;
|
|
1626
|
+
if (!dependency.complete || dependency.packagesScanned === 0 || dependency.errors.length) {
|
|
1627
|
+
dependencySecurity = { ...incomplete(["Dependency advisory scan did not cover the lockfile", ...dependency.errors], dependency.tool, dependency.version, dependency.packagesScanned), findings: dependency.advisories };
|
|
1628
|
+
} else {
|
|
1629
|
+
const high2 = dependency.advisories.filter((item) => item.severity.toLowerCase() === "high").length;
|
|
1630
|
+
const critical2 = dependency.advisories.filter((item) => item.severity.toLowerCase() === "critical").length;
|
|
1631
|
+
const unknown = dependency.advisories.filter((item) => !["low", "moderate", "medium", "high", "critical"].includes(item.severity.toLowerCase()));
|
|
1632
|
+
dependencySecurity = unknown.length ? { ...incomplete([`${unknown.length} advisories have unknown severity`], dependency.tool, dependency.version, dependency.packagesScanned), findings: dependency.advisories } : {
|
|
1633
|
+
status: high2 > policy.dependencySecurity.maximumHigh || critical2 > policy.dependencySecurity.maximumCritical ? "fail" : "pass",
|
|
1634
|
+
issues: [
|
|
1635
|
+
...high2 > policy.dependencySecurity.maximumHigh ? [`${high2} high advisories exceed ${policy.dependencySecurity.maximumHigh}`] : [],
|
|
1636
|
+
...critical2 > policy.dependencySecurity.maximumCritical ? [`${critical2} critical advisories exceed ${policy.dependencySecurity.maximumCritical}`] : []
|
|
1637
|
+
],
|
|
1638
|
+
tool: dependency.tool,
|
|
1639
|
+
version: dependency.version,
|
|
1640
|
+
scanned: dependency.packagesScanned,
|
|
1641
|
+
findings: dependency.advisories
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
const quality = app.codeQuality;
|
|
1645
|
+
const qualityMissing = [
|
|
1646
|
+
...!quality.lint.complete || quality.lint.filesScanned === 0 ? ["ESLint did not scan source files", ...quality.lint.errorsDetail] : [],
|
|
1647
|
+
...policy.codeQuality.requireTypecheck && !quality.typecheck.complete ? ["TypeScript typecheck did not complete", ...quality.typecheck.errors] : [],
|
|
1648
|
+
...policy.codeQuality.requireTests && (!quality.tests.complete || quality.tests.passed + quality.tests.failed === 0) ? ["Tests did not run", ...quality.tests.errors] : [],
|
|
1649
|
+
...!quality.coverage.complete ? ["Line coverage is unavailable", ...quality.coverage.errors] : []
|
|
1650
|
+
];
|
|
1651
|
+
const qualityFailures = [
|
|
1652
|
+
...quality.lint.errorCount > policy.codeQuality.maximumLintErrors ? [`${quality.lint.errorCount} ESLint errors exceed ${policy.codeQuality.maximumLintErrors}`] : [],
|
|
1653
|
+
...policy.codeQuality.requireTypecheck && !quality.typecheck.passed ? ["TypeScript typecheck failed"] : [],
|
|
1654
|
+
...quality.tests.failed > 0 ? [`${quality.tests.failed} tests failed`] : [],
|
|
1655
|
+
...quality.coverage.linesPercent < policy.codeQuality.minimumLineCoverage ? [`Line coverage ${quality.coverage.linesPercent}% is below ${policy.codeQuality.minimumLineCoverage}%`] : []
|
|
1656
|
+
];
|
|
1657
|
+
const qualityVersion = `ESLint ${quality.lint.version} \xB7 TypeScript ${quality.typecheck.version} \xB7 Vitest ${quality.tests.version}`;
|
|
1658
|
+
const qualityMetrics = {
|
|
1659
|
+
lintErrors: quality.lint.errorCount,
|
|
1660
|
+
lintWarnings: quality.lint.warnings,
|
|
1661
|
+
typecheck: quality.typecheck.applicable === false ? "not applicable" : quality.typecheck.passed ? "pass" : "fail",
|
|
1662
|
+
testsPassed: quality.tests.passed,
|
|
1663
|
+
testsFailed: quality.tests.failed,
|
|
1664
|
+
lineCoveragePercent: quality.coverage.linesPercent
|
|
1665
|
+
};
|
|
1666
|
+
const codeQuality = qualityMissing.length ? { ...incomplete([...qualityMissing, ...qualityFailures], "ESLint / TypeScript / tests", qualityVersion, quality.lint.filesScanned), metrics: qualityMetrics } : {
|
|
1667
|
+
status: qualityFailures.length ? "fail" : "pass",
|
|
1668
|
+
issues: qualityFailures,
|
|
1669
|
+
tool: "ESLint / TypeScript / tests",
|
|
1670
|
+
version: qualityVersion,
|
|
1671
|
+
scanned: quality.lint.filesScanned,
|
|
1672
|
+
metrics: qualityMetrics
|
|
1673
|
+
};
|
|
1674
|
+
const security = app.codeSecurity;
|
|
1675
|
+
const securityMissing = [
|
|
1676
|
+
...!security.sast.complete || security.sast.filesScanned === 0 ? ["SAST did not scan source files", ...security.sast.errors] : [],
|
|
1677
|
+
...!security.secrets.complete || security.secrets.filesScanned === 0 ? ["Secret scanner did not scan source files", ...security.secrets.errors] : []
|
|
1678
|
+
];
|
|
1679
|
+
const high = security.sast.findings.filter((item) => item.severity.toLowerCase() === "high").length;
|
|
1680
|
+
const critical = security.sast.findings.filter((item) => item.severity.toLowerCase() === "critical").length;
|
|
1681
|
+
const unknownSecurity = security.sast.findings.filter((item) => !["low", "moderate", "medium", "high", "critical"].includes(item.severity.toLowerCase()));
|
|
1682
|
+
if (unknownSecurity.length) securityMissing.push(`${unknownSecurity.length} SAST findings have unknown severity`);
|
|
1683
|
+
const securityFailures = [
|
|
1684
|
+
...high > policy.codeSecurity.maximumHigh ? [`${high} high SAST findings exceed ${policy.codeSecurity.maximumHigh}`] : [],
|
|
1685
|
+
...critical > policy.codeSecurity.maximumCritical ? [`${critical} critical SAST findings exceed ${policy.codeSecurity.maximumCritical}`] : [],
|
|
1686
|
+
...security.secrets.findings.length > policy.codeSecurity.maximumSecrets ? [`${security.secrets.findings.length} detected secrets exceed ${policy.codeSecurity.maximumSecrets}`] : []
|
|
1687
|
+
];
|
|
1688
|
+
const securityVersion = `Semgrep ${security.sast.version} \xB7 Gitleaks ${security.secrets.version}`;
|
|
1689
|
+
const codeSecurity = securityMissing.length ? { ...incomplete([...securityMissing, ...securityFailures], "Semgrep / Gitleaks", securityVersion, security.sast.filesScanned), findings: [...security.sast.findings, ...security.secrets.findings] } : {
|
|
1690
|
+
status: securityFailures.length ? "fail" : "pass",
|
|
1691
|
+
issues: securityFailures,
|
|
1692
|
+
tool: "Semgrep / Gitleaks",
|
|
1693
|
+
version: securityVersion,
|
|
1694
|
+
scanned: security.sast.filesScanned,
|
|
1695
|
+
findings: [...security.sast.findings, ...security.secrets.findings]
|
|
1696
|
+
};
|
|
1697
|
+
const compatibility2 = app.compatibility;
|
|
1698
|
+
const projectNames = new Set(compatibility2.projects.map((project) => project.name));
|
|
1699
|
+
const missingProjects = policy.compatibility.requiredProjects.filter((name) => !projectNames.has(name));
|
|
1700
|
+
const incompleteProjects = compatibility2.projects.filter((project) => project.passed + project.failed < policy.compatibility.minimumTestsPerProject);
|
|
1701
|
+
const missingBrowserVersions = compatibility2.projects.filter((project) => !project.browserVersion);
|
|
1702
|
+
const expectedEngines = {
|
|
1703
|
+
"chromium-desktop": "chromium",
|
|
1704
|
+
"firefox-desktop": "firefox",
|
|
1705
|
+
"webkit-desktop": "webkit",
|
|
1706
|
+
"android-chrome": "chromium",
|
|
1707
|
+
"iphone-safari": "webkit"
|
|
1708
|
+
};
|
|
1709
|
+
const invalidEngines = compatibility2.projects.filter((project) => expectedEngines[project.name] && project.engine !== expectedEngines[project.name]);
|
|
1710
|
+
const invalidDevices = compatibility2.projects.filter((project) => (project.name === "android-chrome" || project.name === "iphone-safari") && (project.mobile !== true || !project.device));
|
|
1711
|
+
const missingViewports = compatibility2.projects.filter((project) => new Set(project.viewportWidths ?? []).size < 2);
|
|
1712
|
+
const compatibilityMissing = [
|
|
1713
|
+
...!compatibility2.complete ? ["Compatibility test run did not complete", ...compatibility2.errors] : [],
|
|
1714
|
+
...missingProjects.length ? [`Missing Playwright projects: ${missingProjects.join(", ")}`] : [],
|
|
1715
|
+
...incompleteProjects.map((project) => `${project.name} ran fewer than ${policy.compatibility.minimumTestsPerProject} tests`),
|
|
1716
|
+
...missingBrowserVersions.map((project) => `${project.name} did not record its browser engine version`),
|
|
1717
|
+
...invalidEngines.map((project) => `${project.name} used ${project.engine ?? "an unverified engine"} instead of ${expectedEngines[project.name]}`),
|
|
1718
|
+
...invalidDevices.map((project) => `${project.name} did not verify mobile emulation settings`),
|
|
1719
|
+
...missingViewports.map((project) => `${project.name} did not test two viewport widths`)
|
|
1720
|
+
];
|
|
1721
|
+
const failedProjects = compatibility2.projects.filter((project) => project.failed > 0);
|
|
1722
|
+
const compatibilityDomain = compatibilityMissing.length ? { ...incomplete([...compatibilityMissing, ...failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`)], compatibility2.tool, compatibility2.version), matrix: compatibility2.projects } : {
|
|
1723
|
+
status: failedProjects.length ? "fail" : "pass",
|
|
1724
|
+
issues: failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`),
|
|
1725
|
+
tool: compatibility2.tool,
|
|
1726
|
+
version: compatibility2.version,
|
|
1727
|
+
scanned: compatibility2.projects.reduce((count, project) => count + project.passed + project.failed, 0),
|
|
1728
|
+
matrix: compatibility2.projects
|
|
1729
|
+
};
|
|
1730
|
+
return { dependencySecurity, codeQuality, codeSecurity, compatibility: compatibilityDomain };
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// src/governance/policy.ts
|
|
1734
|
+
import { createHash as createHash3 } from "crypto";
|
|
1735
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
1736
|
+
import { resolve as resolve8 } from "path";
|
|
1464
1737
|
import { valid as valid2 } from "semver";
|
|
1465
1738
|
var exactVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
1466
1739
|
var minimumVersion = /^>=(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
@@ -1502,12 +1775,55 @@ function parseAssessmentPolicy(value) {
|
|
|
1502
1775
|
for (const id of architecture.blockedRuleIds) {
|
|
1503
1776
|
if (!known.has(id)) throw new Error(`Unknown blocked rule ID ${id}`);
|
|
1504
1777
|
}
|
|
1778
|
+
let evidence;
|
|
1779
|
+
if (value.evidence !== void 0) {
|
|
1780
|
+
if (!record3(value.evidence)) throw new Error("assessment.evidence must be an object");
|
|
1781
|
+
const gates = value.evidence;
|
|
1782
|
+
const nonnegative2 = (source, key, label) => {
|
|
1783
|
+
const result = source[key];
|
|
1784
|
+
if (!Number.isSafeInteger(result) || result < 0) throw new Error(`${label}.${key} must be a nonnegative integer`);
|
|
1785
|
+
return result;
|
|
1786
|
+
};
|
|
1787
|
+
if (!Number.isFinite(gates.maximumAgeHours) || gates.maximumAgeHours <= 0 || gates.maximumAgeHours > 168) {
|
|
1788
|
+
throw new Error("assessment.evidence.maximumAgeHours must be greater than 0 and at most 168");
|
|
1789
|
+
}
|
|
1790
|
+
for (const domain of ["dependencySecurity", "codeQuality", "codeSecurity", "compatibility"]) {
|
|
1791
|
+
if (!record3(gates[domain])) throw new Error(`assessment.evidence.${domain} is required`);
|
|
1792
|
+
}
|
|
1793
|
+
const dependencySecurity = gates.dependencySecurity;
|
|
1794
|
+
const codeQuality = gates.codeQuality;
|
|
1795
|
+
const codeSecurity = gates.codeSecurity;
|
|
1796
|
+
const compatibility2 = gates.compatibility;
|
|
1797
|
+
const minimumLineCoverage = codeQuality.minimumLineCoverage;
|
|
1798
|
+
if (typeof minimumLineCoverage !== "number" || !Number.isFinite(minimumLineCoverage) || minimumLineCoverage < 0 || minimumLineCoverage > 100) {
|
|
1799
|
+
throw new Error("assessment.evidence.codeQuality.minimumLineCoverage must be 0\u2013100");
|
|
1800
|
+
}
|
|
1801
|
+
if (typeof codeQuality.requireTypecheck !== "boolean" || typeof codeQuality.requireTests !== "boolean") {
|
|
1802
|
+
throw new Error("assessment.evidence.codeQuality requires boolean requireTypecheck and requireTests");
|
|
1803
|
+
}
|
|
1804
|
+
const projects = compatibility2.requiredProjects;
|
|
1805
|
+
if (!Array.isArray(projects) || projects.length === 0 || !projects.every((item) => typeof item === "string" && item.length > 0) || new Set(projects).size !== projects.length) {
|
|
1806
|
+
throw new Error("assessment.evidence.compatibility.requiredProjects must be a unique nonempty string array");
|
|
1807
|
+
}
|
|
1808
|
+
evidence = {
|
|
1809
|
+
maximumAgeHours: gates.maximumAgeHours,
|
|
1810
|
+
dependencySecurity: { maximumHigh: nonnegative2(dependencySecurity, "maximumHigh", "assessment.evidence.dependencySecurity"), maximumCritical: nonnegative2(dependencySecurity, "maximumCritical", "assessment.evidence.dependencySecurity") },
|
|
1811
|
+
codeQuality: {
|
|
1812
|
+
maximumLintErrors: nonnegative2(codeQuality, "maximumLintErrors", "assessment.evidence.codeQuality"),
|
|
1813
|
+
minimumLineCoverage,
|
|
1814
|
+
requireTypecheck: codeQuality.requireTypecheck,
|
|
1815
|
+
requireTests: codeQuality.requireTests
|
|
1816
|
+
},
|
|
1817
|
+
codeSecurity: { maximumHigh: nonnegative2(codeSecurity, "maximumHigh", "assessment.evidence.codeSecurity"), maximumCritical: nonnegative2(codeSecurity, "maximumCritical", "assessment.evidence.codeSecurity"), maximumSecrets: nonnegative2(codeSecurity, "maximumSecrets", "assessment.evidence.codeSecurity") },
|
|
1818
|
+
compatibility: { requiredProjects: projects, minimumTestsPerProject: nonnegative2(compatibility2, "minimumTestsPerProject", "assessment.evidence.compatibility") }
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1505
1821
|
return { architecture: {
|
|
1506
1822
|
minimumScore: architecture.minimumScore,
|
|
1507
1823
|
maximumErrors: architecture.maximumErrors,
|
|
1508
1824
|
blockedRuleIds: architecture.blockedRuleIds,
|
|
1509
1825
|
analysis
|
|
1510
|
-
} };
|
|
1826
|
+
}, ...evidence ? { evidence } : {} };
|
|
1511
1827
|
}
|
|
1512
1828
|
function parsePolicy(raw, source) {
|
|
1513
1829
|
let parsed;
|
|
@@ -1531,11 +1847,11 @@ function parsePolicy(raw, source) {
|
|
|
1531
1847
|
throw new Error(`Policy profile ${name} must list packages`);
|
|
1532
1848
|
}
|
|
1533
1849
|
const versions = {};
|
|
1534
|
-
for (const [packageName,
|
|
1535
|
-
if (typeof
|
|
1850
|
+
for (const [packageName, version2] of Object.entries(rules)) {
|
|
1851
|
+
if (typeof version2 !== "string" || !minimumVersion.test(version2) || valid2(version2.slice(2)) === null) {
|
|
1536
1852
|
throw new Error(`Policy minimum version (>=X.Y.Z) required for ${packageName} in ${name}`);
|
|
1537
1853
|
}
|
|
1538
|
-
versions[packageName] =
|
|
1854
|
+
versions[packageName] = version2;
|
|
1539
1855
|
}
|
|
1540
1856
|
profiles3[name] = versions;
|
|
1541
1857
|
}
|
|
@@ -1545,7 +1861,7 @@ function parsePolicy(raw, source) {
|
|
|
1545
1861
|
profiles: profiles3,
|
|
1546
1862
|
...parsed.assessment === void 0 ? {} : { assessment: parseAssessmentPolicy(parsed.assessment) },
|
|
1547
1863
|
source,
|
|
1548
|
-
sha256:
|
|
1864
|
+
sha256: createHash3("sha256").update(raw).digest("hex")
|
|
1549
1865
|
};
|
|
1550
1866
|
}
|
|
1551
1867
|
async function loadPolicy(source) {
|
|
@@ -1562,7 +1878,7 @@ async function loadPolicy(source) {
|
|
|
1562
1878
|
}
|
|
1563
1879
|
return parsePolicy(await response.text(), source);
|
|
1564
1880
|
}
|
|
1565
|
-
return parsePolicy(await
|
|
1881
|
+
return parsePolicy(await readFile9(resolve8(source), "utf8"), source);
|
|
1566
1882
|
}
|
|
1567
1883
|
|
|
1568
1884
|
// src/governance/assess.ts
|
|
@@ -1585,27 +1901,50 @@ function escapeHtml2(value) {
|
|
|
1585
1901
|
return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
1586
1902
|
}
|
|
1587
1903
|
function renderAssessmentHtml(report) {
|
|
1904
|
+
const labels = {
|
|
1905
|
+
inventory: "App inventory",
|
|
1906
|
+
dependencies: "Dependency policy",
|
|
1907
|
+
architecture: "Architecture",
|
|
1908
|
+
dependencySecurity: "Dependency security",
|
|
1909
|
+
codeQuality: "Code quality",
|
|
1910
|
+
codeSecurity: "Code security",
|
|
1911
|
+
compatibility: "Browser compatibility"
|
|
1912
|
+
};
|
|
1913
|
+
const domainNames = report.apps.length ? Object.keys(report.apps[0].domains) : [];
|
|
1914
|
+
const overview = domainNames.map((name) => {
|
|
1915
|
+
const statuses = report.apps.map((app) => app.domains[name]?.status ?? "incomplete");
|
|
1916
|
+
const status = statusOf(statuses);
|
|
1917
|
+
const passed = statuses.filter((item) => item === "pass").length;
|
|
1918
|
+
return `<li><span>${escapeHtml2(labels[name] ?? name)}</span><strong class="${status}">${escapeHtml2(status)}</strong><small>${passed}/${report.apps.length} apps passed</small></li>`;
|
|
1919
|
+
}).join("");
|
|
1588
1920
|
const apps = report.apps.map((app) => {
|
|
1589
|
-
const domains = Object.entries(app.domains).map(([name, domain]) =>
|
|
1921
|
+
const domains = Object.entries(app.domains).map(([name, domain]) => {
|
|
1922
|
+
const evidence = domain;
|
|
1923
|
+
const identity = evidence.tool ? `<p><small>${escapeHtml2(evidence.tool)} ${escapeHtml2(evidence.version ?? "")} \xB7 scanned ${escapeHtml2(evidence.scanned ?? "unknown")}</small></p>` : "";
|
|
1924
|
+
const metrics = evidence.metrics ? `<p><small>${Object.entries(evidence.metrics).map(([key, value]) => `${escapeHtml2(key)}: ${escapeHtml2(value)}`).join(" \xB7 ")}</small></p>` : "";
|
|
1925
|
+
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>Browser / device</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, item.device].filter(Boolean).join(" \xB7 ") || "Not recorded")}</td><td>${escapeHtml2(item.viewportWidths?.join(", ") ?? "Not recorded")}</td></tr>`).join("")}</tbody></table></div>` : "";
|
|
1927
|
+
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}</td></tr>`;
|
|
1928
|
+
}).join("");
|
|
1590
1929
|
const architecture = app.domains.architecture;
|
|
1591
1930
|
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("") ?? "";
|
|
1592
|
-
const
|
|
1593
|
-
return `<section class="app"><h2>${escapeHtml2(app.path)} <span class="${app.status}">${escapeHtml2(app.status)}</span></h2><p>Profile: ${escapeHtml2(app.profile)} \xB7 Source files scanned: ${architecture.filesScanned} \xB7 Local import edges: ${architecture.localEdges} \xB7 Architecture score: ${architecture.score ?? "unavailable"} \xB7 Findings: ${architecture.findings.length}</p><div class="scroll"><table><thead><tr><th>Domain</th><th>Status</th><th>Evidence</th></tr></thead><tbody>${domains}</tbody></table></div>${categoryRows ? `<details><summary>Score breakdown</summary><div class="scroll"><table><thead><tr><th>Category</th><th>Weight</th><th>Score</th><th>Findings</th></tr></thead><tbody>${categoryRows}</tbody></table></div></details>` : ""}${
|
|
1931
|
+
const findings2 = architecture.findings.map((finding) => `<tr><td>${escapeHtml2(finding.severity)}</td><td>${escapeHtml2(finding.ruleId)}</td><td><code>${escapeHtml2(finding.file)}:${finding.line}</code></td><td>${escapeHtml2(finding.evidence)}</td></tr>`).join("");
|
|
1932
|
+
return `<section class="app"><h2>${escapeHtml2(app.path)} <span class="${app.status}">${escapeHtml2(app.status)}</span></h2><p>Profile: ${escapeHtml2(app.profile)} \xB7 Source files scanned: ${architecture.filesScanned} \xB7 Local import edges: ${architecture.localEdges} \xB7 Architecture score: ${architecture.score ?? "unavailable"} \xB7 Findings: ${architecture.findings.length}</p><div class="scroll"><table><thead><tr><th>Domain</th><th>Status</th><th>Evidence</th></tr></thead><tbody>${domains}</tbody></table></div>${categoryRows ? `<details><summary>Score breakdown</summary><div class="scroll"><table><thead><tr><th>Category</th><th>Weight</th><th>Score</th><th>Findings</th></tr></thead><tbody>${categoryRows}</tbody></table></div></details>` : ""}${findings2 ? `<details><summary>Architecture findings (${architecture.findings.length})</summary><div class="scroll"><table><thead><tr><th>Severity</th><th>Rule</th><th>Location</th><th>Evidence</th></tr></thead><tbody>${findings2}</tbody></table></div></details>` : ""}</section>`;
|
|
1594
1933
|
}).join("");
|
|
1595
1934
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>NKLattice governance assessment</title><style>
|
|
1596
|
-
:root{color-scheme:light;font:16px/1.5 system-ui,sans-serif;background:#
|
|
1597
|
-
</style></head><body><header><small>NKLATTICE \xB7 CONFIGURED POLICY ASSESSMENT</small><h1>Project governance <span class="${report.status}">${escapeHtml2(report.status)}</span></h1><p>${report.apps.length} discovered app(s) \xB7 ${report.inventory.expectedApps} expected app(s)</p></header><section class="meta"><h2>Evidence identity</h2><p>Policy ${escapeHtml2(report.policy.version)} \xB7 <code>${escapeHtml2(report.policy.sha256)}</code></p><p>Policy source: <code>${escapeHtml2(report.policy.source)}</code></p><p>Inventory <code>${escapeHtml2(report.inventory.sha256 ?? "unavailable")}</code></p><p>Inventory source: <code>${escapeHtml2(report.inventory.source)}</code></p>${report.issues.length ? `<ul>${report.issues.map((issue) => `<li>${escapeHtml2(issue)}</li>`).join("")}</ul>` : ""}</section>${apps}<section class="meta"><h2>Assessment scope</h2><p>Evaluated: ${report.scope.domains.map(escapeHtml2).join(", ")}.</p><p>Not evaluated: ${report.scope.notEvaluated.map(escapeHtml2).join(", ")}.</p></section></body></html>
|
|
1935
|
+
:root{color-scheme:light;font:16px/1.5 system-ui,sans-serif;background:#f3f6f4;color:#1b2925}body{max-width:1160px;margin:auto;padding:2rem 1rem}header,.app,.meta,.overview{background:white;border:1px solid #d9e1dc;border-radius:12px;padding:1.4rem;margin:0 0 1.2rem}header{background:#13231d;color:#f5f8f4;padding:2rem}header small{color:#9de2bc;font-weight:700;letter-spacing:.12em}header p{color:#c8d6ce}h1{font-size:clamp(2rem,5vw,3.5rem);line-height:1.08;margin:.3rem 0 1rem}h2{font-size:1.35rem;margin:0 0 .75rem}p{margin:.5rem 0}code{overflow-wrap:anywhere}.pass{color:#146c43;font-weight:700}.fail{color:#b3242b;font-weight:700}.incomplete{color:#8a5500;font-weight:700}header .pass{color:#9de2bc}.scroll{overflow-x:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;vertical-align:top;border-bottom:1px solid #d9e1dc;padding:.8rem}th:first-child{width:12rem}ul{padding-left:1.2rem;margin:.1rem 0}li+li{margin-top:.3rem}small{color:#50645c}details{border-top:1px solid #d9e1dc;margin-top:1rem;padding-top:1rem}summary{cursor:pointer;font-weight:650}details[open] summary{margin-bottom:.6rem}.domain-overview{list-style:none;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.65rem;padding:0;margin:1rem 0 0}.domain-overview li{border:1px solid #d9e1dc;background:#f8faf8;border-radius:8px;padding:.9rem;display:grid;grid-template-columns:1fr auto;gap:.35rem}.domain-overview li small{grid-column:1/-1}.domain-overview li+li{margin:0}
|
|
1936
|
+
@media(max-width:800px){.domain-overview{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:480px){.domain-overview{grid-template-columns:1fr}}</style></head><body><header><small>NKLATTICE \xB7 CONFIGURED POLICY ASSESSMENT</small><h1>Project governance <span class="${report.status}">${escapeHtml2(report.status)}</span></h1><p>${report.apps.length} discovered app(s) \xB7 ${report.inventory.expectedApps} expected app(s) \xB7 policy ${escapeHtml2(report.policy.version)}</p></header><section class="overview"><h2>Assessment by domain</h2><p>Each status is based on the checked apps and fresh scanner evidence. Open an app below for findings and browser versions.</p><ul class="domain-overview">${overview}</ul></section><section class="meta"><h2>Evidence identity</h2><p>Policy ${escapeHtml2(report.policy.version)} \xB7 <code>${escapeHtml2(report.policy.sha256)}</code></p><p>Policy source: <code>${escapeHtml2(report.policy.source)}</code></p><p>Inventory <code>${escapeHtml2(report.inventory.sha256 ?? "unavailable")}</code></p><p>Inventory source: <code>${escapeHtml2(basename2(report.inventory.source))}</code></p>${report.evidence ? `<p>Scanners: ${escapeHtml2(report.evidence.generatedAt)} \xB7 commit <code>${escapeHtml2(report.evidence.commit)}</code> \xB7 input <code>${escapeHtml2(report.evidence.inputSha256)}</code></p>` : "<p>Scanner evidence unavailable.</p>"}${report.issues.length ? `<ul>${report.issues.map((issue) => `<li>${escapeHtml2(issue)}</li>`).join("")}</ul>` : ""}</section>${apps}<section class="meta"><h2>Assessment scope</h2><p>Evaluated: ${report.scope.domains.map(escapeHtml2).join(", ")}.</p><p>Not evaluated: ${report.scope.notEvaluated.map(escapeHtml2).join(", ")}.</p></section></body></html>
|
|
1598
1937
|
`;
|
|
1599
1938
|
}
|
|
1600
1939
|
async function assessGovernance(options) {
|
|
1601
|
-
const scanRoot =
|
|
1602
|
-
const workspaceRoot =
|
|
1603
|
-
const scanRelative =
|
|
1604
|
-
if (scanRelative === ".." || scanRelative.startsWith(`..${
|
|
1940
|
+
const scanRoot = resolve9(options.scanRoot);
|
|
1941
|
+
const workspaceRoot = resolve9(options.workspaceRoot ?? scanRoot);
|
|
1942
|
+
const scanRelative = relative7(workspaceRoot, scanRoot);
|
|
1943
|
+
if (scanRelative === ".." || scanRelative.startsWith(`..${sep6}`)) throw new Error("Scan root must be inside workspace root");
|
|
1605
1944
|
if (!options.policy.assessment) throw new Error("Policy assessment.architecture is required");
|
|
1606
1945
|
const assessment = parseAssessmentPolicy(options.policy.assessment);
|
|
1607
|
-
const outputDirectory =
|
|
1608
|
-
const inventorySource =
|
|
1946
|
+
const outputDirectory = resolve9(options.outputDirectory ?? join10(scanRoot, ".nklattice-governance"));
|
|
1947
|
+
const inventorySource = resolve9(options.inventory ?? join10(scanRoot, ".nklattice-apps.json"));
|
|
1609
1948
|
let inventory;
|
|
1610
1949
|
const issues = [];
|
|
1611
1950
|
try {
|
|
@@ -1624,9 +1963,22 @@ async function assessGovernance(options) {
|
|
|
1624
1963
|
if (options.expectedApps !== void 0 && apps.length !== options.expectedApps) {
|
|
1625
1964
|
issues.push(`Expected ${options.expectedApps} managed apps; discovered ${apps.length}`);
|
|
1626
1965
|
}
|
|
1966
|
+
let evidence;
|
|
1967
|
+
if (assessment.evidence) {
|
|
1968
|
+
if (!options.evidence && !options.trustedEvidence) issues.push("Scan evidence is required by the policy; run governance collect and pass --evidence");
|
|
1969
|
+
else try {
|
|
1970
|
+
evidence = options.trustedEvidence ? await validateScanEvidence(options.trustedEvidence, scanRoot, workspaceRoot, options.policy.sha256, assessment.evidence.maximumAgeHours) : await loadScanEvidence(options.evidence, scanRoot, workspaceRoot, options.policy.sha256, assessment.evidence.maximumAgeHours);
|
|
1971
|
+
} catch (error) {
|
|
1972
|
+
issues.push(`Scan evidence: ${error instanceof Error ? error.message : String(error)}`);
|
|
1973
|
+
}
|
|
1974
|
+
if (evidence) {
|
|
1975
|
+
const discovered = new Set(apps.map((app) => relative7(scanRoot, app.directory).replaceAll("\\", "/") || "."));
|
|
1976
|
+
for (const item of evidence.apps) if (!discovered.has(item.path)) issues.push(`Scan evidence contains unrecognized app ${item.path}`);
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1627
1979
|
const results = [];
|
|
1628
1980
|
for (const app of apps) {
|
|
1629
|
-
const appPath =
|
|
1981
|
+
const appPath = relative7(scanRoot, app.directory).replaceAll("\\", "/") || ".";
|
|
1630
1982
|
const inventoryIssues = issues.filter((issue) => issue.includes(appPath) || issue.startsWith("Inventory:") || issue.startsWith("Discovery:"));
|
|
1631
1983
|
const inventoryDomain = { status: inventoryIssues.length ? "incomplete" : "pass", issues: inventoryIssues };
|
|
1632
1984
|
const dependencyDomain = { status: "pass", issues: [], findings: [] };
|
|
@@ -1679,7 +2031,8 @@ async function assessGovernance(options) {
|
|
|
1679
2031
|
architectureDomain.status = "incomplete";
|
|
1680
2032
|
architectureDomain.issues.push(error instanceof Error ? error.message : String(error));
|
|
1681
2033
|
}
|
|
1682
|
-
const
|
|
2034
|
+
const extra = assessment.evidence ? evaluateAppEvidence(evidence?.apps.find((item) => item.path === appPath && item.profile === app.profile), assessment.evidence) : {};
|
|
2035
|
+
const domains = { inventory: inventoryDomain, dependencies: dependencyDomain, architecture: architectureDomain, ...extra };
|
|
1683
2036
|
results.push({ path: appPath, profile: app.profile, status: statusOf(Object.values(domains).map((domain) => domain.status)), domains });
|
|
1684
2037
|
}
|
|
1685
2038
|
const report = {
|
|
@@ -1687,21 +2040,531 @@ async function assessGovernance(options) {
|
|
|
1687
2040
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1688
2041
|
status: issues.length ? "incomplete" : statusOf(results.map((app) => app.status)),
|
|
1689
2042
|
policy: { version: options.policy.policyVersion, source: options.policy.source, sha256: options.policy.sha256 },
|
|
2043
|
+
...evidence ? { evidence: { source: options.evidence ? resolve9(options.evidence) : "in-process collector", generatedAt: evidence.generatedAt, commit: evidence.commit, inputSha256: evidence.inputSha256 } } : {},
|
|
1690
2044
|
inventory: { source: inventorySource, sha256: inventory?.sha256, expectedApps: inventory?.apps.length ?? 0, discoveredApps: apps.length },
|
|
1691
|
-
scope: {
|
|
2045
|
+
scope: {
|
|
2046
|
+
domains: ["explicit app inventory", "dependency minimums", "source architecture", ...assessment.evidence ? ["known dependency advisories", "source quality", "source security", "browser and mobile emulation"] : []],
|
|
2047
|
+
notEvaluated: assessment.evidence ? ["licenses", "physical Android/iOS devices", "runtime performance", "cryptographic evidence attestation", "branch protection", "repository access controls", "cross-repository rollout status"] : notEvaluated
|
|
2048
|
+
},
|
|
1692
2049
|
issues,
|
|
1693
2050
|
apps: results
|
|
1694
2051
|
};
|
|
1695
2052
|
await mkdir3(outputDirectory, { recursive: true });
|
|
1696
|
-
await writeFile2(
|
|
2053
|
+
await writeFile2(join10(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
|
|
1697
2054
|
`);
|
|
1698
|
-
await writeFile2(
|
|
2055
|
+
await writeFile2(join10(outputDirectory, "report.html"), renderAssessmentHtml(report));
|
|
2056
|
+
return report;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
// src/governance/collect.ts
|
|
2060
|
+
import { execFile as execFile2 } from "child_process";
|
|
2061
|
+
import { createHash as createHash4 } from "crypto";
|
|
2062
|
+
import { mkdtemp, mkdir as mkdir4, readFile as readFile10, rm, writeFile as writeFile3 } from "fs/promises";
|
|
2063
|
+
import { tmpdir } from "os";
|
|
2064
|
+
import { dirname as dirname4, join as join11, relative as relative8, resolve as resolve10, sep as sep7 } from "path";
|
|
2065
|
+
import { promisify as promisify2 } from "util";
|
|
2066
|
+
import fg2 from "fast-glob";
|
|
2067
|
+
import YAML2 from "yaml";
|
|
2068
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
2069
|
+
var cap = 16 * 1024 * 1024;
|
|
2070
|
+
async function run(file, args, cwd, env) {
|
|
2071
|
+
try {
|
|
2072
|
+
const result = await execFileAsync2(file, args, { cwd, env, timeout: 3e5, maxBuffer: cap });
|
|
2073
|
+
return { code: 0, stdout: result.stdout, stderr: result.stderr };
|
|
2074
|
+
} catch (error) {
|
|
2075
|
+
const failure = error;
|
|
2076
|
+
return {
|
|
2077
|
+
code: typeof failure.code === "number" ? failure.code : -1,
|
|
2078
|
+
stdout: failure.stdout ?? "",
|
|
2079
|
+
stderr: failure.stderr ?? "",
|
|
2080
|
+
...typeof failure.code === "number" ? {} : { error: failure.killed ? "Scanner timed out" : failure.message }
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
function inside(parent, child) {
|
|
2085
|
+
const path = relative8(parent, child);
|
|
2086
|
+
return path === "" || path !== ".." && !path.startsWith(`..${sep7}`);
|
|
2087
|
+
}
|
|
2088
|
+
async function binary(name, app, workspaceRoot) {
|
|
2089
|
+
let directory = app;
|
|
2090
|
+
while (inside(workspaceRoot, directory)) {
|
|
2091
|
+
const candidate = join11(directory, "node_modules", ".bin", name);
|
|
2092
|
+
try {
|
|
2093
|
+
await readFile10(candidate);
|
|
2094
|
+
return candidate;
|
|
2095
|
+
} catch {
|
|
2096
|
+
}
|
|
2097
|
+
if (directory === workspaceRoot) break;
|
|
2098
|
+
directory = dirname4(directory);
|
|
2099
|
+
}
|
|
2100
|
+
return name;
|
|
2101
|
+
}
|
|
2102
|
+
async function version(file, cwd) {
|
|
2103
|
+
const result = await run(file, ["--version"], cwd);
|
|
2104
|
+
if (result.code !== 0) return "unavailable";
|
|
2105
|
+
const reported = result.stdout.trim().slice(0, 120) || "unknown";
|
|
2106
|
+
if (file === "gitleaks" && reported.includes("version is set by build process")) {
|
|
2107
|
+
const located = await run("which", [file], cwd);
|
|
2108
|
+
if (located.code === 0) {
|
|
2109
|
+
const build = await run("go", ["version", "-m", located.stdout.trim()], cwd);
|
|
2110
|
+
const match = build.stdout.match(/^\s*mod\s+\S+\/gitleaks\/v8\s+(v\d+\.\d+\.\d+)/m);
|
|
2111
|
+
if (match) return match[1];
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
return reported;
|
|
2115
|
+
}
|
|
2116
|
+
function json(text) {
|
|
2117
|
+
try {
|
|
2118
|
+
return JSON.parse(text);
|
|
2119
|
+
} catch {
|
|
2120
|
+
return void 0;
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
function obj(value) {
|
|
2124
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2125
|
+
}
|
|
2126
|
+
function str(value) {
|
|
2127
|
+
return typeof value === "string" ? value : "";
|
|
2128
|
+
}
|
|
2129
|
+
function num(value) {
|
|
2130
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
2131
|
+
}
|
|
2132
|
+
async function sourcePaths(app) {
|
|
2133
|
+
return fg2("**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}", {
|
|
2134
|
+
cwd: app,
|
|
2135
|
+
dot: true,
|
|
2136
|
+
onlyFiles: true,
|
|
2137
|
+
followSymbolicLinks: false,
|
|
2138
|
+
ignore: ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/build/**", "**/coverage/**", "**/*.d.ts"]
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2141
|
+
async function sourceCount(app) {
|
|
2142
|
+
return (await sourcePaths(app)).length;
|
|
2143
|
+
}
|
|
2144
|
+
async function productionSources(app) {
|
|
2145
|
+
return (await sourcePaths(app)).filter((path) => !/(^|\/)(e2e|tests?|__tests__|fixtures)\//.test(path) && !/\.(test|spec|config)\.[cm]?[jt]sx?$/.test(path));
|
|
2146
|
+
}
|
|
2147
|
+
async function dependencyAudit(app, workspaceRoot, cache) {
|
|
2148
|
+
const failure = (errors2, tool = "npm/pnpm audit") => ({ tool, version: "unavailable", complete: false, packagesScanned: 0, advisories: [], errors: errors2 });
|
|
2149
|
+
let lock;
|
|
2150
|
+
try {
|
|
2151
|
+
lock = await findLockfile(app.directory, workspaceRoot);
|
|
2152
|
+
} catch (error) {
|
|
2153
|
+
return failure([error instanceof Error ? error.message : String(error)]);
|
|
2154
|
+
}
|
|
2155
|
+
const cached = cache.get(lock.path);
|
|
2156
|
+
if (cached) return cached;
|
|
2157
|
+
const raw = await readFile10(lock.path, "utf8");
|
|
2158
|
+
const parsedLock = lock.kind === "npm" ? json(raw) : YAML2.parse(raw);
|
|
2159
|
+
const packages = obj(parsedLock) && obj(parsedLock.packages) ? Object.keys(parsedLock.packages).filter((key) => key !== "").length : 0;
|
|
2160
|
+
const executable = lock.kind === "npm" ? "npm" : "pnpm";
|
|
2161
|
+
const toolVersion = await version(executable, lock.root);
|
|
2162
|
+
const result = await run(executable, ["audit", "--json"], lock.root);
|
|
2163
|
+
const report = json(result.stdout);
|
|
2164
|
+
if (!obj(report)) {
|
|
2165
|
+
const evidence2 = { ...failure([result.error ?? "Audit JSON output is unavailable"], `${executable} audit`), version: toolVersion, packagesScanned: packages };
|
|
2166
|
+
cache.set(lock.path, evidence2);
|
|
2167
|
+
return evidence2;
|
|
2168
|
+
}
|
|
2169
|
+
const auditMetadata = obj(report.metadata) ? report.metadata : void 0;
|
|
2170
|
+
const auditedPackages = lock.kind === "npm" && auditMetadata && obj(auditMetadata.dependencies) ? num(auditMetadata.dependencies.total) : lock.kind === "pnpm" && auditMetadata ? num(auditMetadata.totalDependencies) : 0;
|
|
2171
|
+
const supportedShape = lock.kind === "npm" ? report.auditReportVersion === 2 && obj(report.vulnerabilities) : obj(report.advisories);
|
|
2172
|
+
if (!supportedShape || packages === 0 || auditedPackages < packages || result.error || result.code > 1) {
|
|
2173
|
+
const evidence2 = { ...failure([result.error ?? "Audit output was unavailable or lockfile package inventory was empty"], `${executable} audit`), version: toolVersion, packagesScanned: packages };
|
|
2174
|
+
evidence2.errors = [result.error ?? `Audit schema or package coverage is incomplete (${auditedPackages}/${packages})`];
|
|
2175
|
+
cache.set(lock.path, evidence2);
|
|
2176
|
+
return evidence2;
|
|
2177
|
+
}
|
|
2178
|
+
const advisories = [];
|
|
2179
|
+
if (lock.kind === "npm" && obj(report.vulnerabilities)) {
|
|
2180
|
+
for (const [name, value] of Object.entries(report.vulnerabilities)) {
|
|
2181
|
+
if (!obj(value)) continue;
|
|
2182
|
+
const via = Array.isArray(value.via) ? value.via.find(obj) : void 0;
|
|
2183
|
+
advisories.push({
|
|
2184
|
+
id: obj(via) ? str(via.url) || str(via.title) || `npm:${name}` : `npm:${name}`,
|
|
2185
|
+
severity: str(value.severity) || "unknown",
|
|
2186
|
+
subject: name,
|
|
2187
|
+
message: "Known advisory affects a dependency",
|
|
2188
|
+
remediation: "Upgrade the affected package or apply a reviewed exception"
|
|
2189
|
+
});
|
|
2190
|
+
}
|
|
2191
|
+
} else if (lock.kind === "pnpm" && obj(report.advisories)) {
|
|
2192
|
+
for (const [key, value] of Object.entries(report.advisories)) {
|
|
2193
|
+
if (!obj(value)) continue;
|
|
2194
|
+
advisories.push({
|
|
2195
|
+
id: str(value.url) || str(value.id) || key,
|
|
2196
|
+
severity: str(value.severity) || "unknown",
|
|
2197
|
+
subject: str(value.module_name) || "workspace dependency",
|
|
2198
|
+
message: "Known advisory affects a workspace dependency",
|
|
2199
|
+
remediation: "Upgrade the affected package or apply a reviewed exception"
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
} else if (result.code !== 0) {
|
|
2203
|
+
const evidence2 = { tool: `${executable} audit`, version: toolVersion, complete: false, packagesScanned: packages, advisories: [], errors: ["Audit returned an unsupported JSON shape"] };
|
|
2204
|
+
cache.set(lock.path, evidence2);
|
|
2205
|
+
return evidence2;
|
|
2206
|
+
}
|
|
2207
|
+
const registryErrors = [report.error, report.errors].filter(Boolean);
|
|
2208
|
+
const evidence = {
|
|
2209
|
+
tool: `${executable} audit${lock.root !== app.directory ? " (workspace-wide)" : ""}`,
|
|
2210
|
+
version: toolVersion,
|
|
2211
|
+
complete: registryErrors.length === 0,
|
|
2212
|
+
packagesScanned: packages,
|
|
2213
|
+
advisories,
|
|
2214
|
+
errors: registryErrors.length ? ["Advisory service returned an error"] : []
|
|
2215
|
+
};
|
|
2216
|
+
cache.set(lock.path, evidence);
|
|
2217
|
+
return evidence;
|
|
2218
|
+
}
|
|
2219
|
+
async function lint(app, workspaceRoot) {
|
|
2220
|
+
const file = await binary("eslint", app.directory, workspaceRoot);
|
|
2221
|
+
const toolVersion = await version(file, app.directory);
|
|
2222
|
+
const result = await run(file, [".", "--format", "json"], app.directory);
|
|
2223
|
+
const parsed = json(result.stdout);
|
|
2224
|
+
if (!Array.isArray(parsed) || result.error || !parsed.every(obj)) {
|
|
2225
|
+
return {
|
|
2226
|
+
tool: "ESLint",
|
|
2227
|
+
version: toolVersion,
|
|
2228
|
+
complete: false,
|
|
2229
|
+
filesScanned: 0,
|
|
2230
|
+
errorCount: 0,
|
|
2231
|
+
warnings: 0,
|
|
2232
|
+
errorsDetail: [result.error ?? "ESLint JSON output is unavailable; install and configure ESLint"]
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
2235
|
+
const files = parsed;
|
|
2236
|
+
if (!files.every((item) => str(item.filePath) && Number.isSafeInteger(item.errorCount) && Number.isSafeInteger(item.warningCount))) {
|
|
2237
|
+
return {
|
|
2238
|
+
tool: "ESLint",
|
|
2239
|
+
version: toolVersion,
|
|
2240
|
+
complete: false,
|
|
2241
|
+
filesScanned: 0,
|
|
2242
|
+
errorCount: 0,
|
|
2243
|
+
warnings: 0,
|
|
2244
|
+
errorsDetail: ["ESLint JSON result did not contain reliable file paths or diagnostic counts"]
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2247
|
+
const expected = await sourcePaths(app.directory);
|
|
2248
|
+
const analyzed = new Set(files.map((item) => str(item.filePath).replaceAll("\\", "/")));
|
|
2249
|
+
const missing = expected.filter((path) => ![...analyzed].some((file2) => file2.endsWith(`/${path}`) || file2 === path));
|
|
2250
|
+
return {
|
|
2251
|
+
tool: "ESLint",
|
|
2252
|
+
version: toolVersion,
|
|
2253
|
+
complete: missing.length === 0,
|
|
2254
|
+
filesScanned: expected.length - missing.length,
|
|
2255
|
+
errorCount: files.reduce((sum, file2) => sum + num(file2.errorCount), 0),
|
|
2256
|
+
warnings: files.reduce((sum, file2) => sum + num(file2.warningCount), 0),
|
|
2257
|
+
errorsDetail: missing.length ? [`ESLint skipped ${missing.length} eligible source files`] : []
|
|
2258
|
+
};
|
|
2259
|
+
}
|
|
2260
|
+
async function typecheck(app, workspaceRoot) {
|
|
2261
|
+
const production = await productionSources(app.directory);
|
|
2262
|
+
const applicable = production.some((path) => /\.(ts|tsx|mts|cts)$/.test(path));
|
|
2263
|
+
const file = await binary("tsc", app.directory, workspaceRoot);
|
|
2264
|
+
const toolVersion = await version(file, app.directory);
|
|
2265
|
+
if (!applicable) return { tool: "TypeScript", version: toolVersion, applicable: false, complete: true, passed: true, errors: [] };
|
|
2266
|
+
const config = await readFile10(join11(app.directory, "tsconfig.json"), "utf8").catch(() => "");
|
|
2267
|
+
if (!config) return {
|
|
2268
|
+
tool: "TypeScript",
|
|
2269
|
+
version: toolVersion,
|
|
2270
|
+
applicable: true,
|
|
2271
|
+
complete: false,
|
|
2272
|
+
passed: false,
|
|
2273
|
+
errors: ["TypeScript source has no readable tsconfig.json"]
|
|
2274
|
+
};
|
|
2275
|
+
const result = await run(file, ["--project", app.directory, "--noEmit", "--pretty", "false"], app.directory);
|
|
2276
|
+
return {
|
|
2277
|
+
tool: "TypeScript",
|
|
2278
|
+
version: toolVersion,
|
|
2279
|
+
applicable: true,
|
|
2280
|
+
complete: !result.error && toolVersion !== "unavailable",
|
|
2281
|
+
passed: result.code === 0,
|
|
2282
|
+
errors: result.error ? [result.error] : result.code === 0 ? [] : [result.stdout.trim().slice(0, 500) || "TypeScript typecheck failed"]
|
|
2283
|
+
};
|
|
2284
|
+
}
|
|
2285
|
+
async function testAndCoverage(app, workspaceRoot, temp) {
|
|
2286
|
+
const file = await binary("vitest", app.directory, workspaceRoot);
|
|
2287
|
+
const toolVersion = await version(file, app.directory);
|
|
2288
|
+
const resultPath = join11(temp, "vitest.json");
|
|
2289
|
+
const coverageDirectory = join11(temp, "coverage");
|
|
2290
|
+
const result = await run(file, [
|
|
2291
|
+
"run",
|
|
2292
|
+
"--reporter=json",
|
|
2293
|
+
"--outputFile",
|
|
2294
|
+
resultPath,
|
|
2295
|
+
"--coverage",
|
|
2296
|
+
"--coverage.reporter=json-summary",
|
|
2297
|
+
"--coverage.reportsDirectory",
|
|
2298
|
+
coverageDirectory
|
|
2299
|
+
], app.directory);
|
|
2300
|
+
const output = await readFile10(resultPath, "utf8").catch(() => "");
|
|
2301
|
+
const parsed = json(output);
|
|
2302
|
+
const summary = json(await readFile10(join11(coverageDirectory, "coverage-summary.json"), "utf8").catch(() => ""));
|
|
2303
|
+
const tests = obj(parsed) && typeof parsed.numPassedTests === "number" && typeof parsed.numFailedTests === "number";
|
|
2304
|
+
const lineCoverage = obj(summary) && obj(summary.total) && obj(summary.total.lines) ? summary.total.lines.pct : void 0;
|
|
2305
|
+
const expectedFiles = await productionSources(app.directory);
|
|
2306
|
+
const coveredFiles = obj(summary) ? Object.keys(summary).filter((key) => key !== "total") : [];
|
|
2307
|
+
const coveredPaths = new Set(coveredFiles.map((file2) => resolve10(app.directory, file2)));
|
|
2308
|
+
const uncovered = expectedFiles.filter((path) => !coveredPaths.has(resolve10(app.directory, path)));
|
|
2309
|
+
const coverage = typeof lineCoverage === "number" && expectedFiles.length > 0 && uncovered.length === 0;
|
|
2310
|
+
const unhandled = obj(parsed) && Array.isArray(parsed.unhandledErrors) && parsed.unhandledErrors.length > 0;
|
|
2311
|
+
const reporterFailedWithoutTests = tests && result.code !== 0 && num(parsed.numFailedTests) === 0;
|
|
2312
|
+
return {
|
|
2313
|
+
tests: {
|
|
2314
|
+
tool: "Vitest",
|
|
2315
|
+
version: toolVersion,
|
|
2316
|
+
complete: tests && !result.error && !unhandled && !reporterFailedWithoutTests,
|
|
2317
|
+
passed: tests ? num(parsed.numPassedTests) : 0,
|
|
2318
|
+
failed: tests ? num(parsed.numFailedTests) : 0,
|
|
2319
|
+
errors: tests && !unhandled && !reporterFailedWithoutTests ? [] : [result.error ?? "Vitest reporter recorded an unsuccessful run or unhandled error"]
|
|
2320
|
+
},
|
|
2321
|
+
coverage: {
|
|
2322
|
+
tool: "Vitest coverage",
|
|
2323
|
+
version: toolVersion,
|
|
2324
|
+
complete: coverage,
|
|
2325
|
+
linesPercent: coverage ? lineCoverage : 0,
|
|
2326
|
+
errors: coverage ? [] : [uncovered.length ? `Coverage omitted ${uncovered.length} eligible source files` : "Vitest line coverage JSON summary is unavailable"]
|
|
2327
|
+
}
|
|
2328
|
+
};
|
|
2329
|
+
}
|
|
2330
|
+
async function sast(app, temp) {
|
|
2331
|
+
const file = "semgrep";
|
|
2332
|
+
const toolVersion = await version(file, app.directory);
|
|
2333
|
+
const outputPath = join11(temp, "semgrep.json");
|
|
2334
|
+
const result = await run(file, [
|
|
2335
|
+
"scan",
|
|
2336
|
+
"--config",
|
|
2337
|
+
"p/ci",
|
|
2338
|
+
"--no-git-ignore",
|
|
2339
|
+
"--exclude",
|
|
2340
|
+
"**/node_modules/**",
|
|
2341
|
+
"--exclude",
|
|
2342
|
+
"**/.next/**",
|
|
2343
|
+
"--exclude",
|
|
2344
|
+
"**/coverage/**",
|
|
2345
|
+
"--exclude",
|
|
2346
|
+
"**/dist/**",
|
|
2347
|
+
"--json",
|
|
2348
|
+
"--output",
|
|
2349
|
+
outputPath,
|
|
2350
|
+
app.directory
|
|
2351
|
+
], app.directory);
|
|
2352
|
+
const parsed = json(await readFile10(outputPath, "utf8").catch(() => result.stdout));
|
|
2353
|
+
const expectedSources = await sourcePaths(app.directory);
|
|
2354
|
+
if (!obj(parsed) || !Array.isArray(parsed.results) || result.error) {
|
|
2355
|
+
return {
|
|
2356
|
+
tool: "Semgrep",
|
|
2357
|
+
version: toolVersion,
|
|
2358
|
+
complete: false,
|
|
2359
|
+
filesScanned: 0,
|
|
2360
|
+
findings: [],
|
|
2361
|
+
errors: [result.error ?? "Semgrep JSON result is unavailable; install Semgrep and enable rule download"]
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
2364
|
+
const findings2 = parsed.results.filter(obj).map((item) => {
|
|
2365
|
+
const extra = obj(item.extra) ? item.extra : {};
|
|
2366
|
+
const metadata = obj(extra.metadata) ? extra.metadata : {};
|
|
2367
|
+
const impact = str(metadata.impact).toLowerCase();
|
|
2368
|
+
const severity = impact === "high" || impact === "critical" ? impact : str(extra.severity).toLowerCase() === "error" ? "high" : "moderate";
|
|
2369
|
+
return {
|
|
2370
|
+
id: str(item.check_id) || "semgrep:unknown",
|
|
2371
|
+
severity,
|
|
2372
|
+
subject: `${relative8(app.directory, str(item.path) || app.directory)}:${obj(item.start) ? num(item.start.line) : 0}`,
|
|
2373
|
+
message: "Static security rule matched; inspect the source location",
|
|
2374
|
+
remediation: "Review and fix the matched rule"
|
|
2375
|
+
};
|
|
2376
|
+
});
|
|
2377
|
+
const scannedPaths = obj(parsed.paths) && Array.isArray(parsed.paths.scanned) ? parsed.paths.scanned.filter((item) => typeof item === "string") : [];
|
|
2378
|
+
const missing = expectedSources.filter((path) => !scannedPaths.some((scanned) => scanned.replaceAll("\\", "/").endsWith(`/${path}`) || scanned === path));
|
|
2379
|
+
const scanErrors = [
|
|
2380
|
+
...Array.isArray(parsed.errors) && parsed.errors.length ? ["Semgrep reported scanner errors"] : [],
|
|
2381
|
+
...missing.length ? [`Semgrep skipped ${missing.length} eligible source files`] : []
|
|
2382
|
+
];
|
|
2383
|
+
return { tool: "Semgrep", version: toolVersion, complete: scanErrors.length === 0, filesScanned: expectedSources.length - missing.length, findings: findings2, errors: scanErrors };
|
|
2384
|
+
}
|
|
2385
|
+
async function secrets(app, temp) {
|
|
2386
|
+
const file = "gitleaks";
|
|
2387
|
+
const toolVersion = await version(file, app.directory);
|
|
2388
|
+
const outputPath = join11(temp, "gitleaks.json");
|
|
2389
|
+
const configPath = join11(temp, "gitleaks.toml");
|
|
2390
|
+
await writeFile3(configPath, '[extend]\nuseDefault = true\n\n[allowlist]\npaths = ["(^|/)(node_modules|\\\\.next|dist|build|coverage|test-results|playwright-report)/"]\n');
|
|
2391
|
+
const result = await run(file, [
|
|
2392
|
+
"dir",
|
|
2393
|
+
app.directory,
|
|
2394
|
+
"--config",
|
|
2395
|
+
configPath,
|
|
2396
|
+
"--report-format",
|
|
2397
|
+
"json",
|
|
2398
|
+
"--report-path",
|
|
2399
|
+
outputPath,
|
|
2400
|
+
"--redact",
|
|
2401
|
+
"--no-banner"
|
|
2402
|
+
], app.directory);
|
|
2403
|
+
const parsed = json(await readFile10(outputPath, "utf8").catch(() => ""));
|
|
2404
|
+
const count = await sourceCount(app.directory);
|
|
2405
|
+
if (!Array.isArray(parsed) || result.error) {
|
|
2406
|
+
return {
|
|
2407
|
+
tool: "Gitleaks",
|
|
2408
|
+
version: toolVersion,
|
|
2409
|
+
complete: false,
|
|
2410
|
+
filesScanned: 0,
|
|
2411
|
+
findings: [],
|
|
2412
|
+
errors: [result.error ?? "Gitleaks JSON result is unavailable; install Gitleaks"]
|
|
2413
|
+
};
|
|
2414
|
+
}
|
|
2415
|
+
const findings2 = parsed.filter(obj).map((item) => ({
|
|
2416
|
+
id: str(item.RuleID) || "gitleaks:secret",
|
|
2417
|
+
severity: "critical",
|
|
2418
|
+
subject: `${str(item.File) ? relative8(app.directory, resolve10(app.directory, str(item.File))).replaceAll("\\", "/") : "source"}:${num(item.StartLine)}`,
|
|
2419
|
+
message: `Potential secret detected (fingerprint ${createHash4("sha256").update(str(item.Fingerprint)).digest("hex").slice(0, 16)})`,
|
|
2420
|
+
remediation: "Revoke the credential and remove it from source/history"
|
|
2421
|
+
}));
|
|
2422
|
+
return {
|
|
2423
|
+
tool: "Gitleaks",
|
|
2424
|
+
version: toolVersion,
|
|
2425
|
+
complete: result.code === 0 || result.code === 1,
|
|
2426
|
+
filesScanned: count,
|
|
2427
|
+
findings: findings2,
|
|
2428
|
+
errors: result.code > 1 ? ["Gitleaks scanner failed"] : []
|
|
2429
|
+
};
|
|
2430
|
+
}
|
|
2431
|
+
function playwrightProjects(value) {
|
|
2432
|
+
if (!obj(value) || !Array.isArray(value.suites)) return [];
|
|
2433
|
+
const totals = /* @__PURE__ */ new Map();
|
|
2434
|
+
const visit = (suite) => {
|
|
2435
|
+
if (!obj(suite)) return;
|
|
2436
|
+
for (const spec of Array.isArray(suite.specs) ? suite.specs : []) {
|
|
2437
|
+
if (!obj(spec) || !Array.isArray(spec.tests)) continue;
|
|
2438
|
+
for (const test of spec.tests) {
|
|
2439
|
+
if (!obj(test) || !str(test.projectName)) continue;
|
|
2440
|
+
const projectName = str(test.projectName);
|
|
2441
|
+
const project = totals.get(projectName) ?? {
|
|
2442
|
+
name: projectName,
|
|
2443
|
+
passed: 0,
|
|
2444
|
+
failed: 0,
|
|
2445
|
+
skipped: 0,
|
|
2446
|
+
viewportWidths: /* @__PURE__ */ new Set()
|
|
2447
|
+
};
|
|
2448
|
+
for (const annotation of Array.isArray(test.annotations) ? test.annotations : []) {
|
|
2449
|
+
if (!obj(annotation)) continue;
|
|
2450
|
+
if (annotation.type === "browser-version" && str(annotation.description)) project.browserVersion = str(annotation.description);
|
|
2451
|
+
if (annotation.type === "viewport-width" && /^\d+$/.test(str(annotation.description))) project.viewportWidths.add(Number(annotation.description));
|
|
2452
|
+
}
|
|
2453
|
+
const status = str(test.status);
|
|
2454
|
+
if (status === "expected") project.passed++;
|
|
2455
|
+
else if (status === "skipped") project.skipped++;
|
|
2456
|
+
else project.failed++;
|
|
2457
|
+
totals.set(project.name, project);
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
for (const child of Array.isArray(suite.suites) ? suite.suites : []) visit(child);
|
|
2461
|
+
};
|
|
2462
|
+
for (const suite of value.suites) visit(suite);
|
|
2463
|
+
return [...totals.values()].map((project) => ({ ...project, viewportWidths: [...project.viewportWidths].sort((a, b) => a - b) }));
|
|
2464
|
+
}
|
|
2465
|
+
async function compatibility(app, workspaceRoot, temp) {
|
|
2466
|
+
const file = await binary("playwright", app.directory, workspaceRoot);
|
|
2467
|
+
const toolVersion = await version(file, app.directory);
|
|
2468
|
+
const outputPath = join11(temp, "playwright.json");
|
|
2469
|
+
const configPath = join11(temp, "playwright-projects.json");
|
|
2470
|
+
const reporterPath = join11(temp, "lattice-playwright-reporter.cjs");
|
|
2471
|
+
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
|
+
const result = await run(
|
|
2473
|
+
file,
|
|
2474
|
+
["test", `--reporter=json,${reporterPath}`],
|
|
2475
|
+
app.directory,
|
|
2476
|
+
{ ...process.env, PLAYWRIGHT_JSON_OUTPUT_FILE: outputPath, LATTICE_PLAYWRIGHT_CONFIG_OUT: configPath }
|
|
2477
|
+
);
|
|
2478
|
+
const parsed = json(await readFile10(outputPath, "utf8").catch(() => result.stdout));
|
|
2479
|
+
const projects = playwrightProjects(parsed);
|
|
2480
|
+
const config = json(await readFile10(configPath, "utf8").catch(() => ""));
|
|
2481
|
+
const configurations = Array.isArray(config) && config.every(obj) ? config : [];
|
|
2482
|
+
for (const project of projects) {
|
|
2483
|
+
const configured = configurations.find((item) => item.name === project.name);
|
|
2484
|
+
if (!configured) continue;
|
|
2485
|
+
project.engine = str(configured.engine);
|
|
2486
|
+
project.mobile = configured.mobile === true && configured.touch === true;
|
|
2487
|
+
const agent = str(configured.userAgent);
|
|
2488
|
+
const android = agent.match(/Android\s+([\d.]+)/i);
|
|
2489
|
+
const iphone = agent.match(/iPhone OS\s+([\d_]+)/i);
|
|
2490
|
+
if (project.name === "android-chrome" && android && project.mobile) project.device = `Android ${android[1]} UA emulation`;
|
|
2491
|
+
if (project.name === "iphone-safari" && iphone && project.mobile) project.device = `iOS ${iphone[1].replaceAll("_", ".")} UA emulation`;
|
|
2492
|
+
}
|
|
2493
|
+
const reportedErrors = obj(parsed) && Array.isArray(parsed.errors) ? parsed.errors : [];
|
|
2494
|
+
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
|
+
return {
|
|
2496
|
+
tool: "Playwright",
|
|
2497
|
+
version: toolVersion,
|
|
2498
|
+
complete: Boolean(parsed) && !result.error && !infrastructureFailure && projects.length > 0,
|
|
2499
|
+
projects,
|
|
2500
|
+
errors: infrastructureFailure ? ["Playwright browser infrastructure or configuration failed"] : projects.length ? [] : [result.error ?? "Playwright JSON results are unavailable; configure required browser and mobile projects"]
|
|
2501
|
+
};
|
|
2502
|
+
}
|
|
2503
|
+
async function collectGovernanceEvidence(options) {
|
|
2504
|
+
const scanRoot = resolve10(options.scanRoot);
|
|
2505
|
+
const workspaceRoot = resolve10(options.workspaceRoot ?? scanRoot);
|
|
2506
|
+
const destination = resolve10(options.evidence);
|
|
2507
|
+
if (!inside(workspaceRoot, scanRoot)) throw new Error("Scan root must be inside workspace root");
|
|
2508
|
+
if (inside(scanRoot, destination)) throw new Error("Evidence file must be outside the scan root");
|
|
2509
|
+
if (!options.policy.assessment?.evidence) throw new Error("Policy assessment.evidence is required for governance collect");
|
|
2510
|
+
const inputSha256 = await hashAssessmentInputs(scanRoot, workspaceRoot);
|
|
2511
|
+
const assertStableInputs = async (stage) => {
|
|
2512
|
+
if (inputSha256 !== await hashAssessmentInputs(scanRoot, workspaceRoot)) {
|
|
2513
|
+
throw new Error(`Assessment inputs changed ${stage}; rerun on a stable checkout`);
|
|
2514
|
+
}
|
|
2515
|
+
};
|
|
2516
|
+
const git = await run("git", ["rev-parse", "HEAD"], workspaceRoot);
|
|
2517
|
+
const commit = git.code === 0 && /^[0-9a-f]{40}$/.test(git.stdout.trim()) ? git.stdout.trim() : "unversioned";
|
|
2518
|
+
const apps = await discoverApps(scanRoot, workspaceRoot);
|
|
2519
|
+
const auditCache = /* @__PURE__ */ new Map();
|
|
2520
|
+
const scans = [];
|
|
2521
|
+
for (const app of apps) {
|
|
2522
|
+
const temp = await mkdtemp(join11(tmpdir(), "lattice-scanners-"));
|
|
2523
|
+
try {
|
|
2524
|
+
const dependencySecurity = await dependencyAudit(app, workspaceRoot, auditCache);
|
|
2525
|
+
await assertStableInputs("after dependency audit");
|
|
2526
|
+
const qualityLint = await lint(app, workspaceRoot);
|
|
2527
|
+
await assertStableInputs("after ESLint");
|
|
2528
|
+
const qualityTypecheck = await typecheck(app, workspaceRoot);
|
|
2529
|
+
await assertStableInputs("after TypeScript");
|
|
2530
|
+
const qualityTests = await testAndCoverage(app, workspaceRoot, temp);
|
|
2531
|
+
await assertStableInputs("after Vitest");
|
|
2532
|
+
const securitySast = await sast(app, temp);
|
|
2533
|
+
await assertStableInputs("after Semgrep");
|
|
2534
|
+
const securitySecrets = await secrets(app, temp);
|
|
2535
|
+
await assertStableInputs("after Gitleaks");
|
|
2536
|
+
const compatibilityResult = await compatibility(app, workspaceRoot, temp);
|
|
2537
|
+
await assertStableInputs("after Playwright");
|
|
2538
|
+
scans.push({
|
|
2539
|
+
path: relative8(scanRoot, app.directory).replaceAll("\\", "/") || ".",
|
|
2540
|
+
profile: app.profile,
|
|
2541
|
+
dependencySecurity,
|
|
2542
|
+
codeQuality: { lint: qualityLint, typecheck: qualityTypecheck, ...qualityTests },
|
|
2543
|
+
codeSecurity: { sast: securitySast, secrets: securitySecrets },
|
|
2544
|
+
compatibility: compatibilityResult
|
|
2545
|
+
});
|
|
2546
|
+
} finally {
|
|
2547
|
+
await rm(temp, { recursive: true, force: true });
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
await assertStableInputs("before evidence write");
|
|
2551
|
+
const report = {
|
|
2552
|
+
schemaVersion: 1,
|
|
2553
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2554
|
+
policySha256: options.policy.sha256,
|
|
2555
|
+
inputSha256,
|
|
2556
|
+
commit,
|
|
2557
|
+
apps: scans
|
|
2558
|
+
};
|
|
2559
|
+
await mkdir4(dirname4(destination), { recursive: true });
|
|
2560
|
+
await writeFile3(destination, `${JSON.stringify(report, null, 2)}
|
|
2561
|
+
`, { flag: "w" });
|
|
1699
2562
|
return report;
|
|
1700
2563
|
}
|
|
1701
2564
|
|
|
1702
2565
|
// src/runtime.ts
|
|
1703
|
-
function isSupportedNodeVersion(
|
|
1704
|
-
const major = Number(
|
|
2566
|
+
function isSupportedNodeVersion(version2) {
|
|
2567
|
+
const major = Number(version2.split(".", 1)[0]);
|
|
1705
2568
|
return Number.isInteger(major) && major >= 24;
|
|
1706
2569
|
}
|
|
1707
2570
|
|
|
@@ -1722,12 +2585,12 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
1722
2585
|
program.configureOutput({ writeOut: output.out, writeErr: output.err });
|
|
1723
2586
|
program.exitOverride();
|
|
1724
2587
|
program.command("analyze [path]").description("Analyze local architecture and write offline reports").option("--output <directory>", "Output directory").action(async (path, flags) => {
|
|
1725
|
-
const root =
|
|
1726
|
-
const destination =
|
|
2588
|
+
const root = resolve11(path ?? ".");
|
|
2589
|
+
const destination = resolve11(flags.output ?? join12(root, ".nklattice-report"));
|
|
1727
2590
|
const report = await analyzeProject(root, { outputDirectory: destination });
|
|
1728
2591
|
output.out(`NKLattice analyzed ${report.graph.modules.length} modules. Score ${report.score.score} (${report.score.grade}); ${report.findings.length} findings.
|
|
1729
2592
|
`);
|
|
1730
|
-
output.out(`Reports: ${
|
|
2593
|
+
output.out(`Reports: ${join12(destination, "report.html")} and ${join12(destination, "analysis.json")}
|
|
1731
2594
|
`);
|
|
1732
2595
|
});
|
|
1733
2596
|
program.command("doctor [path]").description("Check whether a directory contains supported source files").action(async (path) => {
|
|
@@ -1737,8 +2600,8 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
1737
2600
|
`);
|
|
1738
2601
|
});
|
|
1739
2602
|
program.command("init [path]").description("Create a project configuration file").action(async (path) => {
|
|
1740
|
-
const destination =
|
|
1741
|
-
await
|
|
2603
|
+
const destination = join12(resolve11(path ?? "."), ".nklattice.json");
|
|
2604
|
+
await writeFile4(destination, '{\n "analysis": {},\n "policies": []\n}\n', { flag: "wx" });
|
|
1742
2605
|
output.out(`Created ${destination}
|
|
1743
2606
|
`);
|
|
1744
2607
|
});
|
|
@@ -1750,7 +2613,7 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
1750
2613
|
expectedApps = Number(flags.expectApps);
|
|
1751
2614
|
if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
|
|
1752
2615
|
}
|
|
1753
|
-
const policyPath = flags.policy ??
|
|
2616
|
+
const policyPath = flags.policy ?? resolve11(dirname5(fileURLToPath(import.meta.url)), "../governance/policy.json");
|
|
1754
2617
|
const policy = await loadPolicy(policyPath);
|
|
1755
2618
|
const result = await checkGovernance({
|
|
1756
2619
|
scanRoot: path ?? ".",
|
|
@@ -1776,21 +2639,22 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
1776
2639
|
}
|
|
1777
2640
|
if (result.findings.length > 0) exitCode = 7;
|
|
1778
2641
|
});
|
|
1779
|
-
governance.command("assess [path]").description("Assess app inventory,
|
|
2642
|
+
governance.command("assess [path]").description("Assess app inventory, dependency, architecture, security, quality, and compatibility policy").option("--policy <source>", "Local file or HTTPS policy URL").option("--inventory <path>", "Reviewed app inventory JSON").option("--workspace-root <path>", "Repository root containing a shared lockfile").option("--expect-apps <count>", "Additional expected managed application count").option("--output <directory>", "Assessment report directory").option("--evidence <file>", "Fresh governance collect JSON outside the scan root").option("--json", "Print machine-readable assessment").action(async (path, flags) => {
|
|
1780
2643
|
let expectedApps;
|
|
1781
2644
|
if (flags.expectApps !== void 0) {
|
|
1782
2645
|
if (!/^[1-9]\d*$/.test(flags.expectApps)) throw new Error("--expect-apps must be a positive integer");
|
|
1783
2646
|
expectedApps = Number(flags.expectApps);
|
|
1784
2647
|
if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
|
|
1785
2648
|
}
|
|
1786
|
-
const scanRoot =
|
|
1787
|
-
const policyPath = flags.policy ??
|
|
2649
|
+
const scanRoot = resolve11(path ?? ".");
|
|
2650
|
+
const policyPath = flags.policy ?? resolve11(dirname5(fileURLToPath(import.meta.url)), "../governance/policy.json");
|
|
1788
2651
|
const report = await assessGovernance({
|
|
1789
2652
|
scanRoot,
|
|
1790
2653
|
workspaceRoot: flags.workspaceRoot,
|
|
1791
2654
|
inventory: flags.inventory,
|
|
1792
2655
|
expectedApps,
|
|
1793
2656
|
outputDirectory: flags.output,
|
|
2657
|
+
evidence: flags.evidence,
|
|
1794
2658
|
policy: await loadPolicy(policyPath)
|
|
1795
2659
|
});
|
|
1796
2660
|
if (flags.json) output.out(`${JSON.stringify(report, null, 2)}
|
|
@@ -1801,12 +2665,58 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
1801
2665
|
for (const issue of report.issues) output.out(`Inventory: ${issue}
|
|
1802
2666
|
`);
|
|
1803
2667
|
for (const app of report.apps) {
|
|
1804
|
-
output.out(`${app.path} (${app.profile}): ${app.status};
|
|
2668
|
+
output.out(`${app.path} (${app.profile}): ${app.status}; ${Object.entries(app.domains).map(([name, domain]) => `${name} ${domain.status}`).join("; ")}; architecture score ${app.domains.architecture.score ?? "unavailable"}.
|
|
2669
|
+
`);
|
|
2670
|
+
for (const [name, domain] of Object.entries(app.domains)) for (const issue of domain.issues) output.out(` ${name}: ${issue}
|
|
2671
|
+
`);
|
|
2672
|
+
}
|
|
2673
|
+
output.out(`Reports: ${resolve11(flags.output ?? join12(scanRoot, ".nklattice-governance"))}
|
|
2674
|
+
`);
|
|
2675
|
+
}
|
|
2676
|
+
exitCode = report.status === "pass" ? 0 : report.status === "fail" ? 7 : 3;
|
|
2677
|
+
});
|
|
2678
|
+
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
|
+
const scanRoot = resolve11(path ?? ".");
|
|
2680
|
+
const policyPath = flags.policy ?? resolve11(dirname5(fileURLToPath(import.meta.url)), "../governance/policy.json");
|
|
2681
|
+
const result = await collectGovernanceEvidence({ scanRoot, workspaceRoot: flags.workspaceRoot, evidence: flags.evidence, policy: await loadPolicy(policyPath) });
|
|
2682
|
+
output.out(`Collected scanner evidence for ${result.apps.length} app(s) at ${resolve11(flags.evidence)}.
|
|
2683
|
+
`);
|
|
2684
|
+
});
|
|
2685
|
+
governance.command("run [path]").description("Collect and assess all governance domains in one trusted process").requiredOption("--evidence <file>", "Evidence artifact outside the scan root").option("--policy <source>", "Local file or HTTPS policy URL").option("--inventory <path>", "Reviewed app inventory JSON").option("--workspace-root <path>", "Repository root containing a shared lockfile").option("--expect-apps <count>", "Additional expected managed application count").option("--output <directory>", "Assessment report directory").option("--json", "Print machine-readable assessment").action(async (path, flags) => {
|
|
2686
|
+
let expectedApps;
|
|
2687
|
+
if (flags.expectApps !== void 0) {
|
|
2688
|
+
if (!/^[1-9]\d*$/.test(flags.expectApps)) throw new Error("--expect-apps must be a positive integer");
|
|
2689
|
+
expectedApps = Number(flags.expectApps);
|
|
2690
|
+
if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
|
|
2691
|
+
}
|
|
2692
|
+
const scanRoot = resolve11(path ?? ".");
|
|
2693
|
+
const policyPath = flags.policy ?? resolve11(dirname5(fileURLToPath(import.meta.url)), "../governance/policy.json");
|
|
2694
|
+
const policy = await loadPolicy(policyPath);
|
|
2695
|
+
const collected = await collectGovernanceEvidence({ scanRoot, workspaceRoot: flags.workspaceRoot, evidence: flags.evidence, policy });
|
|
2696
|
+
const report = await assessGovernance({
|
|
2697
|
+
scanRoot,
|
|
2698
|
+
workspaceRoot: flags.workspaceRoot,
|
|
2699
|
+
inventory: flags.inventory,
|
|
2700
|
+
expectedApps,
|
|
2701
|
+
outputDirectory: flags.output,
|
|
2702
|
+
evidence: flags.evidence,
|
|
2703
|
+
trustedEvidence: collected,
|
|
2704
|
+
policy
|
|
2705
|
+
});
|
|
2706
|
+
if (flags.json) output.out(`${JSON.stringify(report, null, 2)}
|
|
2707
|
+
`);
|
|
2708
|
+
else {
|
|
2709
|
+
output.out(`Governance ${report.status}: ${report.apps.length} app(s); policy ${report.policy.version} (${report.policy.sha256}).
|
|
2710
|
+
`);
|
|
2711
|
+
for (const issue of report.issues) output.out(`Assessment: ${issue}
|
|
2712
|
+
`);
|
|
2713
|
+
for (const app of report.apps) {
|
|
2714
|
+
output.out(`${app.path} (${app.profile}): ${app.status}; ${Object.entries(app.domains).map(([name, domain]) => `${name} ${domain.status}`).join("; ")}; architecture score ${app.domains.architecture.score ?? "unavailable"}.
|
|
1805
2715
|
`);
|
|
1806
2716
|
for (const [name, domain] of Object.entries(app.domains)) for (const issue of domain.issues) output.out(` ${name}: ${issue}
|
|
1807
2717
|
`);
|
|
1808
2718
|
}
|
|
1809
|
-
output.out(`Reports: ${
|
|
2719
|
+
output.out(`Reports: ${resolve11(flags.output ?? join12(scanRoot, ".nklattice-governance"))}
|
|
1810
2720
|
`);
|
|
1811
2721
|
}
|
|
1812
2722
|
exitCode = report.status === "pass" ? 0 : report.status === "fail" ? 7 : 3;
|
|
@@ -1822,7 +2732,7 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
1822
2732
|
}
|
|
1823
2733
|
return exitCode;
|
|
1824
2734
|
}
|
|
1825
|
-
if (process.argv[1] && realpathSync(
|
|
2735
|
+
if (process.argv[1] && realpathSync(resolve11(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
1826
2736
|
runCli(process.argv.slice(2)).then((code) => {
|
|
1827
2737
|
process.exitCode = code;
|
|
1828
2738
|
});
|