@cencori/scan 0.2.0 → 0.3.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/dist/cli.js +350 -12
- package/dist/cli.js.map +1 -1
- package/dist/cli.mjs +350 -12
- package/dist/cli.mjs.map +1 -1
- package/package.json +5 -3
package/dist/cli.js
CHANGED
|
@@ -27,6 +27,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
27
27
|
var import_commander = require("commander");
|
|
28
28
|
var import_chalk = __toESM(require("chalk"));
|
|
29
29
|
var import_ora = __toESM(require("ora"));
|
|
30
|
+
var import_prompts = require("@inquirer/prompts");
|
|
30
31
|
|
|
31
32
|
// src/scanner/index.ts
|
|
32
33
|
var fs = __toESM(require("fs"));
|
|
@@ -714,8 +715,220 @@ async function scan(targetPath) {
|
|
|
714
715
|
};
|
|
715
716
|
}
|
|
716
717
|
|
|
718
|
+
// src/ai/index.ts
|
|
719
|
+
var fs2 = __toESM(require("fs"));
|
|
720
|
+
var path2 = __toESM(require("path"));
|
|
721
|
+
var os = __toESM(require("os"));
|
|
722
|
+
var CENCORI_API_URL = "https://api.cencori.com/v1";
|
|
723
|
+
var CONFIG_FILE = ".cencorirc";
|
|
724
|
+
function getConfigPath() {
|
|
725
|
+
return path2.join(os.homedir(), CONFIG_FILE);
|
|
726
|
+
}
|
|
727
|
+
function loadApiKeyFromConfig() {
|
|
728
|
+
try {
|
|
729
|
+
const configPath = getConfigPath();
|
|
730
|
+
if (fs2.existsSync(configPath)) {
|
|
731
|
+
const content = fs2.readFileSync(configPath, "utf-8");
|
|
732
|
+
const lines = content.split("\n");
|
|
733
|
+
for (const line of lines) {
|
|
734
|
+
if (line.startsWith("api_key=")) {
|
|
735
|
+
return line.slice("api_key=".length).trim();
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
} catch {
|
|
740
|
+
}
|
|
741
|
+
return void 0;
|
|
742
|
+
}
|
|
743
|
+
function saveApiKey(apiKey) {
|
|
744
|
+
const configPath = getConfigPath();
|
|
745
|
+
fs2.writeFileSync(configPath, `api_key=${apiKey}
|
|
746
|
+
`, { mode: 384 });
|
|
747
|
+
}
|
|
748
|
+
function getApiKey() {
|
|
749
|
+
return process.env.CENCORI_API_KEY || loadApiKeyFromConfig();
|
|
750
|
+
}
|
|
751
|
+
var sessionApiKey;
|
|
752
|
+
function setSessionApiKey(apiKey) {
|
|
753
|
+
sessionApiKey = apiKey;
|
|
754
|
+
}
|
|
755
|
+
function getEffectiveApiKey() {
|
|
756
|
+
return sessionApiKey || getApiKey();
|
|
757
|
+
}
|
|
758
|
+
async function validateApiKey(apiKey) {
|
|
759
|
+
try {
|
|
760
|
+
const response = await fetch(`${CENCORI_API_URL}/models`, {
|
|
761
|
+
method: "GET",
|
|
762
|
+
headers: {
|
|
763
|
+
"Authorization": `Bearer ${apiKey}`
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
return response.ok;
|
|
767
|
+
} catch {
|
|
768
|
+
return false;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
async function analyzeIssues(issues, fileContents) {
|
|
772
|
+
const apiKey = getEffectiveApiKey();
|
|
773
|
+
if (!apiKey) {
|
|
774
|
+
throw new Error("No API key available");
|
|
775
|
+
}
|
|
776
|
+
const results = [];
|
|
777
|
+
for (const issue of issues) {
|
|
778
|
+
const content = fileContents.get(issue.file) || "";
|
|
779
|
+
const lines = content.split("\n");
|
|
780
|
+
const startLine = Math.max(0, issue.line - 3);
|
|
781
|
+
const endLine = Math.min(lines.length, issue.line + 3);
|
|
782
|
+
const context = lines.slice(startLine, endLine).join("\n");
|
|
783
|
+
try {
|
|
784
|
+
const response = await fetch(`${CENCORI_API_URL}/chat/completions`, {
|
|
785
|
+
method: "POST",
|
|
786
|
+
headers: {
|
|
787
|
+
"Content-Type": "application/json",
|
|
788
|
+
"Authorization": `Bearer ${apiKey}`
|
|
789
|
+
},
|
|
790
|
+
body: JSON.stringify({
|
|
791
|
+
model: "gpt-4o-mini",
|
|
792
|
+
messages: [
|
|
793
|
+
{
|
|
794
|
+
role: "system",
|
|
795
|
+
content: `You are a security analyst. Analyze code findings and determine if they are real security issues or false positives. Respond in JSON format: {"isFalsePositive": boolean, "confidence": number (0-100), "reason": "brief explanation"}`
|
|
796
|
+
},
|
|
797
|
+
{
|
|
798
|
+
role: "user",
|
|
799
|
+
content: `Analyze this security finding:
|
|
800
|
+
Type: ${issue.type}
|
|
801
|
+
Name: ${issue.name}
|
|
802
|
+
Match: ${issue.match}
|
|
803
|
+
File: ${issue.file}:${issue.line}
|
|
804
|
+
Context:
|
|
805
|
+
\`\`\`
|
|
806
|
+
${context}
|
|
807
|
+
\`\`\`
|
|
808
|
+
|
|
809
|
+
Is this a real security issue or a false positive (e.g., test data, example code, documentation)?`
|
|
810
|
+
}
|
|
811
|
+
],
|
|
812
|
+
temperature: 0,
|
|
813
|
+
max_tokens: 150
|
|
814
|
+
})
|
|
815
|
+
});
|
|
816
|
+
if (!response.ok) {
|
|
817
|
+
throw new Error(`API error: ${response.status}`);
|
|
818
|
+
}
|
|
819
|
+
const data = await response.json();
|
|
820
|
+
const content_response = data.choices[0]?.message?.content || "{}";
|
|
821
|
+
const parsed = JSON.parse(content_response);
|
|
822
|
+
results.push({
|
|
823
|
+
issue,
|
|
824
|
+
isFalsePositive: parsed.isFalsePositive || false,
|
|
825
|
+
confidence: parsed.confidence || 50,
|
|
826
|
+
reason: parsed.reason || "Unable to analyze"
|
|
827
|
+
});
|
|
828
|
+
} catch {
|
|
829
|
+
results.push({
|
|
830
|
+
issue,
|
|
831
|
+
isFalsePositive: false,
|
|
832
|
+
confidence: 50,
|
|
833
|
+
reason: "Analysis failed - treating as potential issue"
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return results;
|
|
838
|
+
}
|
|
839
|
+
async function generateFixes(issues, fileContents) {
|
|
840
|
+
const apiKey = getEffectiveApiKey();
|
|
841
|
+
if (!apiKey) {
|
|
842
|
+
throw new Error("No API key available");
|
|
843
|
+
}
|
|
844
|
+
const results = [];
|
|
845
|
+
for (const issue of issues) {
|
|
846
|
+
const content = fileContents.get(issue.file) || "";
|
|
847
|
+
const lines = content.split("\n");
|
|
848
|
+
const startLine = Math.max(0, issue.line - 5);
|
|
849
|
+
const endLine = Math.min(lines.length, issue.line + 5);
|
|
850
|
+
const codeSnippet = lines.slice(startLine, endLine).join("\n");
|
|
851
|
+
try {
|
|
852
|
+
const response = await fetch(`${CENCORI_API_URL}/chat/completions`, {
|
|
853
|
+
method: "POST",
|
|
854
|
+
headers: {
|
|
855
|
+
"Content-Type": "application/json",
|
|
856
|
+
"Authorization": `Bearer ${apiKey}`
|
|
857
|
+
},
|
|
858
|
+
body: JSON.stringify({
|
|
859
|
+
model: "gpt-4o-mini",
|
|
860
|
+
messages: [
|
|
861
|
+
{
|
|
862
|
+
role: "system",
|
|
863
|
+
content: `You are a security engineer. Generate secure code fixes. For secrets, use environment variables. For XSS, use sanitization. Respond in JSON: {"fixedCode": "the fixed code snippet", "explanation": "what was changed"}`
|
|
864
|
+
},
|
|
865
|
+
{
|
|
866
|
+
role: "user",
|
|
867
|
+
content: `Fix this security issue:
|
|
868
|
+
Type: ${issue.type}
|
|
869
|
+
Name: ${issue.name}
|
|
870
|
+
File: ${issue.file}:${issue.line}
|
|
871
|
+
|
|
872
|
+
Code to fix:
|
|
873
|
+
\`\`\`
|
|
874
|
+
${codeSnippet}
|
|
875
|
+
\`\`\`
|
|
876
|
+
|
|
877
|
+
Generate a secure fix.`
|
|
878
|
+
}
|
|
879
|
+
],
|
|
880
|
+
temperature: 0,
|
|
881
|
+
max_tokens: 500
|
|
882
|
+
})
|
|
883
|
+
});
|
|
884
|
+
if (!response.ok) {
|
|
885
|
+
throw new Error(`API error: ${response.status}`);
|
|
886
|
+
}
|
|
887
|
+
const data = await response.json();
|
|
888
|
+
const content_response = data.choices[0]?.message?.content || "{}";
|
|
889
|
+
const parsed = JSON.parse(content_response);
|
|
890
|
+
results.push({
|
|
891
|
+
issue,
|
|
892
|
+
originalCode: codeSnippet,
|
|
893
|
+
fixedCode: parsed.fixedCode || codeSnippet,
|
|
894
|
+
explanation: parsed.explanation || "No explanation provided",
|
|
895
|
+
applied: false
|
|
896
|
+
});
|
|
897
|
+
} catch {
|
|
898
|
+
results.push({
|
|
899
|
+
issue,
|
|
900
|
+
originalCode: codeSnippet,
|
|
901
|
+
fixedCode: codeSnippet,
|
|
902
|
+
explanation: "Unable to generate fix - manual review required",
|
|
903
|
+
applied: false
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return results;
|
|
908
|
+
}
|
|
909
|
+
async function applyFixes(fixes, fileContents) {
|
|
910
|
+
for (const fix of fixes) {
|
|
911
|
+
if (fix.fixedCode === fix.originalCode) {
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
const content = fileContents.get(fix.issue.file);
|
|
915
|
+
if (!content) {
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
const newContent = content.replace(fix.originalCode, fix.fixedCode);
|
|
919
|
+
if (newContent !== content) {
|
|
920
|
+
const filePath = path2.resolve(fix.issue.file);
|
|
921
|
+
fs2.writeFileSync(filePath, newContent, "utf-8");
|
|
922
|
+
fix.applied = true;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return fixes;
|
|
926
|
+
}
|
|
927
|
+
|
|
717
928
|
// src/cli.ts
|
|
718
|
-
var
|
|
929
|
+
var fs3 = __toESM(require("fs"));
|
|
930
|
+
var path3 = __toESM(require("path"));
|
|
931
|
+
var VERSION = "0.3.1";
|
|
719
932
|
var scoreStyles = {
|
|
720
933
|
A: { color: import_chalk.default.green },
|
|
721
934
|
B: { color: import_chalk.default.blue },
|
|
@@ -808,17 +1021,15 @@ function printSummary(result) {
|
|
|
808
1021
|
}
|
|
809
1022
|
console.log();
|
|
810
1023
|
}
|
|
811
|
-
function
|
|
1024
|
+
function printRecommendations(issues) {
|
|
812
1025
|
if (issues.length === 0) return;
|
|
813
1026
|
console.log(` ${import_chalk.default.bold("Recommendations:")}`);
|
|
814
1027
|
const hasSecrets = issues.some((i) => i.type === "secret");
|
|
815
1028
|
const hasPII = issues.some((i) => i.type === "pii");
|
|
816
1029
|
const hasConfig = issues.some((i) => i.type === "config");
|
|
817
|
-
const hasVulnerabilities = issues.some((i) => i.type === "vulnerability");
|
|
818
1030
|
const hasXSS = issues.some((i) => i.category === "xss");
|
|
819
1031
|
const hasInjection = issues.some((i) => i.category === "injection");
|
|
820
1032
|
const hasCORS = issues.some((i) => i.category === "cors");
|
|
821
|
-
const hasDebug = issues.some((i) => i.category === "debug");
|
|
822
1033
|
if (hasSecrets) {
|
|
823
1034
|
console.log(import_chalk.default.gray(" - Use environment variables for secrets"));
|
|
824
1035
|
console.log(import_chalk.default.gray(" - Never commit API keys to version control"));
|
|
@@ -831,19 +1042,13 @@ function printFixes(issues) {
|
|
|
831
1042
|
}
|
|
832
1043
|
if (hasXSS) {
|
|
833
1044
|
console.log(import_chalk.default.gray(" - Sanitize user input before rendering HTML"));
|
|
834
|
-
console.log(import_chalk.default.gray(" - Use textContent instead of innerHTML"));
|
|
835
1045
|
}
|
|
836
1046
|
if (hasInjection) {
|
|
837
1047
|
console.log(import_chalk.default.gray(" - Use parameterized queries for SQL"));
|
|
838
|
-
console.log(import_chalk.default.gray(" - Avoid using eval and dynamic code execution"));
|
|
839
1048
|
}
|
|
840
1049
|
if (hasCORS) {
|
|
841
1050
|
console.log(import_chalk.default.gray(" - Configure CORS with specific allowed origins"));
|
|
842
1051
|
}
|
|
843
|
-
if (hasDebug) {
|
|
844
|
-
console.log(import_chalk.default.gray(" - Remove console.log statements before production"));
|
|
845
|
-
console.log(import_chalk.default.gray(" - Use environment variables for debug flags"));
|
|
846
|
-
}
|
|
847
1052
|
console.log();
|
|
848
1053
|
}
|
|
849
1054
|
function printFooter() {
|
|
@@ -853,8 +1058,138 @@ function printFooter() {
|
|
|
853
1058
|
console.log(` Docs: ${import_chalk.default.cyan("https://cencori.com/docs")}`);
|
|
854
1059
|
console.log();
|
|
855
1060
|
}
|
|
1061
|
+
function loadFileContents(issues, basePath) {
|
|
1062
|
+
const contents = /* @__PURE__ */ new Map();
|
|
1063
|
+
const uniqueFiles = [...new Set(issues.map((i) => i.file))];
|
|
1064
|
+
for (const file of uniqueFiles) {
|
|
1065
|
+
try {
|
|
1066
|
+
const fullPath = path3.resolve(basePath, file);
|
|
1067
|
+
const content = fs3.readFileSync(fullPath, "utf-8");
|
|
1068
|
+
contents.set(file, content);
|
|
1069
|
+
} catch {
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
return contents;
|
|
1073
|
+
}
|
|
1074
|
+
async function promptForApiKey() {
|
|
1075
|
+
console.log();
|
|
1076
|
+
console.log(import_chalk.default.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
1077
|
+
console.log();
|
|
1078
|
+
console.log(` ${import_chalk.default.cyan.bold("Cencori Pro")}`);
|
|
1079
|
+
console.log(import_chalk.default.gray(" AI-powered auto-fix requires an API key."));
|
|
1080
|
+
console.log();
|
|
1081
|
+
console.log(` Get your free API key at:`);
|
|
1082
|
+
console.log(` ${import_chalk.default.cyan("https://cencori.com/dashboard")} \u2192 API Keys`);
|
|
1083
|
+
console.log();
|
|
1084
|
+
try {
|
|
1085
|
+
const apiKey = await (0, import_prompts.password)({
|
|
1086
|
+
message: "Enter your Cencori API key:",
|
|
1087
|
+
mask: "*"
|
|
1088
|
+
});
|
|
1089
|
+
if (!apiKey || apiKey.trim() === "") {
|
|
1090
|
+
console.log(import_chalk.default.yellow(" No API key entered. Skipping auto-fix."));
|
|
1091
|
+
return void 0;
|
|
1092
|
+
}
|
|
1093
|
+
return apiKey.trim();
|
|
1094
|
+
} catch {
|
|
1095
|
+
return void 0;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
async function handleAutoFix(result, targetPath) {
|
|
1099
|
+
if (result.issues.length === 0) return;
|
|
1100
|
+
console.log();
|
|
1101
|
+
const shouldFix = await (0, import_prompts.confirm)({
|
|
1102
|
+
message: "Would you like Cencori to auto-fix these issues?",
|
|
1103
|
+
default: false
|
|
1104
|
+
});
|
|
1105
|
+
if (!shouldFix) {
|
|
1106
|
+
console.log();
|
|
1107
|
+
console.log(import_chalk.default.gray(" Skipped auto-fix. Run again anytime to fix issues."));
|
|
1108
|
+
console.log();
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
let apiKey = getApiKey();
|
|
1112
|
+
if (!apiKey) {
|
|
1113
|
+
apiKey = await promptForApiKey();
|
|
1114
|
+
if (!apiKey) {
|
|
1115
|
+
console.log();
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
const validatingSpinner = (0, import_ora.default)({
|
|
1119
|
+
text: "Validating API key...",
|
|
1120
|
+
color: "cyan"
|
|
1121
|
+
}).start();
|
|
1122
|
+
const isValid = await validateApiKey(apiKey);
|
|
1123
|
+
if (!isValid) {
|
|
1124
|
+
validatingSpinner.fail("Invalid API key");
|
|
1125
|
+
console.log(import_chalk.default.red(" The API key could not be validated. Please check and try again."));
|
|
1126
|
+
console.log();
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
validatingSpinner.succeed("API key validated");
|
|
1130
|
+
try {
|
|
1131
|
+
saveApiKey(apiKey);
|
|
1132
|
+
console.log(import_chalk.default.green(" \u2714 API key saved to ~/.cencorirc"));
|
|
1133
|
+
} catch {
|
|
1134
|
+
}
|
|
1135
|
+
setSessionApiKey(apiKey);
|
|
1136
|
+
} else {
|
|
1137
|
+
console.log(import_chalk.default.gray(" Using saved API key..."));
|
|
1138
|
+
}
|
|
1139
|
+
const fileContents = loadFileContents(result.issues, targetPath);
|
|
1140
|
+
const analyzeSpinner = (0, import_ora.default)({
|
|
1141
|
+
text: "Analyzing issues with AI...",
|
|
1142
|
+
color: "cyan"
|
|
1143
|
+
}).start();
|
|
1144
|
+
try {
|
|
1145
|
+
const analysis = await analyzeIssues(result.issues, fileContents);
|
|
1146
|
+
const realIssues = analysis.filter((a) => !a.isFalsePositive);
|
|
1147
|
+
const falsePositives = analysis.filter((a) => a.isFalsePositive);
|
|
1148
|
+
if (falsePositives.length > 0) {
|
|
1149
|
+
analyzeSpinner.succeed(`${import_chalk.default.green(falsePositives.length)} false positives filtered`);
|
|
1150
|
+
} else {
|
|
1151
|
+
analyzeSpinner.succeed("Analysis complete");
|
|
1152
|
+
}
|
|
1153
|
+
if (realIssues.length === 0) {
|
|
1154
|
+
console.log(import_chalk.default.green(" All issues were false positives!"));
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
const fixSpinner = (0, import_ora.default)({
|
|
1158
|
+
text: "Generating fixes...",
|
|
1159
|
+
color: "cyan"
|
|
1160
|
+
}).start();
|
|
1161
|
+
const fixes = await generateFixes(
|
|
1162
|
+
realIssues.map((a) => a.issue),
|
|
1163
|
+
fileContents
|
|
1164
|
+
);
|
|
1165
|
+
fixSpinner.succeed(`Generated ${fixes.length} fixes`);
|
|
1166
|
+
const applySpinner = (0, import_ora.default)({
|
|
1167
|
+
text: "Applying fixes...",
|
|
1168
|
+
color: "cyan"
|
|
1169
|
+
}).start();
|
|
1170
|
+
const appliedFixes = await applyFixes(fixes, fileContents);
|
|
1171
|
+
const appliedCount = appliedFixes.filter((f) => f.applied).length;
|
|
1172
|
+
applySpinner.succeed(`Applied ${appliedCount}/${fixes.length} fixes`);
|
|
1173
|
+
console.log();
|
|
1174
|
+
console.log(` ${import_chalk.default.bold("Applied fixes:")}`);
|
|
1175
|
+
for (const fix of appliedFixes.filter((f) => f.applied)) {
|
|
1176
|
+
console.log(import_chalk.default.green(` \u2714 ${fix.issue.file}:${fix.issue.line}`));
|
|
1177
|
+
console.log(import_chalk.default.gray(` ${fix.explanation}`));
|
|
1178
|
+
}
|
|
1179
|
+
const notApplied = appliedFixes.filter((f) => !f.applied);
|
|
1180
|
+
if (notApplied.length > 0) {
|
|
1181
|
+
console.log();
|
|
1182
|
+
console.log(` ${import_chalk.default.yellow(`${notApplied.length} issues require manual review`)}`);
|
|
1183
|
+
}
|
|
1184
|
+
console.log();
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
analyzeSpinner.fail("Auto-fix failed");
|
|
1187
|
+
console.error(import_chalk.default.red(` Error: ${error instanceof Error ? error.message : "Unknown error"}`));
|
|
1188
|
+
console.log();
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
856
1191
|
async function main() {
|
|
857
|
-
import_commander.program.name("cencori-scan").description("Security scanner for AI apps. Detect secrets, PII, and exposed routes.").version(VERSION).argument("[path]", "Path to scan", ".").option("-j, --json", "Output results as JSON").option("-q, --quiet", "Only output the score").option("--no-color", "Disable colored output").action(async (targetPath, options) => {
|
|
1192
|
+
import_commander.program.name("cencori-scan").description("Security scanner for AI apps. Detect secrets, PII, and exposed routes.").version(VERSION).argument("[path]", "Path to scan", ".").option("-j, --json", "Output results as JSON").option("-q, --quiet", "Only output the score").option("--no-prompt", "Skip interactive prompts").option("--no-color", "Disable colored output").action(async (targetPath, options) => {
|
|
858
1193
|
if (options.json) {
|
|
859
1194
|
const result = await scan(targetPath);
|
|
860
1195
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -880,7 +1215,10 @@ async function main() {
|
|
|
880
1215
|
printScore(result);
|
|
881
1216
|
printIssues(result.issues);
|
|
882
1217
|
printSummary(result);
|
|
883
|
-
|
|
1218
|
+
printRecommendations(result.issues);
|
|
1219
|
+
if (options.prompt !== false && result.issues.length > 0) {
|
|
1220
|
+
await handleAutoFix(result, targetPath);
|
|
1221
|
+
}
|
|
884
1222
|
printFooter();
|
|
885
1223
|
process.exit(result.score === "A" || result.score === "B" ? 0 : 1);
|
|
886
1224
|
} catch (error) {
|