lula2 0.5.0 → 0.5.1-nightly.1
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 +8 -0
- package/dist/_app/immutable/assets/{0.Dfpe5goI.css → 0.CJjXKESY.css} +1 -1
- package/dist/_app/immutable/chunks/{BCoAVBju.js → BdFxWaVJ.js} +1 -1
- package/dist/_app/immutable/chunks/Bvop-7hR.js +2 -0
- package/dist/_app/immutable/chunks/{RxRVmDPY.js → Bwv82F42.js} +1 -1
- package/dist/_app/immutable/chunks/{BN4ish10.js → CPEw6sZY.js} +1 -1
- package/dist/_app/immutable/chunks/{BtwnwKFn.js → CXdZVXJf.js} +1 -1
- package/dist/_app/immutable/chunks/{CvviX0Gc.js → DJT-CwHK.js} +2 -2
- package/dist/_app/immutable/chunks/GToHgjp8.js +2 -0
- package/dist/_app/immutable/chunks/{DBN1r830.js → R1gz3SOr.js} +1 -1
- package/dist/_app/immutable/chunks/{D6NghQtU.js → zYrdvxnm.js} +1 -1
- package/dist/_app/immutable/entry/{app.DzkPo2gz.js → app.BSaCFUch.js} +2 -2
- package/dist/_app/immutable/entry/start.BEhRFQd5.js +1 -0
- package/dist/_app/immutable/nodes/{0.BcLFbXUF.js → 0.CVjgNaoX.js} +1 -1
- package/dist/_app/immutable/nodes/{1.8i0FdqjI.js → 1.DuZUhMZB.js} +1 -1
- package/dist/_app/immutable/nodes/{2.jit_WwBQ.js → 2.B5vQqMFa.js} +1 -1
- package/dist/_app/immutable/nodes/{3.CfrrpHWE.js → 3.B4oUQOz5.js} +1 -1
- package/dist/_app/immutable/nodes/{4.BZ-_Jk1v.js → 4.BE7_moKh.js} +1 -1
- package/dist/_app/version.json +1 -1
- package/dist/cli/commands/crawl.js +53 -1
- package/dist/cli/commands/ui.js +356 -308
- package/dist/cli/server/index.js +356 -308
- package/dist/cli/server/server.js +356 -308
- package/dist/cli/server/spreadsheetRoutes.js +343 -295
- package/dist/cli/server/websocketServer.js +356 -308
- package/dist/index.html +10 -10
- package/dist/index.js +408 -309
- package/package.json +22 -23
- package/src/lib/websocket.ts +106 -76
- package/dist/_app/immutable/chunks/B3DV5AB9.js +0 -2
- package/dist/_app/immutable/chunks/BIY1u1I9.js +0 -2
- package/dist/_app/immutable/entry/start.BUtu9VJ8.js +0 -1
package/dist/index.js
CHANGED
|
@@ -2845,8 +2845,20 @@ var init_serverState = __esm({
|
|
|
2845
2845
|
// cli/server/spreadsheetRoutes.ts
|
|
2846
2846
|
var spreadsheetRoutes_exports = {};
|
|
2847
2847
|
__export(spreadsheetRoutes_exports, {
|
|
2848
|
+
applyNamingConvention: () => applyNamingConvention,
|
|
2849
|
+
buildFieldSchema: () => buildFieldSchema,
|
|
2850
|
+
createOutputStructure: () => createOutputStructure,
|
|
2848
2851
|
default: () => spreadsheetRoutes_default,
|
|
2849
|
-
|
|
2852
|
+
detectValueType: () => detectValueType,
|
|
2853
|
+
extractFamilyFromControlId: () => extractFamilyFromControlId,
|
|
2854
|
+
parseCSV: () => parseCSV,
|
|
2855
|
+
parseUploadedFile: () => parseUploadedFile,
|
|
2856
|
+
processImportParameters: () => processImportParameters,
|
|
2857
|
+
processSpreadsheetData: () => processSpreadsheetData,
|
|
2858
|
+
scanControlSets: () => scanControlSets,
|
|
2859
|
+
toCamelCase: () => toCamelCase,
|
|
2860
|
+
toKebabCase: () => toKebabCase,
|
|
2861
|
+
toSnakeCase: () => toSnakeCase
|
|
2850
2862
|
});
|
|
2851
2863
|
import crypto from "crypto";
|
|
2852
2864
|
import { parse as parseCSVSync } from "csv-parse/sync";
|
|
@@ -2895,6 +2907,334 @@ async function scanControlSets() {
|
|
|
2895
2907
|
}).filter((cs) => cs !== null);
|
|
2896
2908
|
return { controlSets };
|
|
2897
2909
|
}
|
|
2910
|
+
function processImportParameters(reqBody) {
|
|
2911
|
+
const {
|
|
2912
|
+
controlIdField = "Control ID",
|
|
2913
|
+
startRow = "1",
|
|
2914
|
+
controlSetName = "Imported Control Set",
|
|
2915
|
+
controlSetDescription = "Imported from spreadsheet"
|
|
2916
|
+
} = reqBody;
|
|
2917
|
+
let justificationFields = [];
|
|
2918
|
+
if (reqBody.justificationFields) {
|
|
2919
|
+
try {
|
|
2920
|
+
justificationFields = JSON.parse(reqBody.justificationFields);
|
|
2921
|
+
debug("Justification fields received:", justificationFields);
|
|
2922
|
+
} catch (e) {
|
|
2923
|
+
console.error("Failed to parse justification fields:", e);
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
let frontendFieldSchema = null;
|
|
2927
|
+
if (reqBody.fieldSchema) {
|
|
2928
|
+
try {
|
|
2929
|
+
frontendFieldSchema = JSON.parse(reqBody.fieldSchema);
|
|
2930
|
+
} catch (e) {
|
|
2931
|
+
console.error("Failed to parse fieldSchema:", e);
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
debug("Import parameters received:", {
|
|
2935
|
+
controlIdField,
|
|
2936
|
+
startRow,
|
|
2937
|
+
controlSetName,
|
|
2938
|
+
controlSetDescription
|
|
2939
|
+
});
|
|
2940
|
+
return {
|
|
2941
|
+
controlIdField,
|
|
2942
|
+
startRow,
|
|
2943
|
+
controlSetName,
|
|
2944
|
+
controlSetDescription,
|
|
2945
|
+
justificationFields,
|
|
2946
|
+
namingConvention: "kebab-case",
|
|
2947
|
+
skipEmpty: true,
|
|
2948
|
+
skipEmptyRows: true,
|
|
2949
|
+
frontendFieldSchema
|
|
2950
|
+
};
|
|
2951
|
+
}
|
|
2952
|
+
async function parseUploadedFile(file) {
|
|
2953
|
+
const fileName = file.originalname || "";
|
|
2954
|
+
const isCSV = fileName.toLowerCase().endsWith(".csv");
|
|
2955
|
+
let rawData = [];
|
|
2956
|
+
if (isCSV) {
|
|
2957
|
+
const csvContent = file.buffer.toString("utf-8");
|
|
2958
|
+
rawData = parseCSV(csvContent);
|
|
2959
|
+
} else {
|
|
2960
|
+
const workbook = new ExcelJS.Workbook();
|
|
2961
|
+
const buffer = Buffer.from(file.buffer);
|
|
2962
|
+
await workbook.xlsx.load(buffer);
|
|
2963
|
+
const worksheet = workbook.worksheets[0];
|
|
2964
|
+
if (!worksheet) {
|
|
2965
|
+
throw new Error("No worksheet found in file");
|
|
2966
|
+
}
|
|
2967
|
+
worksheet.eachRow({ includeEmpty: true }, (row, rowNumber) => {
|
|
2968
|
+
const rowData = [];
|
|
2969
|
+
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
|
2970
|
+
rowData[colNumber - 1] = cell.value;
|
|
2971
|
+
});
|
|
2972
|
+
rawData[rowNumber - 1] = rowData;
|
|
2973
|
+
});
|
|
2974
|
+
}
|
|
2975
|
+
return rawData;
|
|
2976
|
+
}
|
|
2977
|
+
function processSpreadsheetData(rawData, headers, startRowIndex, params) {
|
|
2978
|
+
const controls = [];
|
|
2979
|
+
const families = /* @__PURE__ */ new Map();
|
|
2980
|
+
const fieldMetadata = /* @__PURE__ */ new Map();
|
|
2981
|
+
headers.forEach((header) => {
|
|
2982
|
+
if (header) {
|
|
2983
|
+
const cleanName = applyNamingConvention(header, params.namingConvention);
|
|
2984
|
+
fieldMetadata.set(cleanName, {
|
|
2985
|
+
originalName: header,
|
|
2986
|
+
cleanName,
|
|
2987
|
+
type: "string",
|
|
2988
|
+
maxLength: 0,
|
|
2989
|
+
hasMultipleLines: false,
|
|
2990
|
+
uniqueValues: /* @__PURE__ */ new Set(),
|
|
2991
|
+
emptyCount: 0,
|
|
2992
|
+
totalCount: 0,
|
|
2993
|
+
examples: []
|
|
2994
|
+
});
|
|
2995
|
+
}
|
|
2996
|
+
});
|
|
2997
|
+
for (let i = startRowIndex + 1; i < rawData.length; i++) {
|
|
2998
|
+
const row = rawData[i];
|
|
2999
|
+
if (!row || row.length === 0) continue;
|
|
3000
|
+
const control = {};
|
|
3001
|
+
let hasData = false;
|
|
3002
|
+
headers.forEach((header, index) => {
|
|
3003
|
+
if (header && row[index] !== void 0 && row[index] !== null) {
|
|
3004
|
+
const value = typeof row[index] === "string" ? row[index].trim() : row[index];
|
|
3005
|
+
const fieldName = applyNamingConvention(header, params.namingConvention);
|
|
3006
|
+
const metadata = fieldMetadata.get(fieldName);
|
|
3007
|
+
metadata.totalCount++;
|
|
3008
|
+
if (value === "" || value === null || value === void 0) {
|
|
3009
|
+
metadata.emptyCount++;
|
|
3010
|
+
if (params.skipEmpty) return;
|
|
3011
|
+
} else {
|
|
3012
|
+
const normalizedValue = typeof value === "string" ? value.trim() : value;
|
|
3013
|
+
if (normalizedValue !== "") {
|
|
3014
|
+
metadata.uniqueValues.add(normalizedValue);
|
|
3015
|
+
}
|
|
3016
|
+
const valueType = detectValueType(value);
|
|
3017
|
+
if (metadata.type === "string" || metadata.totalCount === 1) {
|
|
3018
|
+
metadata.type = valueType;
|
|
3019
|
+
} else if (metadata.type !== valueType) {
|
|
3020
|
+
metadata.type = "mixed";
|
|
3021
|
+
}
|
|
3022
|
+
if (typeof value === "string") {
|
|
3023
|
+
const length = value.length;
|
|
3024
|
+
if (length > metadata.maxLength) {
|
|
3025
|
+
metadata.maxLength = length;
|
|
3026
|
+
}
|
|
3027
|
+
if (value.includes("\n") || length > 100) {
|
|
3028
|
+
metadata.hasMultipleLines = true;
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
if (metadata.examples.length < 3 && normalizedValue !== "") {
|
|
3032
|
+
metadata.examples.push(normalizedValue);
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
control[fieldName] = value;
|
|
3036
|
+
hasData = true;
|
|
3037
|
+
}
|
|
3038
|
+
});
|
|
3039
|
+
if (hasData && (!params.skipEmptyRows || Object.keys(control).length > 0)) {
|
|
3040
|
+
const controlIdFieldName = applyNamingConvention(
|
|
3041
|
+
params.controlIdField,
|
|
3042
|
+
params.namingConvention
|
|
3043
|
+
);
|
|
3044
|
+
const controlId = control[controlIdFieldName];
|
|
3045
|
+
if (!controlId) {
|
|
3046
|
+
continue;
|
|
3047
|
+
}
|
|
3048
|
+
const family = extractFamilyFromControlId(controlId);
|
|
3049
|
+
control.family = family;
|
|
3050
|
+
controls.push(control);
|
|
3051
|
+
if (!families.has(family)) {
|
|
3052
|
+
families.set(family, []);
|
|
3053
|
+
}
|
|
3054
|
+
families.get(family).push(control);
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
return { controls, families, fieldMetadata };
|
|
3058
|
+
}
|
|
3059
|
+
function buildFieldSchema(fieldMetadata, controls, params, families) {
|
|
3060
|
+
const fields = {};
|
|
3061
|
+
let displayOrder = 1;
|
|
3062
|
+
const controlIdFieldNameClean = applyNamingConvention(
|
|
3063
|
+
params.controlIdField,
|
|
3064
|
+
params.namingConvention
|
|
3065
|
+
);
|
|
3066
|
+
const familyOptions = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN").sort();
|
|
3067
|
+
fields["family"] = {
|
|
3068
|
+
type: "string",
|
|
3069
|
+
ui_type: familyOptions.length <= 50 ? "select" : "short_text",
|
|
3070
|
+
is_array: false,
|
|
3071
|
+
max_length: 10,
|
|
3072
|
+
usage_count: controls.length,
|
|
3073
|
+
usage_percentage: 100,
|
|
3074
|
+
required: true,
|
|
3075
|
+
visible: true,
|
|
3076
|
+
show_in_table: true,
|
|
3077
|
+
editable: false,
|
|
3078
|
+
display_order: displayOrder++,
|
|
3079
|
+
category: "core",
|
|
3080
|
+
tab: "overview"
|
|
3081
|
+
};
|
|
3082
|
+
if (familyOptions.length <= 50) {
|
|
3083
|
+
fields["family"].options = familyOptions;
|
|
3084
|
+
}
|
|
3085
|
+
fieldMetadata.forEach((metadata, fieldName) => {
|
|
3086
|
+
if (fieldName === "family" || fieldName === "id" && controlIdFieldNameClean !== "id") {
|
|
3087
|
+
return;
|
|
3088
|
+
}
|
|
3089
|
+
const frontendConfig = params.frontendFieldSchema?.find((f) => f.fieldName === fieldName);
|
|
3090
|
+
if (params.frontendFieldSchema && !frontendConfig) {
|
|
3091
|
+
return;
|
|
3092
|
+
}
|
|
3093
|
+
const usageCount = metadata.totalCount - metadata.emptyCount;
|
|
3094
|
+
const usagePercentage = metadata.totalCount > 0 ? Math.round(usageCount / metadata.totalCount * 100) : 0;
|
|
3095
|
+
let uiType = "short_text";
|
|
3096
|
+
const nonEmptyCount = metadata.totalCount - metadata.emptyCount;
|
|
3097
|
+
const isDropdownCandidate = metadata.uniqueValues.size > 0 && metadata.uniqueValues.size <= 20 && // Max 20 unique values for dropdown
|
|
3098
|
+
nonEmptyCount >= 10 && // At least 10 non-empty values to be meaningful
|
|
3099
|
+
metadata.maxLength <= 100 && // Reasonably short values only
|
|
3100
|
+
metadata.uniqueValues.size / nonEmptyCount <= 0.3;
|
|
3101
|
+
if (metadata.hasMultipleLines || metadata.maxLength > 500) {
|
|
3102
|
+
uiType = "textarea";
|
|
3103
|
+
} else if (isDropdownCandidate) {
|
|
3104
|
+
uiType = "select";
|
|
3105
|
+
} else if (metadata.type === "boolean") {
|
|
3106
|
+
uiType = "checkbox";
|
|
3107
|
+
} else if (metadata.type === "number") {
|
|
3108
|
+
uiType = "number";
|
|
3109
|
+
} else if (metadata.type === "date") {
|
|
3110
|
+
uiType = "date";
|
|
3111
|
+
} else if (metadata.maxLength <= 50) {
|
|
3112
|
+
uiType = "short_text";
|
|
3113
|
+
} else if (metadata.maxLength <= 200) {
|
|
3114
|
+
uiType = "medium_text";
|
|
3115
|
+
} else {
|
|
3116
|
+
uiType = "long_text";
|
|
3117
|
+
}
|
|
3118
|
+
let category = frontendConfig?.category || "custom";
|
|
3119
|
+
if (!frontendConfig) {
|
|
3120
|
+
if (fieldName.includes("status") || fieldName.includes("state")) {
|
|
3121
|
+
category = "compliance";
|
|
3122
|
+
} else if (fieldName.includes("title") || fieldName.includes("name") || fieldName.includes("description")) {
|
|
3123
|
+
category = "core";
|
|
3124
|
+
} else if (fieldName.includes("note") || fieldName.includes("comment")) {
|
|
3125
|
+
category = "notes";
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
const isControlIdField = fieldName === controlIdFieldNameClean;
|
|
3129
|
+
const fieldDef = {
|
|
3130
|
+
type: metadata.type,
|
|
3131
|
+
ui_type: uiType,
|
|
3132
|
+
is_array: false,
|
|
3133
|
+
max_length: metadata.maxLength,
|
|
3134
|
+
usage_count: usageCount,
|
|
3135
|
+
usage_percentage: usagePercentage,
|
|
3136
|
+
required: isControlIdField ? true : frontendConfig?.required ?? usagePercentage > 95,
|
|
3137
|
+
visible: frontendConfig?.tab !== "hidden",
|
|
3138
|
+
show_in_table: isControlIdField ? true : metadata.maxLength <= 100 && usagePercentage > 30,
|
|
3139
|
+
editable: isControlIdField ? false : true,
|
|
3140
|
+
display_order: isControlIdField ? 1 : frontendConfig?.displayOrder ?? displayOrder++,
|
|
3141
|
+
category: isControlIdField ? "core" : category,
|
|
3142
|
+
tab: isControlIdField ? "overview" : frontendConfig?.tab || void 0
|
|
3143
|
+
};
|
|
3144
|
+
if (uiType === "select") {
|
|
3145
|
+
fieldDef.options = Array.from(metadata.uniqueValues).sort();
|
|
3146
|
+
}
|
|
3147
|
+
if (frontendConfig?.originalName || metadata.originalName) {
|
|
3148
|
+
fieldDef.original_name = frontendConfig?.originalName || metadata.originalName;
|
|
3149
|
+
}
|
|
3150
|
+
fields[fieldName] = fieldDef;
|
|
3151
|
+
});
|
|
3152
|
+
return {
|
|
3153
|
+
fields,
|
|
3154
|
+
total_controls: controls.length,
|
|
3155
|
+
analyzed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3156
|
+
};
|
|
3157
|
+
}
|
|
3158
|
+
async function createOutputStructure(processedData, fieldSchema, params) {
|
|
3159
|
+
const { controls, families } = processedData;
|
|
3160
|
+
const state = getServerState();
|
|
3161
|
+
const folderName = toKebabCase(params.controlSetName || "imported-controls");
|
|
3162
|
+
const baseDir = join4(state.CONTROL_SET_DIR || process.cwd(), folderName);
|
|
3163
|
+
if (!existsSync3(baseDir)) {
|
|
3164
|
+
mkdirSync2(baseDir, { recursive: true });
|
|
3165
|
+
}
|
|
3166
|
+
const uniqueFamilies = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN");
|
|
3167
|
+
const controlIdFieldNameClean = applyNamingConvention(
|
|
3168
|
+
params.controlIdField,
|
|
3169
|
+
params.namingConvention
|
|
3170
|
+
);
|
|
3171
|
+
const controlSetData = {
|
|
3172
|
+
name: params.controlSetName,
|
|
3173
|
+
description: params.controlSetDescription,
|
|
3174
|
+
version: "1.0.0",
|
|
3175
|
+
control_id_field: controlIdFieldNameClean,
|
|
3176
|
+
controlCount: controls.length,
|
|
3177
|
+
families: uniqueFamilies,
|
|
3178
|
+
fieldSchema
|
|
3179
|
+
};
|
|
3180
|
+
writeFileSync2(join4(baseDir, "lula.yaml"), yaml4.dump(controlSetData));
|
|
3181
|
+
const controlsDir = join4(baseDir, "controls");
|
|
3182
|
+
const mappingsDir = join4(baseDir, "mappings");
|
|
3183
|
+
families.forEach((familyControls, family) => {
|
|
3184
|
+
const familyDir = join4(controlsDir, family);
|
|
3185
|
+
const familyMappingsDir = join4(mappingsDir, family);
|
|
3186
|
+
if (!existsSync3(familyDir)) {
|
|
3187
|
+
mkdirSync2(familyDir, { recursive: true });
|
|
3188
|
+
}
|
|
3189
|
+
if (!existsSync3(familyMappingsDir)) {
|
|
3190
|
+
mkdirSync2(familyMappingsDir, { recursive: true });
|
|
3191
|
+
}
|
|
3192
|
+
familyControls.forEach((control) => {
|
|
3193
|
+
const controlId = control[controlIdFieldNameClean];
|
|
3194
|
+
if (!controlId) {
|
|
3195
|
+
console.error("Missing control ID for control:", control);
|
|
3196
|
+
return;
|
|
3197
|
+
}
|
|
3198
|
+
const controlIdStr = String(controlId).slice(0, 50);
|
|
3199
|
+
const fileName = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}.yaml`;
|
|
3200
|
+
const filePath = join4(familyDir, fileName);
|
|
3201
|
+
const mappingFileName = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}-mappings.yaml`;
|
|
3202
|
+
const mappingFilePath = join4(familyMappingsDir, mappingFileName);
|
|
3203
|
+
const filteredControl = {};
|
|
3204
|
+
const mappingData = {
|
|
3205
|
+
control_id: controlIdStr,
|
|
3206
|
+
justification: "",
|
|
3207
|
+
uuid: crypto.randomUUID()
|
|
3208
|
+
};
|
|
3209
|
+
const justificationContents = [];
|
|
3210
|
+
if (control.family !== void 0) {
|
|
3211
|
+
filteredControl.family = control.family;
|
|
3212
|
+
}
|
|
3213
|
+
Object.keys(control).forEach((fieldName) => {
|
|
3214
|
+
if (fieldName === "family") return;
|
|
3215
|
+
if (params.justificationFields.includes(fieldName) && control[fieldName] !== void 0 && control[fieldName] !== null) {
|
|
3216
|
+
justificationContents.push(control[fieldName]);
|
|
3217
|
+
}
|
|
3218
|
+
const isInFrontendSchema = params.frontendFieldSchema?.some(
|
|
3219
|
+
(f) => f.fieldName === fieldName
|
|
3220
|
+
);
|
|
3221
|
+
const isInFieldsMetadata = fieldSchema.fields.hasOwnProperty(fieldName);
|
|
3222
|
+
if (isInFrontendSchema || isInFieldsMetadata) {
|
|
3223
|
+
filteredControl[fieldName] = control[fieldName];
|
|
3224
|
+
}
|
|
3225
|
+
});
|
|
3226
|
+
writeFileSync2(filePath, yaml4.dump(filteredControl));
|
|
3227
|
+
if (justificationContents.length > 0) {
|
|
3228
|
+
mappingData.justification = justificationContents.join("\n\n");
|
|
3229
|
+
}
|
|
3230
|
+
if (mappingData.justification && mappingData.justification.trim() !== "") {
|
|
3231
|
+
const mappingArray = [mappingData];
|
|
3232
|
+
writeFileSync2(mappingFilePath, yaml4.dump(mappingArray));
|
|
3233
|
+
}
|
|
3234
|
+
});
|
|
3235
|
+
});
|
|
3236
|
+
return { folderName };
|
|
3237
|
+
}
|
|
2898
3238
|
function applyNamingConvention(fieldName, convention) {
|
|
2899
3239
|
if (!fieldName) return fieldName;
|
|
2900
3240
|
const cleanedName = fieldName.trim();
|
|
@@ -3275,53 +3615,9 @@ var init_spreadsheetRoutes = __esm({
|
|
|
3275
3615
|
if (!req.file) {
|
|
3276
3616
|
return res.status(400).json({ error: "No file uploaded" });
|
|
3277
3617
|
}
|
|
3278
|
-
const
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
controlSetName = "Imported Control Set",
|
|
3282
|
-
controlSetDescription = "Imported from spreadsheet"
|
|
3283
|
-
} = req.body;
|
|
3284
|
-
let justificationFields = [];
|
|
3285
|
-
if (req.body.justificationFields) {
|
|
3286
|
-
try {
|
|
3287
|
-
justificationFields = JSON.parse(req.body.justificationFields);
|
|
3288
|
-
debug("Justification fields received:", justificationFields);
|
|
3289
|
-
} catch (e) {
|
|
3290
|
-
console.error("Failed to parse justification fields:", e);
|
|
3291
|
-
}
|
|
3292
|
-
}
|
|
3293
|
-
debug("Import parameters received:", {
|
|
3294
|
-
controlIdField,
|
|
3295
|
-
startRow,
|
|
3296
|
-
controlSetName,
|
|
3297
|
-
controlSetDescription
|
|
3298
|
-
});
|
|
3299
|
-
const namingConvention = "kebab-case";
|
|
3300
|
-
const skipEmpty = true;
|
|
3301
|
-
const skipEmptyRows = true;
|
|
3302
|
-
const fileName = req.file.originalname || "";
|
|
3303
|
-
const isCSV = fileName.toLowerCase().endsWith(".csv");
|
|
3304
|
-
let rawData = [];
|
|
3305
|
-
if (isCSV) {
|
|
3306
|
-
const csvContent = req.file.buffer.toString("utf-8");
|
|
3307
|
-
rawData = parseCSV(csvContent);
|
|
3308
|
-
} else {
|
|
3309
|
-
const workbook = new ExcelJS.Workbook();
|
|
3310
|
-
const buffer = Buffer.from(req.file.buffer);
|
|
3311
|
-
await workbook.xlsx.load(buffer);
|
|
3312
|
-
const worksheet = workbook.worksheets[0];
|
|
3313
|
-
if (!worksheet) {
|
|
3314
|
-
return res.status(400).json({ error: "No worksheet found in file" });
|
|
3315
|
-
}
|
|
3316
|
-
worksheet.eachRow({ includeEmpty: true }, (row, rowNumber) => {
|
|
3317
|
-
const rowData = [];
|
|
3318
|
-
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
|
3319
|
-
rowData[colNumber - 1] = cell.value;
|
|
3320
|
-
});
|
|
3321
|
-
rawData[rowNumber - 1] = rowData;
|
|
3322
|
-
});
|
|
3323
|
-
}
|
|
3324
|
-
const startRowIndex = parseInt(startRow) - 1;
|
|
3618
|
+
const params = processImportParameters(req.body);
|
|
3619
|
+
const rawData = await parseUploadedFile(req.file);
|
|
3620
|
+
const startRowIndex = parseInt(params.startRow) - 1;
|
|
3325
3621
|
if (rawData.length <= startRowIndex) {
|
|
3326
3622
|
return res.status(400).json({ error: "Start row exceeds sheet data" });
|
|
3327
3623
|
}
|
|
@@ -3332,269 +3628,21 @@ var init_spreadsheetRoutes = __esm({
|
|
|
3332
3628
|
debug("Headers found:", headers);
|
|
3333
3629
|
debug(
|
|
3334
3630
|
"After conversion, looking for control ID field:",
|
|
3335
|
-
applyNamingConvention(controlIdField, namingConvention)
|
|
3631
|
+
applyNamingConvention(params.controlIdField, params.namingConvention)
|
|
3336
3632
|
);
|
|
3337
|
-
const
|
|
3338
|
-
const
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
cleanName,
|
|
3346
|
-
type: "string",
|
|
3347
|
-
maxLength: 0,
|
|
3348
|
-
hasMultipleLines: false,
|
|
3349
|
-
uniqueValues: /* @__PURE__ */ new Set(),
|
|
3350
|
-
emptyCount: 0,
|
|
3351
|
-
totalCount: 0,
|
|
3352
|
-
examples: []
|
|
3353
|
-
});
|
|
3354
|
-
}
|
|
3355
|
-
});
|
|
3356
|
-
for (let i = startRowIndex + 1; i < rawData.length; i++) {
|
|
3357
|
-
const row = rawData[i];
|
|
3358
|
-
if (!row || row.length === 0) continue;
|
|
3359
|
-
const control = {};
|
|
3360
|
-
let hasData = false;
|
|
3361
|
-
headers.forEach((header, index) => {
|
|
3362
|
-
if (header && row[index] !== void 0 && row[index] !== null) {
|
|
3363
|
-
const value = typeof row[index] === "string" ? row[index].trim() : row[index];
|
|
3364
|
-
const fieldName = applyNamingConvention(header, namingConvention);
|
|
3365
|
-
const metadata = fieldMetadata.get(fieldName);
|
|
3366
|
-
metadata.totalCount++;
|
|
3367
|
-
if (value === "" || value === null || value === void 0) {
|
|
3368
|
-
metadata.emptyCount++;
|
|
3369
|
-
if (skipEmpty) return;
|
|
3370
|
-
} else {
|
|
3371
|
-
const normalizedValue = typeof value === "string" ? value.trim() : value;
|
|
3372
|
-
if (normalizedValue !== "") {
|
|
3373
|
-
metadata.uniqueValues.add(normalizedValue);
|
|
3374
|
-
}
|
|
3375
|
-
const valueType = detectValueType(value);
|
|
3376
|
-
if (metadata.type === "string" || metadata.totalCount === 1) {
|
|
3377
|
-
metadata.type = valueType;
|
|
3378
|
-
} else if (metadata.type !== valueType) {
|
|
3379
|
-
metadata.type = "mixed";
|
|
3380
|
-
}
|
|
3381
|
-
if (typeof value === "string") {
|
|
3382
|
-
const length = value.length;
|
|
3383
|
-
if (length > metadata.maxLength) {
|
|
3384
|
-
metadata.maxLength = length;
|
|
3385
|
-
}
|
|
3386
|
-
if (value.includes("\n") || length > 100) {
|
|
3387
|
-
metadata.hasMultipleLines = true;
|
|
3388
|
-
}
|
|
3389
|
-
}
|
|
3390
|
-
if (metadata.examples.length < 3 && normalizedValue !== "") {
|
|
3391
|
-
metadata.examples.push(normalizedValue);
|
|
3392
|
-
}
|
|
3393
|
-
}
|
|
3394
|
-
control[fieldName] = value;
|
|
3395
|
-
hasData = true;
|
|
3396
|
-
}
|
|
3397
|
-
});
|
|
3398
|
-
if (hasData && (!skipEmptyRows || Object.keys(control).length > 0)) {
|
|
3399
|
-
const controlIdFieldName = applyNamingConvention(controlIdField, namingConvention);
|
|
3400
|
-
const controlId = control[controlIdFieldName];
|
|
3401
|
-
if (!controlId) {
|
|
3402
|
-
continue;
|
|
3403
|
-
}
|
|
3404
|
-
const family = extractFamilyFromControlId(controlId);
|
|
3405
|
-
control.family = family;
|
|
3406
|
-
controls.push(control);
|
|
3407
|
-
if (!families.has(family)) {
|
|
3408
|
-
families.set(family, []);
|
|
3409
|
-
}
|
|
3410
|
-
families.get(family).push(control);
|
|
3411
|
-
}
|
|
3412
|
-
}
|
|
3413
|
-
const state = getServerState();
|
|
3414
|
-
const folderName = toKebabCase(controlSetName || "imported-controls");
|
|
3415
|
-
const baseDir = join4(state.CONTROL_SET_DIR || process.cwd(), folderName);
|
|
3416
|
-
if (!existsSync3(baseDir)) {
|
|
3417
|
-
mkdirSync2(baseDir, { recursive: true });
|
|
3418
|
-
}
|
|
3419
|
-
const uniqueFamilies = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN");
|
|
3420
|
-
let frontendFieldSchema = null;
|
|
3421
|
-
if (req.body.fieldSchema) {
|
|
3422
|
-
try {
|
|
3423
|
-
frontendFieldSchema = JSON.parse(req.body.fieldSchema);
|
|
3424
|
-
} catch {
|
|
3425
|
-
}
|
|
3426
|
-
}
|
|
3427
|
-
const fields = {};
|
|
3428
|
-
let displayOrder = 1;
|
|
3429
|
-
const controlIdFieldNameClean = applyNamingConvention(controlIdField, namingConvention);
|
|
3430
|
-
const familyOptions = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN").sort();
|
|
3431
|
-
fields["family"] = {
|
|
3432
|
-
type: "string",
|
|
3433
|
-
ui_type: familyOptions.length <= 50 ? "select" : "short_text",
|
|
3434
|
-
// Make select if reasonable number of families
|
|
3435
|
-
is_array: false,
|
|
3436
|
-
max_length: 10,
|
|
3437
|
-
usage_count: controls.length,
|
|
3438
|
-
usage_percentage: 100,
|
|
3439
|
-
required: true,
|
|
3440
|
-
visible: true,
|
|
3441
|
-
show_in_table: true,
|
|
3442
|
-
editable: false,
|
|
3443
|
-
display_order: displayOrder++,
|
|
3444
|
-
category: "core",
|
|
3445
|
-
tab: "overview"
|
|
3446
|
-
};
|
|
3447
|
-
if (familyOptions.length <= 50) {
|
|
3448
|
-
fields["family"].options = familyOptions;
|
|
3449
|
-
}
|
|
3450
|
-
fieldMetadata.forEach((metadata, fieldName) => {
|
|
3451
|
-
if (fieldName === "family" || fieldName === "id" && controlIdFieldNameClean !== "id") {
|
|
3452
|
-
return;
|
|
3453
|
-
}
|
|
3454
|
-
const frontendConfig = frontendFieldSchema?.find((f) => f.fieldName === fieldName);
|
|
3455
|
-
if (frontendFieldSchema && !frontendConfig) {
|
|
3456
|
-
return;
|
|
3457
|
-
}
|
|
3458
|
-
const usageCount = metadata.totalCount - metadata.emptyCount;
|
|
3459
|
-
const usagePercentage = metadata.totalCount > 0 ? Math.round(usageCount / metadata.totalCount * 100) : 0;
|
|
3460
|
-
let uiType = "short_text";
|
|
3461
|
-
const nonEmptyCount = metadata.totalCount - metadata.emptyCount;
|
|
3462
|
-
const isDropdownCandidate = metadata.uniqueValues.size > 0 && metadata.uniqueValues.size <= 20 && // Max 20 unique values for dropdown
|
|
3463
|
-
nonEmptyCount >= 10 && // At least 10 non-empty values to be meaningful
|
|
3464
|
-
metadata.maxLength <= 100 && // Reasonably short values only
|
|
3465
|
-
metadata.uniqueValues.size / nonEmptyCount <= 0.3;
|
|
3466
|
-
if (metadata.hasMultipleLines || metadata.maxLength > 500) {
|
|
3467
|
-
uiType = "textarea";
|
|
3468
|
-
} else if (isDropdownCandidate) {
|
|
3469
|
-
uiType = "select";
|
|
3470
|
-
} else if (metadata.type === "boolean") {
|
|
3471
|
-
uiType = "checkbox";
|
|
3472
|
-
} else if (metadata.type === "number") {
|
|
3473
|
-
uiType = "number";
|
|
3474
|
-
} else if (metadata.type === "date") {
|
|
3475
|
-
uiType = "date";
|
|
3476
|
-
} else if (metadata.maxLength <= 50) {
|
|
3477
|
-
uiType = "short_text";
|
|
3478
|
-
} else if (metadata.maxLength <= 200) {
|
|
3479
|
-
uiType = "medium_text";
|
|
3480
|
-
} else {
|
|
3481
|
-
uiType = "long_text";
|
|
3482
|
-
}
|
|
3483
|
-
let category = frontendConfig?.category || "custom";
|
|
3484
|
-
if (!frontendConfig) {
|
|
3485
|
-
if (fieldName.includes("status") || fieldName.includes("state")) {
|
|
3486
|
-
category = "compliance";
|
|
3487
|
-
} else if (fieldName.includes("title") || fieldName.includes("name") || fieldName.includes("description")) {
|
|
3488
|
-
category = "core";
|
|
3489
|
-
} else if (fieldName.includes("note") || fieldName.includes("comment")) {
|
|
3490
|
-
category = "notes";
|
|
3491
|
-
}
|
|
3492
|
-
}
|
|
3493
|
-
const isControlIdField = fieldName === controlIdFieldNameClean;
|
|
3494
|
-
const fieldDef = {
|
|
3495
|
-
type: metadata.type,
|
|
3496
|
-
ui_type: uiType,
|
|
3497
|
-
is_array: false,
|
|
3498
|
-
max_length: metadata.maxLength,
|
|
3499
|
-
usage_count: usageCount,
|
|
3500
|
-
usage_percentage: usagePercentage,
|
|
3501
|
-
required: isControlIdField ? true : frontendConfig?.required ?? usagePercentage > 95,
|
|
3502
|
-
// Control ID is always required
|
|
3503
|
-
visible: frontendConfig?.tab !== "hidden",
|
|
3504
|
-
show_in_table: isControlIdField ? true : metadata.maxLength <= 100 && usagePercentage > 30,
|
|
3505
|
-
// Always show control ID in table
|
|
3506
|
-
editable: isControlIdField ? false : true,
|
|
3507
|
-
// Control ID is not editable
|
|
3508
|
-
display_order: isControlIdField ? 1 : frontendConfig?.displayOrder ?? displayOrder++,
|
|
3509
|
-
// Control ID is always first
|
|
3510
|
-
category: isControlIdField ? "core" : category,
|
|
3511
|
-
// Control ID is always core
|
|
3512
|
-
tab: isControlIdField ? "overview" : frontendConfig?.tab || void 0
|
|
3513
|
-
// Use frontend config or default
|
|
3514
|
-
};
|
|
3515
|
-
if (uiType === "select") {
|
|
3516
|
-
fieldDef.options = Array.from(metadata.uniqueValues).sort();
|
|
3517
|
-
}
|
|
3518
|
-
if (frontendConfig?.originalName || metadata.originalName) {
|
|
3519
|
-
fieldDef.original_name = frontendConfig?.originalName || metadata.originalName;
|
|
3520
|
-
}
|
|
3521
|
-
fields[fieldName] = fieldDef;
|
|
3522
|
-
});
|
|
3523
|
-
const fieldSchema = {
|
|
3524
|
-
fields,
|
|
3525
|
-
total_controls: controls.length,
|
|
3526
|
-
analyzed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3527
|
-
};
|
|
3528
|
-
const controlSetData = {
|
|
3529
|
-
name: controlSetName,
|
|
3530
|
-
description: controlSetDescription,
|
|
3531
|
-
version: "1.0.0",
|
|
3532
|
-
control_id_field: controlIdFieldNameClean,
|
|
3533
|
-
// Add this to indicate which field is the control ID
|
|
3534
|
-
controlCount: controls.length,
|
|
3535
|
-
families: uniqueFamilies,
|
|
3536
|
-
fieldSchema
|
|
3537
|
-
};
|
|
3538
|
-
writeFileSync2(join4(baseDir, "lula.yaml"), yaml4.dump(controlSetData));
|
|
3539
|
-
const controlsDir = join4(baseDir, "controls");
|
|
3540
|
-
const mappingsDir = join4(baseDir, "mappings");
|
|
3541
|
-
families.forEach((familyControls, family) => {
|
|
3542
|
-
const familyDir = join4(controlsDir, family);
|
|
3543
|
-
const familyMappingsDir = join4(mappingsDir, family);
|
|
3544
|
-
if (!existsSync3(familyDir)) {
|
|
3545
|
-
mkdirSync2(familyDir, { recursive: true });
|
|
3546
|
-
}
|
|
3547
|
-
if (!existsSync3(familyMappingsDir)) {
|
|
3548
|
-
mkdirSync2(familyMappingsDir, { recursive: true });
|
|
3549
|
-
}
|
|
3550
|
-
familyControls.forEach((control) => {
|
|
3551
|
-
const controlId = control[controlIdFieldNameClean];
|
|
3552
|
-
if (!controlId) {
|
|
3553
|
-
console.error("Missing control ID for control:", control);
|
|
3554
|
-
return;
|
|
3555
|
-
}
|
|
3556
|
-
const controlIdStr = String(controlId).slice(0, 50);
|
|
3557
|
-
const fileName2 = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}.yaml`;
|
|
3558
|
-
const filePath = join4(familyDir, fileName2);
|
|
3559
|
-
const mappingFileName = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}-mappings.yaml`;
|
|
3560
|
-
const mappingFilePath = join4(familyMappingsDir, mappingFileName);
|
|
3561
|
-
const filteredControl = {};
|
|
3562
|
-
const mappingData = {
|
|
3563
|
-
control_id: controlIdStr,
|
|
3564
|
-
justification: "",
|
|
3565
|
-
uuid: crypto.randomUUID()
|
|
3566
|
-
};
|
|
3567
|
-
const justificationContents = [];
|
|
3568
|
-
if (control.family !== void 0) {
|
|
3569
|
-
filteredControl.family = control.family;
|
|
3570
|
-
}
|
|
3571
|
-
Object.keys(control).forEach((fieldName) => {
|
|
3572
|
-
if (fieldName === "family") return;
|
|
3573
|
-
if (justificationFields.includes(fieldName) && control[fieldName] !== void 0 && control[fieldName] !== null) {
|
|
3574
|
-
justificationContents.push(control[fieldName]);
|
|
3575
|
-
}
|
|
3576
|
-
const isInFrontendSchema = frontendFieldSchema?.some((f) => f.fieldName === fieldName);
|
|
3577
|
-
const isInFieldsMetadata = fields.hasOwnProperty(fieldName);
|
|
3578
|
-
if (isInFrontendSchema || isInFieldsMetadata) {
|
|
3579
|
-
filteredControl[fieldName] = control[fieldName];
|
|
3580
|
-
}
|
|
3581
|
-
});
|
|
3582
|
-
writeFileSync2(filePath, yaml4.dump(filteredControl));
|
|
3583
|
-
if (justificationContents.length > 0) {
|
|
3584
|
-
mappingData.justification = justificationContents.join("\n\n");
|
|
3585
|
-
}
|
|
3586
|
-
if (mappingData.justification && mappingData.justification.trim() !== "") {
|
|
3587
|
-
const mappingArray = [mappingData];
|
|
3588
|
-
writeFileSync2(mappingFilePath, yaml4.dump(mappingArray));
|
|
3589
|
-
}
|
|
3590
|
-
});
|
|
3591
|
-
});
|
|
3633
|
+
const processedData = processSpreadsheetData(rawData, headers, startRowIndex, params);
|
|
3634
|
+
const fieldSchema = buildFieldSchema(
|
|
3635
|
+
processedData.fieldMetadata,
|
|
3636
|
+
processedData.controls,
|
|
3637
|
+
params,
|
|
3638
|
+
processedData.families
|
|
3639
|
+
);
|
|
3640
|
+
const result = await createOutputStructure(processedData, fieldSchema, params);
|
|
3592
3641
|
res.json({
|
|
3593
3642
|
success: true,
|
|
3594
|
-
controlCount: controls.length,
|
|
3595
|
-
families: Array.from(families.keys()),
|
|
3596
|
-
outputDir: folderName
|
|
3597
|
-
// Return just the folder name, not full path
|
|
3643
|
+
controlCount: processedData.controls.length,
|
|
3644
|
+
families: Array.from(processedData.families.keys()),
|
|
3645
|
+
outputDir: result.folderName
|
|
3598
3646
|
});
|
|
3599
3647
|
} catch (error) {
|
|
3600
3648
|
console.error("Error processing spreadsheet:", error);
|
|
@@ -5580,6 +5628,10 @@ function getChangedBlocks(oldText, newText) {
|
|
|
5580
5628
|
}
|
|
5581
5629
|
return changed;
|
|
5582
5630
|
}
|
|
5631
|
+
function containsLulaAnnotations(text) {
|
|
5632
|
+
const lines = text.split("\n");
|
|
5633
|
+
return lines.some((line) => line.includes("@lulaStart") || line.includes("@lulaEnd"));
|
|
5634
|
+
}
|
|
5583
5635
|
function crawlCommand() {
|
|
5584
5636
|
return new Command().command("crawl").description("Detect compliance-related changes between @lulaStart and @lulaEnd in PR files").addOption(
|
|
5585
5637
|
new Option("--post-mode <mode>", "How to post findings").choices(["review", "comment"]).default("review")
|
|
@@ -5601,8 +5653,55 @@ Please review the changes to ensure they meet compliance standards.
|
|
|
5601
5653
|
Lula reviewed ${files.length} files changed that affect compliance.
|
|
5602
5654
|
|
|
5603
5655
|
`;
|
|
5656
|
+
const deletedFilesWithAnnotations = [];
|
|
5657
|
+
for (const file of files) {
|
|
5658
|
+
if (file.status === "removed") {
|
|
5659
|
+
try {
|
|
5660
|
+
const oldText = await fetchRawFileViaAPI({
|
|
5661
|
+
octokit,
|
|
5662
|
+
owner,
|
|
5663
|
+
repo,
|
|
5664
|
+
path: file.filename,
|
|
5665
|
+
ref: "main"
|
|
5666
|
+
});
|
|
5667
|
+
if (containsLulaAnnotations(oldText)) {
|
|
5668
|
+
deletedFilesWithAnnotations.push(file.filename);
|
|
5669
|
+
}
|
|
5670
|
+
} catch (err) {
|
|
5671
|
+
console.error(`Error checking deleted file ${file.filename}: ${err}`);
|
|
5672
|
+
}
|
|
5673
|
+
}
|
|
5674
|
+
}
|
|
5675
|
+
if (deletedFilesWithAnnotations.length > 0) {
|
|
5676
|
+
leavePost = true;
|
|
5677
|
+
commentBody += `
|
|
5678
|
+
|
|
5679
|
+
**Compliance Warning: Files with Lula annotations were deleted**
|
|
5680
|
+
|
|
5681
|
+
`;
|
|
5682
|
+
commentBody += `The following files contained compliance annotations (\`@lulaStart\`/\`@lulaEnd\`) and were deleted in this PR. This may affect compliance coverage:
|
|
5683
|
+
|
|
5684
|
+
`;
|
|
5685
|
+
for (const filename of deletedFilesWithAnnotations) {
|
|
5686
|
+
commentBody += `- \`${filename}\`
|
|
5687
|
+
`;
|
|
5688
|
+
}
|
|
5689
|
+
commentBody += `
|
|
5690
|
+
Please review whether:
|
|
5691
|
+
`;
|
|
5692
|
+
commentBody += `- The compliance coverage provided by these files is still needed
|
|
5693
|
+
`;
|
|
5694
|
+
commentBody += `- Alternative compliance measures have been implemented
|
|
5695
|
+
`;
|
|
5696
|
+
commentBody += `- The deletion is intentional and compliance-approved
|
|
5697
|
+
|
|
5698
|
+
`;
|
|
5699
|
+
commentBody += `---
|
|
5700
|
+
|
|
5701
|
+
`;
|
|
5702
|
+
}
|
|
5604
5703
|
for (const file of files) {
|
|
5605
|
-
if (file.status === "added") continue;
|
|
5704
|
+
if (file.status === "added" || file.status === "removed") continue;
|
|
5606
5705
|
try {
|
|
5607
5706
|
const [oldText, newText] = await Promise.all([
|
|
5608
5707
|
fetchRawFileViaAPI({ octokit, owner, repo, path: file.filename, ref: "main" }),
|