@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/index.js
CHANGED
|
@@ -139,12 +139,55 @@ function parseAssessmentPolicy(value) {
|
|
|
139
139
|
for (const id of architecture.blockedRuleIds) {
|
|
140
140
|
if (!known.has(id)) throw new Error(`Unknown blocked rule ID ${id}`);
|
|
141
141
|
}
|
|
142
|
+
let evidence;
|
|
143
|
+
if (value.evidence !== void 0) {
|
|
144
|
+
if (!record2(value.evidence)) throw new Error("assessment.evidence must be an object");
|
|
145
|
+
const gates = value.evidence;
|
|
146
|
+
const nonnegative2 = (source, key, label) => {
|
|
147
|
+
const result = source[key];
|
|
148
|
+
if (!Number.isSafeInteger(result) || result < 0) throw new Error(`${label}.${key} must be a nonnegative integer`);
|
|
149
|
+
return result;
|
|
150
|
+
};
|
|
151
|
+
if (!Number.isFinite(gates.maximumAgeHours) || gates.maximumAgeHours <= 0 || gates.maximumAgeHours > 168) {
|
|
152
|
+
throw new Error("assessment.evidence.maximumAgeHours must be greater than 0 and at most 168");
|
|
153
|
+
}
|
|
154
|
+
for (const domain of ["dependencySecurity", "codeQuality", "codeSecurity", "compatibility"]) {
|
|
155
|
+
if (!record2(gates[domain])) throw new Error(`assessment.evidence.${domain} is required`);
|
|
156
|
+
}
|
|
157
|
+
const dependencySecurity = gates.dependencySecurity;
|
|
158
|
+
const codeQuality = gates.codeQuality;
|
|
159
|
+
const codeSecurity = gates.codeSecurity;
|
|
160
|
+
const compatibility = gates.compatibility;
|
|
161
|
+
const minimumLineCoverage = codeQuality.minimumLineCoverage;
|
|
162
|
+
if (typeof minimumLineCoverage !== "number" || !Number.isFinite(minimumLineCoverage) || minimumLineCoverage < 0 || minimumLineCoverage > 100) {
|
|
163
|
+
throw new Error("assessment.evidence.codeQuality.minimumLineCoverage must be 0\u2013100");
|
|
164
|
+
}
|
|
165
|
+
if (typeof codeQuality.requireTypecheck !== "boolean" || typeof codeQuality.requireTests !== "boolean") {
|
|
166
|
+
throw new Error("assessment.evidence.codeQuality requires boolean requireTypecheck and requireTests");
|
|
167
|
+
}
|
|
168
|
+
const projects = compatibility.requiredProjects;
|
|
169
|
+
if (!Array.isArray(projects) || projects.length === 0 || !projects.every((item) => typeof item === "string" && item.length > 0) || new Set(projects).size !== projects.length) {
|
|
170
|
+
throw new Error("assessment.evidence.compatibility.requiredProjects must be a unique nonempty string array");
|
|
171
|
+
}
|
|
172
|
+
evidence = {
|
|
173
|
+
maximumAgeHours: gates.maximumAgeHours,
|
|
174
|
+
dependencySecurity: { maximumHigh: nonnegative2(dependencySecurity, "maximumHigh", "assessment.evidence.dependencySecurity"), maximumCritical: nonnegative2(dependencySecurity, "maximumCritical", "assessment.evidence.dependencySecurity") },
|
|
175
|
+
codeQuality: {
|
|
176
|
+
maximumLintErrors: nonnegative2(codeQuality, "maximumLintErrors", "assessment.evidence.codeQuality"),
|
|
177
|
+
minimumLineCoverage,
|
|
178
|
+
requireTypecheck: codeQuality.requireTypecheck,
|
|
179
|
+
requireTests: codeQuality.requireTests
|
|
180
|
+
},
|
|
181
|
+
codeSecurity: { maximumHigh: nonnegative2(codeSecurity, "maximumHigh", "assessment.evidence.codeSecurity"), maximumCritical: nonnegative2(codeSecurity, "maximumCritical", "assessment.evidence.codeSecurity"), maximumSecrets: nonnegative2(codeSecurity, "maximumSecrets", "assessment.evidence.codeSecurity") },
|
|
182
|
+
compatibility: { requiredProjects: projects, minimumTestsPerProject: nonnegative2(compatibility, "minimumTestsPerProject", "assessment.evidence.compatibility") }
|
|
183
|
+
};
|
|
184
|
+
}
|
|
142
185
|
return { architecture: {
|
|
143
186
|
minimumScore: architecture.minimumScore,
|
|
144
187
|
maximumErrors: architecture.maximumErrors,
|
|
145
188
|
blockedRuleIds: architecture.blockedRuleIds,
|
|
146
189
|
analysis
|
|
147
|
-
} };
|
|
190
|
+
}, ...evidence ? { evidence } : {} };
|
|
148
191
|
}
|
|
149
192
|
function parsePolicy(raw, source) {
|
|
150
193
|
let parsed;
|
|
@@ -390,7 +433,7 @@ async function readResolution(app, workspaceRoot, name) {
|
|
|
390
433
|
async function checkApp(app, workspaceRoot, policy) {
|
|
391
434
|
const requirements = policy.profiles[app.profile];
|
|
392
435
|
if (!requirements) throw new Error(`Policy does not define profile ${app.profile}`);
|
|
393
|
-
const
|
|
436
|
+
const findings2 = [];
|
|
394
437
|
for (const [packageName, expected] of Object.entries(requirements)) {
|
|
395
438
|
const declared = app.dependencies[packageName];
|
|
396
439
|
const resolution = declared === void 0 ? { specifier: void 0, version: void 0 } : await readResolution(app, workspaceRoot, packageName);
|
|
@@ -408,7 +451,7 @@ async function checkApp(app, workspaceRoot, policy) {
|
|
|
408
451
|
reasons.push("lockfile resolution outside manifest range");
|
|
409
452
|
}
|
|
410
453
|
}
|
|
411
|
-
if (reasons.length)
|
|
454
|
+
if (reasons.length) findings2.push({
|
|
412
455
|
app: app.relativePath,
|
|
413
456
|
packageName,
|
|
414
457
|
expected,
|
|
@@ -418,7 +461,7 @@ async function checkApp(app, workspaceRoot, policy) {
|
|
|
418
461
|
reason: reasons.join("; ")
|
|
419
462
|
});
|
|
420
463
|
}
|
|
421
|
-
return
|
|
464
|
+
return findings2;
|
|
422
465
|
}
|
|
423
466
|
async function checkGovernance(options) {
|
|
424
467
|
const scanRoot = resolve3(options.scanRoot);
|
|
@@ -429,10 +472,10 @@ async function checkGovernance(options) {
|
|
|
429
472
|
}
|
|
430
473
|
const apps = await discoverApps(scanRoot, workspaceRoot);
|
|
431
474
|
if (apps.length === 0) throw new Error("No managed applications found");
|
|
432
|
-
const
|
|
475
|
+
const findings2 = (await Promise.all(apps.map((app) => checkApp(app, workspaceRoot, options.policy)))).flat();
|
|
433
476
|
return {
|
|
434
477
|
appsChecked: apps.length,
|
|
435
|
-
findings,
|
|
478
|
+
findings: findings2,
|
|
436
479
|
policyVersion: options.policy.policyVersion,
|
|
437
480
|
policySource: options.policy.source,
|
|
438
481
|
policySha256: options.policy.sha256
|
|
@@ -441,7 +484,7 @@ async function checkGovernance(options) {
|
|
|
441
484
|
|
|
442
485
|
// src/governance/assess.ts
|
|
443
486
|
import { mkdir, writeFile } from "fs/promises";
|
|
444
|
-
import { join as
|
|
487
|
+
import { basename, join as join8, relative as relative7, resolve as resolve8, sep as sep6 } from "path";
|
|
445
488
|
|
|
446
489
|
// src/analysis/graph.ts
|
|
447
490
|
function buildGraph(_modules) {
|
|
@@ -494,12 +537,12 @@ function findCycles(graph) {
|
|
|
494
537
|
// src/analysis/rules.ts
|
|
495
538
|
import { matchesGlob } from "path";
|
|
496
539
|
function evaluateRules(graph, config) {
|
|
497
|
-
const
|
|
540
|
+
const findings2 = [];
|
|
498
541
|
const outgoing = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.from === module.path && !edge.isTypeOnly)]));
|
|
499
542
|
const incoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path && !edge.isTypeOnly)]));
|
|
500
543
|
const allIncoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path)]));
|
|
501
544
|
for (const cycle of findCycles(graph)) {
|
|
502
|
-
|
|
545
|
+
findings2.push({
|
|
503
546
|
ruleId: "module-cycle",
|
|
504
547
|
category: "architecture",
|
|
505
548
|
severity: "error",
|
|
@@ -516,7 +559,7 @@ function evaluateRules(graph, config) {
|
|
|
516
559
|
for (const imported of module.imports) {
|
|
517
560
|
if (imported.isTypeOnly) continue;
|
|
518
561
|
if (seen.has(imported.specifier)) {
|
|
519
|
-
|
|
562
|
+
findings2.push({
|
|
520
563
|
ruleId: "duplicate-import",
|
|
521
564
|
category: "imports",
|
|
522
565
|
severity: "warning",
|
|
@@ -529,7 +572,7 @@ function evaluateRules(graph, config) {
|
|
|
529
572
|
seen.add(imported.specifier);
|
|
530
573
|
}
|
|
531
574
|
if (out.length > config.thresholds.maxFanOut) {
|
|
532
|
-
|
|
575
|
+
findings2.push({
|
|
533
576
|
ruleId: "high-fan-out",
|
|
534
577
|
category: "architecture",
|
|
535
578
|
severity: "warning",
|
|
@@ -540,7 +583,7 @@ function evaluateRules(graph, config) {
|
|
|
540
583
|
});
|
|
541
584
|
}
|
|
542
585
|
if (into.length > config.thresholds.maxFanIn) {
|
|
543
|
-
|
|
586
|
+
findings2.push({
|
|
544
587
|
ruleId: "high-fan-in",
|
|
545
588
|
category: "architecture",
|
|
546
589
|
severity: "warning",
|
|
@@ -551,7 +594,7 @@ function evaluateRules(graph, config) {
|
|
|
551
594
|
});
|
|
552
595
|
}
|
|
553
596
|
if (into.length === 0 && out.length === 0 && graph.modules.length > 1 && !module.isRoute) {
|
|
554
|
-
|
|
597
|
+
findings2.push({
|
|
555
598
|
ruleId: "orphan-module",
|
|
556
599
|
category: "architecture",
|
|
557
600
|
severity: "info",
|
|
@@ -567,7 +610,7 @@ function evaluateRules(graph, config) {
|
|
|
567
610
|
for (const name of module.exports) {
|
|
568
611
|
if (name === "*" || module.typeExports.includes(name)) continue;
|
|
569
612
|
if (!usedNames.has(name)) {
|
|
570
|
-
|
|
613
|
+
findings2.push({
|
|
571
614
|
ruleId: "unused-export",
|
|
572
615
|
category: "imports",
|
|
573
616
|
severity: "info",
|
|
@@ -581,7 +624,7 @@ function evaluateRules(graph, config) {
|
|
|
581
624
|
}
|
|
582
625
|
}
|
|
583
626
|
if (module.componentCount > 0 && module.lines > config.thresholds.maxComponentLines) {
|
|
584
|
-
|
|
627
|
+
findings2.push({
|
|
585
628
|
ruleId: "large-component",
|
|
586
629
|
category: "components",
|
|
587
630
|
severity: "warning",
|
|
@@ -592,7 +635,7 @@ function evaluateRules(graph, config) {
|
|
|
592
635
|
});
|
|
593
636
|
}
|
|
594
637
|
if (module.jsxDepth > config.thresholds.maxJsxDepth) {
|
|
595
|
-
|
|
638
|
+
findings2.push({
|
|
596
639
|
ruleId: "deep-jsx",
|
|
597
640
|
category: "components",
|
|
598
641
|
severity: "warning",
|
|
@@ -603,7 +646,7 @@ function evaluateRules(graph, config) {
|
|
|
603
646
|
});
|
|
604
647
|
}
|
|
605
648
|
if (module.complexity > config.thresholds.maxComplexity) {
|
|
606
|
-
|
|
649
|
+
findings2.push({
|
|
607
650
|
ruleId: "high-complexity",
|
|
608
651
|
category: "complexity",
|
|
609
652
|
severity: "warning",
|
|
@@ -619,7 +662,7 @@ function evaluateRules(graph, config) {
|
|
|
619
662
|
const allowed = policy.allow?.some((pattern) => matchesGlob(edge.to, pattern));
|
|
620
663
|
const disallowed = policy.disallow?.some((pattern) => matchesGlob(edge.to, pattern));
|
|
621
664
|
if (policy.allow && !allowed || policy.disallow && disallowed) {
|
|
622
|
-
|
|
665
|
+
findings2.push({
|
|
623
666
|
ruleId: policy.id,
|
|
624
667
|
category: "architecture",
|
|
625
668
|
severity: policy.severity,
|
|
@@ -646,7 +689,7 @@ function evaluateRules(graph, config) {
|
|
|
646
689
|
return false;
|
|
647
690
|
};
|
|
648
691
|
if (visit(module.path, /* @__PURE__ */ new Set([module.path]), 0)) {
|
|
649
|
-
|
|
692
|
+
findings2.push({
|
|
650
693
|
ruleId: "deep-dependency-chain",
|
|
651
694
|
category: "architecture",
|
|
652
695
|
severity: "warning",
|
|
@@ -657,7 +700,7 @@ function evaluateRules(graph, config) {
|
|
|
657
700
|
});
|
|
658
701
|
}
|
|
659
702
|
}
|
|
660
|
-
return
|
|
703
|
+
return findings2.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.ruleId.localeCompare(b.ruleId));
|
|
661
704
|
}
|
|
662
705
|
|
|
663
706
|
// src/analysis/scan.ts
|
|
@@ -986,10 +1029,10 @@ function grade(score) {
|
|
|
986
1029
|
if (score >= 55) return "D";
|
|
987
1030
|
return "F";
|
|
988
1031
|
}
|
|
989
|
-
function scoreFindings(
|
|
1032
|
+
function scoreFindings(findings2, config) {
|
|
990
1033
|
const penalties = { error: 18, warning: 7, info: 2 };
|
|
991
1034
|
const categories = Object.entries(config.weights).map(([id, weight]) => {
|
|
992
|
-
const categoryFindings =
|
|
1035
|
+
const categoryFindings = findings2.filter((finding) => finding.category === id).sort((a, b) => a.ruleId.localeCompare(b.ruleId) || a.file.localeCompare(b.file));
|
|
993
1036
|
const occurrences = /* @__PURE__ */ new Map();
|
|
994
1037
|
let deduction = 0;
|
|
995
1038
|
for (const finding of categoryFindings) {
|
|
@@ -1001,8 +1044,8 @@ function scoreFindings(findings, config) {
|
|
|
1001
1044
|
return { id, weight, score: round(100 - deduction), deduction: round(deduction), findingCount: categoryFindings.length };
|
|
1002
1045
|
});
|
|
1003
1046
|
const weightedDeduction = round(categories.reduce((sum, category) => sum + category.deduction * category.weight / 100, 0));
|
|
1004
|
-
const maintenanceAdjustment = round(Math.min(5, Math.max(0,
|
|
1005
|
-
const criticalAdjustment = round(Math.min(10,
|
|
1047
|
+
const maintenanceAdjustment = round(Math.min(5, Math.max(0, findings2.length - 10) * 0.15));
|
|
1048
|
+
const criticalAdjustment = round(Math.min(10, findings2.filter((finding) => finding.severity === "error").length * 1.5));
|
|
1006
1049
|
const score = round(Math.max(0, Math.min(100, 100 - weightedDeduction - maintenanceAdjustment - criticalAdjustment)));
|
|
1007
1050
|
return { score, grade: grade(score), weightedDeduction, maintenanceAdjustment, criticalAdjustment, categories };
|
|
1008
1051
|
}
|
|
@@ -1065,6 +1108,279 @@ function compareInventory(inventory, discovered, scanRoot) {
|
|
|
1065
1108
|
return issues;
|
|
1066
1109
|
}
|
|
1067
1110
|
|
|
1111
|
+
// src/governance/evidence.ts
|
|
1112
|
+
import { createHash as createHash3 } from "crypto";
|
|
1113
|
+
import { execFile } from "child_process";
|
|
1114
|
+
import { createReadStream } from "fs";
|
|
1115
|
+
import { lstat, readFile as readFile8, realpath as realpath2 } from "fs/promises";
|
|
1116
|
+
import { dirname as dirname3, join as join7, relative as relative6, resolve as resolve7, sep as sep5 } from "path";
|
|
1117
|
+
import { promisify } from "util";
|
|
1118
|
+
import fg from "fast-glob";
|
|
1119
|
+
var execFileAsync = promisify(execFile);
|
|
1120
|
+
function object(value) {
|
|
1121
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1122
|
+
}
|
|
1123
|
+
function string(value) {
|
|
1124
|
+
return typeof value === "string" && value.length > 0;
|
|
1125
|
+
}
|
|
1126
|
+
function nonnegative(value) {
|
|
1127
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
1128
|
+
}
|
|
1129
|
+
function errors(value) {
|
|
1130
|
+
return Array.isArray(value) && value.every(string);
|
|
1131
|
+
}
|
|
1132
|
+
function findings(value) {
|
|
1133
|
+
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)));
|
|
1134
|
+
}
|
|
1135
|
+
function base(value) {
|
|
1136
|
+
return object(value) && string(value.tool) && string(value.version) && typeof value.complete === "boolean" && errors(value.errors);
|
|
1137
|
+
}
|
|
1138
|
+
function validateApp(value) {
|
|
1139
|
+
if (!object(value) || !string(value.path) || !string(value.profile)) return false;
|
|
1140
|
+
const dependency = value.dependencySecurity;
|
|
1141
|
+
const quality = value.codeQuality;
|
|
1142
|
+
const security = value.codeSecurity;
|
|
1143
|
+
const compatibility = value.compatibility;
|
|
1144
|
+
if (!base(dependency) || !nonnegative(dependency.packagesScanned) || !findings(dependency.advisories)) return false;
|
|
1145
|
+
if (!object(quality) || !object(quality.lint) || !string(quality.lint.tool) || !string(quality.lint.version) || typeof quality.lint.complete !== "boolean" || !base(quality.typecheck) || !base(quality.tests) || !base(quality.coverage)) return false;
|
|
1146
|
+
if (!nonnegative(quality.lint.filesScanned) || !nonnegative(quality.lint.errorCount) || !nonnegative(quality.lint.warnings) || !errors(quality.lint.errorsDetail) || typeof quality.typecheck.passed !== "boolean" || quality.typecheck.applicable !== void 0 && typeof quality.typecheck.applicable !== "boolean" || !nonnegative(quality.tests.passed) || !nonnegative(quality.tests.failed) || typeof quality.coverage.linesPercent !== "number" || !Number.isFinite(quality.coverage.linesPercent) || quality.coverage.linesPercent < 0 || quality.coverage.linesPercent > 100) return false;
|
|
1147
|
+
if (!object(security) || !base(security.sast) || !base(security.secrets) || !nonnegative(security.sast.filesScanned) || !nonnegative(security.secrets.filesScanned) || !findings(security.sast.findings) || !findings(security.secrets.findings)) return false;
|
|
1148
|
+
if (!base(compatibility) || !Array.isArray(compatibility.projects) || !compatibility.projects.every((project) => object(project) && string(project.name) && nonnegative(project.passed) && nonnegative(project.failed) && nonnegative(project.skipped) && (project.browserVersion === void 0 || string(project.browserVersion)) && (project.engine === void 0 || string(project.engine)) && (project.mobile === void 0 || typeof project.mobile === "boolean") && (project.device === void 0 || string(project.device)) && (project.viewportWidths === void 0 || Array.isArray(project.viewportWidths) && project.viewportWidths.every(nonnegative)))) return false;
|
|
1149
|
+
return true;
|
|
1150
|
+
}
|
|
1151
|
+
async function hashFile(hash, path) {
|
|
1152
|
+
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
|
1153
|
+
}
|
|
1154
|
+
async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
|
|
1155
|
+
const root = resolve7(scanRoot);
|
|
1156
|
+
const workspace = resolve7(workspaceRoot);
|
|
1157
|
+
const scanRelative = relative6(workspace, root);
|
|
1158
|
+
if (scanRelative === ".." || scanRelative.startsWith(`..${sep5}`)) throw new Error("Scan root must be inside workspace root");
|
|
1159
|
+
const paths = await fg("**/*", {
|
|
1160
|
+
cwd: root,
|
|
1161
|
+
dot: true,
|
|
1162
|
+
onlyFiles: true,
|
|
1163
|
+
followSymbolicLinks: false,
|
|
1164
|
+
ignore: [
|
|
1165
|
+
"**/.git/**",
|
|
1166
|
+
"**/node_modules/**",
|
|
1167
|
+
"**/dist/**",
|
|
1168
|
+
"**/build/**",
|
|
1169
|
+
"**/.next/**",
|
|
1170
|
+
"**/coverage/**",
|
|
1171
|
+
"**/playwright-report/**",
|
|
1172
|
+
"**/test-results/**",
|
|
1173
|
+
"**/.nklattice-governance/**",
|
|
1174
|
+
"**/.nklattice-report/**",
|
|
1175
|
+
"**/AGENTS.md",
|
|
1176
|
+
"**/*.tsbuildinfo",
|
|
1177
|
+
"**/next-env.d.ts"
|
|
1178
|
+
]
|
|
1179
|
+
});
|
|
1180
|
+
paths.sort();
|
|
1181
|
+
const hash = createHash3("sha256");
|
|
1182
|
+
for (const path of paths) {
|
|
1183
|
+
const absolute = resolve7(root, path);
|
|
1184
|
+
const normalized = relative6(root, absolute);
|
|
1185
|
+
if (normalized === ".." || normalized.startsWith(`..${sep5}`)) throw new Error("Input path escapes scan root");
|
|
1186
|
+
if (!(await lstat(absolute)).isFile()) continue;
|
|
1187
|
+
hash.update(path.replaceAll("\\", "/"));
|
|
1188
|
+
hash.update("\0");
|
|
1189
|
+
await hashFile(hash, absolute);
|
|
1190
|
+
hash.update("\0");
|
|
1191
|
+
}
|
|
1192
|
+
let ancestor = root;
|
|
1193
|
+
while (ancestor === workspace || ancestor !== dirname3(ancestor) && !relative6(workspace, ancestor).startsWith("..")) {
|
|
1194
|
+
for (const name of [
|
|
1195
|
+
"package.json",
|
|
1196
|
+
"package-lock.json",
|
|
1197
|
+
"pnpm-lock.yaml",
|
|
1198
|
+
"pnpm-workspace.yaml",
|
|
1199
|
+
"tsconfig.json",
|
|
1200
|
+
"eslint.config.js",
|
|
1201
|
+
"eslint.config.mjs",
|
|
1202
|
+
"eslint.config.cjs",
|
|
1203
|
+
"eslint.config.ts",
|
|
1204
|
+
"eslint.config.mts",
|
|
1205
|
+
"vitest.config.js",
|
|
1206
|
+
"vitest.config.mjs",
|
|
1207
|
+
"vitest.config.ts",
|
|
1208
|
+
"vitest.config.mts",
|
|
1209
|
+
"playwright.config.js",
|
|
1210
|
+
"playwright.config.mjs",
|
|
1211
|
+
"playwright.config.ts",
|
|
1212
|
+
"playwright.config.mts",
|
|
1213
|
+
".semgrep.yml",
|
|
1214
|
+
".semgrep.yaml",
|
|
1215
|
+
"semgrep.yml",
|
|
1216
|
+
"semgrep.yaml"
|
|
1217
|
+
]) {
|
|
1218
|
+
const path = join7(ancestor, name);
|
|
1219
|
+
try {
|
|
1220
|
+
const stat2 = await lstat(path);
|
|
1221
|
+
if (stat2.isSymbolicLink()) throw new Error(`Symlinked scanner configuration is unsupported: ${path}`);
|
|
1222
|
+
if (!stat2.isFile()) continue;
|
|
1223
|
+
hash.update(relative6(workspace, path).replaceAll("\\", "/"));
|
|
1224
|
+
hash.update("\0");
|
|
1225
|
+
await hashFile(hash, path);
|
|
1226
|
+
hash.update("\0");
|
|
1227
|
+
} catch (error) {
|
|
1228
|
+
if (error.code !== "ENOENT") throw error;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
if (ancestor === workspace) break;
|
|
1232
|
+
ancestor = dirname3(ancestor);
|
|
1233
|
+
}
|
|
1234
|
+
return hash.digest("hex");
|
|
1235
|
+
}
|
|
1236
|
+
async function loadScanEvidence(path, scanRoot, workspaceRoot, policySha256, maximumAgeHours) {
|
|
1237
|
+
const source = resolve7(path);
|
|
1238
|
+
const stat2 = await lstat(source);
|
|
1239
|
+
if (!stat2.isFile() || stat2.isSymbolicLink() || stat2.size > 1e7) throw new Error("Evidence must be a regular JSON file under 10 MB");
|
|
1240
|
+
const root = resolve7(scanRoot);
|
|
1241
|
+
const relativePath = relative6(root, source);
|
|
1242
|
+
if (relativePath !== ".." && !relativePath.startsWith(`..${sep5}`)) throw new Error("Evidence file must be outside the scan root");
|
|
1243
|
+
const actualRoot = await realpath2(root);
|
|
1244
|
+
const actualSource = await realpath2(source);
|
|
1245
|
+
const actualRelative = relative6(actualRoot, actualSource);
|
|
1246
|
+
if (actualRelative !== ".." && !actualRelative.startsWith(`..${sep5}`)) throw new Error("Evidence file resolves inside the scan root");
|
|
1247
|
+
let parsed;
|
|
1248
|
+
try {
|
|
1249
|
+
parsed = JSON.parse(await readFile8(source, "utf8"));
|
|
1250
|
+
} catch {
|
|
1251
|
+
throw new Error("Evidence is not valid JSON");
|
|
1252
|
+
}
|
|
1253
|
+
return validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha256, maximumAgeHours);
|
|
1254
|
+
}
|
|
1255
|
+
async function validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha256, maximumAgeHours) {
|
|
1256
|
+
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");
|
|
1257
|
+
if (parsed.policySha256 !== policySha256) throw new Error("Evidence was generated for another policy");
|
|
1258
|
+
const generatedAt = Date.parse(parsed.generatedAt);
|
|
1259
|
+
if (!Number.isFinite(generatedAt) || generatedAt > Date.now() + 6e4 || Date.now() - generatedAt > maximumAgeHours * 36e5) {
|
|
1260
|
+
throw new Error("Evidence is stale or has an invalid timestamp");
|
|
1261
|
+
}
|
|
1262
|
+
if (parsed.inputSha256 !== await hashAssessmentInputs(scanRoot, workspaceRoot)) throw new Error("Evidence inputs changed after scanning");
|
|
1263
|
+
const current = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: workspaceRoot })).stdout.trim();
|
|
1264
|
+
if (!/^[0-9a-f]{40}$/.test(current) || parsed.commit !== current) throw new Error("Evidence commit differs from the checked-out commit");
|
|
1265
|
+
const appPaths = parsed.apps.map((app) => app.path);
|
|
1266
|
+
if (new Set(appPaths).size !== appPaths.length) throw new Error("Evidence contains duplicate app paths");
|
|
1267
|
+
return parsed;
|
|
1268
|
+
}
|
|
1269
|
+
var incomplete = (issues, tool, version, scanned) => ({ status: "incomplete", issues, tool, version, scanned });
|
|
1270
|
+
function evaluateAppEvidence(app, policy) {
|
|
1271
|
+
if (!app) {
|
|
1272
|
+
const missing = incomplete(["No fresh scan evidence for this app"]);
|
|
1273
|
+
return { dependencySecurity: { ...missing }, codeQuality: { ...missing }, codeSecurity: { ...missing }, compatibility: { ...missing } };
|
|
1274
|
+
}
|
|
1275
|
+
const dependency = app.dependencySecurity;
|
|
1276
|
+
let dependencySecurity;
|
|
1277
|
+
if (!dependency.complete || dependency.packagesScanned === 0 || dependency.errors.length) {
|
|
1278
|
+
dependencySecurity = { ...incomplete(["Dependency advisory scan did not cover the lockfile", ...dependency.errors], dependency.tool, dependency.version, dependency.packagesScanned), findings: dependency.advisories };
|
|
1279
|
+
} else {
|
|
1280
|
+
const high2 = dependency.advisories.filter((item) => item.severity.toLowerCase() === "high").length;
|
|
1281
|
+
const critical2 = dependency.advisories.filter((item) => item.severity.toLowerCase() === "critical").length;
|
|
1282
|
+
const unknown = dependency.advisories.filter((item) => !["low", "moderate", "medium", "high", "critical"].includes(item.severity.toLowerCase()));
|
|
1283
|
+
dependencySecurity = unknown.length ? { ...incomplete([`${unknown.length} advisories have unknown severity`], dependency.tool, dependency.version, dependency.packagesScanned), findings: dependency.advisories } : {
|
|
1284
|
+
status: high2 > policy.dependencySecurity.maximumHigh || critical2 > policy.dependencySecurity.maximumCritical ? "fail" : "pass",
|
|
1285
|
+
issues: [
|
|
1286
|
+
...high2 > policy.dependencySecurity.maximumHigh ? [`${high2} high advisories exceed ${policy.dependencySecurity.maximumHigh}`] : [],
|
|
1287
|
+
...critical2 > policy.dependencySecurity.maximumCritical ? [`${critical2} critical advisories exceed ${policy.dependencySecurity.maximumCritical}`] : []
|
|
1288
|
+
],
|
|
1289
|
+
tool: dependency.tool,
|
|
1290
|
+
version: dependency.version,
|
|
1291
|
+
scanned: dependency.packagesScanned,
|
|
1292
|
+
findings: dependency.advisories
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
const quality = app.codeQuality;
|
|
1296
|
+
const qualityMissing = [
|
|
1297
|
+
...!quality.lint.complete || quality.lint.filesScanned === 0 ? ["ESLint did not scan source files", ...quality.lint.errorsDetail] : [],
|
|
1298
|
+
...policy.codeQuality.requireTypecheck && !quality.typecheck.complete ? ["TypeScript typecheck did not complete", ...quality.typecheck.errors] : [],
|
|
1299
|
+
...policy.codeQuality.requireTests && (!quality.tests.complete || quality.tests.passed + quality.tests.failed === 0) ? ["Tests did not run", ...quality.tests.errors] : [],
|
|
1300
|
+
...!quality.coverage.complete ? ["Line coverage is unavailable", ...quality.coverage.errors] : []
|
|
1301
|
+
];
|
|
1302
|
+
const qualityFailures = [
|
|
1303
|
+
...quality.lint.errorCount > policy.codeQuality.maximumLintErrors ? [`${quality.lint.errorCount} ESLint errors exceed ${policy.codeQuality.maximumLintErrors}`] : [],
|
|
1304
|
+
...policy.codeQuality.requireTypecheck && !quality.typecheck.passed ? ["TypeScript typecheck failed"] : [],
|
|
1305
|
+
...quality.tests.failed > 0 ? [`${quality.tests.failed} tests failed`] : [],
|
|
1306
|
+
...quality.coverage.linesPercent < policy.codeQuality.minimumLineCoverage ? [`Line coverage ${quality.coverage.linesPercent}% is below ${policy.codeQuality.minimumLineCoverage}%`] : []
|
|
1307
|
+
];
|
|
1308
|
+
const qualityVersion = `ESLint ${quality.lint.version} \xB7 TypeScript ${quality.typecheck.version} \xB7 Vitest ${quality.tests.version}`;
|
|
1309
|
+
const qualityMetrics = {
|
|
1310
|
+
lintErrors: quality.lint.errorCount,
|
|
1311
|
+
lintWarnings: quality.lint.warnings,
|
|
1312
|
+
typecheck: quality.typecheck.applicable === false ? "not applicable" : quality.typecheck.passed ? "pass" : "fail",
|
|
1313
|
+
testsPassed: quality.tests.passed,
|
|
1314
|
+
testsFailed: quality.tests.failed,
|
|
1315
|
+
lineCoveragePercent: quality.coverage.linesPercent
|
|
1316
|
+
};
|
|
1317
|
+
const codeQuality = qualityMissing.length ? { ...incomplete([...qualityMissing, ...qualityFailures], "ESLint / TypeScript / tests", qualityVersion, quality.lint.filesScanned), metrics: qualityMetrics } : {
|
|
1318
|
+
status: qualityFailures.length ? "fail" : "pass",
|
|
1319
|
+
issues: qualityFailures,
|
|
1320
|
+
tool: "ESLint / TypeScript / tests",
|
|
1321
|
+
version: qualityVersion,
|
|
1322
|
+
scanned: quality.lint.filesScanned,
|
|
1323
|
+
metrics: qualityMetrics
|
|
1324
|
+
};
|
|
1325
|
+
const security = app.codeSecurity;
|
|
1326
|
+
const securityMissing = [
|
|
1327
|
+
...!security.sast.complete || security.sast.filesScanned === 0 ? ["SAST did not scan source files", ...security.sast.errors] : [],
|
|
1328
|
+
...!security.secrets.complete || security.secrets.filesScanned === 0 ? ["Secret scanner did not scan source files", ...security.secrets.errors] : []
|
|
1329
|
+
];
|
|
1330
|
+
const high = security.sast.findings.filter((item) => item.severity.toLowerCase() === "high").length;
|
|
1331
|
+
const critical = security.sast.findings.filter((item) => item.severity.toLowerCase() === "critical").length;
|
|
1332
|
+
const unknownSecurity = security.sast.findings.filter((item) => !["low", "moderate", "medium", "high", "critical"].includes(item.severity.toLowerCase()));
|
|
1333
|
+
if (unknownSecurity.length) securityMissing.push(`${unknownSecurity.length} SAST findings have unknown severity`);
|
|
1334
|
+
const securityFailures = [
|
|
1335
|
+
...high > policy.codeSecurity.maximumHigh ? [`${high} high SAST findings exceed ${policy.codeSecurity.maximumHigh}`] : [],
|
|
1336
|
+
...critical > policy.codeSecurity.maximumCritical ? [`${critical} critical SAST findings exceed ${policy.codeSecurity.maximumCritical}`] : [],
|
|
1337
|
+
...security.secrets.findings.length > policy.codeSecurity.maximumSecrets ? [`${security.secrets.findings.length} detected secrets exceed ${policy.codeSecurity.maximumSecrets}`] : []
|
|
1338
|
+
];
|
|
1339
|
+
const securityVersion = `Semgrep ${security.sast.version} \xB7 Gitleaks ${security.secrets.version}`;
|
|
1340
|
+
const codeSecurity = securityMissing.length ? { ...incomplete([...securityMissing, ...securityFailures], "Semgrep / Gitleaks", securityVersion, security.sast.filesScanned), findings: [...security.sast.findings, ...security.secrets.findings] } : {
|
|
1341
|
+
status: securityFailures.length ? "fail" : "pass",
|
|
1342
|
+
issues: securityFailures,
|
|
1343
|
+
tool: "Semgrep / Gitleaks",
|
|
1344
|
+
version: securityVersion,
|
|
1345
|
+
scanned: security.sast.filesScanned,
|
|
1346
|
+
findings: [...security.sast.findings, ...security.secrets.findings]
|
|
1347
|
+
};
|
|
1348
|
+
const compatibility = app.compatibility;
|
|
1349
|
+
const projectNames = new Set(compatibility.projects.map((project) => project.name));
|
|
1350
|
+
const missingProjects = policy.compatibility.requiredProjects.filter((name) => !projectNames.has(name));
|
|
1351
|
+
const incompleteProjects = compatibility.projects.filter((project) => project.passed + project.failed < policy.compatibility.minimumTestsPerProject);
|
|
1352
|
+
const missingBrowserVersions = compatibility.projects.filter((project) => !project.browserVersion);
|
|
1353
|
+
const expectedEngines = {
|
|
1354
|
+
"chromium-desktop": "chromium",
|
|
1355
|
+
"firefox-desktop": "firefox",
|
|
1356
|
+
"webkit-desktop": "webkit",
|
|
1357
|
+
"android-chrome": "chromium",
|
|
1358
|
+
"iphone-safari": "webkit"
|
|
1359
|
+
};
|
|
1360
|
+
const invalidEngines = compatibility.projects.filter((project) => expectedEngines[project.name] && project.engine !== expectedEngines[project.name]);
|
|
1361
|
+
const invalidDevices = compatibility.projects.filter((project) => (project.name === "android-chrome" || project.name === "iphone-safari") && (project.mobile !== true || !project.device));
|
|
1362
|
+
const missingViewports = compatibility.projects.filter((project) => new Set(project.viewportWidths ?? []).size < 2);
|
|
1363
|
+
const compatibilityMissing = [
|
|
1364
|
+
...!compatibility.complete ? ["Compatibility test run did not complete", ...compatibility.errors] : [],
|
|
1365
|
+
...missingProjects.length ? [`Missing Playwright projects: ${missingProjects.join(", ")}`] : [],
|
|
1366
|
+
...incompleteProjects.map((project) => `${project.name} ran fewer than ${policy.compatibility.minimumTestsPerProject} tests`),
|
|
1367
|
+
...missingBrowserVersions.map((project) => `${project.name} did not record its browser engine version`),
|
|
1368
|
+
...invalidEngines.map((project) => `${project.name} used ${project.engine ?? "an unverified engine"} instead of ${expectedEngines[project.name]}`),
|
|
1369
|
+
...invalidDevices.map((project) => `${project.name} did not verify mobile emulation settings`),
|
|
1370
|
+
...missingViewports.map((project) => `${project.name} did not test two viewport widths`)
|
|
1371
|
+
];
|
|
1372
|
+
const failedProjects = compatibility.projects.filter((project) => project.failed > 0);
|
|
1373
|
+
const compatibilityDomain = compatibilityMissing.length ? { ...incomplete([...compatibilityMissing, ...failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`)], compatibility.tool, compatibility.version), matrix: compatibility.projects } : {
|
|
1374
|
+
status: failedProjects.length ? "fail" : "pass",
|
|
1375
|
+
issues: failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`),
|
|
1376
|
+
tool: compatibility.tool,
|
|
1377
|
+
version: compatibility.version,
|
|
1378
|
+
scanned: compatibility.projects.reduce((count, project) => count + project.passed + project.failed, 0),
|
|
1379
|
+
matrix: compatibility.projects
|
|
1380
|
+
};
|
|
1381
|
+
return { dependencySecurity, codeQuality, codeSecurity, compatibility: compatibilityDomain };
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1068
1384
|
// src/governance/assess.ts
|
|
1069
1385
|
var notEvaluated = [
|
|
1070
1386
|
"vulnerability databases",
|
|
@@ -1085,27 +1401,50 @@ function escapeHtml(value) {
|
|
|
1085
1401
|
return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
1086
1402
|
}
|
|
1087
1403
|
function renderAssessmentHtml(report) {
|
|
1404
|
+
const labels = {
|
|
1405
|
+
inventory: "App inventory",
|
|
1406
|
+
dependencies: "Dependency policy",
|
|
1407
|
+
architecture: "Architecture",
|
|
1408
|
+
dependencySecurity: "Dependency security",
|
|
1409
|
+
codeQuality: "Code quality",
|
|
1410
|
+
codeSecurity: "Code security",
|
|
1411
|
+
compatibility: "Browser compatibility"
|
|
1412
|
+
};
|
|
1413
|
+
const domainNames = report.apps.length ? Object.keys(report.apps[0].domains) : [];
|
|
1414
|
+
const overview = domainNames.map((name) => {
|
|
1415
|
+
const statuses = report.apps.map((app) => app.domains[name]?.status ?? "incomplete");
|
|
1416
|
+
const status = statusOf(statuses);
|
|
1417
|
+
const passed = statuses.filter((item) => item === "pass").length;
|
|
1418
|
+
return `<li><span>${escapeHtml(labels[name] ?? name)}</span><strong class="${status}">${escapeHtml(status)}</strong><small>${passed}/${report.apps.length} apps passed</small></li>`;
|
|
1419
|
+
}).join("");
|
|
1088
1420
|
const apps = report.apps.map((app) => {
|
|
1089
|
-
const domains = Object.entries(app.domains).map(([name, domain]) =>
|
|
1421
|
+
const domains = Object.entries(app.domains).map(([name, domain]) => {
|
|
1422
|
+
const evidence = domain;
|
|
1423
|
+
const identity = evidence.tool ? `<p><small>${escapeHtml(evidence.tool)} ${escapeHtml(evidence.version ?? "")} \xB7 scanned ${escapeHtml(evidence.scanned ?? "unknown")}</small></p>` : "";
|
|
1424
|
+
const metrics = evidence.metrics ? `<p><small>${Object.entries(evidence.metrics).map(([key, value]) => `${escapeHtml(key)}: ${escapeHtml(value)}`).join(" \xB7 ")}</small></p>` : "";
|
|
1425
|
+
const findings3 = evidence.findings?.length ? `<details><summary>${evidence.findings.length} finding(s)</summary><ul>${evidence.findings.map((item) => `<li><strong>${escapeHtml(item.severity)}</strong> ${escapeHtml(item.id)} \xB7 <code>${escapeHtml(item.subject)}</code> \u2014 ${escapeHtml(item.message)}${item.remediation ? ` \xB7 ${escapeHtml(item.remediation)}` : ""}</li>`).join("")}</ul></details>` : "";
|
|
1426
|
+
const matrix = evidence.matrix?.length ? `<div class="scroll"><table><thead><tr><th>Project</th><th>Passed</th><th>Failed</th><th>Skipped</th><th>Browser / device</th><th>Widths</th></tr></thead><tbody>${evidence.matrix.map((item) => `<tr><th scope="row">${escapeHtml(item.name)}</th><td>${item.passed}</td><td>${item.failed}</td><td>${item.skipped}</td><td>${escapeHtml([item.browserVersion, item.device].filter(Boolean).join(" \xB7 ") || "Not recorded")}</td><td>${escapeHtml(item.viewportWidths?.join(", ") ?? "Not recorded")}</td></tr>`).join("")}</tbody></table></div>` : "";
|
|
1427
|
+
return `<tr><th scope="row">${escapeHtml(labels[name] ?? name)}</th><td class="${domain.status}">${escapeHtml(domain.status)}</td><td>${identity}${metrics}${domain.issues.length ? `<ul>${domain.issues.map((issue) => `<li>${escapeHtml(issue)}</li>`).join("")}</ul>` : "No issues"}${findings3}${matrix}</td></tr>`;
|
|
1428
|
+
}).join("");
|
|
1090
1429
|
const architecture = app.domains.architecture;
|
|
1091
1430
|
const categoryRows = architecture.scoreBreakdown?.categories.map((category) => `<tr><th scope="row">${escapeHtml(category.id)}</th><td>${escapeHtml(category.weight)}%</td><td>${escapeHtml(category.score)}</td><td>${escapeHtml(category.findingCount)}</td></tr>`).join("") ?? "";
|
|
1092
|
-
const
|
|
1093
|
-
return `<section class="app"><h2>${escapeHtml(app.path)} <span class="${app.status}">${escapeHtml(app.status)}</span></h2><p>Profile: ${escapeHtml(app.profile)} \xB7 Source files scanned: ${architecture.filesScanned} \xB7 Local import edges: ${architecture.localEdges} \xB7 Architecture score: ${architecture.score ?? "unavailable"} \xB7 Findings: ${architecture.findings.length}</p><div class="scroll"><table><thead><tr><th>Domain</th><th>Status</th><th>Evidence</th></tr></thead><tbody>${domains}</tbody></table></div>${categoryRows ? `<details><summary>Score breakdown</summary><div class="scroll"><table><thead><tr><th>Category</th><th>Weight</th><th>Score</th><th>Findings</th></tr></thead><tbody>${categoryRows}</tbody></table></div></details>` : ""}${
|
|
1431
|
+
const findings2 = architecture.findings.map((finding) => `<tr><td>${escapeHtml(finding.severity)}</td><td>${escapeHtml(finding.ruleId)}</td><td><code>${escapeHtml(finding.file)}:${finding.line}</code></td><td>${escapeHtml(finding.evidence)}</td></tr>`).join("");
|
|
1432
|
+
return `<section class="app"><h2>${escapeHtml(app.path)} <span class="${app.status}">${escapeHtml(app.status)}</span></h2><p>Profile: ${escapeHtml(app.profile)} \xB7 Source files scanned: ${architecture.filesScanned} \xB7 Local import edges: ${architecture.localEdges} \xB7 Architecture score: ${architecture.score ?? "unavailable"} \xB7 Findings: ${architecture.findings.length}</p><div class="scroll"><table><thead><tr><th>Domain</th><th>Status</th><th>Evidence</th></tr></thead><tbody>${domains}</tbody></table></div>${categoryRows ? `<details><summary>Score breakdown</summary><div class="scroll"><table><thead><tr><th>Category</th><th>Weight</th><th>Score</th><th>Findings</th></tr></thead><tbody>${categoryRows}</tbody></table></div></details>` : ""}${findings2 ? `<details><summary>Architecture findings (${architecture.findings.length})</summary><div class="scroll"><table><thead><tr><th>Severity</th><th>Rule</th><th>Location</th><th>Evidence</th></tr></thead><tbody>${findings2}</tbody></table></div></details>` : ""}</section>`;
|
|
1094
1433
|
}).join("");
|
|
1095
1434
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>NKLattice governance assessment</title><style>
|
|
1096
|
-
:root{color-scheme:light;font:16px/1.5 system-ui,sans-serif;background:#
|
|
1097
|
-
</style></head><body><header><small>NKLATTICE \xB7 CONFIGURED POLICY ASSESSMENT</small><h1>Project governance <span class="${report.status}">${escapeHtml(report.status)}</span></h1><p>${report.apps.length} discovered app(s) \xB7 ${report.inventory.expectedApps} expected app(s)</p></header><section class="meta"><h2>Evidence identity</h2><p>Policy ${escapeHtml(report.policy.version)} \xB7 <code>${escapeHtml(report.policy.sha256)}</code></p><p>Policy source: <code>${escapeHtml(report.policy.source)}</code></p><p>Inventory <code>${escapeHtml(report.inventory.sha256 ?? "unavailable")}</code></p><p>Inventory source: <code>${escapeHtml(report.inventory.source)}</code></p>${report.issues.length ? `<ul>${report.issues.map((issue) => `<li>${escapeHtml(issue)}</li>`).join("")}</ul>` : ""}</section>${apps}<section class="meta"><h2>Assessment scope</h2><p>Evaluated: ${report.scope.domains.map(escapeHtml).join(", ")}.</p><p>Not evaluated: ${report.scope.notEvaluated.map(escapeHtml).join(", ")}.</p></section></body></html>
|
|
1435
|
+
: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}
|
|
1436
|
+
@media(max-width:800px){.domain-overview{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:480px){.domain-overview{grid-template-columns:1fr}}</style></head><body><header><small>NKLATTICE \xB7 CONFIGURED POLICY ASSESSMENT</small><h1>Project governance <span class="${report.status}">${escapeHtml(report.status)}</span></h1><p>${report.apps.length} discovered app(s) \xB7 ${report.inventory.expectedApps} expected app(s) \xB7 policy ${escapeHtml(report.policy.version)}</p></header><section class="overview"><h2>Assessment by domain</h2><p>Each status is based on the checked apps and fresh scanner evidence. Open an app below for findings and browser versions.</p><ul class="domain-overview">${overview}</ul></section><section class="meta"><h2>Evidence identity</h2><p>Policy ${escapeHtml(report.policy.version)} \xB7 <code>${escapeHtml(report.policy.sha256)}</code></p><p>Policy source: <code>${escapeHtml(report.policy.source)}</code></p><p>Inventory <code>${escapeHtml(report.inventory.sha256 ?? "unavailable")}</code></p><p>Inventory source: <code>${escapeHtml(basename(report.inventory.source))}</code></p>${report.evidence ? `<p>Scanners: ${escapeHtml(report.evidence.generatedAt)} \xB7 commit <code>${escapeHtml(report.evidence.commit)}</code> \xB7 input <code>${escapeHtml(report.evidence.inputSha256)}</code></p>` : "<p>Scanner evidence unavailable.</p>"}${report.issues.length ? `<ul>${report.issues.map((issue) => `<li>${escapeHtml(issue)}</li>`).join("")}</ul>` : ""}</section>${apps}<section class="meta"><h2>Assessment scope</h2><p>Evaluated: ${report.scope.domains.map(escapeHtml).join(", ")}.</p><p>Not evaluated: ${report.scope.notEvaluated.map(escapeHtml).join(", ")}.</p></section></body></html>
|
|
1098
1437
|
`;
|
|
1099
1438
|
}
|
|
1100
1439
|
async function assessGovernance(options) {
|
|
1101
|
-
const scanRoot =
|
|
1102
|
-
const workspaceRoot =
|
|
1103
|
-
const scanRelative =
|
|
1104
|
-
if (scanRelative === ".." || scanRelative.startsWith(`..${
|
|
1440
|
+
const scanRoot = resolve8(options.scanRoot);
|
|
1441
|
+
const workspaceRoot = resolve8(options.workspaceRoot ?? scanRoot);
|
|
1442
|
+
const scanRelative = relative7(workspaceRoot, scanRoot);
|
|
1443
|
+
if (scanRelative === ".." || scanRelative.startsWith(`..${sep6}`)) throw new Error("Scan root must be inside workspace root");
|
|
1105
1444
|
if (!options.policy.assessment) throw new Error("Policy assessment.architecture is required");
|
|
1106
1445
|
const assessment = parseAssessmentPolicy(options.policy.assessment);
|
|
1107
|
-
const outputDirectory =
|
|
1108
|
-
const inventorySource =
|
|
1446
|
+
const outputDirectory = resolve8(options.outputDirectory ?? join8(scanRoot, ".nklattice-governance"));
|
|
1447
|
+
const inventorySource = resolve8(options.inventory ?? join8(scanRoot, ".nklattice-apps.json"));
|
|
1109
1448
|
let inventory;
|
|
1110
1449
|
const issues = [];
|
|
1111
1450
|
try {
|
|
@@ -1124,9 +1463,22 @@ async function assessGovernance(options) {
|
|
|
1124
1463
|
if (options.expectedApps !== void 0 && apps.length !== options.expectedApps) {
|
|
1125
1464
|
issues.push(`Expected ${options.expectedApps} managed apps; discovered ${apps.length}`);
|
|
1126
1465
|
}
|
|
1466
|
+
let evidence;
|
|
1467
|
+
if (assessment.evidence) {
|
|
1468
|
+
if (!options.evidence && !options.trustedEvidence) issues.push("Scan evidence is required by the policy; run governance collect and pass --evidence");
|
|
1469
|
+
else try {
|
|
1470
|
+
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);
|
|
1471
|
+
} catch (error) {
|
|
1472
|
+
issues.push(`Scan evidence: ${error instanceof Error ? error.message : String(error)}`);
|
|
1473
|
+
}
|
|
1474
|
+
if (evidence) {
|
|
1475
|
+
const discovered = new Set(apps.map((app) => relative7(scanRoot, app.directory).replaceAll("\\", "/") || "."));
|
|
1476
|
+
for (const item of evidence.apps) if (!discovered.has(item.path)) issues.push(`Scan evidence contains unrecognized app ${item.path}`);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1127
1479
|
const results = [];
|
|
1128
1480
|
for (const app of apps) {
|
|
1129
|
-
const appPath =
|
|
1481
|
+
const appPath = relative7(scanRoot, app.directory).replaceAll("\\", "/") || ".";
|
|
1130
1482
|
const inventoryIssues = issues.filter((issue) => issue.includes(appPath) || issue.startsWith("Inventory:") || issue.startsWith("Discovery:"));
|
|
1131
1483
|
const inventoryDomain = { status: inventoryIssues.length ? "incomplete" : "pass", issues: inventoryIssues };
|
|
1132
1484
|
const dependencyDomain = { status: "pass", issues: [], findings: [] };
|
|
@@ -1179,7 +1531,8 @@ async function assessGovernance(options) {
|
|
|
1179
1531
|
architectureDomain.status = "incomplete";
|
|
1180
1532
|
architectureDomain.issues.push(error instanceof Error ? error.message : String(error));
|
|
1181
1533
|
}
|
|
1182
|
-
const
|
|
1534
|
+
const extra = assessment.evidence ? evaluateAppEvidence(evidence?.apps.find((item) => item.path === appPath && item.profile === app.profile), assessment.evidence) : {};
|
|
1535
|
+
const domains = { inventory: inventoryDomain, dependencies: dependencyDomain, architecture: architectureDomain, ...extra };
|
|
1183
1536
|
results.push({ path: appPath, profile: app.profile, status: statusOf(Object.values(domains).map((domain) => domain.status)), domains });
|
|
1184
1537
|
}
|
|
1185
1538
|
const report = {
|
|
@@ -1187,15 +1540,19 @@ async function assessGovernance(options) {
|
|
|
1187
1540
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1188
1541
|
status: issues.length ? "incomplete" : statusOf(results.map((app) => app.status)),
|
|
1189
1542
|
policy: { version: options.policy.policyVersion, source: options.policy.source, sha256: options.policy.sha256 },
|
|
1543
|
+
...evidence ? { evidence: { source: options.evidence ? resolve8(options.evidence) : "in-process collector", generatedAt: evidence.generatedAt, commit: evidence.commit, inputSha256: evidence.inputSha256 } } : {},
|
|
1190
1544
|
inventory: { source: inventorySource, sha256: inventory?.sha256, expectedApps: inventory?.apps.length ?? 0, discoveredApps: apps.length },
|
|
1191
|
-
scope: {
|
|
1545
|
+
scope: {
|
|
1546
|
+
domains: ["explicit app inventory", "dependency minimums", "source architecture", ...assessment.evidence ? ["known dependency advisories", "source quality", "source security", "browser and mobile emulation"] : []],
|
|
1547
|
+
notEvaluated: assessment.evidence ? ["licenses", "physical Android/iOS devices", "runtime performance", "cryptographic evidence attestation", "branch protection", "repository access controls", "cross-repository rollout status"] : notEvaluated
|
|
1548
|
+
},
|
|
1192
1549
|
issues,
|
|
1193
1550
|
apps: results
|
|
1194
1551
|
};
|
|
1195
1552
|
await mkdir(outputDirectory, { recursive: true });
|
|
1196
|
-
await writeFile(
|
|
1553
|
+
await writeFile(join8(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
|
|
1197
1554
|
`);
|
|
1198
|
-
await writeFile(
|
|
1555
|
+
await writeFile(join8(outputDirectory, "report.html"), renderAssessmentHtml(report));
|
|
1199
1556
|
return report;
|
|
1200
1557
|
}
|
|
1201
1558
|
export {
|