generated from Labyricorn/labyricorn-project-template
Implement CyberSim Phase 2 MVP scenario-driven platform
This commit is contained in:
@@ -1,13 +1,16 @@
|
|||||||
# CyberSim OS
|
# CyberSim OS
|
||||||
|
|
||||||
**Phase 1 MVP: Scriptable CS End User Teaching Environment with Certificate Verification**
|
**Phase 2 MVP: Scenario-Driven Cybersecurity Simulation Platform**
|
||||||
|
|
||||||
CyberSim OS is an offline-first, browser-native cybersecurity simulation platform designed for ordinary end users. Learners enter a convincing fictional enterprise desktop, perform routine workplace activities, investigate ambiguous events, encounter realistic threats (credential phishing, malicious attachments) and legitimate false flags, receive a 7-axis behavioral after-action assessment, and earn cryptographically verifiable completion certificates (`*.cybercert`).
|
CyberSim OS is an offline-first, browser-native cybersecurity simulation platform designed for ordinary end users. Learners enter a convincing fictional enterprise desktop, perform routine workplace activities, investigate ambiguous events, encounter realistic threats (credential phishing, malicious attachments) and legitimate false flags, receive a multi-axis behavioral after-action assessment, and earn cryptographically verifiable completion certificates (`*.cybercert`).
|
||||||
|
|
||||||
|
**Phase 2** transforms CyberSim from a single hard-coded simulation into a **reusable, scenario-driven platform**. Complete experiences are authored through portable declarative JSON scenario packages — no application code changes required.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Key Features
|
## Key Features
|
||||||
|
|
||||||
|
- **Scenario-Driven Architecture**: Complete simulations are defined through JSON scenario packages. No code changes needed to create new experiences.
|
||||||
- **Convincing Fictional Desktop**: Original Windows-like theme with draggable/resizable windows, active/inactive focus states, taskbar, Start launcher, simulation clock, and system toast notifications with audio cues.
|
- **Convincing Fictional Desktop**: Original Windows-like theme with draggable/resizable windows, active/inactive focus states, taskbar, Start launcher, simulation clock, and system toast notifications with audio cues.
|
||||||
- **Core Simulated Workplace Applications**:
|
- **Core Simulated Workplace Applications**:
|
||||||
- **Inlook**: Email client with header inspector (RFC sender vs. friendly name), link hover destination tooltips, phishing report workflow, and reply/delete actions.
|
- **Inlook**: Email client with header inspector (RFC sender vs. friendly name), link hover destination tooltips, phishing report workflow, and reply/delete actions.
|
||||||
@@ -15,16 +18,9 @@ CyberSim OS is an offline-first, browser-native cybersecurity simulation platfor
|
|||||||
- **Files**: Virtual filesystem managing Documents, Downloads, and Company Shared folders.
|
- **Files**: Virtual filesystem managing Documents, Downloads, and Company Shared folders.
|
||||||
- **Doc Viewer**: Lightweight renderer for spreadsheets (`.xlsx`), policies (`.pdf`), and memos.
|
- **Doc Viewer**: Lightweight renderer for spreadsheets (`.xlsx`), policies (`.pdf`), and memos.
|
||||||
- **Security Center**: Endpoint protection dashboard, real-time alert logs, incident report confirmations, and delayed alert triggers.
|
- **Security Center**: Endpoint protection dashboard, real-time alert logs, incident report confirmations, and delayed alert triggers.
|
||||||
- **Realistic Decision Model**: Teaches *"Observe → Investigate → Verify → Decide → Act"* rather than *"Strange = Malicious"*. False flags and legitimate urgent notices test discernment.
|
- **Declarative Event System**: Scenario events with time-based, action-based, and state-based triggers execute through a restricted behavior vocabulary.
|
||||||
- **Delayed Consequences Engine**: Unsafe actions (e.g. submitting credentials on a phishing page) trigger delayed consequence alerts in Security Center without giving immediate arcade-like game-over feedback.
|
- **Data-Driven Scoring**: Scoring categories, rules, and thresholds are defined in the scenario package. The engine evaluates them generically.
|
||||||
- **7-Axis Behavioral Scoring**:
|
- **Delayed Consequences Engine**: Unsafe actions trigger delayed consequence events without giving immediate arcade-like game-over feedback.
|
||||||
1. *Threat Detection* (20 pts)
|
|
||||||
2. *Investigation & Evidence Gathering* (20 pts)
|
|
||||||
3. *Safe Handling* (15 pts)
|
|
||||||
4. *Independent Verification* (15 pts)
|
|
||||||
5. *Incident Reporting* (15 pts)
|
|
||||||
6. *False Positive Control* (15 pts)
|
|
||||||
7. *Operational Judgment* (Passing threshold: 80 / 100)
|
|
||||||
- **Verifiable Cryptographic Certificates**:
|
- **Verifiable Cryptographic Certificates**:
|
||||||
- Web Crypto SHA-256 scenario fingerprinting.
|
- Web Crypto SHA-256 scenario fingerprinting.
|
||||||
- Portable, structured `*.cybercert` JSON credential export.
|
- Portable, structured `*.cybercert` JSON credential export.
|
||||||
@@ -42,7 +38,7 @@ Run the lightweight local launcher to start the server at `http://127.0.0.1:8080
|
|||||||
python launcher.py
|
python launcher.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Your default web browser will open automatically.
|
Your default web browser will open automatically with the default scenario.
|
||||||
|
|
||||||
### Option 2: Static Web Server
|
### Option 2: Static Web Server
|
||||||
Serve the `src/` directory with any static HTTP server:
|
Serve the `src/` directory with any static HTTP server:
|
||||||
@@ -53,8 +49,48 @@ python -m http.server 8000
|
|||||||
```
|
```
|
||||||
Open [http://127.0.0.1:8000](http://127.0.0.1:8000) in your browser.
|
Open [http://127.0.0.1:8000](http://127.0.0.1:8000) in your browser.
|
||||||
|
|
||||||
### Option 3: Direct Static Hosting
|
### Loading a Specific Scenario
|
||||||
Host the repository on GitHub Pages or any static file host pointing to `src/index.html`.
|
Use the `?scenario=` query parameter to load a scenario package:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://127.0.0.1:8080?scenario=scenarios/nexacore-orientation/scenario.json
|
||||||
|
http://127.0.0.1:8080?scenario=scenarios/quickstart-example/scenario.json
|
||||||
|
```
|
||||||
|
|
||||||
|
If no `?scenario=` parameter is specified, the engine loads `scenarios/nexacore-orientation/scenario.json` by default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Included Scenarios
|
||||||
|
|
||||||
|
| Scenario | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| **NexaCore Orientation** (`nexacore-orientation`) | Full-featured scenario: 5 emails, 3 web pages, 3 documents, 6 scoring categories, credential phishing + malicious attachment threats, 2 false flags. ~15 min. |
|
||||||
|
| **Meridian Health Quickstart** (`quickstart-example`) | Minimal scenario: 3 emails, 1 web page, 2 documents, 3 scoring categories, 1 credential phishing threat. ~5 min. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Creating a Scenario
|
||||||
|
|
||||||
|
Scenarios are self-contained JSON packages stored in a directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
scenarios/my-scenario/
|
||||||
|
├── scenario.json # Scenario definition (required)
|
||||||
|
├── assets/ # Optional images, media
|
||||||
|
└── README.md # Author notes (optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `scenario.json` file defines everything the learner experiences:
|
||||||
|
- **People** and **organizations** (contacts, domains, trust boundaries)
|
||||||
|
- **Messages** (emails with links, attachments, threat indicators)
|
||||||
|
- **Pages** (simulated websites with forms)
|
||||||
|
- **Files** (virtual documents, spreadsheets, policies)
|
||||||
|
- **Events** (time-triggered, action-triggered, state-triggered with delays)
|
||||||
|
- **Scoring** (categories, rules, conditions, thresholds)
|
||||||
|
- **Findings** and **feedback** (behavioral evaluation with pedagogical notes)
|
||||||
|
|
||||||
|
See `docs/scenario-format.md` for the full format reference.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -73,6 +109,13 @@ To verify a `*.cybercert` certificate issued by CyberSim OS:
|
|||||||
```
|
```
|
||||||
CyberSim-OS/
|
CyberSim-OS/
|
||||||
├── .labyricorn/ # Labyricorn exhibition & devlog publishing records
|
├── .labyricorn/ # Labyricorn exhibition & devlog publishing records
|
||||||
|
├── scenarios/
|
||||||
|
│ ├── nexacore-orientation/ # Phase 1 scenario (fully declarative)
|
||||||
|
│ │ └── scenario.json
|
||||||
|
│ └── quickstart-example/ # Second example scenario
|
||||||
|
│ └── scenario.json
|
||||||
|
├── docs/
|
||||||
|
│ └── scenario-format.md # Scenario format reference (TODO)
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── index.html # Main desktop simulation interface
|
│ ├── index.html # Main desktop simulation interface
|
||||||
│ ├── verify.html # Standalone offline certificate verifier
|
│ ├── verify.html # Standalone offline certificate verifier
|
||||||
@@ -89,19 +132,26 @@ CyberSim-OS/
|
|||||||
│ │ │ └── notifications.js # Toast notifications & Web Audio synth audio chime
|
│ │ │ └── notifications.js # Toast notifications & Web Audio synth audio chime
|
||||||
│ │ ├── apps/
|
│ │ ├── apps/
|
||||||
│ │ │ ├── inlook.js # Email client with RFC header/link inspection
|
│ │ │ ├── inlook.js # Email client with RFC header/link inspection
|
||||||
│ │ │ ├── navigator.js # Simulated browser with intranet & phishing pages
|
│ │ │ ├── navigator.js # Simulated browser with data-driven pages & forms
|
||||||
│ │ │ ├── files.js # Virtual filesystem explorer
|
│ │ │ ├── files.js # Virtual filesystem explorer
|
||||||
│ │ │ ├── docviewer.js # Spreadsheet and document viewer
|
│ │ │ ├── docviewer.js # Spreadsheet and document viewer
|
||||||
│ │ │ └── security_center.js # Endpoint status & incident report dashboard
|
│ │ │ └── security_center.js # Endpoint status & incident report dashboard
|
||||||
│ │ ├── engine/
|
│ │ ├── engine/
|
||||||
│ │ │ ├── event_bus.js # Behavioral telemetry logger
|
│ │ │ ├── event_bus.js # Behavioral telemetry logger
|
||||||
│ │ │ └── consequence.js # Delayed consequence scheduler
|
│ │ │ ├── consequence.js # Consequence tracking (Phase 2 thin wrapper)
|
||||||
|
│ │ │ ├── scenario_state.js# Central mutable scenario state manager
|
||||||
|
│ │ │ ├── condition_evaluator.js # Declarative condition tree evaluator
|
||||||
|
│ │ │ ├── event_scheduler.js # Time/action/state-driven event executor
|
||||||
|
│ │ │ └── action_dispatcher.js # Restricted behavior vocabulary dispatcher
|
||||||
│ │ ├── scenario/
|
│ │ ├── scenario/
|
||||||
│ │ │ ├── scenario_ref1.js # Reference scenario ("NexaCore Shift 1")
|
│ │ │ ├── schema.js # JSON schema validation
|
||||||
|
│ │ │ ├── loader.js # Scenario package loader & sanitizer
|
||||||
|
│ │ │ ├── validator.js # Deep reference integrity validation
|
||||||
|
│ │ │ ├── diagnostics.js # Dev-mode diagnostics overlay
|
||||||
│ │ │ └── fingerprint.js # Web Crypto SHA-256 scenario fingerprinting
|
│ │ │ └── fingerprint.js # Web Crypto SHA-256 scenario fingerprinting
|
||||||
│ │ ├── scoring/
|
│ │ ├── scoring/
|
||||||
│ │ │ ├── scorer.js # Multi-axis behavioral scoring engine
|
│ │ │ ├── scorer.js # Data-driven behavioral scoring engine
|
||||||
│ │ │ └── aar.js # After-Action Report modal with pedagogical feedback
|
│ │ │ └── aar.js # After-Action Report modal
|
||||||
│ │ └── cert/
|
│ │ └── cert/
|
||||||
│ │ ├── cert_generator.js# *.cybercert JSON generator & printable certificate
|
│ │ ├── cert_generator.js# *.cybercert JSON generator & printable certificate
|
||||||
│ │ └── cert_verifier.js # Offline cryptographic certificate verifier
|
│ │ └── cert_verifier.js # Offline cryptographic certificate verifier
|
||||||
@@ -113,6 +163,22 @@ CyberSim-OS/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Development Mode
|
||||||
|
|
||||||
|
Add `?dev=true` to the URL to enable the diagnostics overlay:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://127.0.0.1:8080?scenario=../scenarios/nexacore-orientation/scenario.json&dev=true
|
||||||
|
```
|
||||||
|
|
||||||
|
The diagnostics panel shows:
|
||||||
|
- Scenario validation results (errors, warnings)
|
||||||
|
- Object counts (people, messages, events, etc.)
|
||||||
|
- Live event states and scores
|
||||||
|
- Scenario fingerprint
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Validation & Devlog Maintenance
|
## Validation & Devlog Maintenance
|
||||||
|
|
||||||
Run the validation suite to ensure `.labyricorn/` records and devlog entries adhere to the schema:
|
Run the validation suite to ensure `.labyricorn/` records and devlog entries adhere to the schema:
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
# CyberSim Scenario Format v1.0 — Specification & Author Guide
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
The **CyberSim Scenario Format v1.0** is a declarative, portable, JSON-based format for authoring simulation scenarios in CyberSim OS. It enables scenario designers to construct complete cybersecurity training experiences without writing or modifying CyberSim OS code.
|
||||||
|
|
||||||
|
A scenario package defines:
|
||||||
|
- **Simulation identity & metadata** (title, description, duration, learner profile, target score)
|
||||||
|
- **Workplace context** (people, organizations, domains, department hierarchies)
|
||||||
|
- **Simulated objects** (emails, web pages, intranet sites, forms, documents, spreadsheets, files)
|
||||||
|
- **Scheduled & reactive events** (timed triggers, action-driven consequences, delays, repetitions)
|
||||||
|
- **Behavior vocabulary & actions** (app launches, notifications, alert generation, inbox deliveries)
|
||||||
|
- **Multi-axis behavioral evaluation** (scoring rules, pedagogical findings, after-action feedback)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Package Structure
|
||||||
|
|
||||||
|
A scenario is stored within a self-contained directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
scenarios/my-scenario/
|
||||||
|
├── scenario.json # Main manifest & scenario definition (required)
|
||||||
|
├── assets/ # Local attachments & assets (optional)
|
||||||
|
└── README.md # Scenario author notes & changelog (optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
For large scenarios, sub-collections can optionally be split across multiple files using `$ref:` references:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"formatVersion": "1.0",
|
||||||
|
"id": "enterprise-phish-triage",
|
||||||
|
"people": "$ref:people.json",
|
||||||
|
"messages": "$ref:messages.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
*Note: All referenced files must reside within the scenario directory tree. Paths with `..`, absolute paths, and external URL schemes are rejected by the loader.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Top-Level Manifest Reference
|
||||||
|
|
||||||
|
| Property | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `formatVersion` | `string` | **Yes** | Must be `"1.0"`. |
|
||||||
|
| `id` | `string` | **Yes** | Unique identifier (lowercase alphanumeric and hyphens). |
|
||||||
|
| `version` | `string` | **Yes** | Semantic version of the scenario (e.g. `"1.0.0"`). |
|
||||||
|
| `title` | `string` | **Yes** | Human-readable title displayed in the launcher and AAR. |
|
||||||
|
| `description` | `string` | **Yes** | Overview of the simulation setting and primary challenges. |
|
||||||
|
| `durationSeconds`| `number` | **Yes** | Expected run time in seconds (e.g. `900`). |
|
||||||
|
| `mode` | `string` | **Yes** | Instruction mode: `"guided"`, `"practice"`, or `"assessment"`. |
|
||||||
|
| `entryEvent` | `string` | **Yes** | ID of the event executed at scenario start. |
|
||||||
|
| `passingScore` | `number` | **Yes** | Minimum score required to pass and earn a certificate (0–100). |
|
||||||
|
| `engine` | `object` | No | Minimum engine version requirements (`{ "minimumVersion": "0.2.0" }`). |
|
||||||
|
| `learner` | `object` | No | Initial persona: `{ name, role, email, department }`. |
|
||||||
|
| `organizations` | `array` | No | List of simulated organizations & trusted domain lists. |
|
||||||
|
| `people` | `array` | No | Simulated personas and contact directory. |
|
||||||
|
| `objectives` | `array` | No | Visible shift objectives shown in Start Menu and UI. |
|
||||||
|
| `messages` | `array` | No | Simulated email messages (Inlook). |
|
||||||
|
| `pages` | `array` | No | Simulated web pages and browser targets (Navigator). |
|
||||||
|
| `files` | `array` | No | Virtual filesystem items (Documents, Downloads, Shared). |
|
||||||
|
| `notifications`| `array` | No | Pre-configured desktop toast notification templates. |
|
||||||
|
| `alerts` | `array` | No | Security Center alert definitions. |
|
||||||
|
| `events` | `array` | **Yes** | Declarative triggers and action lists. |
|
||||||
|
| `scoring` | `object` | **Yes** | Category declarations and rule definitions. |
|
||||||
|
| `findings` | `array` | No | Predefined pedagogical findings and deductions. |
|
||||||
|
| `feedback` | `array` | No | Performance-conditional after-action remarks. |
|
||||||
|
| `completion` | `object` | No | Finalization criteria and certificate issuance rules. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Object Types
|
||||||
|
|
||||||
|
### 4.1 Organizations (`organizations[]`)
|
||||||
|
Defines trusted enterprise domains and third-party corporate entities.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "nexacore",
|
||||||
|
"name": "NexaCore Technologies",
|
||||||
|
"domains": ["nexacore.internal"],
|
||||||
|
"departments": ["Finance", "Information Security", "IT Helpdesk"],
|
||||||
|
"securityContacts": ["[email protected]"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 People (`people[]`)
|
||||||
|
Defines characters and email identities.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "alex-rivera",
|
||||||
|
"name": "Alex Rivera",
|
||||||
|
"role": "Chief Information Security Officer",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"organization": "nexacore",
|
||||||
|
"department": "Information Security"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Messages (`messages[]`)
|
||||||
|
Defines email communications in Inlook.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "email_phish_pwreset",
|
||||||
|
"sender": "NexaCore IT Helpdesk",
|
||||||
|
"rfcSender": "[email protected]",
|
||||||
|
"recipient": "[email protected]",
|
||||||
|
"subject": "URGENT: Password Verification Required",
|
||||||
|
"date": "09:05 AM",
|
||||||
|
"folder": "pending",
|
||||||
|
"unread": true,
|
||||||
|
"body": "Please confirm credentials at: <a class=\"inlook-msg-link\" data-url=\"http://login-nexac0re-portal.com/auth/login\" data-display=\"https://intranet.nexacore.internal/sso\">https://intranet.nexacore.internal/sso</a>",
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"displayText": "https://intranet.nexacore.internal/sso",
|
||||||
|
"actualUrl": "http://login-nexac0re-portal.com/auth/login"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attachments": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Pages (`pages[]`)
|
||||||
|
Defines intranet and external web destinations in Navigator. Form submissions are bound declaratively by ID.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "http://login-nexac0re-portal.com/auth/login",
|
||||||
|
"title": "NexaCore Identity SSO",
|
||||||
|
"isSecure": false,
|
||||||
|
"isPhishing": true,
|
||||||
|
"content": "<div class=\"phish-card\"><form id=\"phish-login-form\">...</form></div>",
|
||||||
|
"forms": [
|
||||||
|
{
|
||||||
|
"id": "phish-login-form",
|
||||||
|
"onSubmit": {
|
||||||
|
"emitEvent": "NAV_FORM_SUBMITTED",
|
||||||
|
"target": "phish_login_form",
|
||||||
|
"response": {
|
||||||
|
"type": "pageContent",
|
||||||
|
"content": "<div>Account Verification Complete</div>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Files (`files[]`)
|
||||||
|
Virtual files accessible in the Files app and DocViewer.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "file_budget",
|
||||||
|
"name": "Q3_Budget_Forecast.xlsx",
|
||||||
|
"type": "spreadsheet",
|
||||||
|
"folder": "Documents",
|
||||||
|
"size": "48 KB",
|
||||||
|
"date": "2026-08-20",
|
||||||
|
"content": {
|
||||||
|
"title": "Q3 Budget Forecast",
|
||||||
|
"headers": ["Category", "Q1", "Q2", "Q3"],
|
||||||
|
"rows": [["Infrastructure", "$142k", "$155k", "$168k"]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Event System & Behavior Vocabulary
|
||||||
|
|
||||||
|
Events are evaluated deterministically on each engine cycle.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "deliver-phish-email",
|
||||||
|
"when": { "elapsedSeconds": 30 },
|
||||||
|
"actions": [
|
||||||
|
{ "type": "mail.deliver", "message": "email_phish_pwreset" },
|
||||||
|
{ "type": "desktop.notify", "title": "Inlook Mail", "body": "New message received.", "type": "info" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.1 Condition Triggers (`when`)
|
||||||
|
- `{ "scenarioStart": true }` — Triggers on the initial simulation tick.
|
||||||
|
- `{ "elapsedSeconds": 45 }` — Triggers when `simSeconds >= 45`.
|
||||||
|
- `{ "actionOccurred": { "type": "EMAIL_OPENED", "target": "email_phish" } }` — Triggers on user action.
|
||||||
|
- `{ "stateEquals": { "object": "inlook", "key": "selected", "value": "email_1" } }` — Triggers on state match.
|
||||||
|
- `{ "all": [ condition1, condition2 ] }` — Logical AND.
|
||||||
|
- `{ "any": [ condition1, condition2 ] }` — Logical OR.
|
||||||
|
- `{ "not": condition }` — Logical NOT.
|
||||||
|
|
||||||
|
### 5.2 Behavior Vocabulary (Action Whitelist)
|
||||||
|
|
||||||
|
#### Desktop Actions
|
||||||
|
- `desktop.notify` — Toast alert (`{ title, body, type, timeout, icon }` or `{ notification: "notif_id" }`).
|
||||||
|
- `desktop.openApp` — Launch app (`{ app: "inlook" | "navigator" | "files" | "security_center" | "docviewer" }`).
|
||||||
|
- `desktop.focusApp` — Bring window to focus.
|
||||||
|
- `desktop.setBadge` — Update taskbar/tab badge counter.
|
||||||
|
- `desktop.endScenario` — Complete the session and launch AAR.
|
||||||
|
|
||||||
|
#### Mail Actions
|
||||||
|
- `mail.deliver` — Deliver message to Inlook inbox (`{ message: "msg_id" }`).
|
||||||
|
- `mail.updateMessage` — Update message attributes.
|
||||||
|
|
||||||
|
#### Navigator Actions
|
||||||
|
- `navigator.open` — Open browser to specified URL (`{ url: "http://..." }`).
|
||||||
|
- `navigator.redirect` — Redirect current browser tab.
|
||||||
|
|
||||||
|
#### Files Actions
|
||||||
|
- `files.create` — Dynamically inject file into virtual filesystem.
|
||||||
|
- `files.open` — Open document in DocViewer (`{ fileId: "file_id" }`).
|
||||||
|
|
||||||
|
#### Security Center Actions
|
||||||
|
- `security.addAlert` — Add security notification item (`{ alert: "alert_id" }` or inline object).
|
||||||
|
- `security.updateStatus` — Change dashboard threat state.
|
||||||
|
|
||||||
|
#### Evaluation Actions
|
||||||
|
- `evaluation.addFinding` — Record a pedagogical finding (`{ finding: "finding_id" }`).
|
||||||
|
- `evaluation.setState` — Set custom runtime state variable.
|
||||||
|
- `evaluation.completeObjective` — Mark a shift task completed (`{ objective: "obj_id" }`).
|
||||||
|
- `evaluation.awardPoints` — Adjust category points (`{ category: "cat_id", points: 10 }`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Scoring & Findings Pipeline
|
||||||
|
|
||||||
|
Scoring is computed at the end of the shift or on-demand:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scoring": {
|
||||||
|
"categories": [
|
||||||
|
{ "id": "threat-detection", "label": "Threat Detection", "maxPoints": 20, "startingPoints": 0 },
|
||||||
|
{ "id": "safe-handling", "label": "Safe Handling", "maxPoints": 15, "startingPoints": 15 }
|
||||||
|
],
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"id": "opened-phish-email",
|
||||||
|
"condition": { "actionOccurred": { "type": "EMAIL_OPENED", "target": "email_phish_pwreset" } },
|
||||||
|
"award": { "category": "threat-detection", "points": 10 },
|
||||||
|
"timeline": { "type": "positive", "text": "Identified and reviewed the password reset notice." }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "submitted-phish-credentials",
|
||||||
|
"condition": { "actionOccurred": { "type": "NAV_FORM_SUBMITTED", "target": "phish_login_form" } },
|
||||||
|
"award": { "category": "safe-handling", "points": -10 },
|
||||||
|
"timeline": { "type": "critical", "text": "Entered password on untrusted external portal." },
|
||||||
|
"feedback": "CRITICAL: Never enter credentials into untrusted external sites."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Cryptographic Fingerprinting & Certificates
|
||||||
|
|
||||||
|
When a scenario is loaded, the engine computes a canonical SHA-256 hash across all evaluation-relevant elements (messages, pages, files, events, scoring rules, findings).
|
||||||
|
|
||||||
|
Upon completion with a passing score (`totalScore >= passingScore`), the learner may claim an exportable `*.cybercert` JSON record. The certificate embeds the scenario fingerprint and an integrity hash that can be verified offline via `verify.html`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Authoring Diagnostics & Development Mode
|
||||||
|
|
||||||
|
To inspect and test scenarios during development, add `?dev=true` to the URL:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://127.0.0.1:8080?scenario=scenarios/my-scenario/scenario.json&dev=true
|
||||||
|
```
|
||||||
|
|
||||||
|
The diagnostics overlay provides:
|
||||||
|
- Live structural & semantic validation errors
|
||||||
|
- Event trigger states and scheduled timers
|
||||||
|
- Current category score breakdowns and recorded findings
|
||||||
|
- Scenario SHA-256 fingerprint calculations
|
||||||
+54
-12
@@ -17,23 +17,20 @@
|
|||||||
<div id="notification-container"></div>
|
<div id="notification-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Start Menu -->
|
<!-- Start Menu (populated dynamically from scenario data) -->
|
||||||
<div id="start-menu">
|
<div id="start-menu">
|
||||||
<div class="start-header">
|
<div class="start-header">
|
||||||
<div class="start-user-avatar">JT</div>
|
<div class="start-user-avatar" id="start-user-avatar">??</div>
|
||||||
<div class="start-user-info">
|
<div class="start-user-info">
|
||||||
<div class="user-name">Jordan Taylor</div>
|
<div class="user-name" id="start-user-name">Loading...</div>
|
||||||
<div class="user-role">Financial Operations • NexaCore</div>
|
<div class="user-role" id="start-user-role">CyberSim OS</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="start-body">
|
<div class="start-body">
|
||||||
<div class="start-section-title">Shift Responsibilities</div>
|
<div class="start-section-title">Shift Responsibilities</div>
|
||||||
<div style="background:rgba(255,255,255,0.05); padding:10px; border-radius:6px; font-size:11px; color:#cbd5e1; line-height:1.4;">
|
<div id="start-objectives" style="background:rgba(255,255,255,0.05); padding:10px; border-radius:6px; font-size:11px; color:#cbd5e1; line-height:1.4;">
|
||||||
• Review Inlook communications & tasks<br>
|
Loading scenario objectives...
|
||||||
• Inspect Q3 Budget Forecast in Documents<br>
|
|
||||||
• Review NexaCore Cybersecurity Policy v4<br>
|
|
||||||
• Report suspicious incidents to Security Center
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="start-section-title" style="margin-top:6px;">Applications</div>
|
<div class="start-section-title" style="margin-top:6px;">Applications</div>
|
||||||
@@ -41,7 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="start-footer">
|
<div class="start-footer">
|
||||||
<div style="font-size:10px; color:#64748b; font-family:var(--font-mono);">CyberSim v1.0.0 (Phase 1 MVP)</div>
|
<div style="font-size:10px; color:#64748b; font-family:var(--font-mono);">CyberSim v0.2.0 (Phase 2)</div>
|
||||||
<button class="start-btn-finish" id="start-btn-finish">Finish Shift & View Assessment</button>
|
<button class="start-btn-finish" id="start-btn-finish">Finish Shift & View Assessment</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,13 +48,13 @@
|
|||||||
<div class="taskbar-left">
|
<div class="taskbar-left">
|
||||||
<button class="taskbar-btn start-btn" id="start-btn">
|
<button class="taskbar-btn start-btn" id="start-btn">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg>
|
||||||
<span>NexaCore</span>
|
<span id="taskbar-org-name">CyberSim</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="taskbar-apps" id="taskbar-apps"></div>
|
<div class="taskbar-apps" id="taskbar-apps"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="taskbar-right">
|
<div class="taskbar-right">
|
||||||
<div class="taskbar-tray-icon" title="Network: NexaCore-Corporate-WPA3 (Protected)">
|
<div class="taskbar-tray-icon" id="taskbar-network-icon" title="Network: Protected">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="taskbar-tray-icon" id="tray-notify-icon" title="Security & Notifications">
|
<div class="taskbar-tray-icon" id="tray-notify-icon" title="Security & Notifications">
|
||||||
@@ -68,6 +65,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Populate start menu from scenario data after load -->
|
||||||
|
<script type="module">
|
||||||
|
import { getScenarioUrl } from './js/scenario/loader.js';
|
||||||
|
|
||||||
|
// After main.js boots, populate the start menu with scenario data
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// The main bootstrapper will populate these elements after scenario loads
|
||||||
|
// Listen for scenario data to become available
|
||||||
|
const checkScenario = setInterval(() => {
|
||||||
|
if (window.CyberSimOS && window.CyberSimOS.engine && window.CyberSimOS.engine.scenario) {
|
||||||
|
clearInterval(checkScenario);
|
||||||
|
const s = window.CyberSimOS.engine.scenario;
|
||||||
|
|
||||||
|
// Populate start menu from scenario
|
||||||
|
const avatarEl = document.getElementById('start-user-avatar');
|
||||||
|
const nameEl = document.getElementById('start-user-name');
|
||||||
|
const roleEl = document.getElementById('start-user-role');
|
||||||
|
const objectivesEl = document.getElementById('start-objectives');
|
||||||
|
const orgNameEl = document.getElementById('taskbar-org-name');
|
||||||
|
const networkEl = document.getElementById('taskbar-network-icon');
|
||||||
|
|
||||||
|
if (s.learner && avatarEl) {
|
||||||
|
const initials = s.learner.name.split(' ').map(n => n[0]).join('');
|
||||||
|
avatarEl.textContent = initials;
|
||||||
|
}
|
||||||
|
if (s.learner && nameEl) nameEl.textContent = s.learner.name;
|
||||||
|
|
||||||
|
const orgName = (s.organizations && s.organizations[0]) ? s.organizations[0].name : 'CyberSim';
|
||||||
|
const roleLine = s.learner ? `${s.learner.role} \u2022 ${orgName}` : orgName;
|
||||||
|
if (roleEl) roleEl.textContent = roleLine;
|
||||||
|
if (orgNameEl) orgNameEl.textContent = orgName;
|
||||||
|
if (networkEl) networkEl.title = `Network: ${orgName}-Corporate-WPA3 (Protected)`;
|
||||||
|
|
||||||
|
if (s.objectives && objectivesEl) {
|
||||||
|
const items = s.objectives.map(obj => {
|
||||||
|
const text = typeof obj === 'string' ? obj : obj.text;
|
||||||
|
return `\u2022 ${text}`;
|
||||||
|
});
|
||||||
|
objectivesEl.innerHTML = items.join('<br>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<script type="module" src="js/main.js"></script>
|
<script type="module" src="js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+64
-11
@@ -1,5 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* CyberSim OS - Inlook Email Application
|
* CyberSim OS - Inlook Email Application (Phase 2)
|
||||||
|
*
|
||||||
|
* All domain trust decisions are driven by the scenario's organization
|
||||||
|
* definitions. No hard-coded domain names or sender addresses.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class InlookApp {
|
export class InlookApp {
|
||||||
@@ -13,17 +16,49 @@ export class InlookApp {
|
|||||||
this.onFileDownloaded = onFileDownloaded;
|
this.onFileDownloaded = onFileDownloaded;
|
||||||
this.currentFolder = 'inbox';
|
this.currentFolder = 'inbox';
|
||||||
this.activeEmailId = null;
|
this.activeEmailId = null;
|
||||||
this.emails = JSON.parse(JSON.stringify(scenario.emails));
|
|
||||||
|
// Deep-copy messages that are in the inbox at start
|
||||||
|
this.emails = [];
|
||||||
|
if (scenario.messages) {
|
||||||
|
scenario.messages.forEach(m => {
|
||||||
|
if (m.folder === 'inbox') {
|
||||||
|
this.emails.push(JSON.parse(JSON.stringify(m)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build trusted domain set from organizations for header inspection
|
||||||
|
this.trustedDomains = new Set();
|
||||||
|
if (scenario.organizations) {
|
||||||
|
scenario.organizations.forEach(org => {
|
||||||
|
if (org.domains) {
|
||||||
|
org.domains.forEach(d => this.trustedDomains.add(d.toLowerCase()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getIconSvg() {
|
getIconSvg() {
|
||||||
return `<svg viewBox="0 0 24 24" fill="none" stroke="#2563eb" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>`;
|
return `<svg viewBox="0 0 24 24" fill="none" stroke="#2563eb" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver a message to the inbox at runtime (called by ActionDispatcher).
|
||||||
|
* @param {Object} messageObj - The message object from the scenario
|
||||||
|
*/
|
||||||
|
deliverMessage(messageObj) {
|
||||||
|
// Deep copy and set to inbox
|
||||||
|
const msg = JSON.parse(JSON.stringify(messageObj));
|
||||||
|
msg.folder = 'inbox';
|
||||||
|
msg.unread = true;
|
||||||
|
this.emails.push(msg);
|
||||||
|
this.renderEmailList();
|
||||||
|
}
|
||||||
|
|
||||||
launch() {
|
launch() {
|
||||||
const win = this.wm.createWindow({
|
const win = this.wm.createWindow({
|
||||||
id: 'inlook',
|
id: 'inlook',
|
||||||
title: 'Inlook Mail - NexaCore Workplace',
|
title: 'Inlook Mail - Workplace',
|
||||||
iconSvg: this.getIconSvg(),
|
iconSvg: this.getIconSvg(),
|
||||||
width: 860,
|
width: 860,
|
||||||
height: 560,
|
height: 560,
|
||||||
@@ -138,7 +173,7 @@ export class InlookApp {
|
|||||||
|
|
||||||
this.eventBus.emit('EMAIL_OPENED', {
|
this.eventBus.emit('EMAIL_OPENED', {
|
||||||
target: id,
|
target: id,
|
||||||
details: { subject: email.subject, rfcSender: email.rfcSender, isThreat: !!email.isThreat }
|
details: { subject: email.subject, rfcSender: email.rfcSender }
|
||||||
});
|
});
|
||||||
|
|
||||||
const readingPane = document.getElementById('inlook-reading-pane');
|
const readingPane = document.getElementById('inlook-reading-pane');
|
||||||
@@ -193,6 +228,17 @@ export class InlookApp {
|
|||||||
this.bindEmailActions(email);
|
this.bindEmailActions(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a sender domain matches a trusted organization domain.
|
||||||
|
* @param {string} senderEmail
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isTrustedSender(senderEmail) {
|
||||||
|
if (!senderEmail || !senderEmail.includes('@')) return false;
|
||||||
|
const domain = senderEmail.split('@')[1].toLowerCase();
|
||||||
|
return this.trustedDomains.has(domain);
|
||||||
|
}
|
||||||
|
|
||||||
bindEmailActions(email) {
|
bindEmailActions(email) {
|
||||||
const inspectBtn = document.getElementById('btn-inspect-header');
|
const inspectBtn = document.getElementById('btn-inspect-header');
|
||||||
if (inspectBtn) {
|
if (inspectBtn) {
|
||||||
@@ -202,9 +248,10 @@ export class InlookApp {
|
|||||||
details: { displaySender: email.sender, rfcSender: email.rfcSender }
|
details: { displaySender: email.sender, rfcSender: email.rfcSender }
|
||||||
});
|
});
|
||||||
|
|
||||||
const isDomainMismatch = email.rfcSender.includes('nexac0re-portal.com') || email.rfcSender.includes('apex-global-supplies.net');
|
const senderDomain = email.rfcSender.split('@')[1] || 'unknown';
|
||||||
|
const isDomainTrusted = this.isTrustedSender(email.rfcSender);
|
||||||
|
|
||||||
alert(`--- Inlook RFC Header Inspector ---\n\nDisplay Name: ${email.sender}\nEnvelope RFC From: <${email.rfcSender}>\nAuthentication-Results: spf=pass (domain: ${email.rfcSender.split('@')[1]})\nReturn-Path: <bounce@${email.rfcSender.split('@')[1]}>\n\n${isDomainMismatch ? '⚠️ Notice: Envelope domain differs from standard corporate (@nexacore.internal).' : '✓ Envelope domain matches internal enterprise domain.'}`);
|
alert(`--- Inlook RFC Header Inspector ---\n\nDisplay Name: ${email.sender}\nEnvelope RFC From: <${email.rfcSender}>\nAuthentication-Results: spf=pass (domain: ${senderDomain})\nReturn-Path: <bounce@${senderDomain}>\n\n${isDomainTrusted ? '✓ Envelope domain matches internal enterprise domain.' : '⚠️ Notice: Envelope domain differs from standard corporate domain.'}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +273,7 @@ export class InlookApp {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.eventBus.emit('EMAIL_LINK_CLICKED', {
|
this.eventBus.emit('EMAIL_LINK_CLICKED', {
|
||||||
target: email.id,
|
target: email.id,
|
||||||
details: { displayText, actualUrl, isPhishing: actualUrl.includes('nexac0re-portal.com') }
|
details: { displayText, actualUrl }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.onNavigateUrl) {
|
if (this.onNavigateUrl) {
|
||||||
@@ -257,6 +304,12 @@ export class InlookApp {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also emit FILE_DOWNLOADED for consequence matching
|
||||||
|
this.eventBus.emit('FILE_DOWNLOADED', {
|
||||||
|
target: attName,
|
||||||
|
details: { emailId: email.id, attachmentName: attName }
|
||||||
|
});
|
||||||
|
|
||||||
this.notifications.show({
|
this.notifications.show({
|
||||||
title: 'Download Complete',
|
title: 'Download Complete',
|
||||||
body: `File "${attName}" saved to Downloads folder.`,
|
body: `File "${attName}" saved to Downloads folder.`,
|
||||||
@@ -286,7 +339,7 @@ export class InlookApp {
|
|||||||
this.renderEmailList();
|
this.renderEmailList();
|
||||||
this.eventBus.emit('EMAIL_DELETED', {
|
this.eventBus.emit('EMAIL_DELETED', {
|
||||||
target: email.id,
|
target: email.id,
|
||||||
details: { subject: email.subject, wasThreat: !!email.isThreat }
|
details: { subject: email.subject }
|
||||||
});
|
});
|
||||||
this.selectFirstEmail();
|
this.selectFirstEmail();
|
||||||
this.notifications.show({
|
this.notifications.show({
|
||||||
@@ -311,7 +364,7 @@ export class InlookApp {
|
|||||||
<button class="cs-btn-win close" id="btn-close-modal">×</button>
|
<button class="cs-btn-win close" id="btn-close-modal">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cs-modal-body">
|
<div class="cs-modal-body">
|
||||||
<p style="margin-bottom:12px;">You are about to report the following email to the NexaCore Security Operations Center:</p>
|
<p style="margin-bottom:12px;">You are about to report the following email to the Security Operations Center:</p>
|
||||||
<div style="background:#f1f5f9; padding:10px; border-radius:4px; font-size:12px; margin-bottom:14px;">
|
<div style="background:#f1f5f9; padding:10px; border-radius:4px; font-size:12px; margin-bottom:14px;">
|
||||||
<strong>Subject:</strong> ${email.subject}<br>
|
<strong>Subject:</strong> ${email.subject}<br>
|
||||||
<strong>Sender:</strong> ${email.rfcSender}
|
<strong>Sender:</strong> ${email.rfcSender}
|
||||||
@@ -328,7 +381,7 @@ export class InlookApp {
|
|||||||
</div>
|
</div>
|
||||||
<div class="cs-form-group">
|
<div class="cs-form-group">
|
||||||
<label class="cs-form-label">Investigative Notes (Optional):</label>
|
<label class="cs-form-label">Investigative Notes (Optional):</label>
|
||||||
<textarea class="cs-textarea" id="report-notes" rows="2" placeholder="e.g. Lookalike domain nexac0re-portal.com detected"></textarea>
|
<textarea class="cs-textarea" id="report-notes" rows="2" placeholder="e.g. Suspicious sender domain detected"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cs-modal-footer">
|
<div class="cs-modal-footer">
|
||||||
@@ -349,7 +402,7 @@ export class InlookApp {
|
|||||||
|
|
||||||
this.eventBus.emit('EMAIL_REPORTED', {
|
this.eventBus.emit('EMAIL_REPORTED', {
|
||||||
target: email.id,
|
target: email.id,
|
||||||
details: { reason, notes, isThreat: !!email.isThreat, threatId: email.threatId || null }
|
details: { reason, notes }
|
||||||
});
|
});
|
||||||
|
|
||||||
email.folder = 'trash';
|
email.folder = 'trash';
|
||||||
|
|||||||
+122
-23
@@ -1,5 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* CyberSim OS - Navigator Simulated Web Browser
|
* CyberSim OS - Navigator Simulated Web Browser (Phase 2)
|
||||||
|
*
|
||||||
|
* All domain trust decisions and page rendering are driven by the scenario
|
||||||
|
* definition. No hard-coded domains or phishing detection.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class NavigatorApp {
|
export class NavigatorApp {
|
||||||
@@ -8,9 +11,24 @@ export class NavigatorApp {
|
|||||||
this.eventBus = eventBus;
|
this.eventBus = eventBus;
|
||||||
this.notifications = notifications;
|
this.notifications = notifications;
|
||||||
this.scenario = scenario;
|
this.scenario = scenario;
|
||||||
this.currentUrl = 'http://intranet.nexacore.internal';
|
|
||||||
|
// Derive home page from scenario or use first page URL
|
||||||
|
this.homePage = (scenario.pages && scenario.pages.length > 0)
|
||||||
|
? scenario.pages[0].url
|
||||||
|
: 'about:blank';
|
||||||
|
this.currentUrl = this.homePage;
|
||||||
this.history = [this.currentUrl];
|
this.history = [this.currentUrl];
|
||||||
this.historyIndex = 0;
|
this.historyIndex = 0;
|
||||||
|
|
||||||
|
// Build trusted domain set from organizations
|
||||||
|
this.trustedDomains = new Set();
|
||||||
|
if (scenario.organizations) {
|
||||||
|
scenario.organizations.forEach(org => {
|
||||||
|
if (org.domains) {
|
||||||
|
org.domains.forEach(d => this.trustedDomains.add(d.toLowerCase()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getIconSvg() {
|
getIconSvg() {
|
||||||
@@ -91,6 +109,30 @@ export class NavigatorApp {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a URL belongs to a trusted domain based on scenario organizations.
|
||||||
|
* @param {string} url
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isUrlTrusted(url) {
|
||||||
|
try {
|
||||||
|
// Extract domain from URL
|
||||||
|
const match = url.match(/^https?:\/\/([^\/\:]+)/);
|
||||||
|
if (!match) return false;
|
||||||
|
const hostname = match[1].toLowerCase();
|
||||||
|
|
||||||
|
// Check if hostname matches or is a subdomain of any trusted domain
|
||||||
|
for (const domain of this.trustedDomains) {
|
||||||
|
if (hostname === domain || hostname.endsWith('.' + domain)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
navigateTo(url, recordHistory = true) {
|
navigateTo(url, recordHistory = true) {
|
||||||
this.currentUrl = url;
|
this.currentUrl = url;
|
||||||
if (recordHistory) {
|
if (recordHistory) {
|
||||||
@@ -106,8 +148,10 @@ export class NavigatorApp {
|
|||||||
|
|
||||||
if (urlInput) urlInput.value = url;
|
if (urlInput) urlInput.value = url;
|
||||||
|
|
||||||
const isSecure = url.startsWith('https://') || url.includes('.nexacore.internal');
|
// Determine security from page definition or domain trust
|
||||||
const isPhish = url.includes('nexac0re-portal.com');
|
const page = this.findPage(url);
|
||||||
|
const isSecure = page ? !!page.isSecure : (url.startsWith('https://') || this.isUrlTrusted(url));
|
||||||
|
const isPhish = page ? !!page.isPhishing : false;
|
||||||
|
|
||||||
if (addrBar) {
|
if (addrBar) {
|
||||||
addrBar.className = `nav-address-bar ${isSecure ? 'secure' : 'insecure'}`;
|
addrBar.className = `nav-address-bar ${isSecure ? 'secure' : 'insecure'}`;
|
||||||
@@ -121,23 +165,37 @@ export class NavigatorApp {
|
|||||||
details: { isSecure, isPhishing: isPhish }
|
details: { isSecure, isPhishing: isPhish }
|
||||||
});
|
});
|
||||||
|
|
||||||
const page = this.scenario.pages.find(p => p.url === url) || this.scenario.pages.find(p => url.startsWith(p.url));
|
|
||||||
|
|
||||||
if (viewport) {
|
if (viewport) {
|
||||||
if (page) {
|
if (page) {
|
||||||
viewport.innerHTML = page.content;
|
viewport.innerHTML = page.content;
|
||||||
this.bindPageLinks(viewport);
|
this.bindPageLinks(viewport);
|
||||||
|
this.bindPageForms(viewport, page);
|
||||||
} else {
|
} else {
|
||||||
viewport.innerHTML = `
|
viewport.innerHTML = `
|
||||||
<div style="padding:60px 20px; text-align:center;">
|
<div style="padding:60px 20px; text-align:center;">
|
||||||
<h2 style="color:#64748b; font-size:18px; margin-bottom:8px;">404 Page Not Found</h2>
|
<h2 style="color:#64748b; font-size:18px; margin-bottom:8px;">404 Page Not Found</h2>
|
||||||
<p style="color:#94a3b8; font-size:13px;">The simulated URL <code>${url}</code> was not found on this simulation network.</p>
|
<p style="color:#94a3b8; font-size:13px;">The simulated URL <code>${url}</code> was not found on this simulation network.</p>
|
||||||
<button class="cs-btn cs-btn-primary" style="margin-top:16px;" onclick="window.CyberSimOS.navigator.navigateTo('http://intranet.nexacore.internal')">Go to Intranet Home</button>
|
<button class="cs-btn cs-btn-primary" style="margin-top:16px;" id="nav-go-home">Go to Home</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
const homeBtn = viewport.querySelector('#nav-go-home');
|
||||||
|
if (homeBtn) {
|
||||||
|
homeBtn.addEventListener('click', () => this.navigateTo(this.homePage));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a page definition matching the given URL.
|
||||||
|
* @param {string} url
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
findPage(url) {
|
||||||
|
if (!this.scenario.pages) return null;
|
||||||
|
return this.scenario.pages.find(p => p.url === url) ||
|
||||||
|
this.scenario.pages.find(p => url.startsWith(p.url));
|
||||||
|
}
|
||||||
|
|
||||||
bindPageLinks(viewport) {
|
bindPageLinks(viewport) {
|
||||||
viewport.querySelectorAll('a').forEach(a => {
|
viewport.querySelectorAll('a').forEach(a => {
|
||||||
@@ -151,35 +209,76 @@ export class NavigatorApp {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePhishFormSubmit(form) {
|
/**
|
||||||
const user = form.querySelector('#phish_user').value;
|
* Bind form submit handlers declaratively from the page definition.
|
||||||
const pass = form.querySelector('#phish_pass').value;
|
* Replaces the Phase 1 inline onsubmit pattern.
|
||||||
|
* @param {HTMLElement} viewport
|
||||||
|
* @param {Object} page - The page definition
|
||||||
|
*/
|
||||||
|
bindPageForms(viewport, page) {
|
||||||
|
if (!page.forms || !Array.isArray(page.forms)) return;
|
||||||
|
|
||||||
this.eventBus.emit('NAV_FORM_SUBMITTED', {
|
page.forms.forEach(formDef => {
|
||||||
target: 'phish_login_form',
|
const formEl = viewport.querySelector(`#${formDef.id}`);
|
||||||
|
if (!formEl) return;
|
||||||
|
|
||||||
|
formEl.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.handleFormSubmit(formEl, formDef, page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic form submit handler driven by scenario form definitions.
|
||||||
|
* @param {HTMLFormElement} formEl
|
||||||
|
* @param {Object} formDef - The form definition from the scenario
|
||||||
|
* @param {Object} page - The page definition
|
||||||
|
*/
|
||||||
|
handleFormSubmit(formEl, formDef, page) {
|
||||||
|
const formData = {};
|
||||||
|
formEl.querySelectorAll('input, textarea, select').forEach(input => {
|
||||||
|
if (input.id || input.name) {
|
||||||
|
const key = input.id || input.name;
|
||||||
|
formData[key] = input.type === 'password' ? !!input.value : input.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitConfig = formDef.onSubmit || {};
|
||||||
|
|
||||||
|
// Emit the configured event
|
||||||
|
this.eventBus.emit(submitConfig.emitEvent || 'NAV_FORM_SUBMITTED', {
|
||||||
|
target: submitConfig.target || formDef.id,
|
||||||
details: {
|
details: {
|
||||||
url: this.currentUrl,
|
url: this.currentUrl,
|
||||||
usernameEntered: user,
|
formId: formDef.id,
|
||||||
submittedPassword: !!pass,
|
formData,
|
||||||
isCompromised: true
|
isCompromised: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show notification if configured
|
||||||
|
if (submitConfig.notification) {
|
||||||
|
this.notifications.show(submitConfig.notification);
|
||||||
|
} else {
|
||||||
this.notifications.show({
|
this.notifications.show({
|
||||||
title: 'Identity Portal',
|
title: 'Form Submitted',
|
||||||
body: 'Credentials accepted. Identity synchronization in progress...',
|
body: 'Your submission has been processed.',
|
||||||
type: 'info'
|
type: 'info'
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle response
|
||||||
|
if (submitConfig.response) {
|
||||||
|
const response = submitConfig.response;
|
||||||
|
if (response.type === 'pageContent') {
|
||||||
const viewport = document.getElementById('nav-viewport');
|
const viewport = document.getElementById('nav-viewport');
|
||||||
if (viewport) {
|
if (viewport) {
|
||||||
viewport.innerHTML = `
|
viewport.innerHTML = response.content;
|
||||||
<div style="padding:60px 20px; text-align:center; max-width:480px; margin:auto;">
|
}
|
||||||
<div style="font-size:36px; margin-bottom:12px;">✔</div>
|
} else if (response.type === 'redirect') {
|
||||||
<h2 style="color:#0f172a; font-size:18px; margin-bottom:8px;">Account Verification Complete</h2>
|
this.navigateTo(response.url);
|
||||||
<p style="color:#64748b; font-size:13px; line-height:1.5;">Your credentials have been verified with our external single sign-on synchronization gateway. You may return to your workplace desktop.</p>
|
}
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* CyberSim OS - Security Center Application
|
* CyberSim OS - Security Center Application (Phase 2)
|
||||||
|
*
|
||||||
|
* Initial alerts and all content are driven by the scenario definition.
|
||||||
|
* No hard-coded alert text or organization-specific content.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class SecurityCenterApp {
|
export class SecurityCenterApp {
|
||||||
constructor({ windowManager, eventBus }) {
|
constructor({ windowManager, eventBus, scenario }) {
|
||||||
this.wm = windowManager;
|
this.wm = windowManager;
|
||||||
this.eventBus = eventBus;
|
this.eventBus = eventBus;
|
||||||
this.alerts = [
|
this.scenario = scenario || {};
|
||||||
{
|
|
||||||
id: 'alert_initial_status',
|
// Initialize with scenario-defined initial alerts
|
||||||
title: 'Endpoint Threat Protection Active',
|
this.alerts = [];
|
||||||
severity: 'info',
|
if (scenario && scenario.alerts) {
|
||||||
source: 'NexaCore Endpoint Agent',
|
// Add alerts marked with a timestamp (initial/pre-existing alerts)
|
||||||
message: 'Endpoint sensor status: Healthy. Definitions version 2026.08.24-1.',
|
scenario.alerts.forEach(alert => {
|
||||||
timestamp: '08:30 AM'
|
if (alert.timestamp) {
|
||||||
|
this.alerts.push({ ...alert });
|
||||||
}
|
}
|
||||||
];
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.reports = [];
|
this.reports = [];
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
@@ -39,6 +45,10 @@ export class SecurityCenterApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addAlert(alertObj) {
|
addAlert(alertObj) {
|
||||||
|
// If alertObj has no timestamp, add one
|
||||||
|
if (!alertObj.timestamp) {
|
||||||
|
alertObj.timestamp = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
this.alerts.unshift(alertObj);
|
this.alerts.unshift(alertObj);
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
@@ -46,7 +56,7 @@ export class SecurityCenterApp {
|
|||||||
launch() {
|
launch() {
|
||||||
const win = this.wm.createWindow({
|
const win = this.wm.createWindow({
|
||||||
id: 'security_center',
|
id: 'security_center',
|
||||||
title: 'NexaCore Security Center',
|
title: 'Security Center',
|
||||||
iconSvg: this.getIconSvg(),
|
iconSvg: this.getIconSvg(),
|
||||||
width: 760,
|
width: 760,
|
||||||
height: 520,
|
height: 520,
|
||||||
@@ -77,12 +87,12 @@ export class SecurityCenterApp {
|
|||||||
const main = document.getElementById('sec-main-content');
|
const main = document.getElementById('sec-main-content');
|
||||||
if (!main) return;
|
if (!main) return;
|
||||||
|
|
||||||
const hasHighAlert = this.alerts.some(a => a.severity === 'high');
|
const hasHighAlert = this.alerts.some(a => a.severity === 'high' || a.severity === 'critical');
|
||||||
|
|
||||||
main.innerHTML = `
|
main.innerHTML = `
|
||||||
<div class="sec-header">
|
<div class="sec-header">
|
||||||
<div class="sec-title">Workstation Security Status</div>
|
<div class="sec-title">Workstation Security Status</div>
|
||||||
<div style="font-size:12px; color:#64748b;">NexaCore Enterprise Zero-Trust Endpoint Protection</div>
|
<div style="font-size:12px; color:#64748b;">Enterprise Zero-Trust Endpoint Protection</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sec-status-banner ${hasHighAlert ? 'alert-state' : ''}">
|
<div class="sec-status-banner ${hasHighAlert ? 'alert-state' : ''}">
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* CyberSim OS - Cryptographic Certificate Generator & *.cybercert Exporter
|
* CyberSim OS - Cryptographic Certificate Generator & *.cybercert Exporter
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const ENGINE_VERSION = '0.2.0-phase2';
|
||||||
|
|
||||||
export class CertificateGenerator {
|
export class CertificateGenerator {
|
||||||
constructor(scenario, scoreResult, scenarioFingerprint) {
|
constructor(scenario, scoreResult, scenarioFingerprint) {
|
||||||
this.scenario = scenario;
|
this.scenario = scenario;
|
||||||
@@ -16,24 +18,25 @@ export class CertificateGenerator {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCertificateData(learnerName = 'Jordan Taylor') {
|
async createCertificateData(learnerName = null) {
|
||||||
|
const finalLearnerName = learnerName || this.scenario.learner?.name || 'Unknown Learner';
|
||||||
const certId = this.generateUUID();
|
const certId = this.generateUUID();
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const engineVersion = '1.0.0-phase1';
|
const companyName = this.scenario.organizations?.[0]?.name || this.scenario.company?.name || 'CyberSim';
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
certificate_id: certId,
|
certificate_id: certId,
|
||||||
product: 'CyberSim OS',
|
product: 'CyberSim OS',
|
||||||
engine_version: engineVersion,
|
engine_version: ENGINE_VERSION,
|
||||||
scenario_id: this.scenario.scenarioId,
|
scenario_id: this.scenario.scenarioId,
|
||||||
scenario_title: this.scenario.title,
|
scenario_title: this.scenario.title,
|
||||||
scenario_version: this.scenario.version,
|
scenario_version: this.scenario.version,
|
||||||
scenario_fingerprint: this.scenarioFingerprint,
|
scenario_fingerprint: this.scenarioFingerprint,
|
||||||
learner: {
|
learner: {
|
||||||
name: learnerName,
|
name: finalLearnerName,
|
||||||
assigned_role: this.scenario.learner.role,
|
assigned_role: this.scenario.learner?.role || 'User',
|
||||||
organization: this.scenario.company.name
|
organization: companyName
|
||||||
},
|
},
|
||||||
evaluation: {
|
evaluation: {
|
||||||
score: this.scoreResult.totalScore,
|
score: this.scoreResult.totalScore,
|
||||||
@@ -47,7 +50,7 @@ export class CertificateGenerator {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Calculate integrity checksum over canonical record
|
// Calculate integrity checksum over canonical record
|
||||||
const canonicalStr = `${certId}:${learnerName}:${this.scenario.scenarioId}:${this.scenarioFingerprint}:${this.scoreResult.totalScore}:${timestamp}`;
|
const canonicalStr = `${certId}:${finalLearnerName}:${this.scenario.scenarioId}:${this.scenarioFingerprint}:${this.scoreResult.totalScore}:${timestamp}`;
|
||||||
const hashBuffer = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonicalStr));
|
const hashBuffer = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonicalStr));
|
||||||
const integrityHash = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
|
const integrityHash = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
@@ -68,8 +71,9 @@ export class CertificateGenerator {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
async showCertificateModal(learnerName = 'Jordan Taylor') {
|
async showCertificateModal(learnerName = null) {
|
||||||
const certData = await this.createCertificateData(learnerName);
|
const certData = await this.createCertificateData(learnerName);
|
||||||
|
const companyName = this.scenario.organizations?.[0]?.name || this.scenario.company?.name || 'CyberSim';
|
||||||
const modalOverlay = document.createElement('div');
|
const modalOverlay = document.createElement('div');
|
||||||
modalOverlay.className = 'cs-modal-overlay';
|
modalOverlay.className = 'cs-modal-overlay';
|
||||||
modalOverlay.style.zIndex = '9500';
|
modalOverlay.style.zIndex = '9500';
|
||||||
@@ -86,7 +90,7 @@ export class CertificateGenerator {
|
|||||||
|
|
||||||
<div class="cs-modal-body" style="background:#f8fafc; padding:20px;">
|
<div class="cs-modal-body" style="background:#f8fafc; padding:20px;">
|
||||||
<div class="cert-printable" id="cert-printable-area">
|
<div class="cert-printable" id="cert-printable-area">
|
||||||
<div class="cert-org">NexaCore Technologies • CyberSim Environment</div>
|
<div class="cert-org">${companyName} • CyberSim Environment</div>
|
||||||
<div class="cert-title">Certificate of Competency</div>
|
<div class="cert-title">Certificate of Competency</div>
|
||||||
<div class="cert-subtitle">End-User Cybersecurity Simulation & Behavioral Verification</div>
|
<div class="cert-subtitle">End-User Cybersecurity Simulation & Behavioral Verification</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
/**
|
||||||
|
* action_dispatcher.js
|
||||||
|
* Implements the restricted behavior vocabulary for mapping declarative action
|
||||||
|
* type strings to engine operations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const VALID_ACTION_TYPES = new Set([
|
||||||
|
'desktop.notify',
|
||||||
|
'desktop.openApp',
|
||||||
|
'desktop.focusApp',
|
||||||
|
'desktop.setBadge',
|
||||||
|
'desktop.endScenario',
|
||||||
|
'mail.deliver',
|
||||||
|
'mail.updateMessage',
|
||||||
|
'navigator.open',
|
||||||
|
'navigator.redirect',
|
||||||
|
'files.create',
|
||||||
|
'files.open',
|
||||||
|
'security.addAlert',
|
||||||
|
'security.updateStatus',
|
||||||
|
'evaluation.addFinding',
|
||||||
|
'evaluation.setState',
|
||||||
|
'evaluation.completeObjective',
|
||||||
|
'evaluation.awardPoints',
|
||||||
|
'evaluation.revealFeedback'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ActionDispatcher handles declarative actions triggered by events,
|
||||||
|
* mapping them to actual system operations within the simulation.
|
||||||
|
*/
|
||||||
|
export class ActionDispatcher {
|
||||||
|
/**
|
||||||
|
* Create an ActionDispatcher.
|
||||||
|
* @param {Object} deps - Dependencies.
|
||||||
|
* @param {Object} deps.scenarioState - The scenario state manager.
|
||||||
|
* @param {Object} deps.scenario - The current scenario definition.
|
||||||
|
* @param {Object} deps.eventBus - The global event bus.
|
||||||
|
* @param {Object} deps.notifications - The desktop notification manager.
|
||||||
|
* @param {Object} [deps.apps] - The initialized applications (can be registered later).
|
||||||
|
*/
|
||||||
|
constructor({ scenarioState, scenario, eventBus, notifications, apps = {} }) {
|
||||||
|
this.scenarioState = scenarioState;
|
||||||
|
this.scenario = scenario;
|
||||||
|
this.eventBus = eventBus;
|
||||||
|
this.notifications = notifications;
|
||||||
|
this.apps = apps;
|
||||||
|
this.onEndScenario = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register or update the application references.
|
||||||
|
* @param {Object} apps - Object containing app instances (e.g., inlook, navigator, files, docViewer, securityCenter).
|
||||||
|
*/
|
||||||
|
registerApps(apps) {
|
||||||
|
this.apps = { ...this.apps, ...apps };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a callback to be called when the scenario ends.
|
||||||
|
* @param {Function} callback - The callback function.
|
||||||
|
*/
|
||||||
|
registerEndScenarioCallback(callback) {
|
||||||
|
this.onEndScenario = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch an action by its declarative type.
|
||||||
|
* @param {Object} action - The action object containing a 'type' and properties.
|
||||||
|
*/
|
||||||
|
dispatch(action) {
|
||||||
|
if (!action || typeof action.type !== 'string') {
|
||||||
|
console.error('ActionDispatcher: Invalid action object', action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_ACTION_TYPES.has(action.type)) {
|
||||||
|
console.error(`ActionDispatcher: Unknown action type rejected: ${action.type}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._handleAction(action);
|
||||||
|
this.eventBus.emit('ACTION_DISPATCHED', { type: action.type, action });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`ActionDispatcher: Error dispatching action ${action.type}:`, error, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal handler to process the action based on its type.
|
||||||
|
* @private
|
||||||
|
* @param {Object} action - The action object.
|
||||||
|
*/
|
||||||
|
_handleAction(action) {
|
||||||
|
const context = this._buildInterpolationContext();
|
||||||
|
|
||||||
|
// Interpolate string properties recursively
|
||||||
|
const interpolatedAction = this._interpolateProperties(action, context);
|
||||||
|
|
||||||
|
switch (interpolatedAction.type) {
|
||||||
|
// Desktop actions
|
||||||
|
case 'desktop.notify':
|
||||||
|
this._handleDesktopNotify(interpolatedAction);
|
||||||
|
break;
|
||||||
|
case 'desktop.openApp':
|
||||||
|
if (this.apps[interpolatedAction.app] && typeof this.apps[interpolatedAction.app].launch === 'function') {
|
||||||
|
this.apps[interpolatedAction.app].launch();
|
||||||
|
} else {
|
||||||
|
console.warn(`ActionDispatcher: App ${interpolatedAction.app} not found or has no launch method.`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'desktop.focusApp':
|
||||||
|
// The desktop window manager should listen to this event
|
||||||
|
this.eventBus.emit('DESKTOP_FOCUS_APP', { app: interpolatedAction.app });
|
||||||
|
break;
|
||||||
|
case 'desktop.setBadge':
|
||||||
|
// The desktop component/taskbar should listen to this event
|
||||||
|
this.eventBus.emit('DESKTOP_SET_BADGE', { app: interpolatedAction.app, count: interpolatedAction.count });
|
||||||
|
break;
|
||||||
|
case 'desktop.endScenario':
|
||||||
|
if (typeof this.onEndScenario === 'function') {
|
||||||
|
this.onEndScenario();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Mail actions
|
||||||
|
case 'mail.deliver': {
|
||||||
|
const messageId = interpolatedAction.message;
|
||||||
|
const messageObj = this.scenario.messages?.find(m => m.id === messageId);
|
||||||
|
if (!messageObj) {
|
||||||
|
throw new Error(`Message ID '${messageId}' not found in scenario.messages`);
|
||||||
|
}
|
||||||
|
if (this.scenarioState && typeof this.scenarioState.deliverMessage === 'function') {
|
||||||
|
this.scenarioState.deliverMessage(messageId);
|
||||||
|
}
|
||||||
|
if (this.apps.inlook && typeof this.apps.inlook.deliverMessage === 'function') {
|
||||||
|
this.apps.inlook.deliverMessage(messageObj);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'mail.updateMessage':
|
||||||
|
if (this.apps.inlook && typeof this.apps.inlook.updateMessage === 'function') {
|
||||||
|
this.apps.inlook.updateMessage(interpolatedAction.message, interpolatedAction.updates);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Navigator actions
|
||||||
|
case 'navigator.open':
|
||||||
|
if (this.apps.navigator && typeof this.apps.navigator.launch === 'function') {
|
||||||
|
this.apps.navigator.launch(interpolatedAction.url);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'navigator.redirect':
|
||||||
|
if (this.apps.navigator && typeof this.apps.navigator.navigateTo === 'function') {
|
||||||
|
this.apps.navigator.navigateTo(interpolatedAction.url);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Files actions
|
||||||
|
case 'files.create':
|
||||||
|
if (this.apps.files && typeof this.apps.files.addFile === 'function') {
|
||||||
|
this.apps.files.addFile(interpolatedAction.file);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'files.open': {
|
||||||
|
const fileId = interpolatedAction.fileId;
|
||||||
|
const fileObj = this.scenario.files?.find(f => f.id === fileId);
|
||||||
|
if (!fileObj) {
|
||||||
|
throw new Error(`File ID '${fileId}' not found in scenario.files`);
|
||||||
|
}
|
||||||
|
if (this.apps.docViewer && typeof this.apps.docViewer.openDocument === 'function') {
|
||||||
|
this.apps.docViewer.openDocument(fileObj);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security Center actions
|
||||||
|
case 'security.addAlert': {
|
||||||
|
let alertObj;
|
||||||
|
if (typeof interpolatedAction.alert === 'string') {
|
||||||
|
alertObj = this.scenario.alerts?.find(a => a.id === interpolatedAction.alert);
|
||||||
|
if (!alertObj) {
|
||||||
|
throw new Error(`Alert ID '${interpolatedAction.alert}' not found in scenario.alerts`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alertObj = interpolatedAction.alert;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.scenarioState && typeof this.scenarioState.addAlert === 'function') {
|
||||||
|
this.scenarioState.addAlert(alertObj);
|
||||||
|
}
|
||||||
|
if (this.apps.securityCenter && typeof this.apps.securityCenter.addAlert === 'function') {
|
||||||
|
this.apps.securityCenter.addAlert(alertObj);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'security.updateStatus':
|
||||||
|
if (this.apps.securityCenter && typeof this.apps.securityCenter.updateStatus === 'function') {
|
||||||
|
this.apps.securityCenter.updateStatus(interpolatedAction.status);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Evaluation actions
|
||||||
|
case 'evaluation.addFinding': {
|
||||||
|
const findingId = interpolatedAction.finding;
|
||||||
|
const findingDef = this.scenario.findings?.find(f => f.id === findingId);
|
||||||
|
if (!findingDef) {
|
||||||
|
throw new Error(`Finding ID '${findingId}' not found in scenario.findings`);
|
||||||
|
}
|
||||||
|
if (this.scenarioState && typeof this.scenarioState.addFinding === 'function') {
|
||||||
|
this.scenarioState.addFinding(findingId, findingDef);
|
||||||
|
}
|
||||||
|
if (findingDef.score && findingDef.category && this.scenarioState && typeof this.scenarioState.awardPoints === 'function') {
|
||||||
|
this.scenarioState.awardPoints(findingDef.category, findingDef.score);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'evaluation.setState':
|
||||||
|
if (this.scenarioState && typeof this.scenarioState.setState === 'function') {
|
||||||
|
this.scenarioState.setState(interpolatedAction.object, interpolatedAction.key, interpolatedAction.value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'evaluation.completeObjective':
|
||||||
|
if (this.scenarioState && typeof this.scenarioState.completeObjective === 'function') {
|
||||||
|
this.scenarioState.completeObjective(interpolatedAction.objective);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'evaluation.awardPoints':
|
||||||
|
if (this.scenarioState && typeof this.scenarioState.awardPoints === 'function') {
|
||||||
|
this.scenarioState.awardPoints(interpolatedAction.category, interpolatedAction.points);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'evaluation.revealFeedback':
|
||||||
|
this.eventBus.emit('EVALUATION_REVEAL_FEEDBACK', { feedback: interpolatedAction.feedback });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle desktop.notify action.
|
||||||
|
* @private
|
||||||
|
* @param {Object} action - The action object.
|
||||||
|
*/
|
||||||
|
_handleDesktopNotify(action) {
|
||||||
|
if (!this.notifications) return;
|
||||||
|
|
||||||
|
if (action.notification) {
|
||||||
|
const notifDef = this.scenario.notifications?.find(n => n.id === action.notification);
|
||||||
|
if (!notifDef) {
|
||||||
|
throw new Error(`Notification ID '${action.notification}' not found in scenario.notifications`);
|
||||||
|
}
|
||||||
|
this.notifications.show(notifDef);
|
||||||
|
} else {
|
||||||
|
this.notifications.show({
|
||||||
|
title: action.title,
|
||||||
|
body: action.body,
|
||||||
|
type: action.type,
|
||||||
|
timeout: action.timeout,
|
||||||
|
icon: action.icon
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the context object for template interpolation.
|
||||||
|
* @private
|
||||||
|
* @returns {Object} The context object.
|
||||||
|
*/
|
||||||
|
_buildInterpolationContext() {
|
||||||
|
const learner = this.scenario.learner || {};
|
||||||
|
const orgs = this.scenario.organizations || [];
|
||||||
|
const company = orgs.length > 0 ? orgs[0] : {};
|
||||||
|
|
||||||
|
let timestamp = new Date().toLocaleTimeString();
|
||||||
|
if (this.scenarioState && typeof this.scenarioState.getCurrentTime === 'function') {
|
||||||
|
const simulatedTime = this.scenarioState.getCurrentTime();
|
||||||
|
if (simulatedTime) {
|
||||||
|
timestamp = new Date(simulatedTime).toLocaleTimeString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'learner.name': learner.name || 'User',
|
||||||
|
'learner.email': learner.email || '[email protected]',
|
||||||
|
'learner.role': learner.role || 'Employee',
|
||||||
|
'company.name': company.name || 'Company',
|
||||||
|
'timestamp': timestamp
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpolate templates in a string.
|
||||||
|
* @param {string} template - The template string (e.g., "Hello {{learner.name}}").
|
||||||
|
* @param {Object} context - The context object mapping keys to values.
|
||||||
|
* @returns {string} The interpolated string.
|
||||||
|
*/
|
||||||
|
interpolate(template, context) {
|
||||||
|
if (typeof template !== 'string') return template;
|
||||||
|
|
||||||
|
return template.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
|
||||||
|
const trimmedKey = key.trim();
|
||||||
|
if (context.hasOwnProperty(trimmedKey)) {
|
||||||
|
return context[trimmedKey];
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively interpolate properties in an object.
|
||||||
|
* @private
|
||||||
|
* @param {*} obj - The object or value to interpolate.
|
||||||
|
* @param {Object} context - The context object.
|
||||||
|
* @returns {*} A new object or value with interpolated strings.
|
||||||
|
*/
|
||||||
|
_interpolateProperties(obj, context) {
|
||||||
|
if (obj === null || obj === undefined) return obj;
|
||||||
|
if (typeof obj === 'string') return this.interpolate(obj, context);
|
||||||
|
if (Array.isArray(obj)) return obj.map(item => this._interpolateProperties(item, context));
|
||||||
|
if (typeof obj === 'object') {
|
||||||
|
const newObj = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
newObj[key] = this._interpolateProperties(value, context);
|
||||||
|
}
|
||||||
|
return newObj;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Evaluates declarative condition trees against the scenario state.
|
||||||
|
* Conditions are pure data objects that determine whether certain triggers or actions should occur.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to identify the type of a condition.
|
||||||
|
* @param {Object} condition The condition object.
|
||||||
|
* @returns {string} The type name of the condition, or 'unknown'.
|
||||||
|
*/
|
||||||
|
export function getConditionType(condition) {
|
||||||
|
if (!condition || typeof condition !== 'object') return 'unknown';
|
||||||
|
|
||||||
|
const types = [
|
||||||
|
'scenarioStart',
|
||||||
|
'elapsedSeconds',
|
||||||
|
'actionOccurred',
|
||||||
|
'stateEquals',
|
||||||
|
'eventCompleted',
|
||||||
|
'objectiveComplete',
|
||||||
|
'elapsedRange',
|
||||||
|
'scoreThreshold',
|
||||||
|
'findingExists',
|
||||||
|
'messageDelivered',
|
||||||
|
'all',
|
||||||
|
'any',
|
||||||
|
'not',
|
||||||
|
'inactivityTimeout'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const type of types) {
|
||||||
|
if (condition.hasOwnProperty(type)) {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to generate a human-readable description of a condition.
|
||||||
|
* @param {Object} condition The condition object.
|
||||||
|
* @returns {string} A string describing what the condition checks.
|
||||||
|
*/
|
||||||
|
export function describeCondition(condition) {
|
||||||
|
const type = getConditionType(condition);
|
||||||
|
switch (type) {
|
||||||
|
case 'scenarioStart':
|
||||||
|
return 'Scenario starts';
|
||||||
|
case 'elapsedSeconds':
|
||||||
|
return `${condition.elapsedSeconds} seconds elapsed`;
|
||||||
|
case 'actionOccurred':
|
||||||
|
return `Action '${condition.actionOccurred.type}' occurred${condition.actionOccurred.target ? ` on '${condition.actionOccurred.target}'` : ''}`;
|
||||||
|
case 'stateEquals':
|
||||||
|
return `State [${condition.stateEquals.object}].${condition.stateEquals.key} == ${condition.stateEquals.value}`;
|
||||||
|
case 'eventCompleted':
|
||||||
|
return `Event '${condition.eventCompleted.event}' completed`;
|
||||||
|
case 'objectiveComplete':
|
||||||
|
return `Objective '${condition.objectiveComplete.objective}' complete`;
|
||||||
|
case 'elapsedRange': {
|
||||||
|
let rangeDesc = 'Elapsed time';
|
||||||
|
if (condition.elapsedRange.min !== undefined) rangeDesc += ` >= ${condition.elapsedRange.min}s`;
|
||||||
|
if (condition.elapsedRange.max !== undefined) rangeDesc += (condition.elapsedRange.min !== undefined ? ' and' : '') + ` <= ${condition.elapsedRange.max}s`;
|
||||||
|
return rangeDesc;
|
||||||
|
}
|
||||||
|
case 'scoreThreshold': {
|
||||||
|
let scoreDesc = condition.scoreThreshold.category ? `Score (${condition.scoreThreshold.category})` : 'Total score';
|
||||||
|
if (condition.scoreThreshold.min !== undefined) scoreDesc += ` >= ${condition.scoreThreshold.min}`;
|
||||||
|
if (condition.scoreThreshold.max !== undefined) scoreDesc += (condition.scoreThreshold.min !== undefined ? ' and' : '') + ` <= ${condition.scoreThreshold.max}`;
|
||||||
|
return scoreDesc;
|
||||||
|
}
|
||||||
|
case 'findingExists':
|
||||||
|
return `Finding '${condition.findingExists.finding}' exists`;
|
||||||
|
case 'messageDelivered':
|
||||||
|
return `Message '${condition.messageDelivered.message}' delivered`;
|
||||||
|
case 'all':
|
||||||
|
return `ALL of: (${condition.all.map(describeCondition).join(', ')})`;
|
||||||
|
case 'any':
|
||||||
|
return `ANY of: (${condition.any.map(describeCondition).join(', ')})`;
|
||||||
|
case 'not':
|
||||||
|
return `NOT (${describeCondition(condition.not)})`;
|
||||||
|
case 'inactivityTimeout':
|
||||||
|
return `No action '${condition.inactivityTimeout.action.type}'${condition.inactivityTimeout.action.target ? ` on '${condition.inactivityTimeout.action.target}'` : ''} after ${condition.inactivityTimeout.seconds}s`;
|
||||||
|
default:
|
||||||
|
return 'Unknown condition';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class to evaluate condition objects against scenario state.
|
||||||
|
*/
|
||||||
|
export class ConditionEvaluator {
|
||||||
|
/**
|
||||||
|
* @param {Object} scenarioState The scenario state instance.
|
||||||
|
*/
|
||||||
|
constructor(scenarioState) {
|
||||||
|
this.state = scenarioState;
|
||||||
|
this.maxDepth = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates a condition against the current state.
|
||||||
|
* @param {Object} condition The condition object to evaluate.
|
||||||
|
* @param {number} [depth=0] Current recursion depth.
|
||||||
|
* @returns {boolean} True if the condition is satisfied, false otherwise.
|
||||||
|
*/
|
||||||
|
evaluate(condition, depth = 0) {
|
||||||
|
if (depth > this.maxDepth) {
|
||||||
|
console.warn('ConditionEvaluator: Max recursion depth exceeded.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!condition || typeof condition !== 'object') {
|
||||||
|
console.warn('ConditionEvaluator: Invalid condition object.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = getConditionType(condition);
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (type) {
|
||||||
|
case 'scenarioStart':
|
||||||
|
// Handled specially by event scheduler (typically fires once at startup)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case 'elapsedSeconds':
|
||||||
|
return this.state.getElapsedSeconds() >= condition.elapsedSeconds;
|
||||||
|
|
||||||
|
case 'actionOccurred':
|
||||||
|
return this.state.hasAction(condition.actionOccurred.type, condition.actionOccurred.target);
|
||||||
|
|
||||||
|
case 'stateEquals':
|
||||||
|
return this.state.getState(condition.stateEquals.object, condition.stateEquals.key) === condition.stateEquals.value;
|
||||||
|
|
||||||
|
case 'eventCompleted':
|
||||||
|
return this.state.isEventCompleted(condition.eventCompleted.event);
|
||||||
|
|
||||||
|
case 'objectiveComplete':
|
||||||
|
return this.state.isObjectiveComplete(condition.objectiveComplete.objective);
|
||||||
|
|
||||||
|
case 'elapsedRange': {
|
||||||
|
const elapsed = this.state.getElapsedSeconds();
|
||||||
|
const { min, max } = condition.elapsedRange;
|
||||||
|
if (min !== undefined && elapsed < min) return false;
|
||||||
|
if (max !== undefined && elapsed > max) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'scoreThreshold': {
|
||||||
|
const { category, min, max } = condition.scoreThreshold;
|
||||||
|
// Depending on ScenarioState API, getting score might differ.
|
||||||
|
const score = category
|
||||||
|
? (this.state.getCategoryScore ? this.state.getCategoryScore(category) : 0)
|
||||||
|
: (this.state.getTotalScore ? this.state.getTotalScore() : 0);
|
||||||
|
|
||||||
|
if (min !== undefined && score < min) return false;
|
||||||
|
if (max !== undefined && score > max) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'findingExists':
|
||||||
|
return this.state.hasFinding(condition.findingExists.finding);
|
||||||
|
|
||||||
|
case 'messageDelivered':
|
||||||
|
return this.state.isMessageDelivered(condition.messageDelivered.message);
|
||||||
|
|
||||||
|
case 'all':
|
||||||
|
if (!Array.isArray(condition.all)) return false;
|
||||||
|
return condition.all.every(c => this.evaluate(c, depth + 1));
|
||||||
|
|
||||||
|
case 'any':
|
||||||
|
if (!Array.isArray(condition.any)) return false;
|
||||||
|
return condition.any.some(c => this.evaluate(c, depth + 1));
|
||||||
|
|
||||||
|
case 'not':
|
||||||
|
return !this.evaluate(condition.not, depth + 1);
|
||||||
|
|
||||||
|
case 'inactivityTimeout': {
|
||||||
|
const elapsed = this.state.getElapsedSeconds();
|
||||||
|
if (elapsed < condition.inactivityTimeout.seconds) return false;
|
||||||
|
return !this.state.hasAction(
|
||||||
|
condition.inactivityTimeout.action.type,
|
||||||
|
condition.inactivityTimeout.action.target
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(`ConditionEvaluator: Unrecognized condition structure.`, condition);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`ConditionEvaluator: Error evaluating condition of type ${type}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,103 +1,53 @@
|
|||||||
/**
|
/**
|
||||||
* CyberSim OS - Delayed Consequence Engine
|
* CyberSim OS - Delayed Consequence Engine (Phase 2)
|
||||||
* Schedules and dispatches delayed consequences based on user behaviors.
|
*
|
||||||
|
* In Phase 2, consequences are no longer hard-coded. They are expressed as
|
||||||
|
* scenario events with `actionOccurred` triggers and `delay` values.
|
||||||
|
*
|
||||||
|
* This module is retained as a thin compatibility wrapper. The actual
|
||||||
|
* consequence logic lives in the EventScheduler, which processes delayed
|
||||||
|
* events from the scenario definition.
|
||||||
|
*
|
||||||
|
* The ConsequenceEngine now simply listens for CONSEQUENCE_TRIGGERED events
|
||||||
|
* and logs them for diagnostics.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class ConsequenceEngine {
|
export class ConsequenceEngine {
|
||||||
constructor(eventBus, notificationsService, securityCenterApp) {
|
/**
|
||||||
|
* @param {import('./event_bus.js').EventBus} eventBus
|
||||||
|
*/
|
||||||
|
constructor(eventBus) {
|
||||||
this.eventBus = eventBus;
|
this.eventBus = eventBus;
|
||||||
this.notifications = notificationsService;
|
this.triggeredConsequences = [];
|
||||||
this.securityCenter = securityCenterApp;
|
|
||||||
this.scheduledEvents = [];
|
|
||||||
this.activeTimer = null;
|
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Listen for risky or notable behaviors to schedule realistic delayed consequences
|
// In Phase 2, consequences are scenario events processed by the EventScheduler.
|
||||||
this.eventBus.on('NAV_FORM_SUBMITTED', (entry) => {
|
// This listener records consequence events for diagnostics and telemetry.
|
||||||
if (entry.target === 'phish_login_form' || (entry.details && entry.details.url && entry.details.url.includes('nexac0re-portal.com'))) {
|
this.eventBus.on('EVENT_EXECUTED', (entry) => {
|
||||||
this.schedule({
|
if (entry.target && entry.target.startsWith && entry.target.startsWith('consequence-')) {
|
||||||
delaySeconds: 35,
|
this.triggeredConsequences.push({
|
||||||
id: 'consequence_credential_leak',
|
eventId: entry.target,
|
||||||
name: 'Suspicious Account Activity Alert',
|
timestamp: new Date().toISOString(),
|
||||||
execute: () => {
|
details: entry.details || {}
|
||||||
if (this.securityCenter && this.securityCenter.addAlert) {
|
|
||||||
this.securityCenter.addAlert({
|
|
||||||
id: 'alert_unauthorized_sso',
|
|
||||||
title: 'Security Alert: Anomalous Login from Unknown Location',
|
|
||||||
severity: 'high',
|
|
||||||
source: 'Identity Threat Detection',
|
|
||||||
message: 'Multiple automated authentication attempts detected originating from an unrecognized IP address (198.51.100.42 - Eastern Europe) using recently submitted portal credentials. Password reset has been initiated.',
|
|
||||||
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (this.notifications) {
|
|
||||||
this.notifications.show({
|
|
||||||
title: 'Security Center Alert',
|
|
||||||
body: 'High Severity Alert: Anomalous login attempt detected on your account.',
|
|
||||||
type: 'danger',
|
|
||||||
timeout: 8000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.eventBus.emit('CONSEQUENCE_TRIGGERED', {
|
|
||||||
target: 'consequence_credential_leak',
|
|
||||||
details: { cause: 'Phishing credentials submitted' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.eventBus.on('FILE_DOWNLOADED', (entry) => {
|
|
||||||
if (entry.target === 'Invoice_88921_Receipt.zip') {
|
|
||||||
this.schedule({
|
|
||||||
delaySeconds: 25,
|
|
||||||
id: 'consequence_suspicious_download',
|
|
||||||
name: 'Antivirus File Warning',
|
|
||||||
execute: () => {
|
|
||||||
if (this.securityCenter && this.securityCenter.addAlert) {
|
|
||||||
this.securityCenter.addAlert({
|
|
||||||
id: 'alert_quarantine_zip',
|
|
||||||
title: 'Endpoint Protection: Suspicious Archive Quarantined',
|
|
||||||
severity: 'medium',
|
|
||||||
source: 'Endpoint Threat Shield',
|
|
||||||
message: 'Downloaded archive "Invoice_88921_Receipt.zip" contains suspicious executable payloads (PaymentReceipt.exe) masquerading as document files.',
|
|
||||||
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (this.notifications) {
|
|
||||||
this.notifications.show({
|
|
||||||
title: 'Endpoint Protection',
|
|
||||||
body: 'Suspicious archive quarantined in Downloads folder.',
|
|
||||||
type: 'warning',
|
|
||||||
timeout: 7000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.eventBus.emit('CONSEQUENCE_TRIGGERED', {
|
|
||||||
target: 'consequence_suspicious_download',
|
|
||||||
details: { cause: 'Malicious zip downloaded' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
schedule(consequence) {
|
/**
|
||||||
const runAt = Date.now() + (consequence.delaySeconds * 1000);
|
* Returns all triggered consequences for diagnostics.
|
||||||
const item = { ...consequence, runAt, executed: false };
|
* @returns {Array<Object>}
|
||||||
this.scheduledEvents.push(item);
|
*/
|
||||||
|
getTriggeredConsequences() {
|
||||||
setTimeout(() => {
|
return [...this.triggeredConsequences];
|
||||||
if (!item.executed) {
|
|
||||||
item.executed = true;
|
|
||||||
item.execute();
|
|
||||||
}
|
|
||||||
}, consequence.delaySeconds * 1000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets consequence tracking state.
|
||||||
|
*/
|
||||||
reset() {
|
reset() {
|
||||||
this.scheduledEvents = [];
|
this.triggeredConsequences = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* Event Scheduler
|
||||||
|
*
|
||||||
|
* Declarative event execution engine. Reads event definitions from the scenario
|
||||||
|
* and evaluates their triggers on a regular tick, dispatching actions when
|
||||||
|
* conditions are met.
|
||||||
|
*/
|
||||||
|
export class EventScheduler {
|
||||||
|
/**
|
||||||
|
* @param {Object} scenario - The scenario definition
|
||||||
|
* @param {Object} scenarioState - State manager for the scenario
|
||||||
|
* @param {Object} conditionEvaluator - Evaluator for event triggers
|
||||||
|
* @param {Object} actionDispatcher - Dispatcher for event actions
|
||||||
|
* @param {Object} eventBus - System event bus
|
||||||
|
*/
|
||||||
|
constructor(scenario, scenarioState, conditionEvaluator, actionDispatcher, eventBus) {
|
||||||
|
this.scenario = scenario;
|
||||||
|
this.scenarioState = scenarioState;
|
||||||
|
this.conditionEvaluator = conditionEvaluator;
|
||||||
|
this.actionDispatcher = actionDispatcher;
|
||||||
|
this.eventBus = eventBus;
|
||||||
|
|
||||||
|
this.intervalId = null;
|
||||||
|
this.firstTick = true;
|
||||||
|
|
||||||
|
this.events = [];
|
||||||
|
this._initEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize event tracking states based on the scenario definitions.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_initEvents() {
|
||||||
|
const scenarioEvents = this.scenario.events || [];
|
||||||
|
for (const evt of scenarioEvents) {
|
||||||
|
this.events.push({
|
||||||
|
id: evt.id,
|
||||||
|
config: evt,
|
||||||
|
state: 'registered',
|
||||||
|
executionCount: 0,
|
||||||
|
nextEvaluateTime: 0,
|
||||||
|
scheduledExecutionTime: 0,
|
||||||
|
conditionMet: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the scheduler tick loop.
|
||||||
|
* @param {number} intervalMs - The tick interval in milliseconds (default: 500)
|
||||||
|
*/
|
||||||
|
start(intervalMs = 500) {
|
||||||
|
if (this.intervalId) return;
|
||||||
|
this.intervalId = setInterval(() => this.tick(), intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the scheduler tick loop.
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the scheduler and all event states.
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
this.stop();
|
||||||
|
this.events = [];
|
||||||
|
this.firstTick = true;
|
||||||
|
this._initEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute one tick of the event evaluation and execution loop.
|
||||||
|
*/
|
||||||
|
tick() {
|
||||||
|
// Skip if scenario has ended
|
||||||
|
if (this.scenarioState.isEnded && this.scenarioState.isEnded()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
let eventFiredThisTick = false;
|
||||||
|
|
||||||
|
// Phase 1: Evaluate conditions and schedule
|
||||||
|
for (const evt of this.events) {
|
||||||
|
// Skip events that have finished their lifecycle
|
||||||
|
if (evt.state === 'exhausted' || evt.state === 'executed') continue;
|
||||||
|
|
||||||
|
if (evt.state === 'registered' || evt.state === 'waiting') {
|
||||||
|
if (now < evt.nextEvaluateTime) continue;
|
||||||
|
|
||||||
|
let isMet = false;
|
||||||
|
|
||||||
|
// Special case for scenario start triggers on the first tick
|
||||||
|
if (this.firstTick && evt.config.when && evt.config.when.scenarioStart) {
|
||||||
|
isMet = true;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
isMet = this.conditionEvaluator.evaluate(evt.config.when);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[EventScheduler] Error evaluating condition for event ${evt.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If condition newly satisfied
|
||||||
|
if (isMet && !evt.conditionMet) {
|
||||||
|
evt.conditionMet = true;
|
||||||
|
this.eventBus.emit('EVENT_CONDITION_MET', { eventId: evt.id });
|
||||||
|
|
||||||
|
if (evt.config.delay && evt.config.delay > 0) {
|
||||||
|
evt.state = 'pending';
|
||||||
|
evt.scheduledExecutionTime = now + (evt.config.delay * 1000);
|
||||||
|
this.eventBus.emit('EVENT_SCHEDULED', { eventId: evt.id, scheduledTime: evt.scheduledExecutionTime });
|
||||||
|
} else {
|
||||||
|
evt.state = 'ready';
|
||||||
|
}
|
||||||
|
} else if (!isMet) {
|
||||||
|
evt.conditionMet = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.firstTick = false;
|
||||||
|
|
||||||
|
// Phase 2: Execute at most one event
|
||||||
|
for (const evt of this.events) {
|
||||||
|
if (eventFiredThisTick) break;
|
||||||
|
|
||||||
|
if (evt.state === 'ready') {
|
||||||
|
this._executeEvent(evt);
|
||||||
|
eventFiredThisTick = true;
|
||||||
|
} else if (evt.state === 'pending') {
|
||||||
|
if (now >= evt.scheduledExecutionTime) {
|
||||||
|
this._executeEvent(evt);
|
||||||
|
eventFiredThisTick = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the actions for a given event and update its lifecycle state.
|
||||||
|
* @param {Object} evt - The tracked event object
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_executeEvent(evt) {
|
||||||
|
// Mark event as completed in state
|
||||||
|
if (typeof this.scenarioState.markEventCompleted === 'function') {
|
||||||
|
this.scenarioState.markEventCompleted(evt.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute actions
|
||||||
|
const actions = evt.config.actions || [];
|
||||||
|
for (const action of actions) {
|
||||||
|
try {
|
||||||
|
this.actionDispatcher.dispatch(action);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[EventScheduler] Error dispatching action for event ${evt.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.eventBus.emit('EVENT_EXECUTED', { eventId: evt.id });
|
||||||
|
evt.executionCount++;
|
||||||
|
|
||||||
|
// Handle repeating events
|
||||||
|
if (evt.config.repeat) {
|
||||||
|
const limit = evt.config.repeat.count;
|
||||||
|
if (limit && evt.executionCount >= limit) {
|
||||||
|
evt.state = 'exhausted';
|
||||||
|
} else {
|
||||||
|
if (!limit) {
|
||||||
|
console.warn(`[EventScheduler] Event ${evt.id} has repeat but no count specified.`);
|
||||||
|
}
|
||||||
|
evt.state = 'waiting';
|
||||||
|
evt.conditionMet = false;
|
||||||
|
const intervalSeconds = evt.config.repeat.intervalSeconds || 0;
|
||||||
|
evt.nextEvaluateTime = Date.now() + (intervalSeconds * 1000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
evt.state = 'executed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current diagnostic states for all events.
|
||||||
|
* @returns {Array<{id: string, state: string, executionCount: number, conditionMet: boolean}>}
|
||||||
|
*/
|
||||||
|
getEventStates() {
|
||||||
|
return this.events.map(evt => ({
|
||||||
|
id: evt.id,
|
||||||
|
state: evt.state,
|
||||||
|
executionCount: evt.executionCount,
|
||||||
|
conditionMet: evt.conditionMet
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview ScenarioState module for CyberSim OS.
|
||||||
|
* Provides central mutable state tracking for a running scenario.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple seeded pseudo-random number generator (Mulberry32).
|
||||||
|
* @param {number} a - The seed state.
|
||||||
|
* @returns {function(): number} A function returning a PRNG value between 0 and 1.
|
||||||
|
*/
|
||||||
|
function mulberry32(a) {
|
||||||
|
return function() {
|
||||||
|
var t = a += 0x6D2B79F5;
|
||||||
|
t = Math.imul(t ^ t >>> 15, t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
||||||
|
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the runtime state of a CyberSim scenario.
|
||||||
|
*/
|
||||||
|
export class ScenarioState {
|
||||||
|
/**
|
||||||
|
* @param {Object} scenario - The loaded, frozen scenario object.
|
||||||
|
* @param {Object} options - Options for the state manager.
|
||||||
|
* @param {number} [options.seed] - Optional deterministic random seed.
|
||||||
|
*/
|
||||||
|
constructor(scenario, options = {}) {
|
||||||
|
this.scenario = scenario;
|
||||||
|
this.startTime = Date.now();
|
||||||
|
this.simStartTime = scenario.environment?.startTime ? new Date(scenario.environment.startTime).getTime() : this.startTime;
|
||||||
|
this.actions = [];
|
||||||
|
this.findings = new Map();
|
||||||
|
this.objectStates = new Map();
|
||||||
|
this.completedEvents = new Set();
|
||||||
|
this.completedObjectives = new Set();
|
||||||
|
this.categoryScores = new Map();
|
||||||
|
this.deliveredMessages = new Set();
|
||||||
|
this.activeAlerts = [];
|
||||||
|
this.scenarioEnded = false;
|
||||||
|
|
||||||
|
// Seed initialization
|
||||||
|
this.seed = options.seed !== undefined ? options.seed : Date.now();
|
||||||
|
this._prng = mulberry32(this.seed);
|
||||||
|
this.actionCounter = 0;
|
||||||
|
|
||||||
|
// Initialize category scores
|
||||||
|
if (scenario.scoring && scenario.scoring.categories) {
|
||||||
|
for (const [categoryId, categoryDef] of Object.entries(scenario.scoring.categories)) {
|
||||||
|
this.categoryScores.set(categoryId, {
|
||||||
|
score: categoryDef.startingPoints || 0,
|
||||||
|
maxPoints: categoryDef.maxPoints || 100,
|
||||||
|
startingPoints: categoryDef.startingPoints || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize delivered messages
|
||||||
|
if (scenario.messages) {
|
||||||
|
for (const [messageId, messageDef] of Object.entries(scenario.messages)) {
|
||||||
|
if (messageDef.folder === 'inbox' && !messageDef.deliveryEvent) {
|
||||||
|
this.deliveredMessages.add(messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the number of real seconds since the scenario started.
|
||||||
|
* @returns {number} Elapsed seconds.
|
||||||
|
*/
|
||||||
|
getElapsedSeconds() {
|
||||||
|
return (Date.now() - this.startTime) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a matching action was recorded.
|
||||||
|
* @param {string} eventType - The type of event (e.g., 'click', 'command').
|
||||||
|
* @param {string} [target=null] - The target of the event.
|
||||||
|
* @returns {boolean} True if a matching action was found.
|
||||||
|
*/
|
||||||
|
hasAction(eventType, target = null) {
|
||||||
|
return this.actions.some(action =>
|
||||||
|
action.event === eventType && (target === null || action.target === target)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds matching actions.
|
||||||
|
* @param {string} eventType - The type of event.
|
||||||
|
* @param {string} [target=null] - The target of the event.
|
||||||
|
* @returns {Array<Object>} Array of matching actions.
|
||||||
|
*/
|
||||||
|
findActions(eventType, target = null) {
|
||||||
|
return this.actions.filter(action =>
|
||||||
|
action.event === eventType && (target === null || action.target === target)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the state of an object.
|
||||||
|
* @param {string} objectId - The ID of the object.
|
||||||
|
* @param {string} [key=null] - The specific key to retrieve.
|
||||||
|
* @returns {*} The value for the key, or the entire state object if key is null.
|
||||||
|
*/
|
||||||
|
getState(objectId, key = null) {
|
||||||
|
const stateObj = this.objectStates.get(objectId) || {};
|
||||||
|
if (key === null) {
|
||||||
|
return stateObj;
|
||||||
|
}
|
||||||
|
return stateObj[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an event has been completed.
|
||||||
|
* @param {string} eventId - The event ID.
|
||||||
|
* @returns {boolean} True if completed.
|
||||||
|
*/
|
||||||
|
isEventCompleted(eventId) {
|
||||||
|
return this.completedEvents.has(eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an objective is complete.
|
||||||
|
* @param {string} objectiveId - The objective ID.
|
||||||
|
* @returns {boolean} True if complete.
|
||||||
|
*/
|
||||||
|
isObjectiveComplete(objectiveId) {
|
||||||
|
return this.completedObjectives.has(objectiveId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current score for a category.
|
||||||
|
* @param {string} categoryId - The category ID.
|
||||||
|
* @returns {number} The current score.
|
||||||
|
*/
|
||||||
|
getScore(categoryId) {
|
||||||
|
const category = this.categoryScores.get(categoryId);
|
||||||
|
return category ? category.score : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the total sum of all category scores, clamped 0-100.
|
||||||
|
* @returns {number} The total score (0-100).
|
||||||
|
*/
|
||||||
|
getTotalScore() {
|
||||||
|
let total = 0;
|
||||||
|
for (const category of this.categoryScores.values()) {
|
||||||
|
total += category.score;
|
||||||
|
}
|
||||||
|
return Math.max(0, Math.min(100, total));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a finding exists.
|
||||||
|
* @param {string} findingId - The finding ID.
|
||||||
|
* @returns {boolean} True if found.
|
||||||
|
*/
|
||||||
|
hasFinding(findingId) {
|
||||||
|
return this.findings.has(findingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a message has been delivered.
|
||||||
|
* @param {string} messageId - The message ID.
|
||||||
|
* @returns {boolean} True if delivered.
|
||||||
|
*/
|
||||||
|
isMessageDelivered(messageId) {
|
||||||
|
return this.deliveredMessages.has(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the scenario has ended.
|
||||||
|
* @returns {boolean} True if ended.
|
||||||
|
*/
|
||||||
|
hasScenarioEnded() {
|
||||||
|
return this.scenarioEnded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a learner action.
|
||||||
|
* @param {Object} entry - The action entry.
|
||||||
|
* @param {string} entry.event - The event type.
|
||||||
|
* @param {string} [entry.target] - The target of the action.
|
||||||
|
* @param {Object} [entry.details] - Additional action details.
|
||||||
|
*/
|
||||||
|
recordAction(entry) {
|
||||||
|
this.actionCounter++;
|
||||||
|
const simSeconds = this.getElapsedSeconds();
|
||||||
|
this.actions.push({
|
||||||
|
id: this.actionCounter,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
simSeconds: simSeconds,
|
||||||
|
event: entry.event,
|
||||||
|
target: entry.target || null,
|
||||||
|
details: entry.details || {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a finding.
|
||||||
|
* @param {string} findingId - The finding ID.
|
||||||
|
* @param {Object} findingData - The finding data.
|
||||||
|
*/
|
||||||
|
addFinding(findingId, findingData) {
|
||||||
|
this.findings.set(findingId, findingData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a state property on an object.
|
||||||
|
* @param {string} objectId - The object ID.
|
||||||
|
* @param {string} key - The state key.
|
||||||
|
* @param {*} value - The state value.
|
||||||
|
*/
|
||||||
|
setState(objectId, key, value) {
|
||||||
|
if (!this.objectStates.has(objectId)) {
|
||||||
|
this.objectStates.set(objectId, {});
|
||||||
|
}
|
||||||
|
const stateObj = this.objectStates.get(objectId);
|
||||||
|
stateObj[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks an event as completed.
|
||||||
|
* @param {string} eventId - The event ID.
|
||||||
|
*/
|
||||||
|
markEventCompleted(eventId) {
|
||||||
|
this.completedEvents.add(eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks an objective as complete.
|
||||||
|
* @param {string} objectiveId - The objective ID.
|
||||||
|
*/
|
||||||
|
completeObjective(objectiveId) {
|
||||||
|
this.completedObjectives.add(objectiveId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Awards points to a category, adjusting its score.
|
||||||
|
* @param {string} categoryId - The category ID.
|
||||||
|
* @param {number} points - The points to add (can be negative).
|
||||||
|
*/
|
||||||
|
awardPoints(categoryId, points) {
|
||||||
|
const category = this.categoryScores.get(categoryId);
|
||||||
|
if (category) {
|
||||||
|
let newScore = category.score + points;
|
||||||
|
newScore = Math.max(0, Math.min(category.maxPoints, newScore));
|
||||||
|
category.score = newScore;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a message as delivered.
|
||||||
|
* @param {string} messageId - The message ID.
|
||||||
|
*/
|
||||||
|
deliverMessage(messageId) {
|
||||||
|
this.deliveredMessages.add(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an active alert.
|
||||||
|
* @param {Object} alertObj - The alert object.
|
||||||
|
*/
|
||||||
|
addAlert(alertObj) {
|
||||||
|
this.activeAlerts.push(alertObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends the scenario.
|
||||||
|
*/
|
||||||
|
endScenario() {
|
||||||
|
this.scenarioEnded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a snapshot of the current state for debugging or serialization.
|
||||||
|
* @returns {Object} Snapshot object.
|
||||||
|
*/
|
||||||
|
toSnapshot() {
|
||||||
|
return {
|
||||||
|
startTime: this.startTime,
|
||||||
|
simStartTime: this.simStartTime,
|
||||||
|
scenarioEnded: this.scenarioEnded,
|
||||||
|
actionCount: this.actionCounter,
|
||||||
|
seed: this.seed,
|
||||||
|
completedEvents: Array.from(this.completedEvents),
|
||||||
|
completedObjectives: Array.from(this.completedObjectives),
|
||||||
|
deliveredMessages: Array.from(this.deliveredMessages),
|
||||||
|
findings: Object.fromEntries(this.findings),
|
||||||
|
objectStates: Object.fromEntries(this.objectStates),
|
||||||
|
categoryScores: Object.fromEntries(this.categoryScores),
|
||||||
|
activeAlerts: [...this.activeAlerts]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a copy of the action log.
|
||||||
|
* @returns {Array<Object>} Copy of actions array.
|
||||||
|
*/
|
||||||
|
getActionLog() {
|
||||||
|
return [...this.actions];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a deterministic random number between 0 and 1.
|
||||||
|
* @returns {number} Random number.
|
||||||
|
*/
|
||||||
|
random() {
|
||||||
|
return this._prng();
|
||||||
|
}
|
||||||
|
}
|
||||||
+213
-47
@@ -1,11 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* CyberSim OS - Main Bootstrapper & Simulation Lifecycle Orchestrator
|
* CyberSim OS - Main Bootstrapper & Simulation Lifecycle Orchestrator (Phase 2)
|
||||||
|
*
|
||||||
|
* Loads a scenario package from a URL, validates it, initializes the engine,
|
||||||
|
* and orchestrates the simulation lifecycle.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { globalEventBus } from './engine/event_bus.js';
|
import { globalEventBus } from './engine/event_bus.js';
|
||||||
import { ConsequenceEngine } from './engine/consequence.js';
|
import { ConsequenceEngine } from './engine/consequence.js';
|
||||||
|
import { ScenarioState } from './engine/scenario_state.js';
|
||||||
|
import { ConditionEvaluator } from './engine/condition_evaluator.js';
|
||||||
|
import { EventScheduler } from './engine/event_scheduler.js';
|
||||||
|
import { ActionDispatcher } from './engine/action_dispatcher.js';
|
||||||
import { calculateScenarioFingerprint } from './scenario/fingerprint.js';
|
import { calculateScenarioFingerprint } from './scenario/fingerprint.js';
|
||||||
import { referenceScenario1 } from './scenario/scenario_ref1.js';
|
import { ScenarioLoader, getScenarioUrl } from './scenario/loader.js';
|
||||||
|
import { validateSchema } from './scenario/schema.js';
|
||||||
|
import { validateScenario } from './scenario/validator.js';
|
||||||
|
import { ScenarioDiagnostics, isDevelopmentMode } from './scenario/diagnostics.js';
|
||||||
import { WindowManager } from './core/window_manager.js';
|
import { WindowManager } from './core/window_manager.js';
|
||||||
import { DesktopShell } from './core/desktop.js';
|
import { DesktopShell } from './core/desktop.js';
|
||||||
import { NotificationService } from './core/notifications.js';
|
import { NotificationService } from './core/notifications.js';
|
||||||
@@ -22,30 +32,214 @@ import { CertificateGenerator } from './cert/cert_generator.js';
|
|||||||
|
|
||||||
class CyberSimEngine {
|
class CyberSimEngine {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.scenario = referenceScenario1;
|
this.scenario = null;
|
||||||
this.scenarioFingerprint = null;
|
this.scenarioFingerprint = null;
|
||||||
this.eventBus = globalEventBus;
|
this.eventBus = globalEventBus;
|
||||||
|
this.scenarioState = null;
|
||||||
|
this.eventScheduler = null;
|
||||||
|
this.diagnostics = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a loading screen while the scenario loads.
|
||||||
|
*/
|
||||||
|
showLoadingScreen(message = 'Loading scenario...') {
|
||||||
|
const desktop = document.getElementById('desktop');
|
||||||
|
if (!desktop) return;
|
||||||
|
desktop.innerHTML = `
|
||||||
|
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; color:#94a3b8; font-family:system-ui, sans-serif;">
|
||||||
|
<div style="font-size:24px; font-weight:700; margin-bottom:8px; color:#e2e8f0;">CyberSim OS</div>
|
||||||
|
<div style="font-size:13px;">${message}</div>
|
||||||
|
<div style="margin-top:16px; width:200px; height:3px; background:#1e293b; border-radius:2px; overflow:hidden;">
|
||||||
|
<div style="width:40%; height:100%; background:#3b82f6; border-radius:2px; animation:loading 1.5s ease-in-out infinite;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>@keyframes loading { 0%{transform:translateX(-100%)} 100%{transform:translateX(350%)} }</style>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a validation error screen.
|
||||||
|
*/
|
||||||
|
showErrorScreen(errors, warnings) {
|
||||||
|
const desktop = document.getElementById('desktop');
|
||||||
|
if (!desktop) return;
|
||||||
|
|
||||||
|
const errorHtml = errors.map(e =>
|
||||||
|
`<div style="background:#450a0a; border:1px solid #7f1d1d; padding:8px 12px; border-radius:4px; font-size:12px; margin-bottom:6px;">
|
||||||
|
<strong style="color:#fca5a5;">[${e.field}]</strong> ${e.message}
|
||||||
|
${e.objectId ? `<span style="color:#94a3b8;"> (${e.objectId})</span>` : ''}
|
||||||
|
${e.expected ? `<div style="color:#6b7280; margin-top:2px;">Expected: ${e.expected}</div>` : ''}
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const warningHtml = warnings.length > 0 ? warnings.slice(0, 10).map(w =>
|
||||||
|
`<div style="background:#422006; border:1px solid #78350f; padding:6px 10px; border-radius:4px; font-size:11px; margin-bottom:4px;">
|
||||||
|
<strong style="color:#fcd34d;">[${w.field}]</strong> ${w.message}
|
||||||
|
</div>`
|
||||||
|
).join('') : '';
|
||||||
|
|
||||||
|
desktop.innerHTML = `
|
||||||
|
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; color:#e2e8f0; font-family:system-ui, sans-serif; padding:40px;">
|
||||||
|
<div style="max-width:640px; width:100%;">
|
||||||
|
<div style="font-size:20px; font-weight:700; margin-bottom:4px; color:#ef4444;">⚠ Scenario Validation Failed</div>
|
||||||
|
<div style="font-size:13px; color:#94a3b8; margin-bottom:16px;">${errors.length} error(s), ${warnings.length} warning(s)</div>
|
||||||
|
<div style="max-height:400px; overflow-y:auto; margin-bottom:16px;">${errorHtml}</div>
|
||||||
|
${warningHtml ? `<div style="margin-bottom:16px;"><div style="font-size:11px; color:#94a3b8; margin-bottom:6px;">Warnings:</div>${warningHtml}</div>` : ''}
|
||||||
|
<button onclick="window.location.reload()" style="background:#3b82f6; color:white; border:none; padding:8px 20px; border-radius:4px; cursor:pointer; font-size:13px;">Reload</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
console.log('[CyberSim OS] Booting enterprise desktop simulation...');
|
console.log('[CyberSim OS] Booting enterprise desktop simulation...');
|
||||||
|
|
||||||
// Calculate cryptographic scenario fingerprint
|
// Phase 1: Load scenario
|
||||||
|
this.showLoadingScreen('Loading scenario package...');
|
||||||
|
|
||||||
|
const scenarioUrl = getScenarioUrl();
|
||||||
|
console.log(`[CyberSim OS] Loading scenario from: ${scenarioUrl}`);
|
||||||
|
|
||||||
|
const loadResult = await ScenarioLoader.load(scenarioUrl);
|
||||||
|
|
||||||
|
if (!loadResult.scenario) {
|
||||||
|
console.error('[CyberSim OS] Failed to load scenario:', loadResult.errors);
|
||||||
|
this.showErrorScreen(loadResult.errors, loadResult.warnings);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scenario = loadResult.scenario;
|
||||||
|
|
||||||
|
// Phase 2: Validate schema
|
||||||
|
this.showLoadingScreen('Validating scenario schema...');
|
||||||
|
const schemaResult = validateSchema(this.scenario);
|
||||||
|
|
||||||
|
// Phase 3: Validate references and consistency
|
||||||
|
const validationResult = validateScenario(this.scenario);
|
||||||
|
|
||||||
|
// Merge all diagnostics
|
||||||
|
const allErrors = [...loadResult.errors, ...schemaResult.errors, ...validationResult.errors];
|
||||||
|
const allWarnings = [...loadResult.warnings, ...schemaResult.warnings, ...validationResult.warnings];
|
||||||
|
|
||||||
|
if (allErrors.length > 0) {
|
||||||
|
console.error(`[CyberSim OS] Scenario validation failed with ${allErrors.length} error(s)`);
|
||||||
|
this.showErrorScreen(allErrors, allWarnings);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allWarnings.length > 0) {
|
||||||
|
console.warn(`[CyberSim OS] Scenario loaded with ${allWarnings.length} warning(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 4: Calculate fingerprint
|
||||||
this.scenarioFingerprint = await calculateScenarioFingerprint(this.scenario);
|
this.scenarioFingerprint = await calculateScenarioFingerprint(this.scenario);
|
||||||
console.log(`[CyberSim OS] Scenario Fingerprint (SHA-256): ${this.scenarioFingerprint}`);
|
console.log(`[CyberSim OS] Scenario Fingerprint (SHA-256): ${this.scenarioFingerprint}`);
|
||||||
|
|
||||||
// Initialize UI Containers
|
// Phase 5: Initialize diagnostics
|
||||||
|
if (isDevelopmentMode()) {
|
||||||
|
this.diagnostics = new ScenarioDiagnostics({
|
||||||
|
scenario: this.scenario,
|
||||||
|
schemaResult,
|
||||||
|
validationResult
|
||||||
|
});
|
||||||
|
this.diagnostics.logToConsole();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 6: Initialize scenario runtime state
|
||||||
|
this.scenarioState = new ScenarioState(this.scenario, {
|
||||||
|
seed: this.scenario.seed
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 7: Initialize UI
|
||||||
|
this.showLoadingScreen('Initializing desktop...');
|
||||||
|
await this.initializeUI();
|
||||||
|
|
||||||
|
// Phase 8: Start event scheduler
|
||||||
|
const conditionEvaluator = new ConditionEvaluator(this.scenarioState);
|
||||||
|
const actionDispatcher = new ActionDispatcher({
|
||||||
|
scenarioState: this.scenarioState,
|
||||||
|
scenario: this.scenario,
|
||||||
|
eventBus: this.eventBus,
|
||||||
|
notifications: this.notifications,
|
||||||
|
apps: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register apps with the action dispatcher
|
||||||
|
actionDispatcher.registerApps({
|
||||||
|
inlook: this.inlook,
|
||||||
|
navigator: this.navigator,
|
||||||
|
files: this.files,
|
||||||
|
docViewer: this.docViewer,
|
||||||
|
securityCenter: this.securityCenter
|
||||||
|
});
|
||||||
|
|
||||||
|
actionDispatcher.registerEndScenarioCallback(() => this.finishScenario());
|
||||||
|
|
||||||
|
this.eventScheduler = new EventScheduler(
|
||||||
|
this.scenario,
|
||||||
|
this.scenarioState,
|
||||||
|
conditionEvaluator,
|
||||||
|
actionDispatcher,
|
||||||
|
this.eventBus
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize consequence tracking
|
||||||
|
this.consequences = new ConsequenceEngine(this.eventBus);
|
||||||
|
|
||||||
|
// Bridge learner actions from the event bus to scenario state
|
||||||
|
this.eventBus.on('*', (entry) => {
|
||||||
|
this.scenarioState.recordAction(entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit scenario start
|
||||||
|
this.eventBus.emit('SCENARIO_STARTED', {
|
||||||
|
target: this.scenario.id,
|
||||||
|
details: {
|
||||||
|
title: this.scenario.title,
|
||||||
|
fingerprint: this.scenarioFingerprint
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the event scheduler tick loop
|
||||||
|
this.eventScheduler.start(500);
|
||||||
|
|
||||||
|
// Show diagnostics overlay in dev mode
|
||||||
|
if (this.diagnostics) {
|
||||||
|
this.diagnostics.setFingerprint(this.scenarioFingerprint);
|
||||||
|
this.diagnostics.setSeed(this.scenarioState.seed);
|
||||||
|
this.diagnostics.showOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose runtime global for form event triggers and diagnostics
|
||||||
|
window.CyberSimOS = {
|
||||||
|
engine: this,
|
||||||
|
navigator: this.navigator,
|
||||||
|
inlook: this.inlook,
|
||||||
|
files: this.files,
|
||||||
|
securityCenter: this.securityCenter,
|
||||||
|
docViewer: this.docViewer,
|
||||||
|
diagnostics: this.diagnostics,
|
||||||
|
state: this.scenarioState,
|
||||||
|
finishSimulation: () => this.finishScenario()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializeUI() {
|
||||||
const desktopEl = document.getElementById('desktop');
|
const desktopEl = document.getElementById('desktop');
|
||||||
const taskbarAppsEl = document.getElementById('taskbar-apps');
|
const taskbarAppsEl = document.getElementById('taskbar-apps');
|
||||||
const startMenuEl = document.getElementById('start-menu');
|
const startMenuEl = document.getElementById('start-menu');
|
||||||
const startBtnEl = document.getElementById('start-btn');
|
const startBtnEl = document.getElementById('start-btn');
|
||||||
const clockEl = document.getElementById('taskbar-clock');
|
const clockEl = document.getElementById('taskbar-clock');
|
||||||
|
|
||||||
|
// Reset desktop content from loading screen
|
||||||
|
desktopEl.innerHTML = '<div id="desktop-icons"></div>';
|
||||||
|
|
||||||
// Initialize Core Services
|
// Initialize Core Services
|
||||||
this.notifications = new NotificationService();
|
this.notifications = new NotificationService();
|
||||||
this.wm = new WindowManager(desktopEl, taskbarAppsEl);
|
this.wm = new WindowManager(desktopEl, taskbarAppsEl);
|
||||||
|
|
||||||
// Initialize Apps
|
// Initialize Apps — pass scenario data generically
|
||||||
this.docViewer = new DocViewerApp({
|
this.docViewer = new DocViewerApp({
|
||||||
windowManager: this.wm,
|
windowManager: this.wm,
|
||||||
eventBus: this.eventBus
|
eventBus: this.eventBus
|
||||||
@@ -68,7 +262,8 @@ class CyberSimEngine {
|
|||||||
|
|
||||||
this.securityCenter = new SecurityCenterApp({
|
this.securityCenter = new SecurityCenterApp({
|
||||||
windowManager: this.wm,
|
windowManager: this.wm,
|
||||||
eventBus: this.eventBus
|
eventBus: this.eventBus,
|
||||||
|
scenario: this.scenario
|
||||||
});
|
});
|
||||||
|
|
||||||
this.inlook = new InlookApp({
|
this.inlook = new InlookApp({
|
||||||
@@ -81,9 +276,6 @@ class CyberSimEngine {
|
|||||||
onFileDownloaded: (file) => this.files.addFile(file)
|
onFileDownloaded: (file) => this.files.addFile(file)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize Delayed Consequence Engine
|
|
||||||
this.consequences = new ConsequenceEngine(this.eventBus, this.notifications, this.securityCenter);
|
|
||||||
|
|
||||||
// Initialize Desktop Shell
|
// Initialize Desktop Shell
|
||||||
this.desktop = new DesktopShell({
|
this.desktop = new DesktopShell({
|
||||||
desktopElement: desktopEl,
|
desktopElement: desktopEl,
|
||||||
@@ -94,7 +286,7 @@ class CyberSimEngine {
|
|||||||
onFinishScenario: () => this.finishScenario()
|
onFinishScenario: () => this.finishScenario()
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register Desktop Apps
|
// Register Desktop Apps — configuration driven
|
||||||
const registeredApps = [
|
const registeredApps = [
|
||||||
{
|
{
|
||||||
id: 'inlook',
|
id: 'inlook',
|
||||||
@@ -125,7 +317,7 @@ class CyberSimEngine {
|
|||||||
name: 'Doc Viewer',
|
name: 'Doc Viewer',
|
||||||
iconSvg: this.docViewer.getIconSvg(),
|
iconSvg: this.docViewer.getIconSvg(),
|
||||||
launch: () => {
|
launch: () => {
|
||||||
const firstDoc = this.scenario.files[0];
|
const firstDoc = this.scenario.files && this.scenario.files[0];
|
||||||
if (firstDoc) this.docViewer.openDocument(firstDoc);
|
if (firstDoc) this.docViewer.openDocument(firstDoc);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -140,47 +332,20 @@ class CyberSimEngine {
|
|||||||
];
|
];
|
||||||
|
|
||||||
this.desktop.renderDesktopIcons(registeredApps);
|
this.desktop.renderDesktopIcons(registeredApps);
|
||||||
|
|
||||||
// Expose runtime global for event triggers
|
|
||||||
window.CyberSimOS = {
|
|
||||||
engine: this,
|
|
||||||
navigator: this.navigator,
|
|
||||||
inlook: this.inlook,
|
|
||||||
files: this.files,
|
|
||||||
securityCenter: this.securityCenter,
|
|
||||||
docViewer: this.docViewer,
|
|
||||||
handlePhishSubmit: (form) => this.navigator.handlePhishFormSubmit(form),
|
|
||||||
finishSimulation: () => this.finishScenario()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Emit Scenario Start Event
|
|
||||||
this.eventBus.emit('SCENARIO_STARTED', {
|
|
||||||
target: this.scenario.scenarioId,
|
|
||||||
details: {
|
|
||||||
title: this.scenario.title,
|
|
||||||
fingerprint: this.scenarioFingerprint
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-launch Inlook and show orientation toast
|
|
||||||
setTimeout(() => {
|
|
||||||
this.inlook.launch();
|
|
||||||
this.notifications.show({
|
|
||||||
title: 'NexaCore Orientation',
|
|
||||||
body: 'Welcome Jordan! Check Inlook for initial tasks from Morgan Chen.',
|
|
||||||
type: 'info',
|
|
||||||
timeout: 8000
|
|
||||||
});
|
|
||||||
}, 400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
finishScenario() {
|
finishScenario() {
|
||||||
|
// Stop the event scheduler
|
||||||
|
if (this.eventScheduler) {
|
||||||
|
this.eventScheduler.stop();
|
||||||
|
}
|
||||||
|
|
||||||
this.eventBus.emit('SCENARIO_FINISHED', {
|
this.eventBus.emit('SCENARIO_FINISHED', {
|
||||||
target: this.scenario.scenarioId
|
target: this.scenario.id
|
||||||
});
|
});
|
||||||
|
|
||||||
const logs = this.eventBus.getLogs();
|
const logs = this.eventBus.getLogs();
|
||||||
const scorer = new BehavioralScorer(this.scenario, logs);
|
const scorer = new BehavioralScorer(this.scenario, logs, this.scenarioState);
|
||||||
const scoreResult = scorer.evaluate();
|
const scoreResult = scorer.evaluate();
|
||||||
|
|
||||||
const aar = new AfterActionReport({
|
const aar = new AfterActionReport({
|
||||||
@@ -188,7 +353,8 @@ class CyberSimEngine {
|
|||||||
scoreResult,
|
scoreResult,
|
||||||
onClaimCertificate: () => {
|
onClaimCertificate: () => {
|
||||||
const certGen = new CertificateGenerator(this.scenario, scoreResult, this.scenarioFingerprint);
|
const certGen = new CertificateGenerator(this.scenario, scoreResult, this.scenarioFingerprint);
|
||||||
certGen.showCertificateModal(this.scenario.learner.name);
|
const learnerName = this.scenario.learner ? this.scenario.learner.name : 'Learner';
|
||||||
|
certGen.showCertificateModal(learnerName);
|
||||||
},
|
},
|
||||||
onRestart: () => {
|
onRestart: () => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Scenario diagnostics and development mode tools.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the application is running in development mode.
|
||||||
|
* @returns {boolean} True if dev mode is enabled.
|
||||||
|
*/
|
||||||
|
export function isDevelopmentMode() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
if (urlParams.get('dev') === 'true' || urlParams.get('debug') === 'true') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (localStorage.getItem('cybersim-dev-mode') === 'true') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a diagnostic object into a human-readable string.
|
||||||
|
* @param {Object} diagnostic The diagnostic object (from validation).
|
||||||
|
* @returns {string} The formatted diagnostic message.
|
||||||
|
*/
|
||||||
|
export function formatDiagnostic(diagnostic) {
|
||||||
|
const { type, field, objectId, message, expected } = diagnostic;
|
||||||
|
let out = `[${type}]`;
|
||||||
|
if (field) out += ` ${field}`;
|
||||||
|
if (objectId) out += ` (${objectId})`;
|
||||||
|
out += `: ${message}`;
|
||||||
|
if (expected) out += ` (expected: ${expected})`;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides structured diagnostic output for scenario validation and runtime debugging.
|
||||||
|
*/
|
||||||
|
export class ScenarioDiagnostics {
|
||||||
|
/**
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {Object} params.scenario The scenario object.
|
||||||
|
* @param {Object} [params.schemaResult] The schema validation result.
|
||||||
|
* @param {Object} [params.validationResult] The logical validation result.
|
||||||
|
*/
|
||||||
|
constructor({ scenario, schemaResult, validationResult }) {
|
||||||
|
this.scenario = scenario;
|
||||||
|
this.schemaResult = schemaResult || { isValid: true, errors: [] };
|
||||||
|
this.validationResult = validationResult || { isValid: true, errors: [], warnings: [] };
|
||||||
|
|
||||||
|
this.panel = null;
|
||||||
|
this.stateData = null;
|
||||||
|
this.timelineData = [];
|
||||||
|
this.scoresData = null;
|
||||||
|
this.fingerprint = null;
|
||||||
|
this.seed = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs validation diagnostics to the console.
|
||||||
|
*/
|
||||||
|
logToConsole() {
|
||||||
|
const errors = [
|
||||||
|
...(this.schemaResult.errors || []),
|
||||||
|
...(this.validationResult.errors || [])
|
||||||
|
].map(e => ({ type: 'ERROR', ...e }));
|
||||||
|
|
||||||
|
const warnings = (this.validationResult.warnings || []).map(w => ({ type: 'WARN', ...w }));
|
||||||
|
|
||||||
|
if (errors.length === 0 && warnings.length === 0) {
|
||||||
|
console.log(
|
||||||
|
'%cScenario Valid',
|
||||||
|
'color: green; font-weight: bold;',
|
||||||
|
`Loaded ${JSON.stringify(this.getObjectCounts())}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.group('%cScenario Diagnostics', 'font-weight: bold;');
|
||||||
|
console.log(`${errors.length} errors, ${warnings.length} warnings`);
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.groupCollapsed('%cErrors', 'color: #ff5555;');
|
||||||
|
errors.forEach(e => {
|
||||||
|
console.error(formatDiagnostic(e));
|
||||||
|
});
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
console.groupCollapsed('%cWarnings', 'color: #ffff55;');
|
||||||
|
warnings.forEach(w => {
|
||||||
|
console.warn(formatDiagnostic(w));
|
||||||
|
});
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes object counts for the scenario.
|
||||||
|
* @returns {Object} Object counts.
|
||||||
|
*/
|
||||||
|
getObjectCounts() {
|
||||||
|
const s = this.scenario;
|
||||||
|
if (!s) return {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
people: Object.keys(s.people || {}).length,
|
||||||
|
organizations: Object.keys(s.organizations || {}).length,
|
||||||
|
messages: Object.keys(s.messages || {}).length,
|
||||||
|
pages: Object.keys(s.pages || {}).length,
|
||||||
|
files: Object.keys(s.files || {}).length,
|
||||||
|
events: Object.keys(s.events || {}).length,
|
||||||
|
scoringCategories: Object.keys(s.scoring?.categories || {}).length,
|
||||||
|
scoringRules: Object.keys(s.scoring?.rules || {}).length,
|
||||||
|
findings: Object.keys(s.findings || {}).length,
|
||||||
|
feedback: Object.keys(s.feedback || {}).length,
|
||||||
|
notifications: Object.keys(s.notifications || {}).length,
|
||||||
|
alerts: Object.keys(s.alerts || {}).length,
|
||||||
|
objectives: Object.keys(s.objectives || {}).length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the overlay panel for development mode.
|
||||||
|
*/
|
||||||
|
showOverlay() {
|
||||||
|
if (!isDevelopmentMode()) return;
|
||||||
|
|
||||||
|
if (this.panel) {
|
||||||
|
this.panel.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.panel = document.createElement('div');
|
||||||
|
this.panel.className = 'cybersim-diagnostics-panel';
|
||||||
|
|
||||||
|
// Inline styles
|
||||||
|
Object.assign(this.panel.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '20px',
|
||||||
|
right: '20px',
|
||||||
|
width: '400px',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
backgroundColor: 'rgba(20, 20, 20, 0.95)',
|
||||||
|
color: '#00ff00',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '12px',
|
||||||
|
padding: '15px',
|
||||||
|
borderRadius: '5px',
|
||||||
|
boxShadow: '0 0 10px rgba(0, 0, 0, 0.5)',
|
||||||
|
zIndex: '9999',
|
||||||
|
border: '1px solid #333'
|
||||||
|
});
|
||||||
|
|
||||||
|
this._renderOverlayContent();
|
||||||
|
document.body.appendChild(this.panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hides the overlay panel.
|
||||||
|
*/
|
||||||
|
hideOverlay() {
|
||||||
|
if (this.panel) {
|
||||||
|
this.panel.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders or re-renders the contents of the overlay panel.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderOverlayContent() {
|
||||||
|
if (!this.panel) return;
|
||||||
|
|
||||||
|
const errors = [
|
||||||
|
...(this.schemaResult.errors || []),
|
||||||
|
...(this.validationResult.errors || [])
|
||||||
|
].map(e => ({ type: 'ERROR', ...e }));
|
||||||
|
|
||||||
|
const warnings = (this.validationResult.warnings || []).map(w => ({ type: 'WARN', ...w }));
|
||||||
|
const counts = this.getObjectCounts();
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #333; padding-bottom: 10px; margin-bottom: 10px;">
|
||||||
|
<h3 style="margin: 0; color: #fff; font-size: 14px;">CyberSim Diagnostics</h3>
|
||||||
|
<button id="cybersim-diag-close" style="background: #333; color: #fff; border: none; padding: 5px 10px; cursor: pointer; border-radius: 3px;">Close</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 10px;">
|
||||||
|
<strong>Scenario ID:</strong> ${this.scenario?.metadata?.id || 'Unknown'}<br>
|
||||||
|
<strong>Version:</strong> ${this.scenario?.metadata?.version || 'Unknown'}<br>
|
||||||
|
<strong>Format:</strong> ${this.scenario?.format_version || 'Unknown'}<br>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (this.fingerprint || this.seed) {
|
||||||
|
html += `<div style="margin-bottom: 10px;">`;
|
||||||
|
if (this.fingerprint) html += `<strong>Fingerprint:</strong> ${this.fingerprint}<br>`;
|
||||||
|
if (this.seed) html += `<strong>Seed:</strong> ${this.seed}<br>`;
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div style="margin-bottom: 10px; border-bottom: 1px solid #333; padding-bottom: 10px;">
|
||||||
|
<strong style="color: #fff;">Object Counts:</strong>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin-top: 5px; color: #aaa;">
|
||||||
|
`;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(counts)) {
|
||||||
|
html += `<div>${key}: <span style="color: #fff;">${value}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (errors.length > 0 || warnings.length > 0) {
|
||||||
|
html += `<div style="margin-bottom: 10px;">
|
||||||
|
<strong style="color: #fff;">Validation (${errors.length} E, ${warnings.length} W):</strong>
|
||||||
|
<ul style="padding-left: 15px; margin: 5px 0;">
|
||||||
|
`;
|
||||||
|
|
||||||
|
errors.forEach(e => {
|
||||||
|
html += `<li style="color: #ff5555; margin-bottom: 3px;">${formatDiagnostic(e)}</li>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
warnings.forEach(w => {
|
||||||
|
html += `<li style="color: #ffff55; margin-bottom: 3px;">${formatDiagnostic(w)}</li>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `</ul></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.scoresData) {
|
||||||
|
html += `<div style="margin-bottom: 10px; border-bottom: 1px solid #333; padding-bottom: 10px;">
|
||||||
|
<strong style="color: #fff;">Scores:</strong>
|
||||||
|
<pre style="margin: 5px 0; color: #aaa; white-space: pre-wrap; word-break: break-all; font-family: monospace;">${JSON.stringify(this.scoresData, null, 2)}</pre>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.stateData) {
|
||||||
|
html += `<div style="margin-bottom: 10px; border-bottom: 1px solid #333; padding-bottom: 10px;">
|
||||||
|
<strong style="color: #fff;">State Snapshot:</strong>
|
||||||
|
<pre style="margin: 5px 0; color: #aaa; max-height: 100px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; font-family: monospace;">${JSON.stringify(this.stateData, null, 2)}</pre>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.timelineData.length > 0) {
|
||||||
|
html += `<div style="margin-bottom: 10px;">
|
||||||
|
<strong style="color: #fff;">Timeline (Last 5):</strong>
|
||||||
|
<ul style="padding-left: 15px; margin: 5px 0; color: #aaa;">
|
||||||
|
`;
|
||||||
|
|
||||||
|
const recent = this.timelineData.slice(-5);
|
||||||
|
recent.forEach(t => {
|
||||||
|
html += `<li>${typeof t === 'string' ? t : JSON.stringify(t)}</li>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `</ul></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.panel.innerHTML = html;
|
||||||
|
|
||||||
|
// Add event listener to close button
|
||||||
|
const closeBtn = this.panel.querySelector('#cybersim-diag-close');
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.addEventListener('click', () => this.hideOverlay());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the overlay with the current scenario state.
|
||||||
|
* @param {Object} stateSnapshot The current state snapshot.
|
||||||
|
*/
|
||||||
|
updateState(stateSnapshot) {
|
||||||
|
this.stateData = stateSnapshot;
|
||||||
|
this._renderOverlayContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an event to the event timeline display.
|
||||||
|
* @param {Object|string} entry The event entry.
|
||||||
|
*/
|
||||||
|
addTimelineEntry(entry) {
|
||||||
|
this.timelineData.push(entry);
|
||||||
|
this._renderOverlayContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the score display.
|
||||||
|
* @param {Object} scoreSnapshot The current scores.
|
||||||
|
*/
|
||||||
|
updateScores(scoreSnapshot) {
|
||||||
|
this.scoresData = scoreSnapshot;
|
||||||
|
this._renderOverlayContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays the scenario fingerprint.
|
||||||
|
* @param {string} fingerprint The scenario fingerprint.
|
||||||
|
*/
|
||||||
|
setFingerprint(fingerprint) {
|
||||||
|
this.fingerprint = fingerprint;
|
||||||
|
this._renderOverlayContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays the deterministic seed.
|
||||||
|
* @param {string} seed The random seed.
|
||||||
|
*/
|
||||||
|
setSeed(seed) {
|
||||||
|
this.seed = seed;
|
||||||
|
this._renderOverlayContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +1,106 @@
|
|||||||
/**
|
/**
|
||||||
* CyberSim OS - Scenario Fingerprinting Utility
|
* Recursively canonicalizes an object by sorting its keys alphabetically.
|
||||||
* Cryptographically hashes immutable scenario definitions using native Web Crypto API (SHA-256).
|
* Arrays are recursively canonicalized but their order is preserved.
|
||||||
|
* Internal properties starting with '_' are excluded.
|
||||||
|
*
|
||||||
|
* @param {*} obj - The object to canonicalize.
|
||||||
|
* @returns {*} The canonicalized object.
|
||||||
*/
|
*/
|
||||||
|
export function canonicalize(obj) {
|
||||||
|
if (obj === null || typeof obj !== 'object') {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map(canonicalize);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedKeys = Object.keys(obj).sort();
|
||||||
|
const result = {};
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
// Skip internal properties starting with _
|
||||||
|
if (key.startsWith('_')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result[key] = canonicalize(obj[key]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates a SHA-256 fingerprint for a scenario based on evaluation-relevant content.
|
||||||
|
*
|
||||||
|
* @param {Object} scenarioData - The scenario data to fingerprint.
|
||||||
|
* @returns {Promise<string>} The hex-encoded SHA-256 fingerprint.
|
||||||
|
*/
|
||||||
export async function calculateScenarioFingerprint(scenarioData) {
|
export async function calculateScenarioFingerprint(scenarioData) {
|
||||||
try {
|
try {
|
||||||
// Create canonical representation excluding volatile runtime state
|
if (!scenarioData || typeof scenarioData !== 'object') {
|
||||||
const canonicalObject = {
|
throw new Error('Invalid scenario data');
|
||||||
scenarioId: scenarioData.scenarioId,
|
}
|
||||||
|
|
||||||
|
// Build the evaluation-relevant canonical object
|
||||||
|
const evaluationData = {
|
||||||
|
formatVersion: scenarioData.formatVersion,
|
||||||
|
id: scenarioData.id,
|
||||||
version: scenarioData.version,
|
version: scenarioData.version,
|
||||||
company: scenarioData.company,
|
|
||||||
emails: scenarioData.emails.map(e => ({
|
// Map arrays to include only specific relevant fields where required
|
||||||
id: e.id,
|
messages: (scenarioData.messages || []).map(msg => ({
|
||||||
sender: e.sender,
|
id: msg.id,
|
||||||
rfcSender: e.rfcSender,
|
sender: msg.sender,
|
||||||
subject: e.subject,
|
rfcSender: msg.rfcSender,
|
||||||
body: e.body,
|
subject: msg.subject,
|
||||||
isThreat: !!e.isThreat,
|
body: msg.body,
|
||||||
isFalseFlag: !!e.isFalseFlag,
|
links: msg.links,
|
||||||
links: e.links || [],
|
attachments: msg.attachments
|
||||||
attachments: e.attachments || []
|
|
||||||
})),
|
})),
|
||||||
files: scenarioData.files.map(f => ({
|
|
||||||
id: f.id,
|
pages: (scenarioData.pages || []).map(page => ({
|
||||||
name: f.name,
|
url: page.url,
|
||||||
type: f.type,
|
title: page.title,
|
||||||
folder: f.folder
|
isSecure: page.isSecure,
|
||||||
|
isPhishing: page.isPhishing,
|
||||||
|
forms: page.forms
|
||||||
})),
|
})),
|
||||||
pages: scenarioData.pages.map(p => ({
|
|
||||||
url: p.url,
|
files: (scenarioData.files || []).map(file => ({
|
||||||
title: p.title
|
id: file.id,
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
folder: file.folder,
|
||||||
|
content: file.content
|
||||||
})),
|
})),
|
||||||
threats: scenarioData.threats,
|
|
||||||
falseFlags: scenarioData.falseFlags
|
// Include entire objects/arrays for the rest
|
||||||
|
events: scenarioData.events || [],
|
||||||
|
scoring: scenarioData.scoring || {},
|
||||||
|
findings: scenarioData.findings || [],
|
||||||
|
feedback: scenarioData.feedback || [],
|
||||||
|
completion: scenarioData.completion || {},
|
||||||
|
objectives: scenarioData.objectives || [],
|
||||||
|
alerts: scenarioData.alerts || []
|
||||||
};
|
};
|
||||||
|
|
||||||
const canonicalJson = JSON.stringify(canonicalObject, Object.keys(canonicalObject).sort());
|
// Canonicalize the object (sorts keys deterministically and removes internal fields)
|
||||||
const msgBuffer = new TextEncoder().encode(canonicalJson);
|
const canonicalObject = canonicalize(evaluationData);
|
||||||
const hashBuffer = await window.crypto.subtle.digest('SHA-256', msgBuffer);
|
|
||||||
|
// Stringify the canonical object
|
||||||
|
const jsonString = JSON.stringify(canonicalObject);
|
||||||
|
|
||||||
|
// Hash the string using Web Crypto API
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(jsonString);
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
|
||||||
|
// Convert buffer to hex string
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
return hashHex;
|
return hashHex;
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
console.error('Error calculating scenario fingerprint:', err);
|
console.error('Error calculating scenario fingerprint:', error);
|
||||||
// Fallback hash
|
// Fallback to SHA-256 of empty string
|
||||||
return 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
|
return 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* Validates whether a file path is safe to resolve within the scenario directory.
|
||||||
|
* @param {string} path - The path to check.
|
||||||
|
* @returns {{ valid: boolean, reason: string|null }}
|
||||||
|
*/
|
||||||
|
function isPathSafe(path) {
|
||||||
|
if (path.includes('..')) {
|
||||||
|
return { valid: false, reason: 'Contains directory traversal (..)' };
|
||||||
|
}
|
||||||
|
if (path.startsWith('/') || /^[a-zA-Z]:/.test(path)) {
|
||||||
|
return { valid: false, reason: 'Absolute path' };
|
||||||
|
}
|
||||||
|
if (path.includes('\\')) {
|
||||||
|
return { valid: false, reason: 'Contains backslash (\\)' };
|
||||||
|
}
|
||||||
|
if (/^(https?|file|data|javascript):/i.test(path)) {
|
||||||
|
return { valid: false, reason: 'Contains protocol prefix' };
|
||||||
|
}
|
||||||
|
return { valid: true, reason: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes an HTML string to remove potential XSS vectors.
|
||||||
|
* @param {string} html - The HTML string to sanitize.
|
||||||
|
* @param {string} sourceId - A string identifying the source for logging purposes.
|
||||||
|
* @returns {{ clean: string, warnings: string[] }}
|
||||||
|
*/
|
||||||
|
function sanitizeHtml(html, sourceId) {
|
||||||
|
if (!html) return { clean: '', warnings: [] };
|
||||||
|
|
||||||
|
const warnings = [];
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(html, 'text/html');
|
||||||
|
|
||||||
|
const elementsToRemove = ['script', 'iframe', 'object', 'embed'];
|
||||||
|
|
||||||
|
// Remove unsafe elements
|
||||||
|
elementsToRemove.forEach(tag => {
|
||||||
|
const els = doc.querySelectorAll(tag);
|
||||||
|
els.forEach(el => {
|
||||||
|
el.remove();
|
||||||
|
warnings.push(`[${sourceId}] Removed unsafe <${tag}> tag.`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove unsafe attributes
|
||||||
|
const allElements = doc.querySelectorAll('*');
|
||||||
|
allElements.forEach(el => {
|
||||||
|
// Iterate backwards over attributes to safely remove them while iterating
|
||||||
|
for (let i = el.attributes.length - 1; i >= 0; i--) {
|
||||||
|
const attr = el.attributes[i];
|
||||||
|
const name = attr.name.toLowerCase();
|
||||||
|
const value = attr.value.toLowerCase().trim();
|
||||||
|
|
||||||
|
if (name.startsWith('on')) {
|
||||||
|
el.removeAttribute(attr.name);
|
||||||
|
warnings.push(`[${sourceId}] Removed unsafe attribute '${attr.name}' from <${el.tagName.toLowerCase()}>.`);
|
||||||
|
} else if ((name === 'href' || name === 'src') && (value.startsWith('javascript:') || value.startsWith('data:text/html'))) {
|
||||||
|
el.removeAttribute(attr.name);
|
||||||
|
warnings.push(`[${sourceId}] Removed unsafe URI in '${attr.name}' attribute from <${el.tagName.toLowerCase()}>.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
clean: doc.body.innerHTML,
|
||||||
|
warnings
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deeply freezes an object to prevent runtime mutations.
|
||||||
|
* @param {Object} obj - The object to freeze.
|
||||||
|
* @returns {Object} The frozen object.
|
||||||
|
*/
|
||||||
|
export function deepFreeze(obj) {
|
||||||
|
if (obj && typeof obj === 'object' && !Object.isFrozen(obj)) {
|
||||||
|
Object.freeze(obj);
|
||||||
|
Object.keys(obj).forEach(key => deepFreeze(obj[key]));
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the scenario URL from the query string or returns the default.
|
||||||
|
* @returns {string} The scenario URL.
|
||||||
|
*/
|
||||||
|
export function getScenarioUrl() {
|
||||||
|
if (typeof window !== 'undefined' && window.location) {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const scenario = params.get('scenario');
|
||||||
|
if (scenario) {
|
||||||
|
return scenario;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'scenarios/nexacore-orientation/scenario.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} LoadResult
|
||||||
|
* @property {Object|null} scenario
|
||||||
|
* @property {string[]} errors
|
||||||
|
* @property {string[]} warnings
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads, resolves, sanitizes, and returns a validated CyberSim scenario package.
|
||||||
|
*/
|
||||||
|
export class ScenarioLoader {
|
||||||
|
/**
|
||||||
|
* Loads a scenario from a given URL.
|
||||||
|
* @param {string} scenarioUrl - The URL to load the scenario from.
|
||||||
|
* @returns {Promise<LoadResult>} The result containing the scenario, errors, and warnings.
|
||||||
|
*/
|
||||||
|
static async load(scenarioUrl) {
|
||||||
|
const errors = [];
|
||||||
|
const warnings = [];
|
||||||
|
|
||||||
|
let baseUrl = scenarioUrl;
|
||||||
|
if (baseUrl.includes('/')) {
|
||||||
|
baseUrl = baseUrl.substring(0, baseUrl.lastIndexOf('/'));
|
||||||
|
} else {
|
||||||
|
baseUrl = '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
let scenarioData;
|
||||||
|
try {
|
||||||
|
const response = await fetch(scenarioUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
errors.push(`Network error fetching scenario from ${scenarioUrl}: ${response.statusText}`);
|
||||||
|
return { scenario: null, errors, warnings };
|
||||||
|
}
|
||||||
|
scenarioData = await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`JSON parse error or fetch failure for ${scenarioUrl}: ${e.message}`);
|
||||||
|
return { scenario: null, errors, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format version check
|
||||||
|
if (scenarioData.formatVersion) {
|
||||||
|
const versionParts = scenarioData.formatVersion.toString().split('.');
|
||||||
|
if (versionParts[0] !== '1') {
|
||||||
|
errors.push(`Unsupported major formatVersion: ${scenarioData.formatVersion}`);
|
||||||
|
return { scenario: null, errors, warnings };
|
||||||
|
} else if (versionParts[1] && versionParts[1] !== '0') {
|
||||||
|
warnings.push(`Unknown minor formatVersion: ${scenarioData.formatVersion}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.push('Missing formatVersion in scenario.');
|
||||||
|
return { scenario: null, errors, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference resolution
|
||||||
|
for (const key of Object.keys(scenarioData)) {
|
||||||
|
const val = scenarioData[key];
|
||||||
|
if (typeof val === 'string' && val.startsWith('$ref:')) {
|
||||||
|
const refPath = val.substring(5);
|
||||||
|
const safety = isPathSafe(refPath);
|
||||||
|
|
||||||
|
if (!safety.valid) {
|
||||||
|
errors.push(`Invalid reference path '${refPath}' in key '${key}': ${safety.reason}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refUrl = `${baseUrl}/${refPath}`;
|
||||||
|
const refResponse = await fetch(refUrl);
|
||||||
|
if (!refResponse.ok) {
|
||||||
|
errors.push(`Failed to fetch reference '${refPath}' for key '${key}': ${refResponse.statusText}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
scenarioData[key] = await refResponse.json();
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`Failed to parse/fetch reference '${refPath}' for key '${key}': ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { scenario: null, errors, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML content sanitization
|
||||||
|
if (Array.isArray(scenarioData.pages)) {
|
||||||
|
scenarioData.pages.forEach((page, idx) => {
|
||||||
|
if (typeof page.content === 'string') {
|
||||||
|
const san = sanitizeHtml(page.content, `pages[${idx}].content`);
|
||||||
|
page.content = san.clean;
|
||||||
|
warnings.push(...san.warnings);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(scenarioData.files)) {
|
||||||
|
scenarioData.files.forEach((file, idx) => {
|
||||||
|
if (typeof file.content === 'string') {
|
||||||
|
const san = sanitizeHtml(file.content, `files[${idx}].content`);
|
||||||
|
file.content = san.clean;
|
||||||
|
warnings.push(...san.warnings);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(scenarioData.notifications)) {
|
||||||
|
scenarioData.notifications.forEach((notif, idx) => {
|
||||||
|
if (typeof notif.body === 'string' && (notif.body.includes('<') || notif.body.includes('>'))) {
|
||||||
|
const san = sanitizeHtml(notif.body, `notifications[${idx}].body`);
|
||||||
|
notif.body = san.clean;
|
||||||
|
warnings.push(...san.warnings);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store base URL for asset resolution
|
||||||
|
scenarioData._baseUrl = baseUrl;
|
||||||
|
|
||||||
|
// Freeze to prevent runtime mutations
|
||||||
|
deepFreeze(scenarioData);
|
||||||
|
|
||||||
|
return { scenario: scenarioData, errors, warnings };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,425 +0,0 @@
|
|||||||
/**
|
|
||||||
* CyberSim OS - Reference Scenario 1
|
|
||||||
* "Day One at NexaCore Technologies - Operational Shift & Security Awareness"
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const referenceScenario1 = {
|
|
||||||
scenarioId: 'nexacore-shift-1',
|
|
||||||
title: 'Operational Shift & Security Awareness',
|
|
||||||
version: '1.0.0',
|
|
||||||
description: 'Begin your work shift at NexaCore Technologies. Review requested budget forecasts, handle communications, investigate anomalies, and make sound security decisions.',
|
|
||||||
learner: {
|
|
||||||
name: 'Jordan Taylor',
|
|
||||||
role: 'Financial Operations Specialist',
|
|
||||||
email: '[email protected]',
|
|
||||||
department: 'Finance & Accounting'
|
|
||||||
},
|
|
||||||
company: {
|
|
||||||
name: 'NexaCore Technologies',
|
|
||||||
domain: 'nexacore.internal',
|
|
||||||
intranetUrl: 'http://intranet.nexacore.internal'
|
|
||||||
},
|
|
||||||
|
|
||||||
// Initial Scenario Goals
|
|
||||||
objectives: [
|
|
||||||
'Review the welcome email and tasks from your manager (Morgan Chen)',
|
|
||||||
'Review the Q3 Budget Forecast spreadsheet in your Documents folder',
|
|
||||||
'Familiarize yourself with the NexaCore Cybersecurity Policy',
|
|
||||||
'Review and handle any incoming communications appropriately',
|
|
||||||
'Investigate any suspicious or unusual events using company tools',
|
|
||||||
'Submit incident reports via Security Center if genuine threats are detected'
|
|
||||||
],
|
|
||||||
|
|
||||||
// Threat & False Flag Declarations
|
|
||||||
threats: [
|
|
||||||
{
|
|
||||||
id: 'threat_phish_pwreset',
|
|
||||||
type: 'credential_phishing',
|
|
||||||
name: 'Fake IT Password Reset Phishing Email',
|
|
||||||
sourceEmailId: 'email_phish_pwreset',
|
|
||||||
indicators: [
|
|
||||||
'Sender RFC domain is @nexac0re-portal.com (typosquatted with zero)',
|
|
||||||
'Display text shows legitimate intranet URL but href links to external http://login-nexac0re-portal.com',
|
|
||||||
'Artificial urgency threatening immediate account deactivation',
|
|
||||||
'Direct violation of NexaCore Security Policy Section 3'
|
|
||||||
],
|
|
||||||
correctAction: 'inspect_and_report',
|
|
||||||
dangerousActions: ['click_link', 'submit_credentials']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'threat_malicious_invoice',
|
|
||||||
type: 'malicious_attachment',
|
|
||||||
name: 'Unsolicited Vendor Invoice with Executable Payload',
|
|
||||||
sourceEmailId: 'email_malicious_invoice',
|
|
||||||
indicators: [
|
|
||||||
'Vendor Apex Supply Partners is NOT on the approved Company Shared Vendor Directory',
|
|
||||||
'Attachment is an executable archive (Invoice_88921_Receipt.zip)',
|
|
||||||
'Urgent legal and penalty threats for non-existent service invoice'
|
|
||||||
],
|
|
||||||
correctAction: 'inspect_and_report',
|
|
||||||
dangerousActions: ['download_attachment', 'open_executable']
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
falseFlags: [
|
|
||||||
{
|
|
||||||
id: 'flag_legit_mfa',
|
|
||||||
type: 'legitimate_security_notice',
|
|
||||||
name: 'CISO Company-Wide MFA Security Policy Announcement',
|
|
||||||
sourceEmailId: 'email_legit_mfa',
|
|
||||||
indicators: [
|
|
||||||
'Sender is genuinely [email protected] (matches Intranet Employee Directory)',
|
|
||||||
'Does NOT ask for password entry or direct link login',
|
|
||||||
'Instructs employees to navigate independently to the Intranet IT Security page',
|
|
||||||
'Confirmatory announcement is present on the company Intranet news feed'
|
|
||||||
],
|
|
||||||
correctAction: 'verify_and_retain',
|
|
||||||
incorrectAction: 'report_as_phishing'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'flag_coworker_spreadsheet',
|
|
||||||
type: 'routine_document_share',
|
|
||||||
name: 'Coworker Financial Inquiry from Sarah Jenkins',
|
|
||||||
sourceEmailId: 'email_coworker_req',
|
|
||||||
indicators: [
|
|
||||||
'Sender is verified internal colleague [email protected]',
|
|
||||||
'Pertains directly to the assigned Q3 Budget Forecast task',
|
|
||||||
'No deceptive links or suspicious payloads'
|
|
||||||
],
|
|
||||||
correctAction: 'reply_or_continue_work',
|
|
||||||
incorrectAction: 'report_as_phishing'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
// Simulated Inlook Emails
|
|
||||||
emails: [
|
|
||||||
{
|
|
||||||
id: 'email_welcome',
|
|
||||||
folder: 'inbox',
|
|
||||||
sender: 'Morgan Chen (VP of Finance)',
|
|
||||||
rfcSender: '[email protected]',
|
|
||||||
recipient: '[email protected]',
|
|
||||||
subject: 'Welcome to the team - Tasks for today',
|
|
||||||
date: '08:45 AM',
|
|
||||||
unread: true,
|
|
||||||
starred: true,
|
|
||||||
body: `Hi Jordan,
|
|
||||||
|
|
||||||
Welcome to the NexaCore Finance team!
|
|
||||||
|
|
||||||
For your shift today, please make sure to complete the following:
|
|
||||||
1. Open the Files app and review the "Q3_Budget_Forecast.xlsx" document in your Documents folder. Sarah Jenkins in Accounting may ask you for an update on the operating margins.
|
|
||||||
2. Read through the "NexaCore_Cyber_Security_Policy_v4.pdf" in your Documents folder to ensure you know our reporting protocols.
|
|
||||||
3. Check the company Intranet for any updates or staff listings.
|
|
||||||
|
|
||||||
Let me know if you run into any questions. Have a great shift!
|
|
||||||
|
|
||||||
Best,
|
|
||||||
Morgan Chen
|
|
||||||
VP of Finance | NexaCore Technologies`,
|
|
||||||
links: [],
|
|
||||||
attachments: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'email_phish_pwreset',
|
|
||||||
folder: 'inbox',
|
|
||||||
sender: 'NexaCore IT Helpdesk',
|
|
||||||
rfcSender: '[email protected]',
|
|
||||||
recipient: '[email protected]',
|
|
||||||
subject: 'URGENT: Mandatory Single Sign-On (SSO) Migration & Password Verification',
|
|
||||||
date: '09:05 AM',
|
|
||||||
unread: true,
|
|
||||||
starred: false,
|
|
||||||
isThreat: true,
|
|
||||||
threatId: 'threat_phish_pwreset',
|
|
||||||
body: `Attention NexaCore Employee,
|
|
||||||
|
|
||||||
Our enterprise IT infrastructure is undergoing a mandatory Single Sign-On (SSO) security migration. All active user accounts must be verified within the next 2 hours to avoid temporary account suspension and loss of network access.
|
|
||||||
|
|
||||||
Please confirm your employee credentials immediately using our secured identity portal:
|
|
||||||
|
|
||||||
👉 Verify Credentials: <a class="inlook-msg-link" data-url="http://login-nexac0re-portal.com/auth/login" data-display="https://intranet.nexacore.internal/sso-verification">https://intranet.nexacore.internal/sso-verification</a>
|
|
||||||
|
|
||||||
Failure to verify before 11:00 AM will require managerial intervention to re-enable your workstation.
|
|
||||||
|
|
||||||
IT Support Services
|
|
||||||
NexaCore Global Infrastructure`,
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
displayText: 'https://intranet.nexacore.internal/sso-verification',
|
|
||||||
actualUrl: 'http://login-nexac0re-portal.com/auth/login',
|
|
||||||
isPhishing: true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
attachments: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'email_legit_mfa',
|
|
||||||
folder: 'inbox',
|
|
||||||
sender: 'Alex Rivera (CISO)',
|
|
||||||
rfcSender: '[email protected]',
|
|
||||||
recipient: '[email protected]',
|
|
||||||
subject: 'Information Security Notice: Upcoming Company-Wide MFA Policy',
|
|
||||||
date: '09:12 AM',
|
|
||||||
unread: true,
|
|
||||||
starred: false,
|
|
||||||
isFalseFlag: true,
|
|
||||||
falseFlagId: 'flag_legit_mfa',
|
|
||||||
body: `Hello Team,
|
|
||||||
|
|
||||||
As part of our continuous cybersecurity hardening, NexaCore Information Security will be rolling out hardware security keys and updated Multi-Factor Authentication (MFA) protocols starting next week.
|
|
||||||
|
|
||||||
Important Safety Reminder:
|
|
||||||
- We will NEVER send you an email link requesting your password or authentication code.
|
|
||||||
- To check your registered devices or read the full deployment schedule, visit our IT Security page directly on the company Intranet (http://intranet.nexacore.internal/it-security).
|
|
||||||
|
|
||||||
Thank you for helping keep NexaCore secure.
|
|
||||||
|
|
||||||
Sincerely,
|
|
||||||
Alex Rivera
|
|
||||||
Chief Information Security Officer
|
|
||||||
NexaCore Technologies`,
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
displayText: 'http://intranet.nexacore.internal/it-security',
|
|
||||||
actualUrl: 'http://intranet.nexacore.internal/it-security',
|
|
||||||
isPhishing: false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
attachments: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'email_malicious_invoice',
|
|
||||||
folder: 'inbox',
|
|
||||||
sender: 'Apex Supply Partners Billing',
|
|
||||||
rfcSender: '[email protected]',
|
|
||||||
recipient: '[email protected]',
|
|
||||||
subject: 'FINAL DEMAND: Overdue Server Hardware Invoice #INV-88921',
|
|
||||||
date: '09:20 AM',
|
|
||||||
unread: true,
|
|
||||||
starred: false,
|
|
||||||
isThreat: true,
|
|
||||||
threatId: 'threat_malicious_invoice',
|
|
||||||
body: `Attention Finance Department,
|
|
||||||
|
|
||||||
Invoice #INV-88921 for the recent delivery of high-density server chassis is now 45 days past due. A late assessment penalty has been added to the balance.
|
|
||||||
|
|
||||||
Please review the attached itemized payment statement and remit payment immediately to avoid collection proceedings:
|
|
||||||
|
|
||||||
Attachment: Invoice_88921_Receipt.zip
|
|
||||||
|
|
||||||
Regards,
|
|
||||||
Accounting & Recovery Division
|
|
||||||
Apex Supply Partners Ltd.`,
|
|
||||||
links: [],
|
|
||||||
attachments: [
|
|
||||||
{
|
|
||||||
name: 'Invoice_88921_Receipt.zip',
|
|
||||||
size: '342 KB',
|
|
||||||
isMalicious: true,
|
|
||||||
type: 'archive'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'email_coworker_req',
|
|
||||||
folder: 'inbox',
|
|
||||||
sender: 'Sarah Jenkins (Accounting)',
|
|
||||||
rfcSender: '[email protected]',
|
|
||||||
recipient: '[email protected]',
|
|
||||||
subject: 'Quick question on Q3 Budget Forecast',
|
|
||||||
date: '09:35 AM',
|
|
||||||
unread: true,
|
|
||||||
starred: false,
|
|
||||||
isFalseFlag: true,
|
|
||||||
falseFlagId: 'flag_coworker_spreadsheet',
|
|
||||||
body: `Hi Jordan,
|
|
||||||
|
|
||||||
Hope your first morning is going smoothly!
|
|
||||||
|
|
||||||
When you get a chance to inspect the Q3 Budget Forecast spreadsheet in your Documents folder, could you double-check the projected Server & Cloud Infrastructure costs on Row 4? Morgan mentioned we might need to adjust the contingency buffer.
|
|
||||||
|
|
||||||
Thanks a lot!
|
|
||||||
Sarah Jenkins
|
|
||||||
Senior Financial Analyst`,
|
|
||||||
links: [],
|
|
||||||
attachments: []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
// Virtual Filesystem Content
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
id: 'file_budget',
|
|
||||||
name: 'Q3_Budget_Forecast.xlsx',
|
|
||||||
type: 'spreadsheet',
|
|
||||||
folder: 'Documents',
|
|
||||||
size: '48 KB',
|
|
||||||
date: '2026-08-20',
|
|
||||||
content: {
|
|
||||||
title: 'NexaCore Technologies - Q3 Budget Forecast (Draft)',
|
|
||||||
headers: ['Category', 'Q1 Actual', 'Q2 Actual', 'Q3 Projected', 'Variance %'],
|
|
||||||
rows: [
|
|
||||||
['Server & Cloud Infrastructure', '$142,000', '$155,000', '$168,000', '+8.4%'],
|
|
||||||
['Research & Robotics Hardware', '$280,000', '$310,000', '$325,000', '+4.8%'],
|
|
||||||
['Software Licenses & SaaS', '$64,000', '$68,000', '$71,000', '+4.4%'],
|
|
||||||
['Security Audits & Compliance', '$35,000', '$40,000', '$45,000', '+12.5%'],
|
|
||||||
['Total Operating Expenditures', '$521,000', '$573,000', '$609,000', '+6.2%']
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'file_sec_policy',
|
|
||||||
name: 'NexaCore_Cyber_Security_Policy_v4.pdf',
|
|
||||||
type: 'pdf',
|
|
||||||
folder: 'Documents',
|
|
||||||
size: '124 KB',
|
|
||||||
date: '2026-08-15',
|
|
||||||
content: {
|
|
||||||
title: 'NexaCore Information Security Policy (v4.2)',
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
heading: '1. Purpose & Scope',
|
|
||||||
text: 'This policy defines mandatory baseline security procedures for all NexaCore personnel handling digital communications, documents, and credentials.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: '2. Email & Phishing Defense',
|
|
||||||
text: 'All employees must inspect the true RFC sender address before trusting emails requesting urgent actions. NexaCore IT will never distribute links requesting direct password entry. Any email utilizing mismatched link targets or urgent threats must be reported immediately via Security Center.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: '3. Vendor & Payment Verification',
|
|
||||||
text: 'Prior to opening attachments or processing invoices from third parties, employees must cross-reference the vendor against the Approved Vendor Directory in the Company Shared folder. Unsolicited invoices containing executable or compressed files must be treated as malicious.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: '4. Reporting Procedures',
|
|
||||||
text: 'Use the Security Center application or the "Report Suspicious Message" button in Inlook to escalate threats to the Security Operations Center (SOC). Do not forward phishing emails to colleagues.'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'file_vendor_dir',
|
|
||||||
name: 'Vendor_Directory.pdf',
|
|
||||||
type: 'pdf',
|
|
||||||
folder: 'Company Shared',
|
|
||||||
size: '88 KB',
|
|
||||||
date: '2026-08-10',
|
|
||||||
content: {
|
|
||||||
title: 'NexaCore Approved Vendor Directory (2026)',
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
heading: 'Approved Hardware & Cloud Vendors',
|
|
||||||
text: 'The following vendors are authorized for procurement and billing:'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
table: {
|
|
||||||
headers: ['Vendor Name', 'Vendor Code', 'Contact Email', 'Status'],
|
|
||||||
rows: [
|
|
||||||
['Titan Cloud Systems', 'VND-104', '[email protected]', 'Active'],
|
|
||||||
['Quantum Edge Hardware', 'VND-209', '[email protected]', 'Active'],
|
|
||||||
['NexaLogistics Global', 'VND-315', '[email protected]', 'Active'],
|
|
||||||
['CyberShield Auditing LLC', 'VND-402', '[email protected]', 'Active']
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
// Simulated Web Pages for Navigator
|
|
||||||
pages: [
|
|
||||||
{
|
|
||||||
url: 'http://intranet.nexacore.internal',
|
|
||||||
title: 'NexaCore Intranet - Home',
|
|
||||||
isSecure: true,
|
|
||||||
content: `
|
|
||||||
<div class="webpage-intranet">
|
|
||||||
<div class="intranet-header">
|
|
||||||
<div class="intranet-title">NexaCore Enterprise Intranet</div>
|
|
||||||
<div style="font-size:12px; color:#64748b;">Monday, August 24, 2026</div>
|
|
||||||
</div>
|
|
||||||
<div class="intranet-grid">
|
|
||||||
<div>
|
|
||||||
<div class="intranet-card" style="margin-bottom:16px;">
|
|
||||||
<h3>Company Announcements</h3>
|
|
||||||
<div style="font-size:12px; color:#334155; line-height:1.5;">
|
|
||||||
<p><strong>🔒 Security Hardening:</strong> As announced by CISO Alex Rivera, company-wide MFA upgrades are underway. Remember to review our security policy in your Documents folder.</p>
|
|
||||||
<p style="margin-top:8px;"><strong>📊 Q3 Financial Review:</strong> Department budget projections are being finalized this week. Contact Morgan Chen with any queries.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="intranet-card">
|
|
||||||
<h3>Quick Resources</h3>
|
|
||||||
<ul style="padding-left:18px; font-size:12px; color:#2563eb; line-height:1.8;">
|
|
||||||
<li><a href="http://intranet.nexacore.internal/it-security" style="color:#2563eb;">IT Security Policies & Verified Alerts</a></li>
|
|
||||||
<li><a href="http://intranet.nexacore.internal/directory" style="color:#2563eb;">Full Employee Directory</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="intranet-card">
|
|
||||||
<h3>Key Contacts</h3>
|
|
||||||
<table class="directory-table">
|
|
||||||
<tr><th>Name</th><th>Role</th><th>Email</th></tr>
|
|
||||||
<tr><td>Morgan Chen</td><td>VP Finance</td><td>[email protected]</td></tr>
|
|
||||||
<tr><td>Alex Rivera</td><td>CISO</td><td>[email protected]</td></tr>
|
|
||||||
<tr><td>Sarah Jenkins</td><td>Sr Accountant</td><td>[email protected]</td></tr>
|
|
||||||
<tr><td>IT Helpdesk</td><td>Support</td><td>[email protected]</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: 'http://intranet.nexacore.internal/it-security',
|
|
||||||
title: 'IT Security Department - Policy & Notices',
|
|
||||||
isSecure: true,
|
|
||||||
content: `
|
|
||||||
<div class="webpage-intranet">
|
|
||||||
<div class="intranet-header">
|
|
||||||
<div class="intranet-title">IT Security & Compliance Portal</div>
|
|
||||||
<a href="http://intranet.nexacore.internal" style="font-size:12px; color:#2563eb;">← Back to Intranet</a>
|
|
||||||
</div>
|
|
||||||
<div class="intranet-card" style="margin-bottom:16px;">
|
|
||||||
<h3>Official Notice: Hardware MFA Rollout (CISO Alex Rivera)</h3>
|
|
||||||
<p style="font-size:12px; color:#334155; line-height:1.5;">
|
|
||||||
NexaCore is transitioning all personnel to hardware FIDO2 keys and authenticator apps.
|
|
||||||
<strong>Official Reminder:</strong> NexaCore IT will NEVER email you asking for your password or sending direct credential reset links.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="intranet-card">
|
|
||||||
<h3>Known Threat Advisory: Phishing Campaigns Targeting NexaCore</h3>
|
|
||||||
<p style="font-size:12px; color:#dc2626; line-height:1.5;">
|
|
||||||
⚠️ Be aware of external lookalike domains such as <code>nexac0re-portal.com</code> attempting credential harvesting. Always verify the address bar before entering any information.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: 'http://login-nexac0re-portal.com/auth/login',
|
|
||||||
title: 'NexaCore SSO - Single Sign-On Authentication',
|
|
||||||
isSecure: false,
|
|
||||||
isPhishing: true,
|
|
||||||
content: `
|
|
||||||
<div class="webpage-phish">
|
|
||||||
<div class="phish-card">
|
|
||||||
<div class="phish-logo">NexaCore Identity SSO</div>
|
|
||||||
<div class="phish-subtitle">Enter your employee credentials to verify your account</div>
|
|
||||||
<form id="phish-login-form" onsubmit="event.preventDefault(); window.CyberSimOS.handlePhishSubmit(this);">
|
|
||||||
<div class="cs-form-group">
|
|
||||||
<label class="cs-form-label">Employee ID / Email</label>
|
|
||||||
<input type="text" id="phish_user" class="cs-input" placeholder="e.g. [email protected]" required>
|
|
||||||
</div>
|
|
||||||
<div class="cs-form-group">
|
|
||||||
<label class="cs-form-label">Password</label>
|
|
||||||
<input type="password" id="phish_pass" class="cs-input" placeholder="••••••••••••" required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="cs-btn cs-btn-primary" style="width:100%; margin-top:8px;">Sign In & Verify Account</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
/**
|
||||||
|
* @module scenario/schema
|
||||||
|
* @description CyberSim Scenario Format v1.0 JSON schema validation.
|
||||||
|
* Provides structural validation for all scenario object types.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} DiagnosticError
|
||||||
|
* @property {'error'} level
|
||||||
|
* @property {string|null} file
|
||||||
|
* @property {string} field
|
||||||
|
* @property {string|null} objectId
|
||||||
|
* @property {string} message
|
||||||
|
* @property {string|null} expected
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} DiagnosticWarning
|
||||||
|
* @property {'warning'} level
|
||||||
|
* @property {string|null} file
|
||||||
|
* @property {string} field
|
||||||
|
* @property {string|null} objectId
|
||||||
|
* @property {string} message
|
||||||
|
* @property {string|null} expected
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const VALID_ACTION_TYPES = new Set([
|
||||||
|
'desktop.notify', 'desktop.openApp', 'desktop.focusApp', 'desktop.setBadge', 'desktop.endScenario',
|
||||||
|
'mail.deliver', 'mail.updateMessage',
|
||||||
|
'navigator.open', 'navigator.redirect',
|
||||||
|
'files.create', 'files.open',
|
||||||
|
'security.addAlert', 'security.updateStatus',
|
||||||
|
'evaluation.addFinding', 'evaluation.setState', 'evaluation.completeObjective', 'evaluation.awardPoints', 'evaluation.revealFeedback'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a scenario data object against the v1.0 schema.
|
||||||
|
* @param {Object} data - The scenario data to validate
|
||||||
|
* @returns {{ valid: boolean, errors: DiagnosticError[], warnings: DiagnosticWarning[] }}
|
||||||
|
*/
|
||||||
|
export function validateSchema(data) {
|
||||||
|
const ctx = {
|
||||||
|
errors: [],
|
||||||
|
warnings: [],
|
||||||
|
file: null,
|
||||||
|
addError(field, message, expected = null, objectId = null) {
|
||||||
|
this.errors.push({ level: 'error', file: this.file, field, objectId, message, expected });
|
||||||
|
},
|
||||||
|
addWarning(field, message, expected = null, objectId = null) {
|
||||||
|
this.warnings.push({ level: 'warning', file: this.file, field, objectId, message, expected });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
ctx.addError('root', 'Scenario data must be an object', 'object');
|
||||||
|
return { valid: false, errors: ctx.errors, warnings: ctx.warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
validateManifest(data, ctx);
|
||||||
|
|
||||||
|
if (data.learner) validateLearner(data.learner, ctx);
|
||||||
|
if (data.people) validateArray(data.people, validatePerson, 'people', ctx);
|
||||||
|
if (data.organizations) validateArray(data.organizations, validateOrganization, 'organizations', ctx);
|
||||||
|
if (data.messages) validateArray(data.messages, validateMessage, 'messages', ctx);
|
||||||
|
if (data.pages) validateArray(data.pages, validatePage, 'pages', ctx);
|
||||||
|
if (data.files) validateArray(data.files, validateFile, 'files', ctx);
|
||||||
|
if (data.objectives) validateObjectives(data.objectives, ctx);
|
||||||
|
if (data.events) validateArray(data.events, validateEvent, 'events', ctx);
|
||||||
|
if (data.scoring) validateScoring(data.scoring, ctx);
|
||||||
|
if (data.findings) validateArray(data.findings, validateFinding, 'findings', ctx);
|
||||||
|
if (data.feedback) validateArray(data.feedback, validateFeedback, 'feedback', ctx);
|
||||||
|
if (data.notifications) validateArray(data.notifications, validateNotification, 'notifications', ctx);
|
||||||
|
if (data.alerts) validateArray(data.alerts, validateAlert, 'alerts', ctx);
|
||||||
|
if (data.completion) validateCompletion(data.completion, ctx);
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: ctx.errors.length === 0,
|
||||||
|
errors: ctx.errors,
|
||||||
|
warnings: ctx.warnings
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateManifest(data, ctx) {
|
||||||
|
if (data.formatVersion !== '1.0') {
|
||||||
|
ctx.addError('formatVersion', 'Invalid formatVersion', '1.0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isString(data.id) || !/^[a-zA-Z0-9-]+$/.test(data.id)) {
|
||||||
|
ctx.addError('id', 'Must be a non-empty alphanumeric string (hyphens allowed)', 'string matching ^[a-zA-Z0-9-]+$');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isString(data.version) || !/^\d+\.\d+\.\d+.*$/.test(data.version)) {
|
||||||
|
ctx.addWarning('version', 'Version should match semver format', 'semver-like string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isString(data.title) || data.title.trim() === '') {
|
||||||
|
ctx.addError('title', 'Must be a non-empty string', 'non-empty string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.description !== undefined && !isString(data.description)) {
|
||||||
|
ctx.addError('description', 'Must be a string', 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPositiveInteger(data.durationSeconds)) {
|
||||||
|
ctx.addError('durationSeconds', 'Must be a positive integer', 'positive integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.engine && data.engine.minimumVersion && !isString(data.engine.minimumVersion)) {
|
||||||
|
ctx.addError('engine.minimumVersion', 'Must be a string', 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['guided', 'practice', 'assessment'].includes(data.mode)) {
|
||||||
|
ctx.addError('mode', 'Invalid mode', "'guided', 'practice', or 'assessment'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isString(data.entryEvent)) {
|
||||||
|
ctx.addError('entryEvent', 'Must be a string referencing an event ID', 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.passingScore !== 'number' || data.passingScore < 0 || data.passingScore > 100) {
|
||||||
|
ctx.addError('passingScore', 'Must be a number between 0 and 100', 'number 0-100');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional manifest fields
|
||||||
|
if (data.learningObjectives && !isStringArray(data.learningObjectives)) {
|
||||||
|
ctx.addError('learningObjectives', 'Must be an array of strings', 'string[]');
|
||||||
|
}
|
||||||
|
if (data.difficulty !== undefined && !isString(data.difficulty)) {
|
||||||
|
ctx.addError('difficulty', 'Must be a string', 'string');
|
||||||
|
}
|
||||||
|
if (data.audience !== undefined && !isString(data.audience)) {
|
||||||
|
ctx.addError('audience', 'Must be a string', 'string');
|
||||||
|
}
|
||||||
|
if (data.tags && !isStringArray(data.tags)) {
|
||||||
|
ctx.addError('tags', 'Must be an array of strings', 'string[]');
|
||||||
|
}
|
||||||
|
if (data.locale !== undefined && !isString(data.locale)) {
|
||||||
|
ctx.addError('locale', 'Must be a string', 'string');
|
||||||
|
}
|
||||||
|
if (data.seed !== undefined && typeof data.seed !== 'number' && typeof data.seed !== 'string') {
|
||||||
|
ctx.addError('seed', 'Must be a number or string', 'number | string');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLearner(learner, ctx) {
|
||||||
|
if (typeof learner !== 'object') {
|
||||||
|
ctx.addError('learner', 'Must be an object', 'object');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isString(learner.name)) ctx.addError('learner.name', 'Must be a string', 'string');
|
||||||
|
if (!isString(learner.role)) ctx.addError('learner.role', 'Must be a string', 'string');
|
||||||
|
if (!isString(learner.email)) ctx.addError('learner.email', 'Must be a string', 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePerson(person, ctx, index) {
|
||||||
|
const id = person.id;
|
||||||
|
if (!isString(id)) ctx.addError(`people[${index}].id`, 'Must be a string', 'string');
|
||||||
|
if (!isString(person.name)) ctx.addError(`people[${index}].name`, 'Must be a string', 'string', id);
|
||||||
|
if (!isString(person.email)) ctx.addError(`people[${index}].email`, 'Must be a string', 'string', id);
|
||||||
|
if (person.isImpersonator !== undefined && typeof person.isImpersonator !== 'boolean') {
|
||||||
|
ctx.addError(`people[${index}].isImpersonator`, 'Must be a boolean', 'boolean', id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateOrganization(org, ctx, index) {
|
||||||
|
const id = org.id;
|
||||||
|
if (!isString(id)) ctx.addError(`organizations[${index}].id`, 'Must be a string', 'string');
|
||||||
|
if (!isString(org.name)) ctx.addError(`organizations[${index}].name`, 'Must be a string', 'string', id);
|
||||||
|
if (!isStringArray(org.domains) || org.domains.length === 0) {
|
||||||
|
ctx.addError(`organizations[${index}].domains`, 'Must be a non-empty array of strings', 'string[]', id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMessage(msg, ctx, index) {
|
||||||
|
const id = msg.id;
|
||||||
|
if (!isString(id)) ctx.addError(`messages[${index}].id`, 'Must be a string', 'string');
|
||||||
|
if (!isString(msg.sender)) ctx.addError(`messages[${index}].sender`, 'Must be a string', 'string', id);
|
||||||
|
if (!isString(msg.rfcSender)) ctx.addError(`messages[${index}].rfcSender`, 'Must be a string', 'string', id);
|
||||||
|
if (!isString(msg.recipient)) ctx.addError(`messages[${index}].recipient`, 'Must be a string', 'string', id);
|
||||||
|
if (!isString(msg.subject)) ctx.addError(`messages[${index}].subject`, 'Must be a string', 'string', id);
|
||||||
|
if (!isString(msg.date)) ctx.addError(`messages[${index}].date`, 'Must be a string', 'string', id);
|
||||||
|
if (!isString(msg.body)) ctx.addError(`messages[${index}].body`, 'Must be a string', 'string', id);
|
||||||
|
|
||||||
|
if (msg.links) {
|
||||||
|
if (!Array.isArray(msg.links)) {
|
||||||
|
ctx.addError(`messages[${index}].links`, 'Must be an array', 'array', id);
|
||||||
|
} else {
|
||||||
|
msg.links.forEach((l, i) => {
|
||||||
|
if (!isString(l.actualUrl)) ctx.addError(`messages[${index}].links[${i}].actualUrl`, 'Must be a string', 'string', id);
|
||||||
|
if (l.isPhishing !== undefined && typeof l.isPhishing !== 'boolean') ctx.addError(`messages[${index}].links[${i}].isPhishing`, 'Must be a boolean', 'boolean', id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msg.attachments) {
|
||||||
|
if (!Array.isArray(msg.attachments)) {
|
||||||
|
ctx.addError(`messages[${index}].attachments`, 'Must be an array', 'array', id);
|
||||||
|
} else {
|
||||||
|
msg.attachments.forEach((a, i) => {
|
||||||
|
if (!isString(a.name)) ctx.addError(`messages[${index}].attachments[${i}].name`, 'Must be a string', 'string', id);
|
||||||
|
if (a.isMalicious !== undefined && typeof a.isMalicious !== 'boolean') ctx.addError(`messages[${index}].attachments[${i}].isMalicious`, 'Must be a boolean', 'boolean', id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePage(page, ctx, index) {
|
||||||
|
const id = page.url;
|
||||||
|
if (!isString(page.url)) ctx.addError(`pages[${index}].url`, 'Must be a string', 'string');
|
||||||
|
if (!isString(page.title)) ctx.addError(`pages[${index}].title`, 'Must be a string', 'string', id);
|
||||||
|
if (!isString(page.content)) ctx.addError(`pages[${index}].content`, 'Must be a string', 'string', id);
|
||||||
|
|
||||||
|
if (page.isSecure !== undefined && typeof page.isSecure !== 'boolean') ctx.addError(`pages[${index}].isSecure`, 'Must be a boolean', 'boolean', id);
|
||||||
|
if (page.isPhishing !== undefined && typeof page.isPhishing !== 'boolean') ctx.addError(`pages[${index}].isPhishing`, 'Must be a boolean', 'boolean', id);
|
||||||
|
|
||||||
|
if (page.forms) {
|
||||||
|
if (!Array.isArray(page.forms)) {
|
||||||
|
ctx.addError(`pages[${index}].forms`, 'Must be an array', 'array', id);
|
||||||
|
} else {
|
||||||
|
page.forms.forEach((f, i) => {
|
||||||
|
if (!isString(f.id)) ctx.addError(`pages[${index}].forms[${i}].id`, 'Must be a string', 'string', id);
|
||||||
|
if (f.onSubmit) {
|
||||||
|
if (!isString(f.onSubmit.emitEvent)) ctx.addError(`pages[${index}].forms[${i}].onSubmit.emitEvent`, 'Must be a string', 'string', id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFile(file, ctx, index) {
|
||||||
|
const id = file.id;
|
||||||
|
if (!isString(id)) ctx.addError(`files[${index}].id`, 'Must be a string', 'string');
|
||||||
|
if (!isString(file.name)) ctx.addError(`files[${index}].name`, 'Must be a string', 'string', id);
|
||||||
|
if (!['spreadsheet', 'pdf', 'archive', 'executable', 'text', 'image'].includes(file.type)) {
|
||||||
|
ctx.addError(`files[${index}].type`, 'Invalid file type', "'spreadsheet', 'pdf', 'archive', 'executable', 'text', 'image'", id);
|
||||||
|
}
|
||||||
|
if (!isString(file.folder)) ctx.addError(`files[${index}].folder`, 'Must be a string', 'string', id);
|
||||||
|
if (!isString(file.size)) ctx.addError(`files[${index}].size`, 'Must be a string', 'string', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateObjectives(objectives, ctx) {
|
||||||
|
if (!Array.isArray(objectives)) {
|
||||||
|
ctx.addError('objectives', 'Must be an array', 'array');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
objectives.forEach((obj, index) => {
|
||||||
|
if (typeof obj === 'string') return;
|
||||||
|
if (typeof obj === 'object' && obj !== null) {
|
||||||
|
if (!isString(obj.id)) ctx.addError(`objectives[${index}].id`, 'Must be a string', 'string');
|
||||||
|
if (!isString(obj.text)) ctx.addError(`objectives[${index}].text`, 'Must be a string', 'string', obj.id);
|
||||||
|
if (obj.visible !== undefined && typeof obj.visible !== 'boolean') ctx.addError(`objectives[${index}].visible`, 'Must be a boolean', 'boolean', obj.id);
|
||||||
|
} else {
|
||||||
|
ctx.addError(`objectives[${index}]`, 'Must be a string or object', 'string | object');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEvent(event, ctx, index) {
|
||||||
|
const id = event.id;
|
||||||
|
if (!isString(id)) ctx.addError(`events[${index}].id`, 'Must be a string', 'string');
|
||||||
|
|
||||||
|
if (typeof event.when !== 'object' || event.when === null) {
|
||||||
|
ctx.addError(`events[${index}].when`, 'Must be an object', 'object', id);
|
||||||
|
} else {
|
||||||
|
validateCondition(event.when, ctx, `events[${index}].when`, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(event.actions)) {
|
||||||
|
ctx.addError(`events[${index}].actions`, 'Must be an array', 'array', id);
|
||||||
|
} else {
|
||||||
|
event.actions.forEach((a, i) => {
|
||||||
|
if (typeof a !== 'object' || a === null || !VALID_ACTION_TYPES.has(a.type)) {
|
||||||
|
ctx.addError(`events[${index}].actions[${i}].type`, 'Invalid action type', 'valid action type', id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.delay !== undefined && typeof event.delay !== 'number') {
|
||||||
|
ctx.addError(`events[${index}].delay`, 'Must be a number', 'number', id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateScoring(scoring, ctx) {
|
||||||
|
if (typeof scoring !== 'object' || scoring === null) {
|
||||||
|
ctx.addError('scoring', 'Must be an object', 'object');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scoring.categories) {
|
||||||
|
if (!Array.isArray(scoring.categories)) {
|
||||||
|
ctx.addError('scoring.categories', 'Must be an array', 'array');
|
||||||
|
} else {
|
||||||
|
scoring.categories.forEach((cat, i) => {
|
||||||
|
if (!isString(cat.id)) ctx.addError(`scoring.categories[${i}].id`, 'Must be a string', 'string');
|
||||||
|
if (!isString(cat.label)) ctx.addError(`scoring.categories[${i}].label`, 'Must be a string', 'string', cat.id);
|
||||||
|
if (typeof cat.maxPoints !== 'number') ctx.addError(`scoring.categories[${i}].maxPoints`, 'Must be a number', 'number', cat.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (scoring.rules) {
|
||||||
|
if (!Array.isArray(scoring.rules)) {
|
||||||
|
ctx.addError('scoring.rules', 'Must be an array', 'array');
|
||||||
|
} else {
|
||||||
|
scoring.rules.forEach((rule, i) => {
|
||||||
|
if (!isString(rule.id)) ctx.addError(`scoring.rules[${i}].id`, 'Must be a string', 'string');
|
||||||
|
if (rule.condition) validateCondition(rule.condition, ctx, `scoring.rules[${i}].condition`, rule.id);
|
||||||
|
if (rule.award) {
|
||||||
|
if (!isString(rule.award.category)) ctx.addError(`scoring.rules[${i}].award.category`, 'Must be a string', 'string', rule.id);
|
||||||
|
if (typeof rule.award.points !== 'number') ctx.addError(`scoring.rules[${i}].award.points`, 'Must be a number', 'number', rule.id);
|
||||||
|
} else {
|
||||||
|
ctx.addError(`scoring.rules[${i}].award`, 'Must be an object', 'object', rule.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFinding(finding, ctx, index) {
|
||||||
|
const id = finding.id;
|
||||||
|
if (!isString(id)) ctx.addError(`findings[${index}].id`, 'Must be a string', 'string');
|
||||||
|
if (!['info', 'minor', 'significant', 'critical'].includes(finding.severity)) {
|
||||||
|
ctx.addError(`findings[${index}].severity`, 'Invalid severity', "'info', 'minor', 'significant', 'critical'", id);
|
||||||
|
}
|
||||||
|
if (!isString(finding.category)) ctx.addError(`findings[${index}].category`, 'Must be a string', 'string', id);
|
||||||
|
if (typeof finding.score !== 'number') ctx.addError(`findings[${index}].score`, 'Must be a number', 'number', id);
|
||||||
|
if (!isString(finding.feedback)) ctx.addError(`findings[${index}].feedback`, 'Must be a string', 'string', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFeedback(feedback, ctx, index) {
|
||||||
|
const id = feedback.id;
|
||||||
|
if (!isString(id)) ctx.addError(`feedback[${index}].id`, 'Must be a string', 'string');
|
||||||
|
if (feedback.condition) validateCondition(feedback.condition, ctx, `feedback[${index}].condition`, id);
|
||||||
|
if (!isString(feedback.text)) ctx.addError(`feedback[${index}].text`, 'Must be a string', 'string', id);
|
||||||
|
if (feedback.type !== undefined && !['positive', 'warning', 'critical'].includes(feedback.type)) {
|
||||||
|
ctx.addError(`feedback[${index}].type`, 'Invalid type', "'positive', 'warning', 'critical'", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNotification(notification, ctx, index) {
|
||||||
|
const id = notification.id;
|
||||||
|
if (!isString(id)) ctx.addError(`notifications[${index}].id`, 'Must be a string', 'string');
|
||||||
|
if (!isString(notification.title)) ctx.addError(`notifications[${index}].title`, 'Must be a string', 'string', id);
|
||||||
|
if (!isString(notification.body)) ctx.addError(`notifications[${index}].body`, 'Must be a string', 'string', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAlert(alert, ctx, index) {
|
||||||
|
const id = alert.id;
|
||||||
|
if (!isString(id)) ctx.addError(`alerts[${index}].id`, 'Must be a string', 'string');
|
||||||
|
if (!isString(alert.title)) ctx.addError(`alerts[${index}].title`, 'Must be a string', 'string', id);
|
||||||
|
if (!['info', 'medium', 'high', 'critical'].includes(alert.severity)) {
|
||||||
|
ctx.addError(`alerts[${index}].severity`, 'Invalid severity', "'info', 'medium', 'high', 'critical'", id);
|
||||||
|
}
|
||||||
|
if (!isString(alert.source)) ctx.addError(`alerts[${index}].source`, 'Must be a string', 'string', id);
|
||||||
|
if (!isString(alert.message)) ctx.addError(`alerts[${index}].message`, 'Must be a string', 'string', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCompletion(completion, ctx) {
|
||||||
|
if (typeof completion !== 'object' || completion === null) {
|
||||||
|
ctx.addError('completion', 'Must be an object', 'object');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (completion.passingScore !== undefined && typeof completion.passingScore !== 'number') {
|
||||||
|
ctx.addError('completion.passingScore', 'Must be a number', 'number');
|
||||||
|
}
|
||||||
|
if (completion.failAction !== undefined && !isString(completion.failAction)) {
|
||||||
|
ctx.addError('completion.failAction', 'Must be a string', 'string');
|
||||||
|
}
|
||||||
|
if (completion.passAction !== undefined && !isString(completion.passAction)) {
|
||||||
|
ctx.addError('completion.passAction', 'Must be a string', 'string');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a condition object.
|
||||||
|
* @param {Object} condition - The condition to validate
|
||||||
|
* @param {Object} ctx - Validation context
|
||||||
|
* @param {string} fieldPrefix - Field prefix for error reporting
|
||||||
|
* @param {string|null} objectId - ID of the object containing this condition
|
||||||
|
*/
|
||||||
|
export function validateCondition(condition, ctx, fieldPrefix, objectId = null) {
|
||||||
|
if (typeof condition !== 'object' || condition === null) {
|
||||||
|
ctx.addError(fieldPrefix, 'Condition must be an object', 'object', objectId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(condition);
|
||||||
|
if (keys.length === 0) {
|
||||||
|
ctx.addError(fieldPrefix, 'Condition cannot be empty', 'condition object', objectId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = keys[0];
|
||||||
|
const value = condition[key];
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case 'scenarioStart':
|
||||||
|
if (typeof value !== 'boolean') ctx.addError(`${fieldPrefix}.scenarioStart`, 'Must be a boolean', 'boolean', objectId);
|
||||||
|
break;
|
||||||
|
case 'elapsedSeconds':
|
||||||
|
if (typeof value !== 'number') ctx.addError(`${fieldPrefix}.elapsedSeconds`, 'Must be a number', 'number', objectId);
|
||||||
|
break;
|
||||||
|
case 'actionOccurred':
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
ctx.addError(`${fieldPrefix}.actionOccurred`, 'Must be an object', 'object', objectId);
|
||||||
|
} else {
|
||||||
|
if (!isString(value.type)) ctx.addError(`${fieldPrefix}.actionOccurred.type`, 'Must be a string', 'string', objectId);
|
||||||
|
if (value.target !== undefined && !isString(value.target)) ctx.addError(`${fieldPrefix}.actionOccurred.target`, 'Must be a string', 'string', objectId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'stateEquals':
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
ctx.addError(`${fieldPrefix}.stateEquals`, 'Must be an object', 'object', objectId);
|
||||||
|
} else {
|
||||||
|
if (!isString(value.object)) ctx.addError(`${fieldPrefix}.stateEquals.object`, 'Must be a string', 'string', objectId);
|
||||||
|
if (!isString(value.key)) ctx.addError(`${fieldPrefix}.stateEquals.key`, 'Must be a string', 'string', objectId);
|
||||||
|
if (value.value === undefined) ctx.addError(`${fieldPrefix}.stateEquals.value`, 'Must be defined', 'any', objectId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'eventCompleted':
|
||||||
|
if (typeof value !== 'object' || value === null || !isString(value.event)) {
|
||||||
|
ctx.addError(`${fieldPrefix}.eventCompleted`, 'Must have event string', '{ event: string }', objectId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'objectiveComplete':
|
||||||
|
if (typeof value !== 'object' || value === null || !isString(value.objective)) {
|
||||||
|
ctx.addError(`${fieldPrefix}.objectiveComplete`, 'Must have objective string', '{ objective: string }', objectId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'scoreThreshold':
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
ctx.addError(`${fieldPrefix}.scoreThreshold`, 'Must be an object', '{ category?: string, min?: number, max?: number }', objectId);
|
||||||
|
} else {
|
||||||
|
if (value.category !== undefined && !isString(value.category)) {
|
||||||
|
ctx.addError(`${fieldPrefix}.scoreThreshold.category`, 'Must be a string if specified', 'string', objectId);
|
||||||
|
}
|
||||||
|
if (value.min !== undefined && typeof value.min !== 'number') {
|
||||||
|
ctx.addError(`${fieldPrefix}.scoreThreshold.min`, 'Must be a number if specified', 'number', objectId);
|
||||||
|
}
|
||||||
|
if (value.max !== undefined && typeof value.max !== 'number') {
|
||||||
|
ctx.addError(`${fieldPrefix}.scoreThreshold.max`, 'Must be a number if specified', 'number', objectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'findingExists':
|
||||||
|
if (typeof value !== 'object' || value === null || !isString(value.finding)) {
|
||||||
|
ctx.addError(`${fieldPrefix}.findingExists`, 'Must have finding string', '{ finding: string }', objectId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'all':
|
||||||
|
case 'any':
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
ctx.addError(`${fieldPrefix}.${key}`, 'Must be an array of conditions', 'condition[]', objectId);
|
||||||
|
} else {
|
||||||
|
value.forEach((c, i) => validateCondition(c, ctx, `${fieldPrefix}.${key}[${i}]`, objectId));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'not':
|
||||||
|
validateCondition(value, ctx, `${fieldPrefix}.not`, objectId);
|
||||||
|
break;
|
||||||
|
case 'inactivityTimeout':
|
||||||
|
if (typeof value !== 'object' || value === null || !isString(value.action) || typeof value.seconds !== 'number') {
|
||||||
|
ctx.addError(`${fieldPrefix}.inactivityTimeout`, 'Must be an object with action and seconds', '{ action: string, seconds: number }', objectId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
ctx.addError(`${fieldPrefix}.${key}`, 'Unknown condition type', 'valid condition type', objectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
function isString(val) {
|
||||||
|
return typeof val === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPositiveInteger(val) {
|
||||||
|
return typeof val === 'number' && Number.isInteger(val) && val > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStringArray(val) {
|
||||||
|
return Array.isArray(val) && val.every(isString);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateArray(arr, validator, field, ctx) {
|
||||||
|
if (!Array.isArray(arr)) {
|
||||||
|
ctx.addError(field, 'Must be an array', 'array');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
arr.forEach((item, index) => validator(item, ctx, index));
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
/**
|
||||||
|
* CyberSim Scenario Validator
|
||||||
|
* Performs deep semantic validation of a loaded scenario.
|
||||||
|
*
|
||||||
|
* @module scenario/validator
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} DiagnosticError
|
||||||
|
* @property {'error'} level
|
||||||
|
* @property {string|null} file
|
||||||
|
* @property {string} field
|
||||||
|
* @property {string|null} objectId
|
||||||
|
* @property {string} message
|
||||||
|
* @property {string|null} expected
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} DiagnosticWarning
|
||||||
|
* @property {'warning'} level
|
||||||
|
* @property {string|null} file
|
||||||
|
* @property {string} field
|
||||||
|
* @property {string|null} objectId
|
||||||
|
* @property {string} message
|
||||||
|
* @property {string|null} expected
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ValidationResult
|
||||||
|
* @property {boolean} valid
|
||||||
|
* @property {DiagnosticError[]} errors
|
||||||
|
* @property {DiagnosticWarning[]} warnings
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a scenario for logical consistency, reference integrity, and semantic rules.
|
||||||
|
*
|
||||||
|
* @param {Object} scenario The parsed scenario JSON object.
|
||||||
|
* @returns {ValidationResult}
|
||||||
|
*/
|
||||||
|
export function validateScenario(scenario) {
|
||||||
|
/** @type {DiagnosticError[]} */
|
||||||
|
const errors = [];
|
||||||
|
/** @type {DiagnosticWarning[]} */
|
||||||
|
const warnings = [];
|
||||||
|
|
||||||
|
const addError = (field, objectId, message, expected = null) => {
|
||||||
|
errors.push({ level: 'error', file: null, field, objectId, message, expected });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addWarning = (field, objectId, message, expected = null) => {
|
||||||
|
warnings.push({ level: 'warning', file: null, field, objectId, message, expected });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!scenario || typeof scenario !== 'object') {
|
||||||
|
addError('root', null, 'Scenario must be a valid object.', 'object');
|
||||||
|
return { valid: false, errors, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Collect IDs and check duplicates
|
||||||
|
const { typeIds, allIds } = collectAllIdsWithDiagnostics(scenario, addError, addWarning);
|
||||||
|
|
||||||
|
// 2. Completeness checks
|
||||||
|
const events = scenario.events || [];
|
||||||
|
const messages = scenario.messages || [];
|
||||||
|
const files = scenario.files || [];
|
||||||
|
const categories = scenario.scoring?.categories || [];
|
||||||
|
const rules = scenario.scoring?.rules || [];
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
addError('events', null, 'Scenario must have at least one event.');
|
||||||
|
}
|
||||||
|
if (messages.length === 0 && files.length === 0) {
|
||||||
|
addError('root', null, 'Scenario must have at least one message or one file for the learner to interact with.');
|
||||||
|
}
|
||||||
|
if (categories.length === 0) {
|
||||||
|
addError('scoring.categories', null, 'Scenario scoring must have at least one category.');
|
||||||
|
}
|
||||||
|
if (rules.length === 0) {
|
||||||
|
addError('scoring.rules', null, 'Scenario scoring must have at least one rule.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Reference Integrity
|
||||||
|
const refs = collectAllReferencedIds(scenario);
|
||||||
|
|
||||||
|
// Messages -> People (Sender)
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.sender) {
|
||||||
|
const hasPersonId = typeIds.get('people')?.has(msg.sender);
|
||||||
|
const hasPersonName = (scenario.people || []).some(p => p.name === msg.sender);
|
||||||
|
if (!hasPersonId && !hasPersonName) {
|
||||||
|
addWarning('messages.sender', msg.id, `Message sender "${msg.sender}" does not match any person ID or name.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events -> Actions
|
||||||
|
for (const evt of events) {
|
||||||
|
const actions = evt.actions || [];
|
||||||
|
for (const action of actions) {
|
||||||
|
if (action.type === 'mail.deliver' && action.message) {
|
||||||
|
if (!typeIds.get('messages')?.has(action.message)) {
|
||||||
|
addError('events.actions', evt.id, `Action mail.deliver references unknown message "${action.message}".`, 'message id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action.type === 'desktop.notify' && action.notification && typeof action.notification === 'string') {
|
||||||
|
if (!typeIds.get('notifications')?.has(action.notification)) {
|
||||||
|
addError('events.actions', evt.id, `Action desktop.notify references unknown notification "${action.notification}".`, 'notification id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action.type === 'security.addAlert' && action.alert) {
|
||||||
|
if (!typeIds.get('alerts')?.has(action.alert)) {
|
||||||
|
addError('events.actions', evt.id, `Action security.addAlert references unknown alert "${action.alert}".`, 'alert id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action.type === 'evaluation.addFinding' && action.finding) {
|
||||||
|
if (!typeIds.get('findings')?.has(action.finding)) {
|
||||||
|
addError('events.actions', evt.id, `Action evaluation.addFinding references unknown finding "${action.finding}".`, 'finding id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action.type === 'evaluation.completeObjective' && action.objective) {
|
||||||
|
if (!typeIds.get('objectives')?.has(action.objective)) {
|
||||||
|
addError('events.actions', evt.id, `Action evaluation.completeObjective references unknown objective "${action.objective}".`, 'objective id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Uncontrolled repeating events
|
||||||
|
if (evt.repeat) {
|
||||||
|
if (evt.count === undefined || evt.count === null) {
|
||||||
|
addWarning('events.count', evt.id, 'Repeating event has no count limit and may loop indefinitely.');
|
||||||
|
} else if (typeof evt.count === 'number' && evt.count > 100) {
|
||||||
|
addWarning('events.count', evt.id, `Repeating event has an excessively large count limit (${evt.count}).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4 & 5. Entry Event Reachability
|
||||||
|
if (scenario.entryEvent) {
|
||||||
|
if (!typeIds.get('events')?.has(scenario.entryEvent)) {
|
||||||
|
addError('entryEvent', null, `Entry event "${scenario.entryEvent}" does not exist in events.`, 'event id');
|
||||||
|
} else {
|
||||||
|
const entryEvt = events.find(e => e.id === scenario.entryEvent);
|
||||||
|
const hasStartTrigger = entryEvt?.triggers?.some(t => t.type === 'scenarioStart');
|
||||||
|
if (!hasStartTrigger) {
|
||||||
|
// Warning, as it could potentially be triggered by some intrinsic framework mechanism,
|
||||||
|
// but usually entryEvent itself acts as the start or should have scenarioStart.
|
||||||
|
addWarning('entryEvent', scenario.entryEvent, `Entry event "${scenario.entryEvent}" does not have a 'scenarioStart' trigger.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (events.length > 0) {
|
||||||
|
// Just a gentle reminder, the framework might pick the first event or rely on scenarioStart
|
||||||
|
addWarning('entryEvent', null, 'Scenario has no explicit entryEvent defined.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scoring Rules -> Categories
|
||||||
|
for (const rule of rules) {
|
||||||
|
const cat = rule.award?.category;
|
||||||
|
if (cat && !typeIds.get('scoring.categories')?.has(cat)) {
|
||||||
|
addError('scoring.rules.award.category', rule.id, `Rule references unknown category "${cat}".`, 'category id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Findings -> Categories
|
||||||
|
for (const finding of (scenario.findings || [])) {
|
||||||
|
if (finding.category && !typeIds.get('scoring.categories')?.has(finding.category)) {
|
||||||
|
addError('findings.category', finding.id, `Finding references unknown category "${finding.category}".`, 'category id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Scoring consistency
|
||||||
|
let totalMaxPoints = 0;
|
||||||
|
const categoryRulesCount = new Map();
|
||||||
|
for (const cat of categories) {
|
||||||
|
categoryRulesCount.set(cat.id, 0);
|
||||||
|
if (typeof cat.maxPoints === 'number') {
|
||||||
|
totalMaxPoints += cat.maxPoints;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
const cat = rule.award?.category;
|
||||||
|
if (cat && categoryRulesCount.has(cat)) {
|
||||||
|
categoryRulesCount.set(cat, categoryRulesCount.get(cat) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [catId, count] of categoryRulesCount.entries()) {
|
||||||
|
if (count === 0) {
|
||||||
|
addWarning('scoring.categories', catId, `Category "${catId}" has no scoring rules that affect it.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalMaxPoints > 200 || totalMaxPoints < 10) {
|
||||||
|
addWarning('scoring.categories', null, `Total maxPoints across categories (${totalMaxPoints}) is unusual (expected 10-200).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passingScore = scenario.scoring?.passingScore;
|
||||||
|
if (typeof passingScore === 'number' && passingScore > totalMaxPoints) {
|
||||||
|
addError('scoring.passingScore', null, `Passing score (${passingScore}) exceeds total max points (${totalMaxPoints}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Warning-level diagnostics (suspicious but valid)
|
||||||
|
|
||||||
|
// Unreachable objectives
|
||||||
|
const allObjectives = scenario.objectives || [];
|
||||||
|
for (const obj of allObjectives) {
|
||||||
|
if (!refs.objectives.has(obj.id)) {
|
||||||
|
addWarning('objectives', obj.id, `Objective "${obj.id}" has no completion path (never referenced in evaluation.completeObjective).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undelivered messages (not referenced in mail.deliver and not in initial state)
|
||||||
|
// Assuming messages without an explicit initial state need delivery.
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!refs.messages.has(msg.id) && !msg.initial) {
|
||||||
|
addWarning('messages', msg.id, `Message "${msg.id}" is never delivered via events and is not marked as initial.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unadded findings
|
||||||
|
for (const finding of (scenario.findings || [])) {
|
||||||
|
if (!refs.findings.has(finding.id)) {
|
||||||
|
addWarning('findings', finding.id, `Finding "${finding.id}" is never added via events.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pages never navigated to (assuming a scenario.pages or files with types)
|
||||||
|
const pages = files.filter(f => f.type === 'page');
|
||||||
|
for (const page of pages) {
|
||||||
|
// Checking if the page id is used in triggers or actions (simplified check)
|
||||||
|
// Without full schema of pages, we assume events could reference them
|
||||||
|
if (!refs.files.has(page.id)) {
|
||||||
|
// It might be linked from another page, but we'll do a simple warn
|
||||||
|
addWarning('files', page.id, `Page "${page.id}" might not be reachable (never referenced in events).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to collect all IDs and report duplicates.
|
||||||
|
* @param {Object} scenario
|
||||||
|
* @param {Function} addError
|
||||||
|
* @param {Function} addWarning
|
||||||
|
* @returns {{ typeIds: Map<string, Set<string>>, allIds: Map<string, string> }}
|
||||||
|
*/
|
||||||
|
function collectAllIdsWithDiagnostics(scenario, addError, addWarning) {
|
||||||
|
const typeIds = new Map();
|
||||||
|
const allIds = new Map(); // id -> type
|
||||||
|
|
||||||
|
const collections = {
|
||||||
|
'people': scenario.people,
|
||||||
|
'messages': scenario.messages,
|
||||||
|
'files': scenario.files,
|
||||||
|
'events': scenario.events,
|
||||||
|
'scoring.categories': scenario.scoring?.categories,
|
||||||
|
'scoring.rules': scenario.scoring?.rules,
|
||||||
|
'findings': scenario.findings,
|
||||||
|
'notifications': scenario.notifications,
|
||||||
|
'alerts': scenario.alerts,
|
||||||
|
'organizations': scenario.organizations,
|
||||||
|
'objectives': scenario.objectives
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [type, items] of Object.entries(collections)) {
|
||||||
|
const idsForType = new Set();
|
||||||
|
typeIds.set(type, idsForType);
|
||||||
|
|
||||||
|
if (!Array.isArray(items)) continue;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item || !item.id) continue;
|
||||||
|
|
||||||
|
// Check within same type
|
||||||
|
if (idsForType.has(item.id)) {
|
||||||
|
addError(type, item.id, `Duplicate ID "${item.id}" found in ${type}.`);
|
||||||
|
} else {
|
||||||
|
idsForType.add(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check across types
|
||||||
|
if (allIds.has(item.id) && allIds.get(item.id) !== type) {
|
||||||
|
addWarning(type, item.id, `ID collision: "${item.id}" is used in both ${allIds.get(item.id)} and ${type}.`);
|
||||||
|
} else {
|
||||||
|
allIds.set(item.id, type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { typeIds, allIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a Map of type -> Set<id> for all IDs defined in the scenario.
|
||||||
|
*
|
||||||
|
* @param {Object} scenario
|
||||||
|
* @returns {Map<string, Set<string>>}
|
||||||
|
*/
|
||||||
|
export function collectAllIds(scenario) {
|
||||||
|
const { typeIds } = collectAllIdsWithDiagnostics(
|
||||||
|
scenario,
|
||||||
|
() => {},
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
return typeIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks all events, scoring rules, etc. and collects every referenced ID by type.
|
||||||
|
*
|
||||||
|
* @param {Object} scenario
|
||||||
|
* @returns {Object<string, Set<string>>} An object containing sets of referenced IDs.
|
||||||
|
*/
|
||||||
|
export function collectAllReferencedIds(scenario) {
|
||||||
|
const refs = {
|
||||||
|
messages: new Set(),
|
||||||
|
notifications: new Set(),
|
||||||
|
alerts: new Set(),
|
||||||
|
findings: new Set(),
|
||||||
|
objectives: new Set(),
|
||||||
|
scoringCategories: new Set(),
|
||||||
|
events: new Set(),
|
||||||
|
files: new Set(),
|
||||||
|
people: new Set()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Events
|
||||||
|
for (const evt of (scenario.events || [])) {
|
||||||
|
for (const action of (evt.actions || [])) {
|
||||||
|
if (action.type === 'mail.deliver' && action.message) refs.messages.add(action.message);
|
||||||
|
if (action.type === 'desktop.notify' && typeof action.notification === 'string') refs.notifications.add(action.notification);
|
||||||
|
if (action.type === 'security.addAlert' && action.alert) refs.alerts.add(action.alert);
|
||||||
|
if (action.type === 'evaluation.addFinding' && action.finding) refs.findings.add(action.finding);
|
||||||
|
if (action.type === 'evaluation.completeObjective' && action.objective) refs.objectives.add(action.objective);
|
||||||
|
if (action.type === 'browser.navigate' && action.file) refs.files.add(action.file);
|
||||||
|
if (action.type === 'event.trigger' && action.event) refs.events.add(action.event);
|
||||||
|
}
|
||||||
|
for (const trigger of (evt.triggers || [])) {
|
||||||
|
if (trigger.type === 'eventFired' && trigger.event) refs.events.add(trigger.event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scoring Rules
|
||||||
|
for (const rule of (scenario.scoring?.rules || [])) {
|
||||||
|
if (rule.award?.category) {
|
||||||
|
refs.scoringCategories.add(rule.award.category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Findings
|
||||||
|
for (const finding of (scenario.findings || [])) {
|
||||||
|
if (finding.category) {
|
||||||
|
refs.scoringCategories.add(finding.category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ export class AfterActionReport {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="aar-result-pill ${r.isPassed ? 'pass' : 'fail'}">
|
<span class="aar-result-pill ${r.isPassed ? 'pass' : 'fail'}">
|
||||||
${r.isPassed ? '✓ Passed (Threshold: 80)' : '✕ Needs Review (Threshold: 80)'}
|
${r.isPassed ? `✓ Passed (Threshold: ${r.passingThreshold})` : `✕ Needs Review (Threshold: ${r.passingThreshold})`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+179
-147
@@ -1,171 +1,203 @@
|
|||||||
/**
|
/**
|
||||||
* CyberSim OS - Multi-Dimensional Behavioral Scorer
|
* Data-driven behavioral scorer for CyberSim OS scenarios.
|
||||||
* Evaluates 7 core cybersecurity competency dimensions (0-100 scale, 80 passing threshold).
|
* Reads scoring rules from the scenario definition and evaluates them against event logs or scenario state.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class BehavioralScorer {
|
export class BehavioralScorer {
|
||||||
constructor(scenario, eventLogs) {
|
/**
|
||||||
this.scenario = scenario;
|
* Creates a new BehavioralScorer.
|
||||||
this.logs = eventLogs;
|
* @param {Object} scenario - The scenario definition containing scoring rules.
|
||||||
this.passingThreshold = 80;
|
* @param {Array} eventLogs - The array of event log objects.
|
||||||
|
* @param {Object} [scenarioState=null] - Optional scenario state object for checking actions/findings.
|
||||||
|
*/
|
||||||
|
constructor(scenario, eventLogs, scenarioState = null) {
|
||||||
|
this.scenario = scenario || {};
|
||||||
|
this.eventLogs = eventLogs || [];
|
||||||
|
this.scenarioState = scenarioState;
|
||||||
|
|
||||||
|
// Track running scores during evaluation
|
||||||
|
this.runningScores = {};
|
||||||
|
this.findings = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates the scenario based on the scoring rules.
|
||||||
|
* @returns {Object} The final scoring result.
|
||||||
|
*/
|
||||||
evaluate() {
|
evaluate() {
|
||||||
const logs = this.logs;
|
if (!this.scenario || !this.scenario.scoring || !this.scenario.scoring.categories || this.scenario.scoring.categories.length === 0) {
|
||||||
const feedback = [];
|
return this._getDefaultResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = {};
|
||||||
|
let totalMaxPoints = 0;
|
||||||
|
|
||||||
|
// Initialize categories
|
||||||
|
this.scenario.scoring.categories.forEach(cat => {
|
||||||
|
categories[cat.id] = {
|
||||||
|
score: cat.startingPoints || 0,
|
||||||
|
max: cat.maxPoints || 0,
|
||||||
|
label: cat.label || cat.id
|
||||||
|
};
|
||||||
|
this.runningScores[cat.id] = categories[cat.id].score;
|
||||||
|
totalMaxPoints += categories[cat.id].max;
|
||||||
|
});
|
||||||
|
|
||||||
const timeline = [];
|
const timeline = [];
|
||||||
|
const feedback = [];
|
||||||
|
const rules = this.scenario.scoring.rules || [];
|
||||||
|
|
||||||
// Helper checks
|
rules.forEach(rule => {
|
||||||
const hasLog = (event, target = null) => logs.some(l => {
|
const isMet = this._evaluateCondition(rule.condition);
|
||||||
if (target !== null) return l.event === event && l.target === target;
|
|
||||||
return l.event === event;
|
if (isMet) {
|
||||||
|
if (rule.award && categories[rule.award.category]) {
|
||||||
|
categories[rule.award.category].score += rule.award.points;
|
||||||
|
this.runningScores[rule.award.category] = categories[rule.award.category].score;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.timeline) {
|
||||||
|
timeline.push({ type: rule.timeline.type || 'info', text: rule.timeline.text });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.feedback) {
|
||||||
|
feedback.push(rule.feedback);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track triggered rules as findings to allow subsequent conditions to depend on them
|
||||||
|
if (rule.id) {
|
||||||
|
this.findings.add(rule.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (rule.missedFeedback) {
|
||||||
|
feedback.push(rule.missedFeedback);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const findLogs = (event, target = null) => logs.filter(l => {
|
let totalScore = 0;
|
||||||
if (target !== null) return l.event === event && l.target === target;
|
|
||||||
return l.event === event;
|
// Clamp categories and calculate total
|
||||||
|
Object.keys(categories).forEach(key => {
|
||||||
|
const cat = categories[key];
|
||||||
|
cat.score = Math.max(0, Math.min(cat.score, cat.max));
|
||||||
|
totalScore += cat.score;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 1. Threat Detection (20 pts max)
|
// Clamp total score
|
||||||
let threatDetectionScore = 0;
|
totalScore = Math.max(0, Math.min(totalScore, 100));
|
||||||
const openedPhish = hasLog('EMAIL_OPENED', 'email_phish_pwreset');
|
|
||||||
const openedInvoice = hasLog('EMAIL_OPENED', 'email_malicious_invoice');
|
|
||||||
|
|
||||||
if (openedPhish) {
|
let passingThreshold = 0;
|
||||||
threatDetectionScore += 10;
|
if (this.scenario.completion && typeof this.scenario.completion.passingScore === 'number') {
|
||||||
timeline.push({ type: 'positive', text: 'Identified and opened the password reset notice for review.' });
|
passingThreshold = this.scenario.completion.passingScore;
|
||||||
} else {
|
} else if (typeof this.scenario.passingScore === 'number') {
|
||||||
feedback.push('You did not review the urgent password reset email during your shift.');
|
passingThreshold = this.scenario.passingScore;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (openedInvoice) {
|
const isPassed = totalScore >= passingThreshold;
|
||||||
threatDetectionScore += 10;
|
|
||||||
timeline.push({ type: 'positive', text: 'Identified and reviewed the overdue vendor invoice notice.' });
|
// Perfect score feedback fallback
|
||||||
} else {
|
if (feedback.length === 0 && totalScore === Math.min(100, totalMaxPoints)) {
|
||||||
feedback.push('You missed reviewing the overdue vendor invoice communication.');
|
feedback.push('Outstanding performance! You investigated all anomalies with sound judgment and protected company assets.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Investigation & Evidence Gathering (20 pts max)
|
|
||||||
let investigationScore = 0;
|
|
||||||
const inspectedPhishSender = hasLog('EMAIL_INSPECTED_SENDER', 'email_phish_pwreset');
|
|
||||||
const hoveredPhishLink = hasLog('EMAIL_LINK_HOVERED', 'email_phish_pwreset');
|
|
||||||
const visitedIntranetDir = hasLog('NAV_VISITED', 'http://intranet.nexacore.internal') || hasLog('NAV_VISITED', 'http://intranet.nexacore.internal/it-security');
|
|
||||||
const checkedVendorPolicy = hasLog('FILE_OPENED', 'Vendor_Directory.pdf') || hasLog('DOC_VIEWED', 'Vendor_Directory.pdf');
|
|
||||||
|
|
||||||
if (inspectedPhishSender || hoveredPhishLink) {
|
|
||||||
investigationScore += 10;
|
|
||||||
timeline.push({ type: 'positive', text: 'Thoroughly investigated the phishing email headers and link destination.' });
|
|
||||||
} else {
|
|
||||||
feedback.push('You evaluated the password reset email without inspecting its sender header or hovering over the destination link.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visitedIntranetDir || checkedVendorPolicy) {
|
|
||||||
investigationScore += 10;
|
|
||||||
timeline.push({ type: 'positive', text: 'Consulted company resources (Intranet/Vendor Directory) to cross-reference ambiguous information.' });
|
|
||||||
} else {
|
|
||||||
feedback.push('Independent verification tools (Intranet directory, Approved Vendor Directory) were available but not consulted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Safe Handling (15 pts max)
|
|
||||||
let safeHandlingScore = 15;
|
|
||||||
const enteredPhishCredentials = hasLog('NAV_FORM_SUBMITTED', 'phish_login_form');
|
|
||||||
const downloadedMaliciousZip = hasLog('EMAIL_ATTACHMENT_OPENED', 'email_malicious_invoice') || hasLog('FILE_DOWNLOADED', 'Invoice_88921_Receipt.zip');
|
|
||||||
|
|
||||||
if (enteredPhishCredentials) {
|
|
||||||
safeHandlingScore -= 10;
|
|
||||||
timeline.push({ type: 'critical', text: 'Submitted enterprise credentials on an external credential harvesting portal.' });
|
|
||||||
feedback.push('CRITICAL: You entered your password into an external lookalike portal (login-nexac0re-portal.com). Always check the browser address bar.');
|
|
||||||
} else {
|
|
||||||
timeline.push({ type: 'positive', text: 'Refrained from submitting credentials to untrusted external websites.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (downloadedMaliciousZip) {
|
|
||||||
safeHandlingScore -= 5;
|
|
||||||
timeline.push({ type: 'warning', text: 'Downloaded an unsolicited archive file from an unverified vendor.' });
|
|
||||||
feedback.push('CAUTION: You opened an archive attachment from an unapproved vendor. Unsolicited archives often conceal malicious scripts or executables.');
|
|
||||||
}
|
|
||||||
|
|
||||||
safeHandlingScore = Math.max(0, safeHandlingScore);
|
|
||||||
|
|
||||||
// 4. Independent Verification (15 pts max)
|
|
||||||
let verificationScore = 0;
|
|
||||||
const viewedPolicy = hasLog('DOC_VIEWED', 'NexaCore_Cyber_Security_Policy_v4.pdf') || hasLog('FILE_OPENED', 'NexaCore_Cyber_Security_Policy_v4.pdf');
|
|
||||||
const checkedITSecurityPage = hasLog('NAV_VISITED', 'http://intranet.nexacore.internal/it-security');
|
|
||||||
|
|
||||||
if (viewedPolicy) {
|
|
||||||
verificationScore += 8;
|
|
||||||
timeline.push({ type: 'positive', text: 'Reviewed the NexaCore Cybersecurity Policy v4.' });
|
|
||||||
}
|
|
||||||
if (checkedITSecurityPage) {
|
|
||||||
verificationScore += 7;
|
|
||||||
timeline.push({ type: 'positive', text: 'Verified security announcements on the official IT Security Intranet portal.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (verificationScore === 0) {
|
|
||||||
feedback.push('Take time to review official corporate policies and security bulletins before making decisions under pressure.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Incident Reporting (15 pts max)
|
|
||||||
let reportingScore = 0;
|
|
||||||
const reportedPhish = findLogs('EMAIL_REPORTED', 'email_phish_pwreset');
|
|
||||||
const reportedInvoice = findLogs('EMAIL_REPORTED', 'email_malicious_invoice');
|
|
||||||
|
|
||||||
if (reportedPhish.length > 0) {
|
|
||||||
reportingScore += 8;
|
|
||||||
timeline.push({ type: 'positive', text: 'Promptly reported the SSO credential phishing threat to the Security Operations Center.' });
|
|
||||||
} else {
|
|
||||||
feedback.push('The phishing email was not reported to the Security Operations Center, leaving coworkers vulnerable.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reportedInvoice.length > 0) {
|
|
||||||
reportingScore += 7;
|
|
||||||
timeline.push({ type: 'positive', text: 'Reported the fake vendor invoice to the Security Center.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. False Positive Control (15 pts max)
|
|
||||||
let falsePositiveScore = 15;
|
|
||||||
const reportedLegitMFA = findLogs('EMAIL_REPORTED', 'email_legit_mfa');
|
|
||||||
const reportedCoworker = findLogs('EMAIL_REPORTED', 'email_coworker_req');
|
|
||||||
|
|
||||||
if (reportedLegitMFA.length > 0) {
|
|
||||||
falsePositiveScore -= 10;
|
|
||||||
timeline.push({ type: 'warning', text: 'Incorrectly flagged the legitimate CISO MFA policy notice as phishing.' });
|
|
||||||
feedback.push('NOTE: The MFA notice from Alex Rivera was legitimate. Verifying the sender on the employee directory and checking the intranet avoids false alarms.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reportedCoworker.length > 0) {
|
|
||||||
falsePositiveScore -= 5;
|
|
||||||
timeline.push({ type: 'warning', text: 'Flagged routine internal communication from Sarah Jenkins as suspicious.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
falsePositiveScore = Math.max(0, falsePositiveScore);
|
|
||||||
|
|
||||||
// Total Score Calculation (0-100)
|
|
||||||
const totalScore = Math.min(100, Math.max(0,
|
|
||||||
threatDetectionScore +
|
|
||||||
investigationScore +
|
|
||||||
safeHandlingScore +
|
|
||||||
verificationScore +
|
|
||||||
reportingScore +
|
|
||||||
falsePositiveScore
|
|
||||||
));
|
|
||||||
|
|
||||||
const isPassed = totalScore >= this.passingThreshold;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalScore,
|
totalScore,
|
||||||
isPassed,
|
isPassed,
|
||||||
passingThreshold: this.passingThreshold,
|
passingThreshold,
|
||||||
categories: {
|
categories,
|
||||||
threatDetection: { score: threatDetectionScore, max: 20, label: 'Threat Detection' },
|
|
||||||
investigation: { score: investigationScore, max: 20, label: 'Investigation & Evidence' },
|
|
||||||
safeHandling: { score: safeHandlingScore, max: 15, label: 'Safe Handling' },
|
|
||||||
verification: { score: verificationScore, max: 15, label: 'Independent Verification' },
|
|
||||||
reporting: { score: reportingScore, max: 15, label: 'Incident Reporting' },
|
|
||||||
falsePositiveControl: { score: falsePositiveScore, max: 15, label: 'False Positive Control' }
|
|
||||||
},
|
|
||||||
timeline,
|
timeline,
|
||||||
feedback: feedback.length > 0 ? feedback : ['Outstanding performance! You investigated all anomalies with sound judgment and protected company assets.']
|
feedback
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates a condition object.
|
||||||
|
* @param {Object} condition - The condition to evaluate.
|
||||||
|
* @returns {boolean} Whether the condition is met.
|
||||||
|
*/
|
||||||
|
_evaluateCondition(condition) {
|
||||||
|
if (!condition) return true; // Empty condition is implicitly true
|
||||||
|
|
||||||
|
if (condition.actionOccurred) {
|
||||||
|
return this._checkAction(condition.actionOccurred.type, condition.actionOccurred.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (condition.all) {
|
||||||
|
return condition.all.every(cond => this._evaluateCondition(cond));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (condition.any) {
|
||||||
|
return condition.any.some(cond => this._evaluateCondition(cond));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (condition.not) {
|
||||||
|
return !this._evaluateCondition(condition.not);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (condition.scoreThreshold) {
|
||||||
|
const categoryId = condition.scoreThreshold.category;
|
||||||
|
const score = categoryId && this.runningScores[categoryId] !== undefined
|
||||||
|
? this.runningScores[categoryId]
|
||||||
|
: Object.values(this.runningScores).reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
const min = condition.scoreThreshold.min;
|
||||||
|
const max = condition.scoreThreshold.max;
|
||||||
|
|
||||||
|
if (min !== undefined && score < min) return false;
|
||||||
|
if (max !== undefined && score > max) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (condition.findingExists) {
|
||||||
|
return this._checkFinding(condition.findingExists.finding);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an action occurred.
|
||||||
|
* @param {string} type - The event type.
|
||||||
|
* @param {string} [target] - The event target.
|
||||||
|
* @returns {boolean} True if the action occurred.
|
||||||
|
*/
|
||||||
|
_checkAction(type, target) {
|
||||||
|
if (this.scenarioState && typeof this.scenarioState.hasAction === 'function') {
|
||||||
|
return this.scenarioState.hasAction(type, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.eventLogs.some(log => {
|
||||||
|
if (log.event !== type) return false;
|
||||||
|
if (target !== undefined && log.target !== target) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a finding exists.
|
||||||
|
* @param {string} findingId - The finding ID to check.
|
||||||
|
* @returns {boolean} True if the finding exists.
|
||||||
|
*/
|
||||||
|
_checkFinding(findingId) {
|
||||||
|
if (this.scenarioState && typeof this.scenarioState.hasFinding === 'function') {
|
||||||
|
return this.scenarioState.hasFinding(findingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findings.has(findingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a default result when no scoring configuration is present.
|
||||||
|
* @returns {Object} Default scoring result.
|
||||||
|
*/
|
||||||
|
_getDefaultResult() {
|
||||||
|
return {
|
||||||
|
totalScore: 0,
|
||||||
|
isPassed: false,
|
||||||
|
passingThreshold: 0,
|
||||||
|
categories: {},
|
||||||
|
timeline: [],
|
||||||
|
feedback: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,639 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": "1.0",
|
||||||
|
"id": "nexacore-orientation",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"title": "Operational Shift & Security Awareness",
|
||||||
|
"description": "Begin your work shift at NexaCore Technologies. Review requested budget forecasts, handle communications, investigate anomalies, and make sound security decisions.",
|
||||||
|
"durationSeconds": 900,
|
||||||
|
"engine": {
|
||||||
|
"minimumVersion": "0.2.0"
|
||||||
|
},
|
||||||
|
"mode": "assessment",
|
||||||
|
"entryEvent": "begin-workday",
|
||||||
|
"passingScore": 80,
|
||||||
|
"seed": 42,
|
||||||
|
|
||||||
|
"learner": {
|
||||||
|
"name": "Jordan Taylor",
|
||||||
|
"role": "Financial Operations Specialist",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"department": "Finance & Accounting"
|
||||||
|
},
|
||||||
|
|
||||||
|
"organizations": [
|
||||||
|
{
|
||||||
|
"id": "nexacore",
|
||||||
|
"name": "NexaCore Technologies",
|
||||||
|
"domains": ["nexacore.internal"],
|
||||||
|
"departments": ["Finance & Accounting", "Information Security", "IT Helpdesk", "Engineering"],
|
||||||
|
"securityContacts": ["[email protected]", "[email protected]"],
|
||||||
|
"policies": ["NexaCore IT will NEVER send password reset links via email"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "apex-supply",
|
||||||
|
"name": "Apex Supply Partners",
|
||||||
|
"domains": ["apex-global-supplies.net"],
|
||||||
|
"departments": ["Billing"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"people": [
|
||||||
|
{
|
||||||
|
"id": "morgan-chen",
|
||||||
|
"name": "Morgan Chen",
|
||||||
|
"role": "VP of Finance",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"organization": "nexacore",
|
||||||
|
"department": "Finance & Accounting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sarah-jenkins",
|
||||||
|
"name": "Sarah Jenkins",
|
||||||
|
"role": "Senior Financial Analyst",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"organization": "nexacore",
|
||||||
|
"department": "Finance & Accounting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "alex-rivera",
|
||||||
|
"name": "Alex Rivera",
|
||||||
|
"role": "Chief Information Security Officer",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"organization": "nexacore",
|
||||||
|
"department": "Information Security"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "it-helpdesk",
|
||||||
|
"name": "NexaCore IT Helpdesk",
|
||||||
|
"role": "IT Support",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"organization": "nexacore",
|
||||||
|
"department": "IT Helpdesk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fake-it-helpdesk",
|
||||||
|
"name": "NexaCore IT Helpdesk",
|
||||||
|
"role": "IT Support",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"isImpersonator": true,
|
||||||
|
"impersonates": "it-helpdesk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "apex-billing",
|
||||||
|
"name": "Apex Supply Partners Billing",
|
||||||
|
"role": "Accounting & Recovery Division",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"organization": "apex-supply"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"objectives": [
|
||||||
|
{
|
||||||
|
"id": "obj-review-welcome",
|
||||||
|
"text": "Review the welcome email and tasks from your manager (Morgan Chen)",
|
||||||
|
"visible": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "obj-review-budget",
|
||||||
|
"text": "Review the Q3 Budget Forecast spreadsheet in your Documents folder",
|
||||||
|
"visible": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "obj-read-policy",
|
||||||
|
"text": "Familiarize yourself with the NexaCore Cybersecurity Policy",
|
||||||
|
"visible": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "obj-handle-comms",
|
||||||
|
"text": "Review and handle any incoming communications appropriately",
|
||||||
|
"visible": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "obj-investigate",
|
||||||
|
"text": "Investigate any suspicious or unusual events using company tools",
|
||||||
|
"visible": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "obj-report-threats",
|
||||||
|
"text": "Submit incident reports via Security Center if genuine threats are detected",
|
||||||
|
"visible": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"id": "email_welcome",
|
||||||
|
"sender": "Morgan Chen (VP of Finance)",
|
||||||
|
"rfcSender": "[email protected]",
|
||||||
|
"recipient": "[email protected]",
|
||||||
|
"subject": "Welcome to the team - Tasks for today",
|
||||||
|
"date": "08:45 AM",
|
||||||
|
"folder": "inbox",
|
||||||
|
"unread": true,
|
||||||
|
"starred": true,
|
||||||
|
"body": "Hi Jordan,\n\nWelcome to the NexaCore Finance team!\n\nFor your shift today, please make sure to complete the following:\n1. Open the Files app and review the \"Q3_Budget_Forecast.xlsx\" document in your Documents folder. Sarah Jenkins in Accounting may ask you for an update on the operating margins.\n2. Read through the \"NexaCore_Cyber_Security_Policy_v4.pdf\" in your Documents folder to ensure you know our reporting protocols.\n3. Check the company Intranet for any updates or staff listings.\n\nLet me know if you run into any questions. Have a great shift!\n\nBest,\nMorgan Chen\nVP of Finance | NexaCore Technologies",
|
||||||
|
"links": [],
|
||||||
|
"attachments": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "email_phish_pwreset",
|
||||||
|
"sender": "NexaCore IT Helpdesk",
|
||||||
|
"rfcSender": "[email protected]",
|
||||||
|
"recipient": "[email protected]",
|
||||||
|
"subject": "URGENT: Mandatory Single Sign-On (SSO) Migration & Password Verification",
|
||||||
|
"date": "09:05 AM",
|
||||||
|
"folder": "pending",
|
||||||
|
"unread": true,
|
||||||
|
"starred": false,
|
||||||
|
"body": "Attention NexaCore Employee,\n\nOur enterprise IT infrastructure is undergoing a mandatory Single Sign-On (SSO) security migration. All active user accounts must be verified within the next 2 hours to avoid temporary account suspension and loss of network access.\n\nPlease confirm your employee credentials immediately using our secured identity portal:\n\n👉 Verify Credentials: <a class=\"inlook-msg-link\" data-url=\"http://login-nexac0re-portal.com/auth/login\" data-display=\"https://intranet.nexacore.internal/sso-verification\">https://intranet.nexacore.internal/sso-verification</a>\n\nFailure to verify before 11:00 AM will require managerial intervention to re-enable your workstation.\n\nIT Support Services\nNexaCore Global Infrastructure",
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"displayText": "https://intranet.nexacore.internal/sso-verification",
|
||||||
|
"actualUrl": "http://login-nexac0re-portal.com/auth/login"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attachments": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "email_legit_mfa",
|
||||||
|
"sender": "Alex Rivera (CISO)",
|
||||||
|
"rfcSender": "[email protected]",
|
||||||
|
"recipient": "[email protected]",
|
||||||
|
"subject": "Information Security Notice: Upcoming Company-Wide MFA Policy",
|
||||||
|
"date": "09:12 AM",
|
||||||
|
"folder": "pending",
|
||||||
|
"unread": true,
|
||||||
|
"starred": false,
|
||||||
|
"body": "Hello Team,\n\nAs part of our continuous cybersecurity hardening, NexaCore Information Security will be rolling out hardware security keys and updated Multi-Factor Authentication (MFA) protocols starting next week.\n\nImportant Safety Reminder:\n- We will NEVER send you an email link requesting your password or authentication code.\n- To check your registered devices or read the full deployment schedule, visit our IT Security page directly on the company Intranet (http://intranet.nexacore.internal/it-security).\n\nThank you for helping keep NexaCore secure.\n\nSincerely,\nAlex Rivera\nChief Information Security Officer\nNexaCore Technologies",
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"displayText": "http://intranet.nexacore.internal/it-security",
|
||||||
|
"actualUrl": "http://intranet.nexacore.internal/it-security"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attachments": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "email_malicious_invoice",
|
||||||
|
"sender": "Apex Supply Partners Billing",
|
||||||
|
"rfcSender": "[email protected]",
|
||||||
|
"recipient": "[email protected]",
|
||||||
|
"subject": "FINAL DEMAND: Overdue Server Hardware Invoice #INV-88921",
|
||||||
|
"date": "09:20 AM",
|
||||||
|
"folder": "pending",
|
||||||
|
"unread": true,
|
||||||
|
"starred": false,
|
||||||
|
"body": "Attention Finance Department,\n\nInvoice #INV-88921 for the recent delivery of high-density server chassis is now 45 days past due. A late assessment penalty has been added to the balance.\n\nPlease review the attached itemized payment statement and remit payment immediately to avoid collection proceedings:\n\nAttachment: Invoice_88921_Receipt.zip\n\nRegards,\nAccounting & Recovery Division\nApex Supply Partners Ltd.",
|
||||||
|
"links": [],
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"name": "Invoice_88921_Receipt.zip",
|
||||||
|
"size": "342 KB",
|
||||||
|
"type": "archive",
|
||||||
|
"isMalicious": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "email_coworker_req",
|
||||||
|
"sender": "Sarah Jenkins (Accounting)",
|
||||||
|
"rfcSender": "[email protected]",
|
||||||
|
"recipient": "[email protected]",
|
||||||
|
"subject": "Quick question on Q3 Budget Forecast",
|
||||||
|
"date": "09:35 AM",
|
||||||
|
"folder": "pending",
|
||||||
|
"unread": true,
|
||||||
|
"starred": false,
|
||||||
|
"body": "Hi Jordan,\n\nHope your first morning is going smoothly!\n\nWhen you get a chance to inspect the Q3 Budget Forecast spreadsheet in your Documents folder, could you double-check the projected Server & Cloud Infrastructure costs on Row 4? Morgan mentioned we might need to adjust the contingency buffer.\n\nThanks a lot!\nSarah Jenkins\nSenior Financial Analyst",
|
||||||
|
"links": [],
|
||||||
|
"attachments": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"id": "file_budget",
|
||||||
|
"name": "Q3_Budget_Forecast.xlsx",
|
||||||
|
"type": "spreadsheet",
|
||||||
|
"folder": "Documents",
|
||||||
|
"size": "48 KB",
|
||||||
|
"date": "2026-08-20",
|
||||||
|
"content": {
|
||||||
|
"title": "NexaCore Technologies - Q3 Budget Forecast (Draft)",
|
||||||
|
"headers": ["Category", "Q1 Actual", "Q2 Actual", "Q3 Projected", "Variance %"],
|
||||||
|
"rows": [
|
||||||
|
["Server & Cloud Infrastructure", "$142,000", "$155,000", "$168,000", "+8.4%"],
|
||||||
|
["Research & Robotics Hardware", "$280,000", "$310,000", "$325,000", "+4.8%"],
|
||||||
|
["Software Licenses & SaaS", "$64,000", "$68,000", "$71,000", "+4.4%"],
|
||||||
|
["Security Audits & Compliance", "$35,000", "$40,000", "$45,000", "+12.5%"],
|
||||||
|
["Total Operating Expenditures", "$521,000", "$573,000", "$609,000", "+6.2%"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "file_sec_policy",
|
||||||
|
"name": "NexaCore_Cyber_Security_Policy_v4.pdf",
|
||||||
|
"type": "pdf",
|
||||||
|
"folder": "Documents",
|
||||||
|
"size": "124 KB",
|
||||||
|
"date": "2026-08-15",
|
||||||
|
"content": {
|
||||||
|
"title": "NexaCore Information Security Policy (v4.2)",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"heading": "1. Purpose & Scope",
|
||||||
|
"text": "This policy defines mandatory baseline security procedures for all NexaCore personnel handling digital communications, documents, and credentials."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heading": "2. Email & Phishing Defense",
|
||||||
|
"text": "All employees must inspect the true RFC sender address before trusting emails requesting urgent actions. NexaCore IT will never distribute links requesting direct password entry. Any email utilizing mismatched link targets or urgent threats must be reported immediately via Security Center."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heading": "3. Vendor & Payment Verification",
|
||||||
|
"text": "Prior to opening attachments or processing invoices from third parties, employees must cross-reference the vendor against the Approved Vendor Directory in the Company Shared folder. Unsolicited invoices containing executable or compressed files must be treated as malicious."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heading": "4. Reporting Procedures",
|
||||||
|
"text": "Use the Security Center application or the \"Report Suspicious Message\" button in Inlook to escalate threats to the Security Operations Center (SOC). Do not forward phishing emails to colleagues."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "file_vendor_dir",
|
||||||
|
"name": "Vendor_Directory.pdf",
|
||||||
|
"type": "pdf",
|
||||||
|
"folder": "Company Shared",
|
||||||
|
"size": "88 KB",
|
||||||
|
"date": "2026-08-10",
|
||||||
|
"content": {
|
||||||
|
"title": "NexaCore Approved Vendor Directory (2026)",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"heading": "Approved Hardware & Cloud Vendors",
|
||||||
|
"text": "The following vendors are authorized for procurement and billing:"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"table": {
|
||||||
|
"headers": ["Vendor Name", "Vendor Code", "Contact Email", "Status"],
|
||||||
|
"rows": [
|
||||||
|
["Titan Cloud Systems", "VND-104", "[email protected]", "Active"],
|
||||||
|
["Quantum Edge Hardware", "VND-209", "[email protected]", "Active"],
|
||||||
|
["NexaLogistics Global", "VND-315", "[email protected]", "Active"],
|
||||||
|
["CyberShield Auditing LLC", "VND-402", "[email protected]", "Active"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"url": "http://intranet.nexacore.internal",
|
||||||
|
"title": "NexaCore Intranet - Home",
|
||||||
|
"isSecure": true,
|
||||||
|
"content": "<div class=\"webpage-intranet\"><div class=\"intranet-header\"><div class=\"intranet-title\">NexaCore Enterprise Intranet</div><div style=\"font-size:12px; color:#64748b;\">Monday, August 24, 2026</div></div><div class=\"intranet-grid\"><div><div class=\"intranet-card\" style=\"margin-bottom:16px;\"><h3>Company Announcements</h3><div style=\"font-size:12px; color:#334155; line-height:1.5;\"><p><strong>\uD83D\uDD12 Security Hardening:</strong> As announced by CISO Alex Rivera, company-wide MFA upgrades are underway. Remember to review our security policy in your Documents folder.</p><p style=\"margin-top:8px;\"><strong>\uD83D\uDCCA Q3 Financial Review:</strong> Department budget projections are being finalized this week. Contact Morgan Chen with any queries.</p></div></div><div class=\"intranet-card\"><h3>Quick Resources</h3><ul style=\"padding-left:18px; font-size:12px; color:#2563eb; line-height:1.8;\"><li><a href=\"http://intranet.nexacore.internal/it-security\" style=\"color:#2563eb;\">IT Security Policies & Verified Alerts</a></li><li><a href=\"http://intranet.nexacore.internal/directory\" style=\"color:#2563eb;\">Full Employee Directory</a></li></ul></div></div><div><div class=\"intranet-card\"><h3>Key Contacts</h3><table class=\"directory-table\"><tr><th>Name</th><th>Role</th><th>Email</th></tr><tr><td>Morgan Chen</td><td>VP Finance</td><td>[email protected]</td></tr><tr><td>Alex Rivera</td><td>CISO</td><td>[email protected]</td></tr><tr><td>Sarah Jenkins</td><td>Sr Accountant</td><td>[email protected]</td></tr><tr><td>IT Helpdesk</td><td>Support</td><td>[email protected]</td></tr></table></div></div></div></div>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://intranet.nexacore.internal/it-security",
|
||||||
|
"title": "IT Security Department - Policy & Notices",
|
||||||
|
"isSecure": true,
|
||||||
|
"content": "<div class=\"webpage-intranet\"><div class=\"intranet-header\"><div class=\"intranet-title\">IT Security & Compliance Portal</div><a href=\"http://intranet.nexacore.internal\" style=\"font-size:12px; color:#2563eb;\">\u2190 Back to Intranet</a></div><div class=\"intranet-card\" style=\"margin-bottom:16px;\"><h3>Official Notice: Hardware MFA Rollout (CISO Alex Rivera)</h3><p style=\"font-size:12px; color:#334155; line-height:1.5;\">NexaCore is transitioning all personnel to hardware FIDO2 keys and authenticator apps. <strong>Official Reminder:</strong> NexaCore IT will NEVER email you asking for your password or sending direct credential reset links.</p></div><div class=\"intranet-card\"><h3>Known Threat Advisory: Phishing Campaigns Targeting NexaCore</h3><p style=\"font-size:12px; color:#dc2626; line-height:1.5;\">\u26A0\uFE0F Be aware of external lookalike domains such as <code>nexac0re-portal.com</code> attempting credential harvesting. Always verify the address bar before entering any information.</p></div></div>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://login-nexac0re-portal.com/auth/login",
|
||||||
|
"title": "NexaCore SSO - Single Sign-On Authentication",
|
||||||
|
"isSecure": false,
|
||||||
|
"isPhishing": true,
|
||||||
|
"content": "<div class=\"webpage-phish\"><div class=\"phish-card\"><div class=\"phish-logo\">NexaCore Identity SSO</div><div class=\"phish-subtitle\">Enter your employee credentials to verify your account</div><form id=\"phish-login-form\"><div class=\"cs-form-group\"><label class=\"cs-form-label\">Employee ID / Email</label><input type=\"text\" id=\"phish_user\" class=\"cs-input\" placeholder=\"e.g. [email protected]\" required></div><div class=\"cs-form-group\"><label class=\"cs-form-label\">Password</label><input type=\"password\" id=\"phish_pass\" class=\"cs-input\" placeholder=\"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\" required></div><button type=\"submit\" class=\"cs-btn cs-btn-primary\" style=\"width:100%; margin-top:8px;\">Sign In & Verify Account</button></form></div></div>",
|
||||||
|
"forms": [
|
||||||
|
{
|
||||||
|
"id": "phish-login-form",
|
||||||
|
"onSubmit": {
|
||||||
|
"emitEvent": "NAV_FORM_SUBMITTED",
|
||||||
|
"target": "phish_login_form",
|
||||||
|
"response": {
|
||||||
|
"type": "pageContent",
|
||||||
|
"content": "<div style=\"padding:60px 20px; text-align:center; max-width:480px; margin:auto;\"><div style=\"font-size:36px; margin-bottom:12px;\">✔</div><h2 style=\"color:#0f172a; font-size:18px; margin-bottom:8px;\">Account Verification Complete</h2><p style=\"color:#64748b; font-size:13px; line-height:1.5;\">Your credentials have been verified with our external single sign-on synchronization gateway. You may return to your workplace desktop.</p></div>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"notifications": [
|
||||||
|
{
|
||||||
|
"id": "notif-welcome",
|
||||||
|
"title": "NexaCore Orientation",
|
||||||
|
"body": "Welcome {{learner.name}}! Check Inlook for initial tasks from Morgan Chen.",
|
||||||
|
"type": "info",
|
||||||
|
"timeout": 8000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "notif-new-mail",
|
||||||
|
"title": "Inlook Mail",
|
||||||
|
"body": "New message received.",
|
||||||
|
"type": "info",
|
||||||
|
"timeout": 5000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "notif-credential-leak",
|
||||||
|
"title": "Security Center Alert",
|
||||||
|
"body": "High Severity Alert: Anomalous login attempt detected on your account.",
|
||||||
|
"type": "danger",
|
||||||
|
"timeout": 8000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "notif-quarantine",
|
||||||
|
"title": "Endpoint Protection",
|
||||||
|
"body": "Suspicious archive quarantined in Downloads folder.",
|
||||||
|
"type": "warning",
|
||||||
|
"timeout": 7000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "notif-phish-submit",
|
||||||
|
"title": "Identity Portal",
|
||||||
|
"body": "Credentials accepted. Identity synchronization in progress...",
|
||||||
|
"type": "info",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"alerts": [
|
||||||
|
{
|
||||||
|
"id": "alert_initial_status",
|
||||||
|
"title": "Endpoint Threat Protection Active",
|
||||||
|
"severity": "info",
|
||||||
|
"source": "NexaCore Endpoint Agent",
|
||||||
|
"message": "Endpoint sensor status: Healthy. Definitions version 2026.08.24-1.",
|
||||||
|
"timestamp": "08:30 AM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "alert_unauthorized_sso",
|
||||||
|
"title": "Security Alert: Anomalous Login from Unknown Location",
|
||||||
|
"severity": "high",
|
||||||
|
"source": "Identity Threat Detection",
|
||||||
|
"message": "Multiple automated authentication attempts detected originating from an unrecognized IP address (198.51.100.42 - Eastern Europe) using recently submitted portal credentials. Password reset has been initiated."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "alert_quarantine_zip",
|
||||||
|
"title": "Endpoint Protection: Suspicious Archive Quarantined",
|
||||||
|
"severity": "medium",
|
||||||
|
"source": "Endpoint Threat Shield",
|
||||||
|
"message": "Downloaded archive \"Invoice_88921_Receipt.zip\" contains suspicious executable payloads (PaymentReceipt.exe) masquerading as document files."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"id": "begin-workday",
|
||||||
|
"when": { "scenarioStart": true },
|
||||||
|
"actions": [
|
||||||
|
{ "type": "mail.deliver", "message": "email_welcome" },
|
||||||
|
{ "type": "desktop.openApp", "app": "inlook" },
|
||||||
|
{ "type": "desktop.notify", "notification": "notif-welcome" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "deliver-phish-email",
|
||||||
|
"when": { "elapsedSeconds": 5 },
|
||||||
|
"actions": [
|
||||||
|
{ "type": "mail.deliver", "message": "email_phish_pwreset" },
|
||||||
|
{ "type": "desktop.notify", "notification": "notif-new-mail" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "deliver-mfa-notice",
|
||||||
|
"when": { "elapsedSeconds": 15 },
|
||||||
|
"actions": [
|
||||||
|
{ "type": "mail.deliver", "message": "email_legit_mfa" },
|
||||||
|
{ "type": "desktop.notify", "notification": "notif-new-mail" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "deliver-invoice-email",
|
||||||
|
"when": { "elapsedSeconds": 30 },
|
||||||
|
"actions": [
|
||||||
|
{ "type": "mail.deliver", "message": "email_malicious_invoice" },
|
||||||
|
{ "type": "desktop.notify", "notification": "notif-new-mail" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "deliver-coworker-email",
|
||||||
|
"when": { "elapsedSeconds": 50 },
|
||||||
|
"actions": [
|
||||||
|
{ "type": "mail.deliver", "message": "email_coworker_req" },
|
||||||
|
{ "type": "desktop.notify", "notification": "notif-new-mail" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "consequence-credential-leak",
|
||||||
|
"when": {
|
||||||
|
"actionOccurred": { "type": "NAV_FORM_SUBMITTED", "target": "phish_login_form" }
|
||||||
|
},
|
||||||
|
"delay": 35,
|
||||||
|
"actions": [
|
||||||
|
{ "type": "security.addAlert", "alert": "alert_unauthorized_sso" },
|
||||||
|
{ "type": "desktop.notify", "notification": "notif-credential-leak" },
|
||||||
|
{ "type": "evaluation.addFinding", "finding": "credential-leak" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "consequence-suspicious-download",
|
||||||
|
"when": {
|
||||||
|
"any": [
|
||||||
|
{ "actionOccurred": { "type": "EMAIL_ATTACHMENT_OPENED", "target": "email_malicious_invoice" } },
|
||||||
|
{ "actionOccurred": { "type": "FILE_DOWNLOADED", "target": "Invoice_88921_Receipt.zip" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"delay": 25,
|
||||||
|
"actions": [
|
||||||
|
{ "type": "security.addAlert", "alert": "alert_quarantine_zip" },
|
||||||
|
{ "type": "desktop.notify", "notification": "notif-quarantine" },
|
||||||
|
{ "type": "evaluation.addFinding", "finding": "malicious-download" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"id": "credential-leak",
|
||||||
|
"severity": "critical",
|
||||||
|
"category": "safe-handling",
|
||||||
|
"score": -10,
|
||||||
|
"feedback": "CRITICAL: You entered your password into an external lookalike portal (login-nexac0re-portal.com). Always check the browser address bar."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "malicious-download",
|
||||||
|
"severity": "significant",
|
||||||
|
"category": "safe-handling",
|
||||||
|
"score": -5,
|
||||||
|
"feedback": "CAUTION: You opened an archive attachment from an unapproved vendor. Unsolicited archives often conceal malicious scripts or executables."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "false-positive-mfa",
|
||||||
|
"severity": "minor",
|
||||||
|
"category": "false-positive-control",
|
||||||
|
"score": -10,
|
||||||
|
"feedback": "NOTE: The MFA notice from Alex Rivera was legitimate. Verifying the sender on the employee directory and checking the intranet avoids false alarms."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "false-positive-coworker",
|
||||||
|
"severity": "minor",
|
||||||
|
"category": "false-positive-control",
|
||||||
|
"score": -5,
|
||||||
|
"feedback": "The routine request from Sarah Jenkins was a legitimate internal communication. Over-reporting routine emails reduces SOC effectiveness."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"scoring": {
|
||||||
|
"categories": [
|
||||||
|
{ "id": "threat-detection", "label": "Threat Detection", "maxPoints": 20, "startingPoints": 0 },
|
||||||
|
{ "id": "investigation", "label": "Investigation & Evidence", "maxPoints": 20, "startingPoints": 0 },
|
||||||
|
{ "id": "safe-handling", "label": "Safe Handling", "maxPoints": 15, "startingPoints": 15 },
|
||||||
|
{ "id": "verification", "label": "Independent Verification", "maxPoints": 15, "startingPoints": 0 },
|
||||||
|
{ "id": "reporting", "label": "Incident Reporting", "maxPoints": 15, "startingPoints": 0 },
|
||||||
|
{ "id": "false-positive-control", "label": "False Positive Control", "maxPoints": 15, "startingPoints": 15 }
|
||||||
|
],
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"id": "opened-phish-email",
|
||||||
|
"condition": { "actionOccurred": { "type": "EMAIL_OPENED", "target": "email_phish_pwreset" } },
|
||||||
|
"award": { "category": "threat-detection", "points": 10 },
|
||||||
|
"timeline": { "type": "positive", "text": "Identified and opened the password reset notice for review." },
|
||||||
|
"missedFeedback": "You did not review the urgent password reset email during your shift."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "opened-invoice-email",
|
||||||
|
"condition": { "actionOccurred": { "type": "EMAIL_OPENED", "target": "email_malicious_invoice" } },
|
||||||
|
"award": { "category": "threat-detection", "points": 10 },
|
||||||
|
"timeline": { "type": "positive", "text": "Identified and reviewed the overdue vendor invoice notice." },
|
||||||
|
"missedFeedback": "You missed reviewing the overdue vendor invoice communication."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "inspected-phish-sender",
|
||||||
|
"condition": {
|
||||||
|
"any": [
|
||||||
|
{ "actionOccurred": { "type": "EMAIL_INSPECTED_SENDER", "target": "email_phish_pwreset" } },
|
||||||
|
{ "actionOccurred": { "type": "EMAIL_LINK_HOVERED", "target": "email_phish_pwreset" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"award": { "category": "investigation", "points": 10 },
|
||||||
|
"timeline": { "type": "positive", "text": "Thoroughly investigated the phishing email headers and link destination." },
|
||||||
|
"missedFeedback": "You evaluated the password reset email without inspecting its sender header or hovering over the destination link."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "consulted-company-resources",
|
||||||
|
"condition": {
|
||||||
|
"any": [
|
||||||
|
{ "actionOccurred": { "type": "NAV_VISITED", "target": "http://intranet.nexacore.internal" } },
|
||||||
|
{ "actionOccurred": { "type": "NAV_VISITED", "target": "http://intranet.nexacore.internal/it-security" } },
|
||||||
|
{ "actionOccurred": { "type": "FILE_OPENED", "target": "Vendor_Directory.pdf" } },
|
||||||
|
{ "actionOccurred": { "type": "DOC_VIEWED", "target": "Vendor_Directory.pdf" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"award": { "category": "investigation", "points": 10 },
|
||||||
|
"timeline": { "type": "positive", "text": "Consulted company resources (Intranet/Vendor Directory) to cross-reference ambiguous information." },
|
||||||
|
"missedFeedback": "Independent verification tools (Intranet directory, Approved Vendor Directory) were available but not consulted."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "submitted-phish-credentials",
|
||||||
|
"condition": { "actionOccurred": { "type": "NAV_FORM_SUBMITTED", "target": "phish_login_form" } },
|
||||||
|
"award": { "category": "safe-handling", "points": -10 },
|
||||||
|
"timeline": { "type": "critical", "text": "Submitted enterprise credentials on an external credential harvesting portal." },
|
||||||
|
"feedback": "CRITICAL: You entered your password into an external lookalike portal (login-nexac0re-portal.com). Always check the browser address bar."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "did-not-submit-credentials",
|
||||||
|
"condition": {
|
||||||
|
"not": { "actionOccurred": { "type": "NAV_FORM_SUBMITTED", "target": "phish_login_form" } }
|
||||||
|
},
|
||||||
|
"award": { "category": "safe-handling", "points": 0 },
|
||||||
|
"timeline": { "type": "positive", "text": "Refrained from submitting credentials to untrusted external websites." }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "downloaded-malicious-zip",
|
||||||
|
"condition": {
|
||||||
|
"any": [
|
||||||
|
{ "actionOccurred": { "type": "EMAIL_ATTACHMENT_OPENED", "target": "email_malicious_invoice" } },
|
||||||
|
{ "actionOccurred": { "type": "FILE_DOWNLOADED", "target": "Invoice_88921_Receipt.zip" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"award": { "category": "safe-handling", "points": -5 },
|
||||||
|
"timeline": { "type": "warning", "text": "Downloaded an unsolicited archive file from an unverified vendor." },
|
||||||
|
"feedback": "CAUTION: You opened an archive attachment from an unapproved vendor. Unsolicited archives often conceal malicious scripts or executables."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "viewed-security-policy",
|
||||||
|
"condition": {
|
||||||
|
"any": [
|
||||||
|
{ "actionOccurred": { "type": "DOC_VIEWED", "target": "NexaCore_Cyber_Security_Policy_v4.pdf" } },
|
||||||
|
{ "actionOccurred": { "type": "FILE_OPENED", "target": "NexaCore_Cyber_Security_Policy_v4.pdf" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"award": { "category": "verification", "points": 8 },
|
||||||
|
"timeline": { "type": "positive", "text": "Reviewed the NexaCore Cybersecurity Policy v4." }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "checked-it-security-page",
|
||||||
|
"condition": { "actionOccurred": { "type": "NAV_VISITED", "target": "http://intranet.nexacore.internal/it-security" } },
|
||||||
|
"award": { "category": "verification", "points": 7 },
|
||||||
|
"timeline": { "type": "positive", "text": "Verified security announcements on the official IT Security Intranet portal." }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "no-verification",
|
||||||
|
"condition": {
|
||||||
|
"all": [
|
||||||
|
{ "not": { "actionOccurred": { "type": "DOC_VIEWED", "target": "NexaCore_Cyber_Security_Policy_v4.pdf" } } },
|
||||||
|
{ "not": { "actionOccurred": { "type": "FILE_OPENED", "target": "NexaCore_Cyber_Security_Policy_v4.pdf" } } },
|
||||||
|
{ "not": { "actionOccurred": { "type": "NAV_VISITED", "target": "http://intranet.nexacore.internal/it-security" } } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"award": { "category": "verification", "points": 0 },
|
||||||
|
"feedback": "Take time to review official corporate policies and security bulletins before making decisions under pressure."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "reported-phish",
|
||||||
|
"condition": { "actionOccurred": { "type": "EMAIL_REPORTED", "target": "email_phish_pwreset" } },
|
||||||
|
"award": { "category": "reporting", "points": 8 },
|
||||||
|
"timeline": { "type": "positive", "text": "Promptly reported the SSO credential phishing threat to the Security Operations Center." },
|
||||||
|
"missedFeedback": "The phishing email was not reported to the Security Operations Center, leaving coworkers vulnerable."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "reported-invoice",
|
||||||
|
"condition": { "actionOccurred": { "type": "EMAIL_REPORTED", "target": "email_malicious_invoice" } },
|
||||||
|
"award": { "category": "reporting", "points": 7 },
|
||||||
|
"timeline": { "type": "positive", "text": "Reported the fake vendor invoice to the Security Center." }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "false-positive-mfa-report",
|
||||||
|
"condition": { "actionOccurred": { "type": "EMAIL_REPORTED", "target": "email_legit_mfa" } },
|
||||||
|
"award": { "category": "false-positive-control", "points": -10 },
|
||||||
|
"timeline": { "type": "warning", "text": "Incorrectly flagged the legitimate CISO MFA policy notice as phishing." },
|
||||||
|
"feedback": "NOTE: The MFA notice from Alex Rivera was legitimate. Verifying the sender on the employee directory and checking the intranet avoids false alarms."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "false-positive-coworker-report",
|
||||||
|
"condition": { "actionOccurred": { "type": "EMAIL_REPORTED", "target": "email_coworker_req" } },
|
||||||
|
"award": { "category": "false-positive-control", "points": -5 },
|
||||||
|
"timeline": { "type": "warning", "text": "Flagged routine internal communication from Sarah Jenkins as suspicious." }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"feedback": [
|
||||||
|
{
|
||||||
|
"id": "perfect-score",
|
||||||
|
"condition": { "scoreThreshold": { "min": 100 } },
|
||||||
|
"text": "Outstanding performance! You investigated all anomalies with sound judgment and protected company assets.",
|
||||||
|
"type": "positive"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"completion": {
|
||||||
|
"passingScore": 80,
|
||||||
|
"passAction": "certificate",
|
||||||
|
"failAction": "retry"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": "1.0",
|
||||||
|
"id": "meridian-onboarding",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"title": "Meridian Health — First Day Email Triage",
|
||||||
|
"description": "You are a new administrative assistant at Meridian Health Partners. Review your inbox, handle a legitimate scheduling request, and spot a phishing attempt targeting patient records access.",
|
||||||
|
"durationSeconds": 300,
|
||||||
|
"engine": {
|
||||||
|
"minimumVersion": "0.2.0"
|
||||||
|
},
|
||||||
|
"mode": "assessment",
|
||||||
|
"entryEvent": "begin-shift",
|
||||||
|
"passingScore": 70,
|
||||||
|
"seed": 7,
|
||||||
|
|
||||||
|
"learner": {
|
||||||
|
"name": "Casey Morgan",
|
||||||
|
"role": "Administrative Assistant",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"department": "Front Office"
|
||||||
|
},
|
||||||
|
|
||||||
|
"organizations": [
|
||||||
|
{
|
||||||
|
"id": "meridian",
|
||||||
|
"name": "Meridian Health Partners",
|
||||||
|
"domains": ["meridianhealth.org"],
|
||||||
|
"departments": ["Front Office", "IT Security", "Clinical"],
|
||||||
|
"securityContacts": ["[email protected]"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"people": [
|
||||||
|
{
|
||||||
|
"id": "dr-patel",
|
||||||
|
"name": "Dr. Priya Patel",
|
||||||
|
"role": "Chief of Staff",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"organization": "meridian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "it-support",
|
||||||
|
"name": "Meridian IT Support",
|
||||||
|
"role": "IT Helpdesk",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"organization": "meridian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fake-it",
|
||||||
|
"name": "Meridian IT Support",
|
||||||
|
"role": "IT Helpdesk",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"isImpersonator": true,
|
||||||
|
"impersonates": "it-support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"objectives": [
|
||||||
|
{
|
||||||
|
"id": "obj-check-inbox",
|
||||||
|
"text": "Review your inbox messages",
|
||||||
|
"visible": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "obj-handle-scheduling",
|
||||||
|
"text": "Handle the scheduling request from Dr. Patel appropriately",
|
||||||
|
"visible": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "obj-handle-phish",
|
||||||
|
"text": "Identify and report any suspicious messages",
|
||||||
|
"visible": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"id": "email_welcome",
|
||||||
|
"sender": "Dr. Priya Patel (Chief of Staff)",
|
||||||
|
"rfcSender": "[email protected]",
|
||||||
|
"recipient": "[email protected]",
|
||||||
|
"subject": "Welcome aboard — quick scheduling request",
|
||||||
|
"date": "08:30 AM",
|
||||||
|
"folder": "inbox",
|
||||||
|
"unread": true,
|
||||||
|
"starred": false,
|
||||||
|
"body": "Hi Casey,\n\nWelcome to Meridian Health Partners! We're glad to have you on the team.\n\nWhen you get a chance, could you check the staff schedule document in your Documents folder? I need to confirm my Thursday clinic hours are listed correctly.\n\nAlso, please take a few minutes to review our IT Security Guidelines — they cover our policies on email safety and patient data handling.\n\nThanks!\nDr. Priya Patel\nChief of Staff | Meridian Health Partners",
|
||||||
|
"links": [],
|
||||||
|
"attachments": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "email_phish_records",
|
||||||
|
"sender": "Meridian IT Support",
|
||||||
|
"rfcSender": "[email protected]",
|
||||||
|
"recipient": "[email protected]",
|
||||||
|
"subject": "ACTION REQUIRED: Patient Records System Access Renewal",
|
||||||
|
"date": "08:45 AM",
|
||||||
|
"folder": "pending",
|
||||||
|
"unread": true,
|
||||||
|
"starred": false,
|
||||||
|
"body": "Dear Meridian Employee,\n\nYour access to the Patient Records Management System expires today. To avoid disruption to clinical operations, you must re-verify your credentials immediately.\n\nClick here to verify: <a class=\"inlook-msg-link\" data-url=\"http://meridian-health-portal.com/verify\" data-display=\"https://records.meridianhealth.org/renew\">https://records.meridianhealth.org/renew</a>\n\nFailure to verify within 4 hours will result in access suspension.\n\nMeridian IT Support",
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"displayText": "https://records.meridianhealth.org/renew",
|
||||||
|
"actualUrl": "http://meridian-health-portal.com/verify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attachments": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "email_legit_it",
|
||||||
|
"sender": "Meridian IT Support",
|
||||||
|
"rfcSender": "[email protected]",
|
||||||
|
"recipient": "[email protected]",
|
||||||
|
"subject": "Reminder: Mandatory security training due Friday",
|
||||||
|
"date": "09:00 AM",
|
||||||
|
"folder": "pending",
|
||||||
|
"unread": true,
|
||||||
|
"starred": false,
|
||||||
|
"body": "Hi everyone,\n\nJust a friendly reminder that all staff must complete the annual cybersecurity awareness training by end of day Friday.\n\nYou can access it through the Staff Portal on the intranet. No links in this email — navigate there directly.\n\nThanks,\nMeridian IT Support",
|
||||||
|
"links": [],
|
||||||
|
"attachments": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"id": "file_schedule",
|
||||||
|
"name": "Staff_Schedule_August.xlsx",
|
||||||
|
"type": "spreadsheet",
|
||||||
|
"folder": "Documents",
|
||||||
|
"size": "32 KB",
|
||||||
|
"date": "2026-08-18",
|
||||||
|
"content": {
|
||||||
|
"title": "Meridian Health Partners — Staff Schedule (August 2026)",
|
||||||
|
"headers": ["Name", "Role", "Monday", "Wednesday", "Thursday", "Friday"],
|
||||||
|
"rows": [
|
||||||
|
["Dr. Priya Patel", "Chief of Staff", "8am-4pm", "8am-4pm", "9am-1pm", "8am-4pm"],
|
||||||
|
["Casey Morgan", "Admin Assistant", "9am-5pm", "9am-5pm", "9am-5pm", "9am-5pm"],
|
||||||
|
["Nurse Linda Cho", "Head Nurse", "7am-3pm", "7am-3pm", "7am-3pm", "OFF"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "file_it_guidelines",
|
||||||
|
"name": "IT_Security_Guidelines.pdf",
|
||||||
|
"type": "pdf",
|
||||||
|
"folder": "Documents",
|
||||||
|
"size": "64 KB",
|
||||||
|
"date": "2026-08-01",
|
||||||
|
"content": {
|
||||||
|
"title": "Meridian Health Partners — IT Security Guidelines",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"heading": "1. Email Safety",
|
||||||
|
"text": "Always verify the sender's domain before clicking links. Meridian IT will never ask for your password via email. Report suspicious messages immediately using the Report button in Inlook."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heading": "2. Patient Data Protection",
|
||||||
|
"text": "Access to patient records requires verified credentials through the Staff Portal only. Never enter credentials on external websites."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"url": "http://meridian-health-portal.com/verify",
|
||||||
|
"title": "Meridian Health — Credential Verification",
|
||||||
|
"isSecure": false,
|
||||||
|
"isPhishing": true,
|
||||||
|
"content": "<div class=\"webpage-phish\"><div class=\"phish-card\"><div class=\"phish-logo\">Meridian Health Records Portal</div><div class=\"phish-subtitle\">Verify your employee credentials to maintain access</div><form id=\"phish-cred-form\"><div class=\"cs-form-group\"><label class=\"cs-form-label\">Employee Email</label><input type=\"text\" class=\"cs-input\" placeholder=\"[email protected]\" required></div><div class=\"cs-form-group\"><label class=\"cs-form-label\">Password</label><input type=\"password\" class=\"cs-input\" placeholder=\"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\" required></div><button type=\"submit\" class=\"cs-btn cs-btn-primary\" style=\"width:100%; margin-top:8px;\">Verify & Continue</button></form></div></div>",
|
||||||
|
"forms": [
|
||||||
|
{
|
||||||
|
"id": "phish-cred-form",
|
||||||
|
"onSubmit": {
|
||||||
|
"emitEvent": "NAV_FORM_SUBMITTED",
|
||||||
|
"target": "phish_cred_form",
|
||||||
|
"response": {
|
||||||
|
"type": "pageContent",
|
||||||
|
"content": "<div style=\"padding:60px 20px; text-align:center;\"><div style=\"font-size:36px; margin-bottom:12px;\">✔</div><h2 style=\"color:#0f172a; font-size:18px;\">Access Verified</h2><p style=\"color:#64748b; font-size:13px;\">Your credentials have been confirmed. You may close this window.</p></div>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"notifications": [
|
||||||
|
{
|
||||||
|
"id": "notif-welcome",
|
||||||
|
"title": "Meridian Health",
|
||||||
|
"body": "Welcome {{learner.name}}! Check Inlook for messages from Dr. Patel.",
|
||||||
|
"type": "info",
|
||||||
|
"timeout": 7000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "notif-new-mail",
|
||||||
|
"title": "Inlook Mail",
|
||||||
|
"body": "New message received.",
|
||||||
|
"type": "info",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"alerts": [
|
||||||
|
{
|
||||||
|
"id": "alert_initial",
|
||||||
|
"title": "Endpoint Protection Active",
|
||||||
|
"severity": "info",
|
||||||
|
"source": "Meridian Endpoint Agent",
|
||||||
|
"message": "System status: Healthy. All security definitions are up to date.",
|
||||||
|
"timestamp": "08:00 AM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "alert_credential_leak",
|
||||||
|
"title": "Suspicious Login Detected",
|
||||||
|
"severity": "high",
|
||||||
|
"source": "Identity Protection Service",
|
||||||
|
"message": "An unauthorized login attempt was detected using your credentials from an unrecognized location."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"id": "begin-shift",
|
||||||
|
"when": { "scenarioStart": true },
|
||||||
|
"actions": [
|
||||||
|
{ "type": "mail.deliver", "message": "email_welcome" },
|
||||||
|
{ "type": "desktop.openApp", "app": "inlook" },
|
||||||
|
{ "type": "desktop.notify", "notification": "notif-welcome" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "deliver-phish",
|
||||||
|
"when": { "elapsedSeconds": 10 },
|
||||||
|
"actions": [
|
||||||
|
{ "type": "mail.deliver", "message": "email_phish_records" },
|
||||||
|
{ "type": "desktop.notify", "notification": "notif-new-mail" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "deliver-legit-it",
|
||||||
|
"when": { "elapsedSeconds": 25 },
|
||||||
|
"actions": [
|
||||||
|
{ "type": "mail.deliver", "message": "email_legit_it" },
|
||||||
|
{ "type": "desktop.notify", "notification": "notif-new-mail" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "consequence-credential-leak",
|
||||||
|
"when": {
|
||||||
|
"actionOccurred": { "type": "NAV_FORM_SUBMITTED", "target": "phish_cred_form" }
|
||||||
|
},
|
||||||
|
"delay": 30,
|
||||||
|
"actions": [
|
||||||
|
{ "type": "security.addAlert", "alert": "alert_credential_leak" },
|
||||||
|
{ "type": "evaluation.addFinding", "finding": "credential-compromise" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"id": "credential-compromise",
|
||||||
|
"severity": "critical",
|
||||||
|
"category": "safe-handling",
|
||||||
|
"score": -15,
|
||||||
|
"feedback": "CRITICAL: You entered your credentials on an external impersonation portal. Always verify the URL domain before entering any login information."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "false-positive-it-notice",
|
||||||
|
"severity": "minor",
|
||||||
|
"category": "false-positive-control",
|
||||||
|
"score": -10,
|
||||||
|
"feedback": "The security training reminder from IT Support was legitimate. The sender domain matched your organization and the email contained no suspicious links."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"scoring": {
|
||||||
|
"categories": [
|
||||||
|
{ "id": "threat-detection", "label": "Threat Detection", "maxPoints": 30, "startingPoints": 0 },
|
||||||
|
{ "id": "safe-handling", "label": "Safe Handling", "maxPoints": 40, "startingPoints": 30 },
|
||||||
|
{ "id": "false-positive-control", "label": "False Positive Control", "maxPoints": 30, "startingPoints": 20 }
|
||||||
|
],
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"id": "opened-phish",
|
||||||
|
"condition": { "actionOccurred": { "type": "EMAIL_OPENED", "target": "email_phish_records" } },
|
||||||
|
"award": { "category": "threat-detection", "points": 15 },
|
||||||
|
"timeline": { "type": "positive", "text": "Reviewed the suspicious credential renewal email." },
|
||||||
|
"missedFeedback": "You did not review the credential renewal email."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "inspected-phish-sender",
|
||||||
|
"condition": {
|
||||||
|
"any": [
|
||||||
|
{ "actionOccurred": { "type": "EMAIL_INSPECTED_SENDER", "target": "email_phish_records" } },
|
||||||
|
{ "actionOccurred": { "type": "EMAIL_LINK_HOVERED", "target": "email_phish_records" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"award": { "category": "threat-detection", "points": 15 },
|
||||||
|
"timeline": { "type": "positive", "text": "Investigated the phishing email's sender or link destination." },
|
||||||
|
"missedFeedback": "You did not inspect the sender header or link destination of the suspicious email."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "submitted-phish-creds",
|
||||||
|
"condition": { "actionOccurred": { "type": "NAV_FORM_SUBMITTED", "target": "phish_cred_form" } },
|
||||||
|
"award": { "category": "safe-handling", "points": -15 },
|
||||||
|
"timeline": { "type": "critical", "text": "Entered credentials on a phishing page." },
|
||||||
|
"feedback": "CRITICAL: You entered your credentials on an external impersonation portal."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "avoided-phish",
|
||||||
|
"condition": {
|
||||||
|
"not": { "actionOccurred": { "type": "NAV_FORM_SUBMITTED", "target": "phish_cred_form" } }
|
||||||
|
},
|
||||||
|
"award": { "category": "safe-handling", "points": 10 },
|
||||||
|
"timeline": { "type": "positive", "text": "Refrained from submitting credentials to untrusted websites." }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "reported-phish",
|
||||||
|
"condition": { "actionOccurred": { "type": "EMAIL_REPORTED", "target": "email_phish_records" } },
|
||||||
|
"award": { "category": "threat-detection", "points": 0 },
|
||||||
|
"timeline": { "type": "positive", "text": "Reported the phishing email to the security team." }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "false-positive-it",
|
||||||
|
"condition": { "actionOccurred": { "type": "EMAIL_REPORTED", "target": "email_legit_it" } },
|
||||||
|
"award": { "category": "false-positive-control", "points": -10 },
|
||||||
|
"timeline": { "type": "warning", "text": "Incorrectly flagged the legitimate IT training reminder as suspicious." },
|
||||||
|
"feedback": "The security training reminder was a legitimate internal communication."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "read-security-guidelines",
|
||||||
|
"condition": {
|
||||||
|
"any": [
|
||||||
|
{ "actionOccurred": { "type": "FILE_OPENED", "target": "IT_Security_Guidelines.pdf" } },
|
||||||
|
{ "actionOccurred": { "type": "DOC_VIEWED", "target": "IT_Security_Guidelines.pdf" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"award": { "category": "safe-handling", "points": 0 },
|
||||||
|
"timeline": { "type": "positive", "text": "Reviewed the IT Security Guidelines document." }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"feedback": [
|
||||||
|
{
|
||||||
|
"id": "perfect",
|
||||||
|
"condition": { "scoreThreshold": { "min": 100 } },
|
||||||
|
"text": "Excellent work! You demonstrated strong email safety awareness on your first day.",
|
||||||
|
"type": "positive"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"completion": {
|
||||||
|
"passingScore": 70,
|
||||||
|
"passAction": "certificate",
|
||||||
|
"failAction": "retry"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user