@oculum/scanner 1.0.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/dist/formatters/cli-terminal.d.ts +27 -0
- package/dist/formatters/cli-terminal.d.ts.map +1 -0
- package/dist/formatters/cli-terminal.js +412 -0
- package/dist/formatters/cli-terminal.js.map +1 -0
- package/dist/formatters/github-comment.d.ts +41 -0
- package/dist/formatters/github-comment.d.ts.map +1 -0
- package/dist/formatters/github-comment.js +306 -0
- package/dist/formatters/github-comment.js.map +1 -0
- package/dist/formatters/grouping.d.ts +52 -0
- package/dist/formatters/grouping.d.ts.map +1 -0
- package/dist/formatters/grouping.js +152 -0
- package/dist/formatters/grouping.js.map +1 -0
- package/dist/formatters/index.d.ts +9 -0
- package/dist/formatters/index.d.ts.map +1 -0
- package/dist/formatters/index.js +35 -0
- package/dist/formatters/index.js.map +1 -0
- package/dist/formatters/vscode-diagnostic.d.ts +103 -0
- package/dist/formatters/vscode-diagnostic.d.ts.map +1 -0
- package/dist/formatters/vscode-diagnostic.js +151 -0
- package/dist/formatters/vscode-diagnostic.js.map +1 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +648 -0
- package/dist/index.js.map +1 -0
- package/dist/layer1/comments.d.ts +8 -0
- package/dist/layer1/comments.d.ts.map +1 -0
- package/dist/layer1/comments.js +203 -0
- package/dist/layer1/comments.js.map +1 -0
- package/dist/layer1/config-audit.d.ts +8 -0
- package/dist/layer1/config-audit.d.ts.map +1 -0
- package/dist/layer1/config-audit.js +252 -0
- package/dist/layer1/config-audit.js.map +1 -0
- package/dist/layer1/entropy.d.ts +8 -0
- package/dist/layer1/entropy.d.ts.map +1 -0
- package/dist/layer1/entropy.js +500 -0
- package/dist/layer1/entropy.js.map +1 -0
- package/dist/layer1/file-flags.d.ts +7 -0
- package/dist/layer1/file-flags.d.ts.map +1 -0
- package/dist/layer1/file-flags.js +112 -0
- package/dist/layer1/file-flags.js.map +1 -0
- package/dist/layer1/index.d.ts +36 -0
- package/dist/layer1/index.d.ts.map +1 -0
- package/dist/layer1/index.js +132 -0
- package/dist/layer1/index.js.map +1 -0
- package/dist/layer1/patterns.d.ts +8 -0
- package/dist/layer1/patterns.d.ts.map +1 -0
- package/dist/layer1/patterns.js +482 -0
- package/dist/layer1/patterns.js.map +1 -0
- package/dist/layer1/urls.d.ts +8 -0
- package/dist/layer1/urls.d.ts.map +1 -0
- package/dist/layer1/urls.js +296 -0
- package/dist/layer1/urls.js.map +1 -0
- package/dist/layer1/weak-crypto.d.ts +7 -0
- package/dist/layer1/weak-crypto.d.ts.map +1 -0
- package/dist/layer1/weak-crypto.js +291 -0
- package/dist/layer1/weak-crypto.js.map +1 -0
- package/dist/layer2/ai-agent-tools.d.ts +19 -0
- package/dist/layer2/ai-agent-tools.d.ts.map +1 -0
- package/dist/layer2/ai-agent-tools.js +528 -0
- package/dist/layer2/ai-agent-tools.js.map +1 -0
- package/dist/layer2/ai-endpoint-protection.d.ts +36 -0
- package/dist/layer2/ai-endpoint-protection.d.ts.map +1 -0
- package/dist/layer2/ai-endpoint-protection.js +332 -0
- package/dist/layer2/ai-endpoint-protection.js.map +1 -0
- package/dist/layer2/ai-execution-sinks.d.ts +18 -0
- package/dist/layer2/ai-execution-sinks.d.ts.map +1 -0
- package/dist/layer2/ai-execution-sinks.js +496 -0
- package/dist/layer2/ai-execution-sinks.js.map +1 -0
- package/dist/layer2/ai-fingerprinting.d.ts +7 -0
- package/dist/layer2/ai-fingerprinting.d.ts.map +1 -0
- package/dist/layer2/ai-fingerprinting.js +654 -0
- package/dist/layer2/ai-fingerprinting.js.map +1 -0
- package/dist/layer2/ai-prompt-hygiene.d.ts +19 -0
- package/dist/layer2/ai-prompt-hygiene.d.ts.map +1 -0
- package/dist/layer2/ai-prompt-hygiene.js +356 -0
- package/dist/layer2/ai-prompt-hygiene.js.map +1 -0
- package/dist/layer2/ai-rag-safety.d.ts +21 -0
- package/dist/layer2/ai-rag-safety.d.ts.map +1 -0
- package/dist/layer2/ai-rag-safety.js +459 -0
- package/dist/layer2/ai-rag-safety.js.map +1 -0
- package/dist/layer2/ai-schema-validation.d.ts +25 -0
- package/dist/layer2/ai-schema-validation.d.ts.map +1 -0
- package/dist/layer2/ai-schema-validation.js +375 -0
- package/dist/layer2/ai-schema-validation.js.map +1 -0
- package/dist/layer2/auth-antipatterns.d.ts +20 -0
- package/dist/layer2/auth-antipatterns.d.ts.map +1 -0
- package/dist/layer2/auth-antipatterns.js +333 -0
- package/dist/layer2/auth-antipatterns.js.map +1 -0
- package/dist/layer2/byok-patterns.d.ts +12 -0
- package/dist/layer2/byok-patterns.d.ts.map +1 -0
- package/dist/layer2/byok-patterns.js +299 -0
- package/dist/layer2/byok-patterns.js.map +1 -0
- package/dist/layer2/dangerous-functions.d.ts +7 -0
- package/dist/layer2/dangerous-functions.d.ts.map +1 -0
- package/dist/layer2/dangerous-functions.js +1375 -0
- package/dist/layer2/dangerous-functions.js.map +1 -0
- package/dist/layer2/data-exposure.d.ts +16 -0
- package/dist/layer2/data-exposure.d.ts.map +1 -0
- package/dist/layer2/data-exposure.js +279 -0
- package/dist/layer2/data-exposure.js.map +1 -0
- package/dist/layer2/framework-checks.d.ts +7 -0
- package/dist/layer2/framework-checks.d.ts.map +1 -0
- package/dist/layer2/framework-checks.js +388 -0
- package/dist/layer2/framework-checks.js.map +1 -0
- package/dist/layer2/index.d.ts +58 -0
- package/dist/layer2/index.d.ts.map +1 -0
- package/dist/layer2/index.js +380 -0
- package/dist/layer2/index.js.map +1 -0
- package/dist/layer2/logic-gates.d.ts +7 -0
- package/dist/layer2/logic-gates.d.ts.map +1 -0
- package/dist/layer2/logic-gates.js +182 -0
- package/dist/layer2/logic-gates.js.map +1 -0
- package/dist/layer2/risky-imports.d.ts +7 -0
- package/dist/layer2/risky-imports.d.ts.map +1 -0
- package/dist/layer2/risky-imports.js +161 -0
- package/dist/layer2/risky-imports.js.map +1 -0
- package/dist/layer2/variables.d.ts +8 -0
- package/dist/layer2/variables.d.ts.map +1 -0
- package/dist/layer2/variables.js +152 -0
- package/dist/layer2/variables.js.map +1 -0
- package/dist/layer3/anthropic.d.ts +83 -0
- package/dist/layer3/anthropic.d.ts.map +1 -0
- package/dist/layer3/anthropic.js +1745 -0
- package/dist/layer3/anthropic.js.map +1 -0
- package/dist/layer3/index.d.ts +24 -0
- package/dist/layer3/index.d.ts.map +1 -0
- package/dist/layer3/index.js +119 -0
- package/dist/layer3/index.js.map +1 -0
- package/dist/layer3/openai.d.ts +25 -0
- package/dist/layer3/openai.d.ts.map +1 -0
- package/dist/layer3/openai.js +238 -0
- package/dist/layer3/openai.js.map +1 -0
- package/dist/layer3/package-check.d.ts +63 -0
- package/dist/layer3/package-check.d.ts.map +1 -0
- package/dist/layer3/package-check.js +508 -0
- package/dist/layer3/package-check.js.map +1 -0
- package/dist/modes/incremental.d.ts +66 -0
- package/dist/modes/incremental.d.ts.map +1 -0
- package/dist/modes/incremental.js +200 -0
- package/dist/modes/incremental.js.map +1 -0
- package/dist/tiers.d.ts +125 -0
- package/dist/tiers.d.ts.map +1 -0
- package/dist/tiers.js +234 -0
- package/dist/tiers.js.map +1 -0
- package/dist/types.d.ts +175 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +50 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/auth-helper-detector.d.ts +56 -0
- package/dist/utils/auth-helper-detector.d.ts.map +1 -0
- package/dist/utils/auth-helper-detector.js +360 -0
- package/dist/utils/auth-helper-detector.js.map +1 -0
- package/dist/utils/context-helpers.d.ts +96 -0
- package/dist/utils/context-helpers.d.ts.map +1 -0
- package/dist/utils/context-helpers.js +493 -0
- package/dist/utils/context-helpers.js.map +1 -0
- package/dist/utils/diff-detector.d.ts +53 -0
- package/dist/utils/diff-detector.d.ts.map +1 -0
- package/dist/utils/diff-detector.js +104 -0
- package/dist/utils/diff-detector.js.map +1 -0
- package/dist/utils/diff-parser.d.ts +80 -0
- package/dist/utils/diff-parser.d.ts.map +1 -0
- package/dist/utils/diff-parser.js +202 -0
- package/dist/utils/diff-parser.js.map +1 -0
- package/dist/utils/imported-auth-detector.d.ts +37 -0
- package/dist/utils/imported-auth-detector.d.ts.map +1 -0
- package/dist/utils/imported-auth-detector.js +251 -0
- package/dist/utils/imported-auth-detector.js.map +1 -0
- package/dist/utils/middleware-detector.d.ts +55 -0
- package/dist/utils/middleware-detector.d.ts.map +1 -0
- package/dist/utils/middleware-detector.js +260 -0
- package/dist/utils/middleware-detector.js.map +1 -0
- package/dist/utils/oauth-flow-detector.d.ts +41 -0
- package/dist/utils/oauth-flow-detector.d.ts.map +1 -0
- package/dist/utils/oauth-flow-detector.js +202 -0
- package/dist/utils/oauth-flow-detector.js.map +1 -0
- package/dist/utils/path-exclusions.d.ts +55 -0
- package/dist/utils/path-exclusions.d.ts.map +1 -0
- package/dist/utils/path-exclusions.js +222 -0
- package/dist/utils/path-exclusions.js.map +1 -0
- package/dist/utils/project-context-builder.d.ts +119 -0
- package/dist/utils/project-context-builder.d.ts.map +1 -0
- package/dist/utils/project-context-builder.js +534 -0
- package/dist/utils/project-context-builder.js.map +1 -0
- package/dist/utils/registry-clients.d.ts +93 -0
- package/dist/utils/registry-clients.d.ts.map +1 -0
- package/dist/utils/registry-clients.js +273 -0
- package/dist/utils/registry-clients.js.map +1 -0
- package/dist/utils/trpc-analyzer.d.ts +78 -0
- package/dist/utils/trpc-analyzer.d.ts.map +1 -0
- package/dist/utils/trpc-analyzer.js +297 -0
- package/dist/utils/trpc-analyzer.js.map +1 -0
- package/package.json +45 -0
- package/src/__tests__/benchmark/fixtures/false-positives.ts +227 -0
- package/src/__tests__/benchmark/fixtures/index.ts +68 -0
- package/src/__tests__/benchmark/fixtures/layer1/config-audit.ts +364 -0
- package/src/__tests__/benchmark/fixtures/layer1/hardcoded-secrets.ts +173 -0
- package/src/__tests__/benchmark/fixtures/layer1/high-entropy.ts +234 -0
- package/src/__tests__/benchmark/fixtures/layer1/index.ts +31 -0
- package/src/__tests__/benchmark/fixtures/layer1/sensitive-urls.ts +90 -0
- package/src/__tests__/benchmark/fixtures/layer1/weak-crypto.ts +197 -0
- package/src/__tests__/benchmark/fixtures/layer2/ai-agent-tools.ts +170 -0
- package/src/__tests__/benchmark/fixtures/layer2/ai-endpoint-protection.ts +418 -0
- package/src/__tests__/benchmark/fixtures/layer2/ai-execution-sinks.ts +189 -0
- package/src/__tests__/benchmark/fixtures/layer2/ai-fingerprinting.ts +316 -0
- package/src/__tests__/benchmark/fixtures/layer2/ai-prompt-hygiene.ts +178 -0
- package/src/__tests__/benchmark/fixtures/layer2/ai-rag-safety.ts +184 -0
- package/src/__tests__/benchmark/fixtures/layer2/ai-schema-validation.ts +434 -0
- package/src/__tests__/benchmark/fixtures/layer2/auth-antipatterns.ts +159 -0
- package/src/__tests__/benchmark/fixtures/layer2/byok-patterns.ts +112 -0
- package/src/__tests__/benchmark/fixtures/layer2/dangerous-functions.ts +246 -0
- package/src/__tests__/benchmark/fixtures/layer2/data-exposure.ts +168 -0
- package/src/__tests__/benchmark/fixtures/layer2/framework-checks.ts +346 -0
- package/src/__tests__/benchmark/fixtures/layer2/index.ts +67 -0
- package/src/__tests__/benchmark/fixtures/layer2/injection-vulnerabilities.ts +239 -0
- package/src/__tests__/benchmark/fixtures/layer2/logic-gates.ts +246 -0
- package/src/__tests__/benchmark/fixtures/layer2/risky-imports.ts +231 -0
- package/src/__tests__/benchmark/fixtures/layer2/variables.ts +167 -0
- package/src/__tests__/benchmark/index.ts +29 -0
- package/src/__tests__/benchmark/run-benchmark.ts +144 -0
- package/src/__tests__/benchmark/run-depth-validation.ts +206 -0
- package/src/__tests__/benchmark/run-real-world-test.ts +243 -0
- package/src/__tests__/benchmark/security-benchmark-script.ts +1737 -0
- package/src/__tests__/benchmark/tier-integration-script.ts +177 -0
- package/src/__tests__/benchmark/types.ts +144 -0
- package/src/__tests__/benchmark/utils/test-runner.ts +475 -0
- package/src/__tests__/regression/known-false-positives.test.ts +467 -0
- package/src/__tests__/snapshots/__snapshots__/scan-depth.test.ts.snap +178 -0
- package/src/__tests__/snapshots/scan-depth.test.ts +258 -0
- package/src/__tests__/validation/analyze-results.ts +542 -0
- package/src/__tests__/validation/extract-for-triage.ts +146 -0
- package/src/__tests__/validation/fp-deep-analysis.ts +327 -0
- package/src/__tests__/validation/run-validation.ts +364 -0
- package/src/__tests__/validation/triage-template.md +132 -0
- package/src/formatters/cli-terminal.ts +446 -0
- package/src/formatters/github-comment.ts +382 -0
- package/src/formatters/grouping.ts +190 -0
- package/src/formatters/index.ts +47 -0
- package/src/formatters/vscode-diagnostic.ts +243 -0
- package/src/index.ts +823 -0
- package/src/layer1/comments.ts +218 -0
- package/src/layer1/config-audit.ts +289 -0
- package/src/layer1/entropy.ts +583 -0
- package/src/layer1/file-flags.ts +127 -0
- package/src/layer1/index.ts +181 -0
- package/src/layer1/patterns.ts +516 -0
- package/src/layer1/urls.ts +334 -0
- package/src/layer1/weak-crypto.ts +328 -0
- package/src/layer2/ai-agent-tools.ts +601 -0
- package/src/layer2/ai-endpoint-protection.ts +387 -0
- package/src/layer2/ai-execution-sinks.ts +580 -0
- package/src/layer2/ai-fingerprinting.ts +758 -0
- package/src/layer2/ai-prompt-hygiene.ts +411 -0
- package/src/layer2/ai-rag-safety.ts +511 -0
- package/src/layer2/ai-schema-validation.ts +421 -0
- package/src/layer2/auth-antipatterns.ts +394 -0
- package/src/layer2/byok-patterns.ts +336 -0
- package/src/layer2/dangerous-functions.ts +1563 -0
- package/src/layer2/data-exposure.ts +315 -0
- package/src/layer2/framework-checks.ts +433 -0
- package/src/layer2/index.ts +473 -0
- package/src/layer2/logic-gates.ts +206 -0
- package/src/layer2/risky-imports.ts +186 -0
- package/src/layer2/variables.ts +166 -0
- package/src/layer3/anthropic.ts +2030 -0
- package/src/layer3/index.ts +130 -0
- package/src/layer3/package-check.ts +604 -0
- package/src/modes/incremental.ts +293 -0
- package/src/tiers.ts +318 -0
- package/src/types.ts +284 -0
- package/src/utils/auth-helper-detector.ts +443 -0
- package/src/utils/context-helpers.ts +535 -0
- package/src/utils/diff-detector.ts +135 -0
- package/src/utils/diff-parser.ts +272 -0
- package/src/utils/imported-auth-detector.ts +320 -0
- package/src/utils/middleware-detector.ts +333 -0
- package/src/utils/oauth-flow-detector.ts +246 -0
- package/src/utils/path-exclusions.ts +266 -0
- package/src/utils/project-context-builder.ts +707 -0
- package/src/utils/registry-clients.ts +351 -0
- package/src/utils/trpc-analyzer.ts +382 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# M7: Finding Triage Template
|
|
2
|
+
|
|
3
|
+
Use this template to document triage decisions for each medium+ severity finding from real-repo validation.
|
|
4
|
+
|
|
5
|
+
## Instructions
|
|
6
|
+
|
|
7
|
+
1. For each medium+ finding from the validation scan:
|
|
8
|
+
- Copy the template below
|
|
9
|
+
- Fill in the finding details
|
|
10
|
+
- Classify as TP/FP/Borderline
|
|
11
|
+
- Document your reasoning
|
|
12
|
+
- Decide on action
|
|
13
|
+
|
|
14
|
+
2. After triage, calculate FP rate:
|
|
15
|
+
- FP Rate = FP count / (TP count + FP count)
|
|
16
|
+
- Target: < 20%
|
|
17
|
+
|
|
18
|
+
3. For each FP:
|
|
19
|
+
- Add a regression test to `known-false-positives.test.ts`
|
|
20
|
+
- Consider tuning the detector if pattern is common
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Triage Records
|
|
25
|
+
|
|
26
|
+
### [Finding #1]
|
|
27
|
+
|
|
28
|
+
**Category:** [e.g., ai_rag_exfiltration]
|
|
29
|
+
**File:** [e.g., langchainjs/src/retrievers/base.ts]
|
|
30
|
+
**Line:** [e.g., 142]
|
|
31
|
+
**Severity:** [critical/high/medium]
|
|
32
|
+
**Title:** [Finding title from scan]
|
|
33
|
+
|
|
34
|
+
**Code Context:**
|
|
35
|
+
```typescript
|
|
36
|
+
// Paste relevant code snippet here
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Classification:** [ ] True Positive [ ] False Positive [ ] Borderline
|
|
40
|
+
|
|
41
|
+
**Code Type:** [ ] Library internal [ ] Example code [ ] Test file [ ] Other
|
|
42
|
+
|
|
43
|
+
**Reasoning:**
|
|
44
|
+
> [Explain why this is/isn't a real security issue. Consider:
|
|
45
|
+
> - Is this intentional design (library deferring to consumers)?
|
|
46
|
+
> - Is this example/demo code?
|
|
47
|
+
> - Is the finding contextually accurate?]
|
|
48
|
+
|
|
49
|
+
**Action:**
|
|
50
|
+
- [ ] Keep finding as-is
|
|
51
|
+
- [ ] Tune detector (describe change needed)
|
|
52
|
+
- [ ] Add to known-FP fixtures
|
|
53
|
+
- [ ] Downgrade severity
|
|
54
|
+
|
|
55
|
+
**Notes:**
|
|
56
|
+
> [Any additional context or follow-up needed]
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
### [Finding #2]
|
|
61
|
+
|
|
62
|
+
**Category:**
|
|
63
|
+
**File:**
|
|
64
|
+
**Line:**
|
|
65
|
+
**Severity:**
|
|
66
|
+
**Title:**
|
|
67
|
+
|
|
68
|
+
**Code Context:**
|
|
69
|
+
```typescript
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Classification:** [ ] True Positive [ ] False Positive [ ] Borderline
|
|
73
|
+
|
|
74
|
+
**Code Type:** [ ] Library internal [ ] Example code [ ] Test file [ ] Other
|
|
75
|
+
|
|
76
|
+
**Reasoning:**
|
|
77
|
+
>
|
|
78
|
+
|
|
79
|
+
**Action:**
|
|
80
|
+
- [ ] Keep finding as-is
|
|
81
|
+
- [ ] Tune detector
|
|
82
|
+
- [ ] Add to known-FP fixtures
|
|
83
|
+
- [ ] Downgrade severity
|
|
84
|
+
|
|
85
|
+
**Notes:**
|
|
86
|
+
>
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Summary Statistics
|
|
91
|
+
|
|
92
|
+
After completing triage:
|
|
93
|
+
|
|
94
|
+
| Metric | Count |
|
|
95
|
+
|--------|-------|
|
|
96
|
+
| Total medium+ findings | |
|
|
97
|
+
| True Positives | |
|
|
98
|
+
| False Positives | |
|
|
99
|
+
| Borderline | |
|
|
100
|
+
| **FP Rate** | % |
|
|
101
|
+
|
|
102
|
+
### Notable True Positives
|
|
103
|
+
|
|
104
|
+
List significant security issues found:
|
|
105
|
+
|
|
106
|
+
1.
|
|
107
|
+
2.
|
|
108
|
+
3.
|
|
109
|
+
|
|
110
|
+
### Common False Positive Patterns
|
|
111
|
+
|
|
112
|
+
List patterns that frequently triggered FPs:
|
|
113
|
+
|
|
114
|
+
1.
|
|
115
|
+
2.
|
|
116
|
+
3.
|
|
117
|
+
|
|
118
|
+
### Recommended Detector Tuning
|
|
119
|
+
|
|
120
|
+
Based on FP analysis:
|
|
121
|
+
|
|
122
|
+
1. **[Detector name]**: [Suggested change]
|
|
123
|
+
2. **[Detector name]**: [Suggested change]
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Action Items
|
|
128
|
+
|
|
129
|
+
- [ ] Add FP regression tests
|
|
130
|
+
- [ ] Update detectors based on findings
|
|
131
|
+
- [ ] Re-run validation to verify improvements
|
|
132
|
+
- [ ] Update docs/RESULTSCOMPARISON.md with final metrics
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Terminal Formatter
|
|
3
|
+
* Formats scan results with ANSI colors for terminal output
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ScanResult, Vulnerability, VulnerabilitySeverity } from '../types'
|
|
7
|
+
import { groupByTheme, getBlockingIssues, GroupedFindings, THEME_CONFIG } from './grouping'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* ANSI color codes
|
|
11
|
+
*/
|
|
12
|
+
const colors = {
|
|
13
|
+
reset: '\x1b[0m',
|
|
14
|
+
bold: '\x1b[1m',
|
|
15
|
+
dim: '\x1b[2m',
|
|
16
|
+
underline: '\x1b[4m',
|
|
17
|
+
|
|
18
|
+
// Foreground colors
|
|
19
|
+
red: '\x1b[31m',
|
|
20
|
+
green: '\x1b[32m',
|
|
21
|
+
yellow: '\x1b[33m',
|
|
22
|
+
blue: '\x1b[34m',
|
|
23
|
+
magenta: '\x1b[35m',
|
|
24
|
+
cyan: '\x1b[36m',
|
|
25
|
+
white: '\x1b[37m',
|
|
26
|
+
gray: '\x1b[90m',
|
|
27
|
+
|
|
28
|
+
// Background colors
|
|
29
|
+
bgRed: '\x1b[41m',
|
|
30
|
+
bgYellow: '\x1b[43m',
|
|
31
|
+
bgBlue: '\x1b[44m',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Severity colors and symbols
|
|
36
|
+
*/
|
|
37
|
+
const SEVERITY_STYLE: Record<VulnerabilitySeverity, { color: string; symbol: string; label: string }> = {
|
|
38
|
+
critical: { color: colors.bgRed + colors.white, symbol: '●', label: 'CRITICAL' },
|
|
39
|
+
high: { color: colors.red, symbol: '●', label: 'HIGH' },
|
|
40
|
+
medium: { color: colors.yellow, symbol: '●', label: 'MEDIUM' },
|
|
41
|
+
low: { color: colors.blue, symbol: '○', label: 'LOW' },
|
|
42
|
+
info: { color: colors.gray, symbol: '○', label: 'INFO' },
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Format colored text
|
|
47
|
+
*/
|
|
48
|
+
function c(color: string, text: string): string {
|
|
49
|
+
return `${color}${text}${colors.reset}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Format severity badge
|
|
54
|
+
*/
|
|
55
|
+
function severityBadge(severity: VulnerabilitySeverity): string {
|
|
56
|
+
const style = SEVERITY_STYLE[severity]
|
|
57
|
+
return c(style.color, `${style.symbol} ${style.label}`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Format a single finding for terminal
|
|
62
|
+
*/
|
|
63
|
+
function formatFinding(finding: Vulnerability, indent: string = ' '): string {
|
|
64
|
+
const badge = severityBadge(finding.severity)
|
|
65
|
+
const location = c(colors.cyan, `${finding.filePath}:${finding.lineNumber}`)
|
|
66
|
+
|
|
67
|
+
let output = `${indent}${badge} ${c(colors.bold, finding.title)}\n`
|
|
68
|
+
output += `${indent} ${location}\n`
|
|
69
|
+
output += `${indent} ${c(colors.dim, finding.description)}\n`
|
|
70
|
+
|
|
71
|
+
if (finding.suggestedFix) {
|
|
72
|
+
output += `${indent} ${c(colors.green, '💡 ' + finding.suggestedFix)}\n`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return output
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Format a group of findings
|
|
80
|
+
*/
|
|
81
|
+
function formatGroup(group: GroupedFindings, maxFindings: number = 10): string {
|
|
82
|
+
const { theme, themeName, findings, severityCounts } = group
|
|
83
|
+
const config = THEME_CONFIG[theme]
|
|
84
|
+
|
|
85
|
+
// Count summary
|
|
86
|
+
const counts: string[] = []
|
|
87
|
+
if (severityCounts.critical > 0) counts.push(c(colors.red, `${severityCounts.critical} critical`))
|
|
88
|
+
if (severityCounts.high > 0) counts.push(c(colors.red, `${severityCounts.high} high`))
|
|
89
|
+
if (severityCounts.medium > 0) counts.push(c(colors.yellow, `${severityCounts.medium} medium`))
|
|
90
|
+
if (severityCounts.low > 0) counts.push(c(colors.blue, `${severityCounts.low} low`))
|
|
91
|
+
if (severityCounts.info > 0) counts.push(c(colors.gray, `${severityCounts.info} info`))
|
|
92
|
+
|
|
93
|
+
let output = `\n${c(colors.bold, `${config.icon} ${themeName}`)} (${counts.join(', ')})\n`
|
|
94
|
+
output += c(colors.dim, '─'.repeat(60)) + '\n'
|
|
95
|
+
|
|
96
|
+
// Show findings
|
|
97
|
+
const shown = findings.slice(0, maxFindings)
|
|
98
|
+
for (const finding of shown) {
|
|
99
|
+
output += formatFinding(finding) + '\n'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Truncation notice
|
|
103
|
+
if (findings.length > maxFindings) {
|
|
104
|
+
output += c(colors.dim, ` ... and ${findings.length - maxFindings} more\n`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return output
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Format full scan result for terminal
|
|
112
|
+
*/
|
|
113
|
+
export function formatTerminalOutput(result: ScanResult, options: {
|
|
114
|
+
maxFindingsPerGroup?: number
|
|
115
|
+
showAllFindings?: boolean
|
|
116
|
+
noColor?: boolean
|
|
117
|
+
} = {}): string {
|
|
118
|
+
const {
|
|
119
|
+
maxFindingsPerGroup = 10,
|
|
120
|
+
showAllFindings = false,
|
|
121
|
+
} = options
|
|
122
|
+
|
|
123
|
+
const { vulnerabilities, severityCounts, hasBlockingIssues, filesScanned, scanDuration } = result
|
|
124
|
+
|
|
125
|
+
let output = '\n'
|
|
126
|
+
|
|
127
|
+
// Header
|
|
128
|
+
output += c(colors.bold, '═'.repeat(60)) + '\n'
|
|
129
|
+
output += c(colors.bold, ' OCULUM SECURITY SCAN RESULTS') + '\n'
|
|
130
|
+
output += c(colors.bold, '═'.repeat(60)) + '\n\n'
|
|
131
|
+
|
|
132
|
+
// Status
|
|
133
|
+
if (hasBlockingIssues) {
|
|
134
|
+
const blocking = severityCounts.critical + severityCounts.high
|
|
135
|
+
output += c(colors.bgRed + colors.white + colors.bold, ` 🚨 ${blocking} BLOCKING ISSUES FOUND `) + '\n\n'
|
|
136
|
+
} else if (vulnerabilities.length > 0) {
|
|
137
|
+
output += c(colors.yellow, `⚠️ ${vulnerabilities.length} issues found (no blocking issues)`) + '\n\n'
|
|
138
|
+
} else {
|
|
139
|
+
output += c(colors.green, '✅ No security issues found!') + '\n\n'
|
|
140
|
+
output += c(colors.dim, `Scanned ${filesScanned} files in ${(scanDuration / 1000).toFixed(1)}s`) + '\n'
|
|
141
|
+
return output
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Summary counts
|
|
145
|
+
output += c(colors.bold, 'Summary:') + '\n'
|
|
146
|
+
if (severityCounts.critical > 0) output += ` ${severityBadge('critical')} ${severityCounts.critical}\n`
|
|
147
|
+
if (severityCounts.high > 0) output += ` ${severityBadge('high')} ${severityCounts.high}\n`
|
|
148
|
+
if (severityCounts.medium > 0) output += ` ${severityBadge('medium')} ${severityCounts.medium}\n`
|
|
149
|
+
if (severityCounts.low > 0) output += ` ${severityBadge('low')} ${severityCounts.low}\n`
|
|
150
|
+
if (severityCounts.info > 0) output += ` ${severityBadge('info')} ${severityCounts.info}\n`
|
|
151
|
+
output += '\n'
|
|
152
|
+
|
|
153
|
+
// Blocking issues first
|
|
154
|
+
const blockingIssues = getBlockingIssues(vulnerabilities)
|
|
155
|
+
if (blockingIssues.length > 0) {
|
|
156
|
+
output += c(colors.bgRed + colors.white + colors.bold, ' BLOCKING ISSUES ') + '\n'
|
|
157
|
+
output += c(colors.red, 'These must be fixed before merging:') + '\n\n'
|
|
158
|
+
|
|
159
|
+
for (const finding of blockingIssues.slice(0, 10)) {
|
|
160
|
+
output += formatFinding(finding)
|
|
161
|
+
output += '\n'
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (blockingIssues.length > 10) {
|
|
165
|
+
output += c(colors.dim, ` ... and ${blockingIssues.length - 10} more blocking issues\n`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
output += '\n'
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Grouped findings
|
|
172
|
+
const grouped = groupByTheme(vulnerabilities)
|
|
173
|
+
output += c(colors.bold, '─'.repeat(60)) + '\n'
|
|
174
|
+
output += c(colors.bold, 'ALL FINDINGS BY CATEGORY') + '\n'
|
|
175
|
+
|
|
176
|
+
for (const group of grouped) {
|
|
177
|
+
// Skip if only showing non-blocking and all are blocking
|
|
178
|
+
if (!showAllFindings) {
|
|
179
|
+
const nonBlocking = group.findings.filter(
|
|
180
|
+
f => f.severity !== 'critical' && f.severity !== 'high'
|
|
181
|
+
)
|
|
182
|
+
if (nonBlocking.length === 0 && blockingIssues.length > 0) continue
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
output += formatGroup(group, maxFindingsPerGroup)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Footer
|
|
189
|
+
output += '\n' + c(colors.dim, '─'.repeat(60)) + '\n'
|
|
190
|
+
output += c(colors.dim, `Scanned ${filesScanned} files in ${(scanDuration / 1000).toFixed(1)}s`) + '\n'
|
|
191
|
+
|
|
192
|
+
return output
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Format as simple list (no grouping, no colors)
|
|
197
|
+
*/
|
|
198
|
+
export function formatSimpleList(vulnerabilities: Vulnerability[]): string {
|
|
199
|
+
let output = ''
|
|
200
|
+
|
|
201
|
+
for (const finding of vulnerabilities) {
|
|
202
|
+
const severity = finding.severity.toUpperCase().padEnd(8)
|
|
203
|
+
output += `[${severity}] ${finding.filePath}:${finding.lineNumber} - ${finding.title}\n`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return output
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Format as JSON (for piping to other tools)
|
|
211
|
+
*/
|
|
212
|
+
export function formatJSON(result: ScanResult, pretty: boolean = false): string {
|
|
213
|
+
if (pretty) {
|
|
214
|
+
return JSON.stringify(result, null, 2)
|
|
215
|
+
}
|
|
216
|
+
return JSON.stringify(result)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Rule metadata for SARIF output
|
|
221
|
+
*/
|
|
222
|
+
const RULE_METADATA: Record<string, { name: string; description: string; helpUri: string; tags: string[] }> = {
|
|
223
|
+
hardcoded_secret: {
|
|
224
|
+
name: 'Hardcoded Secret',
|
|
225
|
+
description: 'Sensitive credentials or API keys hardcoded in source code. These can be extracted from version control history or compiled binaries.',
|
|
226
|
+
helpUri: 'https://oculum.dev/docs/rules/hardcoded-secrets',
|
|
227
|
+
tags: ['security', 'secrets', 'credentials'],
|
|
228
|
+
},
|
|
229
|
+
high_entropy_string: {
|
|
230
|
+
name: 'High Entropy String',
|
|
231
|
+
description: 'A high-entropy string that may be a secret or API key. Review to ensure it is not sensitive data.',
|
|
232
|
+
helpUri: 'https://oculum.dev/docs/rules/high-entropy',
|
|
233
|
+
tags: ['security', 'secrets'],
|
|
234
|
+
},
|
|
235
|
+
ai_prompt_injection: {
|
|
236
|
+
name: 'AI Prompt Injection',
|
|
237
|
+
description: 'User input is included in AI prompts without proper sanitization, potentially allowing prompt injection attacks.',
|
|
238
|
+
helpUri: 'https://oculum.dev/docs/rules/prompt-injection',
|
|
239
|
+
tags: ['security', 'ai', 'injection'],
|
|
240
|
+
},
|
|
241
|
+
ai_unsafe_execution: {
|
|
242
|
+
name: 'AI Unsafe Execution',
|
|
243
|
+
description: 'AI-generated content is used in code execution, SQL queries, or other dangerous sinks without validation.',
|
|
244
|
+
helpUri: 'https://oculum.dev/docs/rules/unsafe-execution',
|
|
245
|
+
tags: ['security', 'ai', 'injection'],
|
|
246
|
+
},
|
|
247
|
+
ai_overpermissive_tool: {
|
|
248
|
+
name: 'AI Overpermissive Tool',
|
|
249
|
+
description: 'AI agent tool has excessive permissions without proper restrictions or sandboxing.',
|
|
250
|
+
helpUri: 'https://oculum.dev/docs/rules/overpermissive-tools',
|
|
251
|
+
tags: ['security', 'ai', 'authorization'],
|
|
252
|
+
},
|
|
253
|
+
ai_rag_exfiltration: {
|
|
254
|
+
name: 'AI RAG Data Exfiltration',
|
|
255
|
+
description: 'RAG (Retrieval Augmented Generation) queries may expose data across tenant boundaries or leak sensitive context.',
|
|
256
|
+
helpUri: 'https://oculum.dev/docs/rules/rag-exfiltration',
|
|
257
|
+
tags: ['security', 'ai', 'data-exposure'],
|
|
258
|
+
},
|
|
259
|
+
ai_endpoint_unprotected: {
|
|
260
|
+
name: 'AI Endpoint Unprotected',
|
|
261
|
+
description: 'AI endpoint lacks authentication or rate limiting, potentially allowing abuse or cost attacks.',
|
|
262
|
+
helpUri: 'https://oculum.dev/docs/rules/unprotected-endpoints',
|
|
263
|
+
tags: ['security', 'ai', 'authentication'],
|
|
264
|
+
},
|
|
265
|
+
ai_schema_mismatch: {
|
|
266
|
+
name: 'AI Schema Validation Missing',
|
|
267
|
+
description: 'AI-generated output is used without schema validation, potentially allowing malformed or malicious data.',
|
|
268
|
+
helpUri: 'https://oculum.dev/docs/rules/schema-validation',
|
|
269
|
+
tags: ['security', 'ai', 'validation'],
|
|
270
|
+
},
|
|
271
|
+
sql_injection: {
|
|
272
|
+
name: 'SQL Injection',
|
|
273
|
+
description: 'User input is concatenated into SQL queries without parameterization, allowing SQL injection attacks.',
|
|
274
|
+
helpUri: 'https://oculum.dev/docs/rules/sql-injection',
|
|
275
|
+
tags: ['security', 'injection', 'database'],
|
|
276
|
+
},
|
|
277
|
+
xss: {
|
|
278
|
+
name: 'Cross-Site Scripting (XSS)',
|
|
279
|
+
description: 'User input is rendered in HTML without proper escaping, allowing script injection.',
|
|
280
|
+
helpUri: 'https://oculum.dev/docs/rules/xss',
|
|
281
|
+
tags: ['security', 'injection', 'web'],
|
|
282
|
+
},
|
|
283
|
+
command_injection: {
|
|
284
|
+
name: 'Command Injection',
|
|
285
|
+
description: 'User input is passed to shell commands without sanitization, allowing arbitrary command execution.',
|
|
286
|
+
helpUri: 'https://oculum.dev/docs/rules/command-injection',
|
|
287
|
+
tags: ['security', 'injection', 'shell'],
|
|
288
|
+
},
|
|
289
|
+
missing_auth: {
|
|
290
|
+
name: 'Missing Authentication',
|
|
291
|
+
description: 'Sensitive endpoint or route lacks authentication checks.',
|
|
292
|
+
helpUri: 'https://oculum.dev/docs/rules/missing-auth',
|
|
293
|
+
tags: ['security', 'authentication'],
|
|
294
|
+
},
|
|
295
|
+
data_exposure: {
|
|
296
|
+
name: 'Data Exposure',
|
|
297
|
+
description: 'Sensitive data may be exposed through logging, error messages, or API responses.',
|
|
298
|
+
helpUri: 'https://oculum.dev/docs/rules/data-exposure',
|
|
299
|
+
tags: ['security', 'data-exposure'],
|
|
300
|
+
},
|
|
301
|
+
insecure_config: {
|
|
302
|
+
name: 'Insecure Configuration',
|
|
303
|
+
description: 'Security-relevant configuration is set to an insecure value.',
|
|
304
|
+
helpUri: 'https://oculum.dev/docs/rules/insecure-config',
|
|
305
|
+
tags: ['security', 'configuration'],
|
|
306
|
+
},
|
|
307
|
+
dangerous_function: {
|
|
308
|
+
name: 'Dangerous Function',
|
|
309
|
+
description: 'Use of a function known to be dangerous or deprecated for security reasons.',
|
|
310
|
+
helpUri: 'https://oculum.dev/docs/rules/dangerous-functions',
|
|
311
|
+
tags: ['security', 'code-quality'],
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Format as SARIF (Static Analysis Results Interchange Format)
|
|
317
|
+
* For integration with GitHub Code Scanning
|
|
318
|
+
*/
|
|
319
|
+
export function formatSARIF(result: ScanResult): object {
|
|
320
|
+
return {
|
|
321
|
+
$schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
|
|
322
|
+
version: '2.1.0',
|
|
323
|
+
runs: [{
|
|
324
|
+
tool: {
|
|
325
|
+
driver: {
|
|
326
|
+
name: 'Oculum',
|
|
327
|
+
version: '1.0.0',
|
|
328
|
+
informationUri: 'https://oculum.dev',
|
|
329
|
+
organization: 'Oculum Security',
|
|
330
|
+
rules: getUniqueRules(result.vulnerabilities),
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
results: result.vulnerabilities.map((v, index) => ({
|
|
334
|
+
ruleId: v.category,
|
|
335
|
+
ruleIndex: getRuleIndex(result.vulnerabilities, v.category),
|
|
336
|
+
level: mapSeverityToSARIF(v.severity),
|
|
337
|
+
message: {
|
|
338
|
+
text: v.description,
|
|
339
|
+
},
|
|
340
|
+
locations: [{
|
|
341
|
+
physicalLocation: {
|
|
342
|
+
artifactLocation: {
|
|
343
|
+
uri: v.filePath,
|
|
344
|
+
uriBaseId: '%SRCROOT%',
|
|
345
|
+
},
|
|
346
|
+
region: {
|
|
347
|
+
startLine: v.lineNumber,
|
|
348
|
+
startColumn: 1,
|
|
349
|
+
snippet: v.lineContent ? { text: v.lineContent } : undefined,
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
}],
|
|
353
|
+
fingerprints: {
|
|
354
|
+
'oculum/v1': `${v.category}:${v.filePath}:${v.lineNumber}`,
|
|
355
|
+
},
|
|
356
|
+
fixes: v.suggestedFix ? [{
|
|
357
|
+
description: {
|
|
358
|
+
text: v.suggestedFix,
|
|
359
|
+
},
|
|
360
|
+
}] : undefined,
|
|
361
|
+
properties: {
|
|
362
|
+
confidence: v.confidence,
|
|
363
|
+
layer: v.layer,
|
|
364
|
+
},
|
|
365
|
+
})),
|
|
366
|
+
columnKind: 'utf16CodeUnits',
|
|
367
|
+
}],
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function mapSeverityToSARIF(severity: VulnerabilitySeverity): 'error' | 'warning' | 'note' {
|
|
372
|
+
switch (severity) {
|
|
373
|
+
case 'critical':
|
|
374
|
+
case 'high':
|
|
375
|
+
return 'error'
|
|
376
|
+
case 'medium':
|
|
377
|
+
return 'warning'
|
|
378
|
+
default:
|
|
379
|
+
return 'note'
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function getRuleIndex(vulnerabilities: Vulnerability[], category: string): number {
|
|
384
|
+
const seen = new Set<string>()
|
|
385
|
+
let index = 0
|
|
386
|
+
for (const v of vulnerabilities) {
|
|
387
|
+
if (!seen.has(v.category)) {
|
|
388
|
+
if (v.category === category) return index
|
|
389
|
+
seen.add(v.category)
|
|
390
|
+
index++
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return 0
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function getUniqueRules(vulnerabilities: Vulnerability[]): object[] {
|
|
397
|
+
const seen = new Set<string>()
|
|
398
|
+
const rules: object[] = []
|
|
399
|
+
|
|
400
|
+
for (const v of vulnerabilities) {
|
|
401
|
+
if (seen.has(v.category)) continue
|
|
402
|
+
seen.add(v.category)
|
|
403
|
+
|
|
404
|
+
const metadata = RULE_METADATA[v.category]
|
|
405
|
+
const ruleName = metadata?.name || v.category.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
|
406
|
+
|
|
407
|
+
rules.push({
|
|
408
|
+
id: v.category,
|
|
409
|
+
name: ruleName,
|
|
410
|
+
shortDescription: { text: ruleName },
|
|
411
|
+
fullDescription: {
|
|
412
|
+
text: metadata?.description || v.description,
|
|
413
|
+
},
|
|
414
|
+
helpUri: metadata?.helpUri || `https://oculum.dev/docs/rules/${v.category.replace(/_/g, '-')}`,
|
|
415
|
+
help: {
|
|
416
|
+
text: metadata?.description || v.description,
|
|
417
|
+
markdown: `# ${ruleName}\n\n${metadata?.description || v.description}\n\n[Learn more](${metadata?.helpUri || 'https://oculum.dev/docs'})`,
|
|
418
|
+
},
|
|
419
|
+
defaultConfiguration: {
|
|
420
|
+
level: mapSeverityToSARIF(v.severity),
|
|
421
|
+
},
|
|
422
|
+
properties: {
|
|
423
|
+
tags: metadata?.tags || ['security'],
|
|
424
|
+
precision: v.confidence === 'high' ? 'high' : v.confidence === 'medium' ? 'medium' : 'low',
|
|
425
|
+
'security-severity': mapSeverityToScore(v.severity),
|
|
426
|
+
},
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return rules
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function mapSeverityToScore(severity: VulnerabilitySeverity): string {
|
|
434
|
+
switch (severity) {
|
|
435
|
+
case 'critical':
|
|
436
|
+
return '9.0'
|
|
437
|
+
case 'high':
|
|
438
|
+
return '7.0'
|
|
439
|
+
case 'medium':
|
|
440
|
+
return '5.0'
|
|
441
|
+
case 'low':
|
|
442
|
+
return '3.0'
|
|
443
|
+
default:
|
|
444
|
+
return '1.0'
|
|
445
|
+
}
|
|
446
|
+
}
|