@sentriflow/cli 0.1.4 → 0.1.5
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 +7 -7
- package/dist/index.js +106 -107
- package/package.json +54 -54
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @sentriflow/cli
|
|
2
2
|
|
|
3
|
-
Command-line interface for SentriFlow -
|
|
3
|
+
Command-line interface for SentriFlow - check network configurations for compliance against best practices or organization-specific policies.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -17,10 +17,10 @@ bun add -g @sentriflow/cli
|
|
|
17
17
|
## Quick Start
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
#
|
|
20
|
+
# Check a single configuration file
|
|
21
21
|
sentriflow router.conf
|
|
22
22
|
|
|
23
|
-
#
|
|
23
|
+
# Check with specific vendor
|
|
24
24
|
sentriflow -v cisco-ios router.conf
|
|
25
25
|
|
|
26
26
|
# Scan a directory of configs
|
|
@@ -44,7 +44,7 @@ sentriflow --list-rules
|
|
|
44
44
|
```
|
|
45
45
|
Usage: sentriflow [options] [file]
|
|
46
46
|
|
|
47
|
-
SentriFlow Network Configuration
|
|
47
|
+
SentriFlow Network Configuration Compliance Checker
|
|
48
48
|
|
|
49
49
|
Arguments:
|
|
50
50
|
file Path to the configuration file
|
|
@@ -164,7 +164,7 @@ sentriflow router.conf -f sarif > results.sarif
|
|
|
164
164
|
### GitHub Actions
|
|
165
165
|
|
|
166
166
|
```yaml
|
|
167
|
-
- name:
|
|
167
|
+
- name: Check network config compliance
|
|
168
168
|
run: |
|
|
169
169
|
npx @sentriflow/cli -D configs/ -R -f sarif > results.sarif
|
|
170
170
|
|
|
@@ -190,8 +190,8 @@ SentriFlow automatically looks for `.sentriflowrc` or `.sentriflowrc.json` in th
|
|
|
190
190
|
|
|
191
191
|
## Related Packages
|
|
192
192
|
|
|
193
|
-
- [`@sentriflow/core`](https://github.com/sentriflow/sentriflow/tree/main/packages/core) - Core parsing engine
|
|
194
|
-
- [`@sentriflow/rules-default`](https://github.com/sentriflow/sentriflow/tree/main/packages/rules-default) - Default
|
|
193
|
+
- [`@sentriflow/core`](https://github.com/sentriflow/sentriflow/tree/main/packages/core) - Core parsing and compliance engine
|
|
194
|
+
- [`@sentriflow/rules-default`](https://github.com/sentriflow/sentriflow/tree/main/packages/rules-default) - Default compliance rules
|
|
195
195
|
|
|
196
196
|
## License
|
|
197
197
|
|
package/dist/index.js
CHANGED
|
@@ -10407,7 +10407,7 @@ function generateSarif(results, filePath, rules, options = {}) {
|
|
|
10407
10407
|
tool: {
|
|
10408
10408
|
driver: {
|
|
10409
10409
|
name: "Sentriflow",
|
|
10410
|
-
version: "0.1.
|
|
10410
|
+
version: "0.1.5",
|
|
10411
10411
|
informationUri: "https://github.com/sentriflow/sentriflow",
|
|
10412
10412
|
rules: sarifRules,
|
|
10413
10413
|
// SEC-007: Include CWE taxonomy when rules reference it
|
|
@@ -10513,7 +10513,7 @@ function generateMultiFileSarif(fileResults, rules, options = {}) {
|
|
|
10513
10513
|
tool: {
|
|
10514
10514
|
driver: {
|
|
10515
10515
|
name: "Sentriflow",
|
|
10516
|
-
version: "0.1.
|
|
10516
|
+
version: "0.1.5",
|
|
10517
10517
|
informationUri: "https://github.com/sentriflow/sentriflow",
|
|
10518
10518
|
rules: sarifRules,
|
|
10519
10519
|
// SEC-007: Include CWE taxonomy when rules reference it
|
|
@@ -13490,6 +13490,43 @@ function validateInputFilePath(filePath, maxSize = 10 * 1024 * 1024, baseDirs) {
|
|
|
13490
13490
|
});
|
|
13491
13491
|
}
|
|
13492
13492
|
|
|
13493
|
+
// src/loaders/index.ts
|
|
13494
|
+
function validatePathOrThrow(path, pathValidator, errorContext, baseDirs) {
|
|
13495
|
+
const validation = pathValidator(path, baseDirs);
|
|
13496
|
+
if (!validation.valid) {
|
|
13497
|
+
throw new SentriflowConfigError(
|
|
13498
|
+
`Invalid ${errorContext} path: ${validation.error}`
|
|
13499
|
+
);
|
|
13500
|
+
}
|
|
13501
|
+
return validation.canonicalPath;
|
|
13502
|
+
}
|
|
13503
|
+
async function wrapLoadError(operation, errorContext) {
|
|
13504
|
+
try {
|
|
13505
|
+
return await operation();
|
|
13506
|
+
} catch (error) {
|
|
13507
|
+
if (error instanceof SentriflowConfigError) {
|
|
13508
|
+
throw error;
|
|
13509
|
+
}
|
|
13510
|
+
throw new SentriflowConfigError(`Failed to load ${errorContext} file`);
|
|
13511
|
+
}
|
|
13512
|
+
}
|
|
13513
|
+
async function loadAndValidate(options) {
|
|
13514
|
+
const { path, baseDirs, pathValidator, loader, validator, errorContext } = options;
|
|
13515
|
+
const canonicalPath = validatePathOrThrow(
|
|
13516
|
+
path,
|
|
13517
|
+
pathValidator,
|
|
13518
|
+
errorContext,
|
|
13519
|
+
baseDirs
|
|
13520
|
+
);
|
|
13521
|
+
return wrapLoadError(async () => {
|
|
13522
|
+
const data = await loader(canonicalPath);
|
|
13523
|
+
if (!validator(data)) {
|
|
13524
|
+
throw new SentriflowConfigError(`Invalid ${errorContext} structure`);
|
|
13525
|
+
}
|
|
13526
|
+
return data;
|
|
13527
|
+
}, errorContext);
|
|
13528
|
+
}
|
|
13529
|
+
|
|
13493
13530
|
// src/config.ts
|
|
13494
13531
|
var CONFIG_FILES = [
|
|
13495
13532
|
"sentriflow.config.ts",
|
|
@@ -13655,36 +13692,30 @@ function isValidRulePack(pack) {
|
|
|
13655
13692
|
return true;
|
|
13656
13693
|
}
|
|
13657
13694
|
async function loadConfigFile(configPath, baseDirs) {
|
|
13658
|
-
|
|
13659
|
-
|
|
13660
|
-
|
|
13661
|
-
|
|
13662
|
-
|
|
13663
|
-
|
|
13664
|
-
|
|
13665
|
-
|
|
13666
|
-
|
|
13667
|
-
|
|
13668
|
-
|
|
13669
|
-
} catch (error) {
|
|
13670
|
-
if (error instanceof SentriflowConfigError) {
|
|
13671
|
-
throw error;
|
|
13672
|
-
}
|
|
13673
|
-
throw new SentriflowConfigError("Failed to load configuration file");
|
|
13674
|
-
}
|
|
13695
|
+
return loadAndValidate({
|
|
13696
|
+
path: configPath,
|
|
13697
|
+
baseDirs,
|
|
13698
|
+
pathValidator: validateConfigPath,
|
|
13699
|
+
loader: async (p) => {
|
|
13700
|
+
const m = await import(p);
|
|
13701
|
+
return m.default ?? m;
|
|
13702
|
+
},
|
|
13703
|
+
validator: isValidSentriflowConfig,
|
|
13704
|
+
errorContext: "config"
|
|
13705
|
+
});
|
|
13675
13706
|
}
|
|
13676
13707
|
async function loadExternalRules(rulesPath, baseDirs) {
|
|
13677
|
-
const
|
|
13678
|
-
|
|
13679
|
-
|
|
13680
|
-
|
|
13681
|
-
|
|
13682
|
-
|
|
13708
|
+
const canonicalPath = validatePathOrThrow(
|
|
13709
|
+
rulesPath,
|
|
13710
|
+
validateConfigPath,
|
|
13711
|
+
"rules",
|
|
13712
|
+
baseDirs
|
|
13713
|
+
);
|
|
13714
|
+
return wrapLoadError(async () => {
|
|
13715
|
+
const module = await import(canonicalPath);
|
|
13683
13716
|
const rules = module.default ?? module.rules ?? module;
|
|
13684
13717
|
if (!Array.isArray(rules)) {
|
|
13685
|
-
throw new SentriflowConfigError(
|
|
13686
|
-
"Rules file must export an array of rules"
|
|
13687
|
-
);
|
|
13718
|
+
throw new SentriflowConfigError("Rules file must export an array of rules");
|
|
13688
13719
|
}
|
|
13689
13720
|
const validRules = [];
|
|
13690
13721
|
for (const rule of rules) {
|
|
@@ -13692,29 +13723,24 @@ async function loadExternalRules(rulesPath, baseDirs) {
|
|
|
13692
13723
|
validRules.push(rule);
|
|
13693
13724
|
} else {
|
|
13694
13725
|
const safeRuleId = typeof rule === "object" && rule !== null && "id" in rule ? String(rule.id).slice(0, 50) : "unknown";
|
|
13695
|
-
console.warn(
|
|
13696
|
-
`Skipping invalid rule: ${safeRuleId} (validation failed)`
|
|
13697
|
-
);
|
|
13726
|
+
console.warn(`Skipping invalid rule: ${safeRuleId} (validation failed)`);
|
|
13698
13727
|
}
|
|
13699
13728
|
}
|
|
13700
13729
|
if (validRules.length === 0 && rules.length > 0) {
|
|
13701
13730
|
throw new SentriflowConfigError("No valid rules found in rules file");
|
|
13702
13731
|
}
|
|
13703
13732
|
return validRules;
|
|
13704
|
-
}
|
|
13705
|
-
if (error instanceof SentriflowConfigError) {
|
|
13706
|
-
throw error;
|
|
13707
|
-
}
|
|
13708
|
-
throw new SentriflowConfigError("Failed to load rules file");
|
|
13709
|
-
}
|
|
13733
|
+
}, "rules");
|
|
13710
13734
|
}
|
|
13711
13735
|
async function loadJsonRules(jsonPath, baseDirs) {
|
|
13712
|
-
const
|
|
13713
|
-
|
|
13714
|
-
|
|
13715
|
-
|
|
13716
|
-
|
|
13717
|
-
|
|
13736
|
+
const canonicalPath = validatePathOrThrow(
|
|
13737
|
+
jsonPath,
|
|
13738
|
+
validateJsonRulesPath,
|
|
13739
|
+
"JSON rules",
|
|
13740
|
+
baseDirs
|
|
13741
|
+
);
|
|
13742
|
+
return wrapLoadError(async () => {
|
|
13743
|
+
const content = await readFileAsync(canonicalPath, "utf-8");
|
|
13718
13744
|
let jsonData;
|
|
13719
13745
|
try {
|
|
13720
13746
|
jsonData = JSON.parse(content);
|
|
@@ -13723,16 +13749,12 @@ async function loadJsonRules(jsonPath, baseDirs) {
|
|
|
13723
13749
|
}
|
|
13724
13750
|
const validationResult = validateJsonRuleFile(jsonData);
|
|
13725
13751
|
if (!validationResult.valid) {
|
|
13726
|
-
const
|
|
13727
|
-
throw new SentriflowConfigError(
|
|
13728
|
-
|
|
13729
|
-
${errorMessages}`
|
|
13730
|
-
);
|
|
13752
|
+
const errors = validationResult.errors.map((e) => ` ${e.path}: ${e.message}`).join("\n");
|
|
13753
|
+
throw new SentriflowConfigError(`Invalid JSON rules file:
|
|
13754
|
+
${errors}`);
|
|
13731
13755
|
}
|
|
13732
|
-
|
|
13733
|
-
|
|
13734
|
-
console.warn(`[JSON Rules] Warning: ${warning.path}: ${warning.message}`);
|
|
13735
|
-
}
|
|
13756
|
+
for (const warning of validationResult.warnings) {
|
|
13757
|
+
console.warn(`[JSON Rules] Warning: ${warning.path}: ${warning.message}`);
|
|
13736
13758
|
}
|
|
13737
13759
|
const ruleFile = jsonData;
|
|
13738
13760
|
const compiledRules = compileJsonRules(ruleFile.rules);
|
|
@@ -13740,12 +13762,7 @@ ${errorMessages}`
|
|
|
13740
13762
|
throw new SentriflowConfigError("No valid rules compiled from JSON file");
|
|
13741
13763
|
}
|
|
13742
13764
|
return compiledRules;
|
|
13743
|
-
}
|
|
13744
|
-
if (error instanceof SentriflowConfigError) {
|
|
13745
|
-
throw error;
|
|
13746
|
-
}
|
|
13747
|
-
throw new SentriflowConfigError("Failed to load JSON rules file");
|
|
13748
|
-
}
|
|
13765
|
+
}, "JSON rules");
|
|
13749
13766
|
}
|
|
13750
13767
|
function ruleAppliesToVendor(rule, vendorId) {
|
|
13751
13768
|
if (!rule.vendor) return true;
|
|
@@ -13766,70 +13783,52 @@ function isDefaultRuleDisabled(ruleId, vendorId, packs, legacyDisableIds) {
|
|
|
13766
13783
|
return false;
|
|
13767
13784
|
}
|
|
13768
13785
|
async function loadRulePackFile(packPath, baseDirs) {
|
|
13769
|
-
|
|
13770
|
-
|
|
13771
|
-
|
|
13772
|
-
|
|
13773
|
-
)
|
|
13774
|
-
|
|
13775
|
-
|
|
13776
|
-
|
|
13777
|
-
|
|
13778
|
-
|
|
13779
|
-
|
|
13780
|
-
|
|
13781
|
-
|
|
13782
|
-
|
|
13783
|
-
|
|
13784
|
-
|
|
13785
|
-
|
|
13786
|
+
return loadAndValidate({
|
|
13787
|
+
path: packPath,
|
|
13788
|
+
baseDirs,
|
|
13789
|
+
pathValidator: validateConfigPath,
|
|
13790
|
+
loader: async (p) => {
|
|
13791
|
+
const m = await import(p);
|
|
13792
|
+
return m.default ?? m;
|
|
13793
|
+
},
|
|
13794
|
+
validator: isValidRulePack,
|
|
13795
|
+
errorContext: "rule pack"
|
|
13796
|
+
});
|
|
13797
|
+
}
|
|
13798
|
+
function mapPackLoadError(error) {
|
|
13799
|
+
const messages = {
|
|
13800
|
+
DECRYPTION_FAILED: "Invalid license key for encrypted pack",
|
|
13801
|
+
EXPIRED: "Encrypted pack has expired",
|
|
13802
|
+
MACHINE_MISMATCH: "License is not valid for this machine",
|
|
13803
|
+
ACTIVATION_LIMIT: "Maximum activations exceeded for this license"
|
|
13804
|
+
};
|
|
13805
|
+
throw new SentriflowConfigError(
|
|
13806
|
+
messages[error.code] ?? `Failed to load encrypted pack: ${error.message}`
|
|
13807
|
+
);
|
|
13786
13808
|
}
|
|
13787
13809
|
async function loadEncryptedRulePack(packPath, licenseKey, baseDirs) {
|
|
13788
|
-
const
|
|
13789
|
-
|
|
13790
|
-
|
|
13791
|
-
|
|
13792
|
-
|
|
13793
|
-
|
|
13810
|
+
const canonicalPath = validatePathOrThrow(
|
|
13811
|
+
packPath,
|
|
13812
|
+
validateEncryptedPackPath,
|
|
13813
|
+
"encrypted pack",
|
|
13814
|
+
baseDirs
|
|
13815
|
+
);
|
|
13794
13816
|
try {
|
|
13795
|
-
const packData = await readFileAsync(
|
|
13817
|
+
const packData = await readFileAsync(canonicalPath);
|
|
13796
13818
|
if (!validatePackFormat(packData)) {
|
|
13797
13819
|
throw new SentriflowConfigError("Invalid encrypted pack format");
|
|
13798
13820
|
}
|
|
13799
13821
|
const loadedPack = await loadEncryptedPack(packData, {
|
|
13800
13822
|
licenseKey,
|
|
13801
13823
|
timeout: 1e4
|
|
13802
|
-
// 10 seconds for pack validation
|
|
13803
13824
|
});
|
|
13804
13825
|
return {
|
|
13805
13826
|
...loadedPack.metadata,
|
|
13806
13827
|
priority: 200,
|
|
13807
|
-
// High priority for licensed packs
|
|
13808
13828
|
rules: loadedPack.rules
|
|
13809
13829
|
};
|
|
13810
13830
|
} catch (error) {
|
|
13811
|
-
if (error instanceof PackLoadError)
|
|
13812
|
-
switch (error.code) {
|
|
13813
|
-
case "DECRYPTION_FAILED":
|
|
13814
|
-
throw new SentriflowConfigError(
|
|
13815
|
-
"Invalid license key for encrypted pack"
|
|
13816
|
-
);
|
|
13817
|
-
case "EXPIRED":
|
|
13818
|
-
throw new SentriflowConfigError("Encrypted pack has expired");
|
|
13819
|
-
case "MACHINE_MISMATCH":
|
|
13820
|
-
throw new SentriflowConfigError(
|
|
13821
|
-
"License is not valid for this machine"
|
|
13822
|
-
);
|
|
13823
|
-
case "ACTIVATION_LIMIT":
|
|
13824
|
-
throw new SentriflowConfigError(
|
|
13825
|
-
"Maximum activations exceeded for this license"
|
|
13826
|
-
);
|
|
13827
|
-
default:
|
|
13828
|
-
throw new SentriflowConfigError(
|
|
13829
|
-
`Failed to load encrypted pack: ${error.message}`
|
|
13830
|
-
);
|
|
13831
|
-
}
|
|
13832
|
-
}
|
|
13831
|
+
if (error instanceof PackLoadError) mapPackLoadError(error);
|
|
13833
13832
|
if (error instanceof SentriflowConfigError) throw error;
|
|
13834
13833
|
throw new SentriflowConfigError("Failed to load encrypted rule pack");
|
|
13835
13834
|
}
|
|
@@ -14191,7 +14190,7 @@ function validateDirectoryPath(dirPath, allowedBaseDirs) {
|
|
|
14191
14190
|
|
|
14192
14191
|
// index.ts
|
|
14193
14192
|
var program = new Command();
|
|
14194
|
-
program.name("sentriflow").description("SentriFlow Network Configuration Validator").version("0.1.
|
|
14193
|
+
program.name("sentriflow").description("SentriFlow Network Configuration Validator").version("0.1.5").argument("[file]", "Path to the configuration file").option("--ast", "Output the AST instead of rule results").option("-f, --format <format>", "Output format (json, sarif)", "json").option("-q, --quiet", "Only output failures (suppress passed results)").option("-c, --config <path>", "Path to config file (default: auto-detect)").option("--no-config", "Ignore config file").option("-r, --rules <path>", "Additional rules file to load (legacy)").option("-p, --rule-pack <path>", "Rule pack file to load").option(
|
|
14195
14194
|
"--encrypted-pack <path...>",
|
|
14196
14195
|
"SEC-012: Path(s) to encrypted rule pack(s) (.grpx), can specify multiple"
|
|
14197
14196
|
).option(
|
package/package.json
CHANGED
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@sentriflow/cli",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "SentriFlow CLI - Network configuration linter and validator",
|
|
5
|
-
"license": "Apache-2.0",
|
|
6
|
-
"main": "dist/index.js",
|
|
7
|
-
"module": "dist/index.js",
|
|
8
|
-
"type": "module",
|
|
9
|
-
"repository": {
|
|
10
|
-
"type": "git",
|
|
11
|
-
"url": "git+https://github.com/sentriflow/sentriflow.git",
|
|
12
|
-
"directory": "packages/cli"
|
|
13
|
-
},
|
|
14
|
-
"homepage": "https://github.com/sentriflow/sentriflow#readme",
|
|
15
|
-
"bugs": {
|
|
16
|
-
"url": "https://github.com/sentriflow/sentriflow/issues"
|
|
17
|
-
},
|
|
18
|
-
"keywords": [
|
|
19
|
-
"cli",
|
|
20
|
-
"network",
|
|
21
|
-
"configuration",
|
|
22
|
-
"linter",
|
|
23
|
-
"security",
|
|
24
|
-
"compliance",
|
|
25
|
-
"sarif"
|
|
26
|
-
],
|
|
27
|
-
"files": [
|
|
28
|
-
"dist",
|
|
29
|
-
"LICENSE",
|
|
30
|
-
"README.md"
|
|
31
|
-
],
|
|
32
|
-
"publishConfig": {
|
|
33
|
-
"access": "public"
|
|
34
|
-
},
|
|
35
|
-
"scripts": {
|
|
36
|
-
"build": "bun build.mjs",
|
|
37
|
-
"prepublishOnly": "bun run build"
|
|
38
|
-
},
|
|
39
|
-
"dependencies": {
|
|
40
|
-
"commander": "^14.0.2"
|
|
41
|
-
},
|
|
42
|
-
"devDependencies": {
|
|
43
|
-
"@sentriflow/core": "workspace:*",
|
|
44
|
-
"@sentriflow/rules-default": "workspace:*",
|
|
45
|
-
"bun-types": "latest",
|
|
46
|
-
"esbuild": "^0.27.0"
|
|
47
|
-
},
|
|
48
|
-
"bin": {
|
|
49
|
-
"sentriflow": "dist/index.js"
|
|
50
|
-
},
|
|
51
|
-
"engines": {
|
|
52
|
-
"node": ">=18.0.0"
|
|
53
|
-
}
|
|
54
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@sentriflow/cli",
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "SentriFlow CLI - Network configuration linter and validator",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"module": "dist/index.js",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/sentriflow/sentriflow.git",
|
|
12
|
+
"directory": "packages/cli"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/sentriflow/sentriflow#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/sentriflow/sentriflow/issues"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"cli",
|
|
20
|
+
"network",
|
|
21
|
+
"configuration",
|
|
22
|
+
"linter",
|
|
23
|
+
"security",
|
|
24
|
+
"compliance",
|
|
25
|
+
"sarif"
|
|
26
|
+
],
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"LICENSE",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "bun build.mjs",
|
|
37
|
+
"prepublishOnly": "bun run build"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"commander": "^14.0.2"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@sentriflow/core": "workspace:*",
|
|
44
|
+
"@sentriflow/rules-default": "workspace:*",
|
|
45
|
+
"bun-types": "latest",
|
|
46
|
+
"esbuild": "^0.27.0"
|
|
47
|
+
},
|
|
48
|
+
"bin": {
|
|
49
|
+
"sentriflow": "dist/index.js"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=18.0.0"
|
|
53
|
+
}
|
|
54
|
+
}
|