From e6b70f427632bcebf43722ee1f1e02087f1a3e90 Mon Sep 17 00:00:00 2001 From: Labyricorn Date: Mon, 24 Aug 2026 06:12:34 -0700 Subject: [PATCH] Implement CyberSim Phase 2 MVP scenario-driven platform --- AGENTS.md | 2 +- README.md | 322 +-- docs/scenario-format.md | 283 +++ src/css/apps.css | 1742 ++++++++--------- src/css/components.css | 454 ++--- src/css/desktop.css | 1106 +++++------ src/css/theme-windows.css | 94 +- src/index.html | 188 +- src/js/apps/docviewer.js | 246 +-- src/js/apps/files.js | 248 +-- src/js/apps/inlook.js | 887 +++++---- src/js/apps/navigator.js | 469 +++-- src/js/apps/security_center.js | 274 +-- src/js/cert/cert_generator.js | 288 +-- src/js/cert/cert_verifier.js | 108 +- src/js/core/desktop.js | 250 +-- src/js/core/notifications.js | 268 +-- src/js/core/window_manager.js | 542 ++--- src/js/engine/action_dispatcher.js | 330 ++++ src/js/engine/condition_evaluator.js | 196 ++ src/js/engine/consequence.js | 156 +- src/js/engine/event_bus.js | 216 +- src/js/engine/event_scheduler.js | 202 ++ src/js/engine/scenario_state.js | 313 +++ src/js/main.js | 578 ++++-- src/js/scenario/diagnostics.js | 319 +++ src/js/scenario/fingerprint.js | 155 +- src/js/scenario/loader.js | 222 +++ src/js/scenario/scenario_ref1.js | 425 ---- src/js/scenario/schema.js | 487 +++++ src/js/scenario/validator.js | 364 ++++ src/js/scoring/aar.js | 232 +-- src/js/scoring/scorer.js | 374 ++-- .../nexacore-orientation/scenario.json | 639 ++++++ .../quickstart-example/scenario.json | 363 ++++ src/verify.html | 328 ++-- 36 files changed, 8721 insertions(+), 4949 deletions(-) create mode 100644 docs/scenario-format.md create mode 100644 src/js/engine/action_dispatcher.js create mode 100644 src/js/engine/condition_evaluator.js create mode 100644 src/js/engine/event_scheduler.js create mode 100644 src/js/engine/scenario_state.js create mode 100644 src/js/scenario/diagnostics.js create mode 100644 src/js/scenario/loader.js delete mode 100644 src/js/scenario/scenario_ref1.js create mode 100644 src/js/scenario/schema.js create mode 100644 src/js/scenario/validator.js create mode 100644 src/scenarios/nexacore-orientation/scenario.json create mode 100644 src/scenarios/quickstart-example/scenario.json diff --git a/AGENTS.md b/AGENTS.md index 1740954..5aff4bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,4 +45,4 @@ scoped `AGENTS.md` files before making changes. - Alternatively, launch the graphical editor with `python devlog_editor.py` for interactive management of devlog entries and publishing status. - Run `git diff --check` and review diffs carefully before staging or committing. -- At handoff, summarize files changed, validation performed, and any next steps. \ No newline at end of file +- At handoff, summarize files changed, validation performed, and any next steps. diff --git a/README.md b/README.md index e3be220..aa0aeff 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,194 @@ -# CyberSim OS - -**Phase 1 MVP: Scriptable CS End User Teaching Environment with Certificate Verification** - -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`). - ---- - -## Key Features - -- **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**: - - **Inlook**: Email client with header inspector (RFC sender vs. friendly name), link hover destination tooltips, phishing report workflow, and reply/delete actions. - - **Navigator**: Web browser supporting intranet directories, policy hubs, and external spoofed credential-harvesting portals. - - **Files**: Virtual filesystem managing Documents, Downloads, and Company Shared folders. - - **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. -- **Realistic Decision Model**: Teaches *"Observe → Investigate → Verify → Decide → Act"* rather than *"Strange = Malicious"*. False flags and legitimate urgent notices test discernment. -- **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. -- **7-Axis Behavioral Scoring**: - 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**: - - Web Crypto SHA-256 scenario fingerprinting. - - Portable, structured `*.cybercert` JSON credential export. - - Standalone offline certificate validator (`verify.html`). -- **100% Offline-First & Zero Dependencies**: Runs directly from any static web server, GitHub Pages, or the bundled `launcher.py` with zero npm/node/external CDN requirements. - ---- - -## Quick Start - -### Option 1: Standalone Local Launcher (Python 3) -Run the lightweight local launcher to start the server at `http://127.0.0.1:8080`: - -```bash -python launcher.py -``` - -Your default web browser will open automatically. - -### Option 2: Static Web Server -Serve the `src/` directory with any static HTTP server: - -```bash -cd src -python -m http.server 8000 -``` -Open [http://127.0.0.1:8000](http://127.0.0.1:8000) in your browser. - -### Option 3: Direct Static Hosting -Host the repository on GitHub Pages or any static file host pointing to `src/index.html`. - ---- - -## Certificate Verification - -To verify a `*.cybercert` certificate issued by CyberSim OS: - -1. Open `src/verify.html` in your web browser. -2. Drag and drop the `*.cybercert` file into the verification area. -3. The offline verifier recalculates the SHA-256 integrity hash, verifies the scenario fingerprint, and confirms the passing score and learner identity. - ---- - -## Architecture & Directory Structure - -``` -CyberSim-OS/ -├── .labyricorn/ # Labyricorn exhibition & devlog publishing records -├── src/ -│ ├── index.html # Main desktop simulation interface -│ ├── verify.html # Standalone offline certificate verifier -│ ├── css/ -│ │ ├── theme-windows.css # Fictional Windows-like enterprise theme tokens -│ │ ├── desktop.css # Window manager, taskbar, start menu, tray, toasts -│ │ ├── components.css # Modals, form inputs, buttons, badges, tabs -│ │ └── apps.css # Inlook, Navigator, Files, DocViewer, SecurityCenter -│ ├── js/ -│ │ ├── main.js # Application bootstrapper & scenario loader -│ │ ├── core/ -│ │ │ ├── window_manager.js# Window lifecycle, stacking, drag, min/max/close -│ │ │ ├── desktop.js # Desktop shell, launcher, clock, simulation controls -│ │ │ └── notifications.js # Toast notifications & Web Audio synth audio chime -│ │ ├── apps/ -│ │ │ ├── inlook.js # Email client with RFC header/link inspection -│ │ │ ├── navigator.js # Simulated browser with intranet & phishing pages -│ │ │ ├── files.js # Virtual filesystem explorer -│ │ │ ├── docviewer.js # Spreadsheet and document viewer -│ │ │ └── security_center.js # Endpoint status & incident report dashboard -│ │ ├── engine/ -│ │ │ ├── event_bus.js # Behavioral telemetry logger -│ │ │ └── consequence.js # Delayed consequence scheduler -│ │ ├── scenario/ -│ │ │ ├── scenario_ref1.js # Reference scenario ("NexaCore Shift 1") -│ │ │ └── fingerprint.js # Web Crypto SHA-256 scenario fingerprinting -│ │ ├── scoring/ -│ │ │ ├── scorer.js # Multi-axis behavioral scoring engine -│ │ │ └── aar.js # After-Action Report modal with pedagogical feedback -│ │ └── cert/ -│ │ ├── cert_generator.js# *.cybercert JSON generator & printable certificate -│ │ └── cert_verifier.js # Offline cryptographic certificate verifier -│ └── assets/ # Embedded SVG icons and media -├── launcher.py # Zero-dependency local 127.0.0.1 web server -├── devlog_editor.py # Labyricorn devlog validation & editing tool -└── README.md # Project documentation -``` - ---- - -## Validation & Devlog Maintenance - -Run the validation suite to ensure `.labyricorn/` records and devlog entries adhere to the schema: - -```bash -python devlog_editor.py --validate -``` - ---- - -## License - -MIT License. See `LICENSE` for details. +# CyberSim OS + +**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 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 + +- **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. +- **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. + - **Navigator**: Web browser supporting intranet directories, policy hubs, and external spoofed credential-harvesting portals. + - **Files**: Virtual filesystem managing Documents, Downloads, and Company Shared folders. + - **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. +- **Declarative Event System**: Scenario events with time-based, action-based, and state-based triggers execute through a restricted behavior vocabulary. +- **Data-Driven Scoring**: Scoring categories, rules, and thresholds are defined in the scenario package. The engine evaluates them generically. +- **Delayed Consequences Engine**: Unsafe actions trigger delayed consequence events without giving immediate arcade-like game-over feedback. +- **Verifiable Cryptographic Certificates**: + - Web Crypto SHA-256 scenario fingerprinting. + - Portable, structured `*.cybercert` JSON credential export. + - Standalone offline certificate validator (`verify.html`). +- **100% Offline-First & Zero Dependencies**: Runs directly from any static web server, GitHub Pages, or the bundled `launcher.py` with zero npm/node/external CDN requirements. + +--- + +## Quick Start + +### Option 1: Standalone Local Launcher (Python 3) +Run the lightweight local launcher to start the server at `http://127.0.0.1:8080`: + +```bash +python launcher.py +``` + +Your default web browser will open automatically with the default scenario. + +### Option 2: Static Web Server +Serve the `src/` directory with any static HTTP server: + +```bash +cd src +python -m http.server 8000 +``` +Open [http://127.0.0.1:8000](http://127.0.0.1:8000) in your browser. + +### Loading a Specific Scenario +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. + +--- + +## Certificate Verification + +To verify a `*.cybercert` certificate issued by CyberSim OS: + +1. Open `src/verify.html` in your web browser. +2. Drag and drop the `*.cybercert` file into the verification area. +3. The offline verifier recalculates the SHA-256 integrity hash, verifies the scenario fingerprint, and confirms the passing score and learner identity. + +--- + +## Architecture & Directory Structure + +``` +CyberSim-OS/ +├── .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/ +│ ├── index.html # Main desktop simulation interface +│ ├── verify.html # Standalone offline certificate verifier +│ ├── css/ +│ │ ├── theme-windows.css # Fictional Windows-like enterprise theme tokens +│ │ ├── desktop.css # Window manager, taskbar, start menu, tray, toasts +│ │ ├── components.css # Modals, form inputs, buttons, badges, tabs +│ │ └── apps.css # Inlook, Navigator, Files, DocViewer, SecurityCenter +│ ├── js/ +│ │ ├── main.js # Application bootstrapper & scenario loader +│ │ ├── core/ +│ │ │ ├── window_manager.js# Window lifecycle, stacking, drag, min/max/close +│ │ │ ├── desktop.js # Desktop shell, launcher, clock, simulation controls +│ │ │ └── notifications.js # Toast notifications & Web Audio synth audio chime +│ │ ├── apps/ +│ │ │ ├── inlook.js # Email client with RFC header/link inspection +│ │ │ ├── navigator.js # Simulated browser with data-driven pages & forms +│ │ │ ├── files.js # Virtual filesystem explorer +│ │ │ ├── docviewer.js # Spreadsheet and document viewer +│ │ │ └── security_center.js # Endpoint status & incident report dashboard +│ │ ├── engine/ +│ │ │ ├── event_bus.js # Behavioral telemetry logger +│ │ │ ├── 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/ +│ │ │ ├── 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 +│ │ ├── scoring/ +│ │ │ ├── scorer.js # Data-driven behavioral scoring engine +│ │ │ └── aar.js # After-Action Report modal +│ │ └── cert/ +│ │ ├── cert_generator.js# *.cybercert JSON generator & printable certificate +│ │ └── cert_verifier.js # Offline cryptographic certificate verifier +│ └── assets/ # Embedded SVG icons and media +├── launcher.py # Zero-dependency local 127.0.0.1 web server +├── devlog_editor.py # Labyricorn devlog validation & editing tool +└── README.md # Project documentation +``` + +--- + +## 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 + +Run the validation suite to ensure `.labyricorn/` records and devlog entries adhere to the schema: + +```bash +python devlog_editor.py --validate +``` + +--- + +## License + +MIT License. See `LICENSE` for details. diff --git a/docs/scenario-format.md b/docs/scenario-format.md new file mode 100644 index 0000000..5c5be86 --- /dev/null +++ b/docs/scenario-format.md @@ -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": ["alex.rivera@nexacore.internal"] +} +``` + +### 4.2 People (`people[]`) +Defines characters and email identities. + +```json +{ + "id": "alex-rivera", + "name": "Alex Rivera", + "role": "Chief Information Security Officer", + "email": "alex.rivera@nexacore.internal", + "organization": "nexacore", + "department": "Information Security" +} +``` + +### 4.3 Messages (`messages[]`) +Defines email communications in Inlook. + +```json +{ + "id": "email_phish_pwreset", + "sender": "NexaCore IT Helpdesk", + "rfcSender": "support@nexac0re-portal.com", + "recipient": "jordan.taylor@nexacore.internal", + "subject": "URGENT: Password Verification Required", + "date": "09:05 AM", + "folder": "pending", + "unread": true, + "body": "Please confirm credentials at: https://intranet.nexacore.internal/sso", + "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": "
...
", + "forms": [ + { + "id": "phish-login-form", + "onSubmit": { + "emitEvent": "NAV_FORM_SUBMITTED", + "target": "phish_login_form", + "response": { + "type": "pageContent", + "content": "
Account Verification Complete
" + } + } + } + ] +} +``` + +### 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 diff --git a/src/css/apps.css b/src/css/apps.css index 0092ad0..2c5517f 100644 --- a/src/css/apps.css +++ b/src/css/apps.css @@ -1,871 +1,871 @@ -/* CyberSim OS - App Specific Styles */ - -/* === INLOOK (Email Client) === */ -.inlook-container { - display: flex; - height: 100%; - width: 100%; - overflow: hidden; - background: #f8fafc; -} - -.inlook-sidebar { - width: 160px; - background: #f1f5f9; - border-right: 1px solid var(--border-medium); - display: flex; - flex-direction: column; - padding: 10px 6px; - gap: 4px; - flex-shrink: 0; -} - -.inlook-folder-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 6px 8px; - border-radius: var(--radius-sm); - cursor: pointer; - color: #334155; - font-weight: 500; - font-size: 12px; -} - -.inlook-folder-item:hover { - background: #e2e8f0; -} - -.inlook-folder-item.active { - background: var(--color-primary-light); - color: var(--color-primary); - font-weight: 600; -} - -.inlook-list-pane { - width: 280px; - border-right: 1px solid var(--border-medium); - background: #ffffff; - overflow-y: auto; - flex-shrink: 0; - display: flex; - flex-direction: column; -} - -.inlook-list-item { - padding: 10px 12px; - border-bottom: 1px solid var(--border-subtle); - cursor: pointer; - transition: background 0.12s ease; -} - -.inlook-list-item:hover { - background: #f8fafc; -} - -.inlook-list-item.active { - background: #eff6ff; - border-left: 3px solid var(--color-primary); -} - -.inlook-list-item.unread .inlook-item-sender, -.inlook-list-item.unread .inlook-item-subject { - font-weight: 700; - color: #0f172a; -} - -.inlook-item-header { - display: flex; - justify-content: space-between; - font-size: 11px; - margin-bottom: 3px; -} - -.inlook-item-sender { - font-weight: 600; - color: #334155; -} - -.inlook-item-date { - color: #94a3b8; -} - -.inlook-item-subject { - font-size: 12px; - color: #475569; - margin-bottom: 4px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.inlook-item-snippet { - font-size: 11px; - color: #94a3b8; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.inlook-reading-pane { - flex: 1; - display: flex; - flex-direction: column; - background: #ffffff; - overflow-y: auto; -} - -.inlook-toolbar { - padding: 8px 12px; - background: #f8fafc; - border-bottom: 1px solid var(--border-medium); - display: flex; - align-items: center; - gap: 6px; -} - -.inlook-msg-header { - padding: 14px 18px; - border-bottom: 1px solid var(--border-subtle); - background: #ffffff; -} - -.inlook-msg-subject { - font-size: 16px; - font-weight: 700; - color: #0f172a; - margin-bottom: 8px; -} - -.inlook-msg-meta { - display: flex; - flex-direction: column; - gap: 4px; - font-size: 12px; -} - -.inlook-sender-row { - display: flex; - align-items: center; - justify-content: space-between; -} - -.inlook-sender-box { - display: flex; - align-items: center; - gap: 8px; -} - -.inlook-sender-avatar { - width: 32px; - height: 32px; - border-radius: var(--radius-full); - background: #e2e8f0; - color: #475569; - font-weight: 700; - display: flex; - align-items: center; - justify-content: center; - font-size: 13px; -} - -.inlook-sender-details .sender-name { - font-weight: 600; - color: #0f172a; -} - -.inlook-sender-details .sender-email { - color: #64748b; - font-size: 11px; - font-family: var(--font-mono); -} - -.inlook-inspect-btn { - font-size: 10px; - color: var(--color-primary); - background: transparent; - border: 1px solid var(--color-primary); - border-radius: var(--radius-sm); - padding: 2px 6px; - cursor: pointer; - margin-left: 8px; -} - -.inlook-inspect-btn:hover { - background: var(--color-primary-light); -} - -.inlook-attachments { - margin-top: 10px; - padding-top: 10px; - border-top: 1px dashed var(--border-medium); - display: flex; - align-items: center; - gap: 8px; -} - -.inlook-attachment-chip { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 8px; - background: #f1f5f9; - border: 1px solid var(--border-medium); - border-radius: var(--radius-sm); - font-size: 11px; - cursor: pointer; - transition: all 0.12s ease; -} - -.inlook-attachment-chip:hover { - background: #e2e8f0; - border-color: #94a3b8; -} - -.inlook-msg-body { - padding: 18px; - font-size: 13px; - line-height: 1.6; - color: #1e293b; - user-select: text; -} - -.inlook-msg-body p { - margin-bottom: 12px; -} - -.inlook-msg-link { - color: var(--color-primary); - text-decoration: underline; - cursor: pointer; - position: relative; -} - -/* === NAVIGATOR (Web Browser) === */ -.nav-container { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - background: #ffffff; -} - -.nav-toolbar { - padding: 6px 10px; - background: #f1f5f9; - border-bottom: 1px solid var(--border-medium); - display: flex; - align-items: center; - gap: 8px; -} - -.nav-buttons { - display: flex; - align-items: center; - gap: 4px; -} - -.nav-btn-icon { - width: 26px; - height: 26px; - display: flex; - align-items: center; - justify-content: center; - background: transparent; - border: 1px solid transparent; - border-radius: var(--radius-sm); - color: #475569; - cursor: pointer; -} - -.nav-btn-icon:hover { - background: #e2e8f0; - border-color: #cbd5e1; -} - -.nav-address-bar { - flex: 1; - display: flex; - align-items: center; - background: #ffffff; - border: 1px solid var(--border-medium); - border-radius: var(--radius-full); - padding: 3px 12px; - gap: 6px; - font-size: 12px; -} - -.nav-address-bar.secure { - border-color: #10b981; -} - -.nav-address-bar.insecure { - border-color: #f59e0b; -} - -.nav-lock-icon { - color: #10b981; - display: flex; - align-items: center; -} - -.nav-url-input { - flex: 1; - border: none; - outline: none; - font-family: var(--font-mono); - font-size: 11px; - color: #0f172a; - user-select: text; -} - -.nav-viewport { - flex: 1; - overflow-y: auto; - background: #f8fafc; - user-select: text; -} - -/* Simulated Web Pages */ -.webpage-intranet { - padding: 24px; - max-width: 800px; - margin: 0 auto; -} - -.intranet-header { - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 2px solid #2563eb; - padding-bottom: 12px; - margin-bottom: 20px; -} - -.intranet-title { - font-size: 20px; - font-weight: 700; - color: #1e3a8a; -} - -.intranet-grid { - display: grid; - grid-template-columns: 2fr 1fr; - gap: 20px; -} - -.intranet-card { - background: #ffffff; - border: 1px solid var(--border-medium); - border-radius: var(--radius-md); - padding: 16px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); -} - -.intranet-card h3 { - font-size: 14px; - font-weight: 600; - margin-bottom: 10px; - color: #0f172a; -} - -.directory-table { - width: 100%; - border-collapse: collapse; - font-size: 11px; -} - -.directory-table th, .directory-table td { - padding: 6px 8px; - text-align: left; - border-bottom: 1px solid var(--border-subtle); -} - -.directory-table th { - background: #f1f5f9; - font-weight: 600; -} - -/* Phishing Web Page */ -.webpage-phish { - display: flex; - align-items: center; - justify-content: center; - min-height: 100%; - padding: 30px; - background: #f1f5f9; -} - -.phish-card { - background: #ffffff; - border: 1px solid var(--border-medium); - border-radius: var(--radius-lg); - padding: 28px; - max-width: 400px; - width: 100%; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); -} - -.phish-logo { - font-size: 18px; - font-weight: 800; - color: #2563eb; - margin-bottom: 6px; - text-align: center; -} - -.phish-subtitle { - font-size: 12px; - color: #64748b; - margin-bottom: 20px; - text-align: center; -} - -/* === FILES (Virtual File Explorer) === */ -.files-container { - display: flex; - height: 100%; - width: 100%; - background: #ffffff; -} - -.files-sidebar { - width: 180px; - background: #f8fafc; - border-right: 1px solid var(--border-medium); - padding: 12px 8px; - display: flex; - flex-direction: column; - gap: 4px; -} - -.files-folder-btn { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 10px; - border-radius: var(--radius-sm); - cursor: pointer; - color: #334155; - font-size: 12px; - font-weight: 500; -} - -.files-folder-btn:hover { - background: #e2e8f0; -} - -.files-folder-btn.active { - background: var(--color-primary-light); - color: var(--color-primary); - font-weight: 600; -} - -.files-content { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.files-address-bar { - padding: 8px 12px; - background: #f8fafc; - border-bottom: 1px solid var(--border-medium); - font-size: 12px; - color: #475569; - font-weight: 500; -} - -.files-grid { - flex: 1; - padding: 16px; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); - gap: 12px; - overflow-y: auto; - align-content: flex-start; -} - -.file-item { - display: flex; - flex-direction: column; - align-items: center; - padding: 10px 6px; - border-radius: var(--radius-md); - border: 1px solid transparent; - cursor: pointer; - text-align: center; - transition: all 0.12s ease; -} - -.file-item:hover { - background: #f1f5f9; - border-color: var(--border-medium); -} - -.file-item.selected { - background: var(--color-primary-light); - border-color: var(--color-primary); -} - -.file-icon { - width: 36px; - height: 36px; - margin-bottom: 6px; -} - -.file-name { - font-size: 11px; - color: #0f172a; - word-break: break-word; - line-height: 1.2; -} - -.file-size { - font-size: 10px; - color: #94a3b8; - margin-top: 2px; -} - -/* === DOCUMENT VIEWER === */ -.doc-viewer-container { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - background: #e2e8f0; -} - -.doc-toolbar { - padding: 6px 12px; - background: #0f172a; - color: #ffffff; - display: flex; - align-items: center; - justify-content: space-between; -} - -.doc-title-info { - font-size: 12px; - font-weight: 600; - display: flex; - align-items: center; - gap: 8px; -} - -.doc-canvas { - flex: 1; - overflow-y: auto; - padding: 20px; - display: flex; - justify-content: center; -} - -.doc-page { - background: #ffffff; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); - padding: 32px 40px; - max-width: 720px; - width: 100%; - min-height: 800px; - color: #0f172a; - user-select: text; - font-size: 13px; - line-height: 1.6; -} - -.doc-page h1 { - font-size: 20px; - color: #1e3a8a; - border-bottom: 2px solid #2563eb; - padding-bottom: 6px; - margin-bottom: 16px; -} - -.doc-page h2 { - font-size: 15px; - color: #0f172a; - margin: 16px 0 8px 0; -} - -.doc-table { - width: 100%; - border-collapse: collapse; - margin: 14px 0; - font-size: 12px; -} - -.doc-table th, .doc-table td { - border: 1px solid #cbd5e1; - padding: 8px 10px; - text-align: left; -} - -.doc-table th { - background: #f1f5f9; - font-weight: 600; -} - -/* === SECURITY CENTER === */ -.sec-center-container { - display: flex; - height: 100%; - width: 100%; - background: #f8fafc; -} - -.sec-sidebar { - width: 180px; - background: #0f172a; - color: #f8fafc; - padding: 14px 10px; - display: flex; - flex-direction: column; - gap: 6px; -} - -.sec-nav-btn { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 10px; - border-radius: var(--radius-sm); - color: #94a3b8; - cursor: pointer; - font-size: 12px; - font-weight: 500; - transition: all 0.12s ease; -} - -.sec-nav-btn:hover { - background: rgba(255, 255, 255, 0.08); - color: #ffffff; -} - -.sec-nav-btn.active { - background: #2563eb; - color: #ffffff; - font-weight: 600; -} - -.sec-main { - flex: 1; - padding: 20px; - overflow-y: auto; -} - -.sec-header { - margin-bottom: 18px; -} - -.sec-title { - font-size: 18px; - font-weight: 700; - color: #0f172a; -} - -.sec-status-banner { - display: flex; - align-items: center; - gap: 14px; - padding: 14px 18px; - border-radius: var(--radius-md); - margin-bottom: 18px; - background: #dcfce7; - border: 1px solid #86efac; - color: #166534; -} - -.sec-status-banner.alert-state { - background: #fee2e2; - border-color: #fca5a5; - color: #991b1b; -} - -.sec-alert-list { - display: flex; - flex-direction: column; - gap: 10px; -} - -.sec-alert-item { - background: #ffffff; - border: 1px solid var(--border-medium); - border-radius: var(--radius-md); - padding: 12px 16px; - display: flex; - align-items: flex-start; - gap: 12px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); -} - -.sec-alert-item.high { - border-left: 4px solid var(--color-danger); -} - -.sec-alert-item.medium { - border-left: 4px solid var(--color-warning); -} - -.sec-alert-item.info { - border-left: 4px solid var(--color-info); -} - -/* === AFTER-ACTION REPORT (AAR) === */ -.aar-modal-content { - padding: 24px; - max-width: 680px; -} - -.aar-header-summary { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px; - border-radius: var(--radius-lg); - background: #f1f5f9; - margin-bottom: 20px; -} - -.aar-score-badge { - font-size: 32px; - font-weight: 800; - color: #0f172a; -} - -.aar-result-pill { - padding: 6px 14px; - border-radius: var(--radius-full); - font-weight: 700; - font-size: 13px; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.aar-result-pill.pass { - background: #dcfce7; - color: #166534; - border: 1px solid #86efac; -} - -.aar-result-pill.fail { - background: #fee2e2; - color: #991b1b; - border: 1px solid #fca5a5; -} - -.aar-category-row { - margin-bottom: 12px; -} - -.aar-cat-header { - display: flex; - justify-content: space-between; - font-size: 12px; - font-weight: 600; - margin-bottom: 4px; -} - -.aar-progress-bg { - height: 8px; - background: #e2e8f0; - border-radius: var(--radius-full); - overflow: hidden; -} - -.aar-progress-fill { - height: 100%; - background: #2563eb; - border-radius: var(--radius-full); - transition: width 0.4s ease; -} - -.aar-progress-fill.danger { - background: #dc2626; -} - -.aar-progress-fill.warning { - background: #d97706; -} - -.aar-progress-fill.success { - background: #16a34a; -} - -.aar-feedback-box { - margin-top: 18px; - padding: 14px; - background: #f8fafc; - border: 1px solid var(--border-medium); - border-radius: var(--radius-md); - font-size: 12px; - line-height: 1.5; - color: #334155; -} - -/* === PRINTABLE CERTIFICATE === */ -.cert-printable { - border: 8px double #1e3a8a; - padding: 30px; - background: #fffdfa; - text-align: center; - color: #0f172a; - position: relative; - margin: 10px auto; - max-width: 600px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); -} - -.cert-org { - font-size: 12px; - font-weight: 700; - letter-spacing: 0.15em; - color: #475569; - text-transform: uppercase; -} - -.cert-title { - font-size: 24px; - font-weight: 800; - color: #1e3a8a; - margin: 12px 0 6px 0; - text-transform: uppercase; -} - -.cert-subtitle { - font-size: 13px; - color: #64748b; - margin-bottom: 18px; -} - -.cert-recipient { - font-size: 22px; - font-weight: 700; - color: #0f172a; - border-bottom: 2px solid #cbd5e1; - display: inline-block; - padding: 0 24px 4px 24px; - margin-bottom: 12px; -} - -.cert-text { - font-size: 12px; - color: #475569; - max-width: 480px; - margin: 0 auto 18px auto; - line-height: 1.4; -} - -.cert-meta-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; - font-size: 10px; - color: #64748b; - text-align: left; - border-top: 1px solid #e2e8f0; - padding-top: 12px; - margin-top: 12px; - font-family: var(--font-mono); -} +/* CyberSim OS - App Specific Styles */ + +/* === INLOOK (Email Client) === */ +.inlook-container { + display: flex; + height: 100%; + width: 100%; + overflow: hidden; + background: #f8fafc; +} + +.inlook-sidebar { + width: 160px; + background: #f1f5f9; + border-right: 1px solid var(--border-medium); + display: flex; + flex-direction: column; + padding: 10px 6px; + gap: 4px; + flex-shrink: 0; +} + +.inlook-folder-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 8px; + border-radius: var(--radius-sm); + cursor: pointer; + color: #334155; + font-weight: 500; + font-size: 12px; +} + +.inlook-folder-item:hover { + background: #e2e8f0; +} + +.inlook-folder-item.active { + background: var(--color-primary-light); + color: var(--color-primary); + font-weight: 600; +} + +.inlook-list-pane { + width: 280px; + border-right: 1px solid var(--border-medium); + background: #ffffff; + overflow-y: auto; + flex-shrink: 0; + display: flex; + flex-direction: column; +} + +.inlook-list-item { + padding: 10px 12px; + border-bottom: 1px solid var(--border-subtle); + cursor: pointer; + transition: background 0.12s ease; +} + +.inlook-list-item:hover { + background: #f8fafc; +} + +.inlook-list-item.active { + background: #eff6ff; + border-left: 3px solid var(--color-primary); +} + +.inlook-list-item.unread .inlook-item-sender, +.inlook-list-item.unread .inlook-item-subject { + font-weight: 700; + color: #0f172a; +} + +.inlook-item-header { + display: flex; + justify-content: space-between; + font-size: 11px; + margin-bottom: 3px; +} + +.inlook-item-sender { + font-weight: 600; + color: #334155; +} + +.inlook-item-date { + color: #94a3b8; +} + +.inlook-item-subject { + font-size: 12px; + color: #475569; + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.inlook-item-snippet { + font-size: 11px; + color: #94a3b8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.inlook-reading-pane { + flex: 1; + display: flex; + flex-direction: column; + background: #ffffff; + overflow-y: auto; +} + +.inlook-toolbar { + padding: 8px 12px; + background: #f8fafc; + border-bottom: 1px solid var(--border-medium); + display: flex; + align-items: center; + gap: 6px; +} + +.inlook-msg-header { + padding: 14px 18px; + border-bottom: 1px solid var(--border-subtle); + background: #ffffff; +} + +.inlook-msg-subject { + font-size: 16px; + font-weight: 700; + color: #0f172a; + margin-bottom: 8px; +} + +.inlook-msg-meta { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; +} + +.inlook-sender-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.inlook-sender-box { + display: flex; + align-items: center; + gap: 8px; +} + +.inlook-sender-avatar { + width: 32px; + height: 32px; + border-radius: var(--radius-full); + background: #e2e8f0; + color: #475569; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; +} + +.inlook-sender-details .sender-name { + font-weight: 600; + color: #0f172a; +} + +.inlook-sender-details .sender-email { + color: #64748b; + font-size: 11px; + font-family: var(--font-mono); +} + +.inlook-inspect-btn { + font-size: 10px; + color: var(--color-primary); + background: transparent; + border: 1px solid var(--color-primary); + border-radius: var(--radius-sm); + padding: 2px 6px; + cursor: pointer; + margin-left: 8px; +} + +.inlook-inspect-btn:hover { + background: var(--color-primary-light); +} + +.inlook-attachments { + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed var(--border-medium); + display: flex; + align-items: center; + gap: 8px; +} + +.inlook-attachment-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: #f1f5f9; + border: 1px solid var(--border-medium); + border-radius: var(--radius-sm); + font-size: 11px; + cursor: pointer; + transition: all 0.12s ease; +} + +.inlook-attachment-chip:hover { + background: #e2e8f0; + border-color: #94a3b8; +} + +.inlook-msg-body { + padding: 18px; + font-size: 13px; + line-height: 1.6; + color: #1e293b; + user-select: text; +} + +.inlook-msg-body p { + margin-bottom: 12px; +} + +.inlook-msg-link { + color: var(--color-primary); + text-decoration: underline; + cursor: pointer; + position: relative; +} + +/* === NAVIGATOR (Web Browser) === */ +.nav-container { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + background: #ffffff; +} + +.nav-toolbar { + padding: 6px 10px; + background: #f1f5f9; + border-bottom: 1px solid var(--border-medium); + display: flex; + align-items: center; + gap: 8px; +} + +.nav-buttons { + display: flex; + align-items: center; + gap: 4px; +} + +.nav-btn-icon { + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); + color: #475569; + cursor: pointer; +} + +.nav-btn-icon:hover { + background: #e2e8f0; + border-color: #cbd5e1; +} + +.nav-address-bar { + flex: 1; + display: flex; + align-items: center; + background: #ffffff; + border: 1px solid var(--border-medium); + border-radius: var(--radius-full); + padding: 3px 12px; + gap: 6px; + font-size: 12px; +} + +.nav-address-bar.secure { + border-color: #10b981; +} + +.nav-address-bar.insecure { + border-color: #f59e0b; +} + +.nav-lock-icon { + color: #10b981; + display: flex; + align-items: center; +} + +.nav-url-input { + flex: 1; + border: none; + outline: none; + font-family: var(--font-mono); + font-size: 11px; + color: #0f172a; + user-select: text; +} + +.nav-viewport { + flex: 1; + overflow-y: auto; + background: #f8fafc; + user-select: text; +} + +/* Simulated Web Pages */ +.webpage-intranet { + padding: 24px; + max-width: 800px; + margin: 0 auto; +} + +.intranet-header { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 2px solid #2563eb; + padding-bottom: 12px; + margin-bottom: 20px; +} + +.intranet-title { + font-size: 20px; + font-weight: 700; + color: #1e3a8a; +} + +.intranet-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 20px; +} + +.intranet-card { + background: #ffffff; + border: 1px solid var(--border-medium); + border-radius: var(--radius-md); + padding: 16px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.intranet-card h3 { + font-size: 14px; + font-weight: 600; + margin-bottom: 10px; + color: #0f172a; +} + +.directory-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; +} + +.directory-table th, .directory-table td { + padding: 6px 8px; + text-align: left; + border-bottom: 1px solid var(--border-subtle); +} + +.directory-table th { + background: #f1f5f9; + font-weight: 600; +} + +/* Phishing Web Page */ +.webpage-phish { + display: flex; + align-items: center; + justify-content: center; + min-height: 100%; + padding: 30px; + background: #f1f5f9; +} + +.phish-card { + background: #ffffff; + border: 1px solid var(--border-medium); + border-radius: var(--radius-lg); + padding: 28px; + max-width: 400px; + width: 100%; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); +} + +.phish-logo { + font-size: 18px; + font-weight: 800; + color: #2563eb; + margin-bottom: 6px; + text-align: center; +} + +.phish-subtitle { + font-size: 12px; + color: #64748b; + margin-bottom: 20px; + text-align: center; +} + +/* === FILES (Virtual File Explorer) === */ +.files-container { + display: flex; + height: 100%; + width: 100%; + background: #ffffff; +} + +.files-sidebar { + width: 180px; + background: #f8fafc; + border-right: 1px solid var(--border-medium); + padding: 12px 8px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.files-folder-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + color: #334155; + font-size: 12px; + font-weight: 500; +} + +.files-folder-btn:hover { + background: #e2e8f0; +} + +.files-folder-btn.active { + background: var(--color-primary-light); + color: var(--color-primary); + font-weight: 600; +} + +.files-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.files-address-bar { + padding: 8px 12px; + background: #f8fafc; + border-bottom: 1px solid var(--border-medium); + font-size: 12px; + color: #475569; + font-weight: 500; +} + +.files-grid { + flex: 1; + padding: 16px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 12px; + overflow-y: auto; + align-content: flex-start; +} + +.file-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 10px 6px; + border-radius: var(--radius-md); + border: 1px solid transparent; + cursor: pointer; + text-align: center; + transition: all 0.12s ease; +} + +.file-item:hover { + background: #f1f5f9; + border-color: var(--border-medium); +} + +.file-item.selected { + background: var(--color-primary-light); + border-color: var(--color-primary); +} + +.file-icon { + width: 36px; + height: 36px; + margin-bottom: 6px; +} + +.file-name { + font-size: 11px; + color: #0f172a; + word-break: break-word; + line-height: 1.2; +} + +.file-size { + font-size: 10px; + color: #94a3b8; + margin-top: 2px; +} + +/* === DOCUMENT VIEWER === */ +.doc-viewer-container { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + background: #e2e8f0; +} + +.doc-toolbar { + padding: 6px 12px; + background: #0f172a; + color: #ffffff; + display: flex; + align-items: center; + justify-content: space-between; +} + +.doc-title-info { + font-size: 12px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.doc-canvas { + flex: 1; + overflow-y: auto; + padding: 20px; + display: flex; + justify-content: center; +} + +.doc-page { + background: #ffffff; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); + padding: 32px 40px; + max-width: 720px; + width: 100%; + min-height: 800px; + color: #0f172a; + user-select: text; + font-size: 13px; + line-height: 1.6; +} + +.doc-page h1 { + font-size: 20px; + color: #1e3a8a; + border-bottom: 2px solid #2563eb; + padding-bottom: 6px; + margin-bottom: 16px; +} + +.doc-page h2 { + font-size: 15px; + color: #0f172a; + margin: 16px 0 8px 0; +} + +.doc-table { + width: 100%; + border-collapse: collapse; + margin: 14px 0; + font-size: 12px; +} + +.doc-table th, .doc-table td { + border: 1px solid #cbd5e1; + padding: 8px 10px; + text-align: left; +} + +.doc-table th { + background: #f1f5f9; + font-weight: 600; +} + +/* === SECURITY CENTER === */ +.sec-center-container { + display: flex; + height: 100%; + width: 100%; + background: #f8fafc; +} + +.sec-sidebar { + width: 180px; + background: #0f172a; + color: #f8fafc; + padding: 14px 10px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.sec-nav-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: var(--radius-sm); + color: #94a3b8; + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: all 0.12s ease; +} + +.sec-nav-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: #ffffff; +} + +.sec-nav-btn.active { + background: #2563eb; + color: #ffffff; + font-weight: 600; +} + +.sec-main { + flex: 1; + padding: 20px; + overflow-y: auto; +} + +.sec-header { + margin-bottom: 18px; +} + +.sec-title { + font-size: 18px; + font-weight: 700; + color: #0f172a; +} + +.sec-status-banner { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 18px; + border-radius: var(--radius-md); + margin-bottom: 18px; + background: #dcfce7; + border: 1px solid #86efac; + color: #166534; +} + +.sec-status-banner.alert-state { + background: #fee2e2; + border-color: #fca5a5; + color: #991b1b; +} + +.sec-alert-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.sec-alert-item { + background: #ffffff; + border: 1px solid var(--border-medium); + border-radius: var(--radius-md); + padding: 12px 16px; + display: flex; + align-items: flex-start; + gap: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.sec-alert-item.high { + border-left: 4px solid var(--color-danger); +} + +.sec-alert-item.medium { + border-left: 4px solid var(--color-warning); +} + +.sec-alert-item.info { + border-left: 4px solid var(--color-info); +} + +/* === AFTER-ACTION REPORT (AAR) === */ +.aar-modal-content { + padding: 24px; + max-width: 680px; +} + +.aar-header-summary { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-radius: var(--radius-lg); + background: #f1f5f9; + margin-bottom: 20px; +} + +.aar-score-badge { + font-size: 32px; + font-weight: 800; + color: #0f172a; +} + +.aar-result-pill { + padding: 6px 14px; + border-radius: var(--radius-full); + font-weight: 700; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.aar-result-pill.pass { + background: #dcfce7; + color: #166534; + border: 1px solid #86efac; +} + +.aar-result-pill.fail { + background: #fee2e2; + color: #991b1b; + border: 1px solid #fca5a5; +} + +.aar-category-row { + margin-bottom: 12px; +} + +.aar-cat-header { + display: flex; + justify-content: space-between; + font-size: 12px; + font-weight: 600; + margin-bottom: 4px; +} + +.aar-progress-bg { + height: 8px; + background: #e2e8f0; + border-radius: var(--radius-full); + overflow: hidden; +} + +.aar-progress-fill { + height: 100%; + background: #2563eb; + border-radius: var(--radius-full); + transition: width 0.4s ease; +} + +.aar-progress-fill.danger { + background: #dc2626; +} + +.aar-progress-fill.warning { + background: #d97706; +} + +.aar-progress-fill.success { + background: #16a34a; +} + +.aar-feedback-box { + margin-top: 18px; + padding: 14px; + background: #f8fafc; + border: 1px solid var(--border-medium); + border-radius: var(--radius-md); + font-size: 12px; + line-height: 1.5; + color: #334155; +} + +/* === PRINTABLE CERTIFICATE === */ +.cert-printable { + border: 8px double #1e3a8a; + padding: 30px; + background: #fffdfa; + text-align: center; + color: #0f172a; + position: relative; + margin: 10px auto; + max-width: 600px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); +} + +.cert-org { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.15em; + color: #475569; + text-transform: uppercase; +} + +.cert-title { + font-size: 24px; + font-weight: 800; + color: #1e3a8a; + margin: 12px 0 6px 0; + text-transform: uppercase; +} + +.cert-subtitle { + font-size: 13px; + color: #64748b; + margin-bottom: 18px; +} + +.cert-recipient { + font-size: 22px; + font-weight: 700; + color: #0f172a; + border-bottom: 2px solid #cbd5e1; + display: inline-block; + padding: 0 24px 4px 24px; + margin-bottom: 12px; +} + +.cert-text { + font-size: 12px; + color: #475569; + max-width: 480px; + margin: 0 auto 18px auto; + line-height: 1.4; +} + +.cert-meta-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + font-size: 10px; + color: #64748b; + text-align: left; + border-top: 1px solid #e2e8f0; + padding-top: 12px; + margin-top: 12px; + font-family: var(--font-mono); +} diff --git a/src/css/components.css b/src/css/components.css index 4eefc7c..d87edfb 100644 --- a/src/css/components.css +++ b/src/css/components.css @@ -1,227 +1,227 @@ -/* CyberSim OS - Reusable Components */ -.cs-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - padding: 6px 12px; - font-size: 12px; - font-weight: 500; - border-radius: var(--radius-sm); - border: 1px solid var(--border-medium); - background: #ffffff; - color: var(--text-main); - cursor: pointer; - transition: all 0.12s ease; - user-select: none; -} - -.cs-btn:hover { - background: #f1f5f9; - border-color: #94a3b8; -} - -.cs-btn:active { - background: #e2e8f0; -} - -.cs-btn-primary { - background: var(--color-primary); - border-color: var(--color-primary); - color: #ffffff; -} - -.cs-btn-primary:hover { - background: var(--color-primary-hover); - border-color: var(--color-primary-hover); -} - -.cs-btn-danger { - background: var(--color-danger); - border-color: var(--color-danger); - color: #ffffff; -} - -.cs-btn-danger:hover { - background: #b91c1c; - border-color: #b91c1c; -} - -.cs-btn-warning { - background: var(--color-warning); - border-color: var(--color-warning); - color: #ffffff; -} - -.cs-btn-warning:hover { - background: #b45309; -} - -.cs-btn-sm { - padding: 4px 8px; - font-size: 11px; -} - -.cs-badge { - display: inline-flex; - align-items: center; - padding: 2px 6px; - font-size: 10px; - font-weight: 600; - border-radius: var(--radius-full); - line-height: 1; -} - -.cs-badge-danger { - background: var(--color-danger-bg); - color: var(--color-danger); -} - -.cs-badge-warning { - background: var(--color-warning-bg); - color: var(--color-warning); -} - -.cs-badge-success { - background: var(--color-success-bg); - color: var(--color-success); -} - -.cs-badge-info { - background: var(--color-info-bg); - color: var(--color-info); -} - -/* Modal Overlay */ -.cs-modal-overlay { - position: fixed; - top: 0; left: 0; right: 0; bottom: 0; - background: rgba(15, 23, 42, 0.65); - backdrop-filter: blur(4px); - display: flex; - align-items: center; - justify-content: center; - z-index: 5000; - padding: 20px; -} - -.cs-modal { - background: #ffffff; - border-radius: var(--radius-lg); - box-shadow: 0 20px 48px rgba(0, 0, 0, 0.4); - max-width: 560px; - width: 100%; - overflow: hidden; - display: flex; - flex-direction: column; - animation: modalPop 0.2s ease-out; -} - -@keyframes modalPop { - from { - opacity: 0; - transform: scale(0.95); - } - to { - opacity: 1; - transform: scale(1); - } -} - -.cs-modal-header { - padding: 14px 18px; - background: #0f172a; - color: #ffffff; - display: flex; - align-items: center; - justify-content: space-between; -} - -.cs-modal-title { - font-size: 14px; - font-weight: 600; - display: flex; - align-items: center; - gap: 8px; -} - -.cs-modal-body { - padding: 18px; - overflow-y: auto; - max-height: 70vh; - font-size: 13px; - line-height: 1.5; - color: #334155; -} - -.cs-modal-footer { - padding: 12px 18px; - background: #f8fafc; - border-top: 1px solid var(--border-medium); - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; -} - -/* Forms */ -.cs-form-group { - margin-bottom: 14px; -} - -.cs-form-label { - display: block; - font-size: 12px; - font-weight: 600; - color: var(--text-main); - margin-bottom: 5px; -} - -.cs-input, .cs-select, .cs-textarea { - width: 100%; - padding: 8px 10px; - font-size: 12px; - font-family: inherit; - border: 1px solid var(--border-medium); - border-radius: var(--radius-sm); - background: #ffffff; - color: var(--text-main); - user-select: text; -} - -.cs-input:focus, .cs-select:focus, .cs-textarea:focus { - outline: none; - border-color: var(--color-primary); - box-shadow: 0 0 0 2px var(--color-primary-light); -} - -/* Tooltips */ -.cs-tooltip-target { - position: relative; - display: inline-block; -} - -.cs-link-inspector-tooltip { - position: absolute; - bottom: calc(100% + 6px); - left: 0; - background: #0f172a; - color: #ffffff; - padding: 6px 10px; - border-radius: var(--radius-sm); - font-size: 11px; - font-family: var(--font-mono); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - z-index: 100; - white-space: nowrap; - pointer-events: none; - border: 1px solid #334155; -} - -.cs-link-inspector-tooltip .label { - color: #94a3b8; - font-size: 10px; - display: block; - margin-bottom: 2px; - font-family: var(--font-system); -} +/* CyberSim OS - Reusable Components */ +.cs-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 12px; + font-size: 12px; + font-weight: 500; + border-radius: var(--radius-sm); + border: 1px solid var(--border-medium); + background: #ffffff; + color: var(--text-main); + cursor: pointer; + transition: all 0.12s ease; + user-select: none; +} + +.cs-btn:hover { + background: #f1f5f9; + border-color: #94a3b8; +} + +.cs-btn:active { + background: #e2e8f0; +} + +.cs-btn-primary { + background: var(--color-primary); + border-color: var(--color-primary); + color: #ffffff; +} + +.cs-btn-primary:hover { + background: var(--color-primary-hover); + border-color: var(--color-primary-hover); +} + +.cs-btn-danger { + background: var(--color-danger); + border-color: var(--color-danger); + color: #ffffff; +} + +.cs-btn-danger:hover { + background: #b91c1c; + border-color: #b91c1c; +} + +.cs-btn-warning { + background: var(--color-warning); + border-color: var(--color-warning); + color: #ffffff; +} + +.cs-btn-warning:hover { + background: #b45309; +} + +.cs-btn-sm { + padding: 4px 8px; + font-size: 11px; +} + +.cs-badge { + display: inline-flex; + align-items: center; + padding: 2px 6px; + font-size: 10px; + font-weight: 600; + border-radius: var(--radius-full); + line-height: 1; +} + +.cs-badge-danger { + background: var(--color-danger-bg); + color: var(--color-danger); +} + +.cs-badge-warning { + background: var(--color-warning-bg); + color: var(--color-warning); +} + +.cs-badge-success { + background: var(--color-success-bg); + color: var(--color-success); +} + +.cs-badge-info { + background: var(--color-info-bg); + color: var(--color-info); +} + +/* Modal Overlay */ +.cs-modal-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(15, 23, 42, 0.65); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 5000; + padding: 20px; +} + +.cs-modal { + background: #ffffff; + border-radius: var(--radius-lg); + box-shadow: 0 20px 48px rgba(0, 0, 0, 0.4); + max-width: 560px; + width: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + animation: modalPop 0.2s ease-out; +} + +@keyframes modalPop { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.cs-modal-header { + padding: 14px 18px; + background: #0f172a; + color: #ffffff; + display: flex; + align-items: center; + justify-content: space-between; +} + +.cs-modal-title { + font-size: 14px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.cs-modal-body { + padding: 18px; + overflow-y: auto; + max-height: 70vh; + font-size: 13px; + line-height: 1.5; + color: #334155; +} + +.cs-modal-footer { + padding: 12px 18px; + background: #f8fafc; + border-top: 1px solid var(--border-medium); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +/* Forms */ +.cs-form-group { + margin-bottom: 14px; +} + +.cs-form-label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--text-main); + margin-bottom: 5px; +} + +.cs-input, .cs-select, .cs-textarea { + width: 100%; + padding: 8px 10px; + font-size: 12px; + font-family: inherit; + border: 1px solid var(--border-medium); + border-radius: var(--radius-sm); + background: #ffffff; + color: var(--text-main); + user-select: text; +} + +.cs-input:focus, .cs-select:focus, .cs-textarea:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-light); +} + +/* Tooltips */ +.cs-tooltip-target { + position: relative; + display: inline-block; +} + +.cs-link-inspector-tooltip { + position: absolute; + bottom: calc(100% + 6px); + left: 0; + background: #0f172a; + color: #ffffff; + padding: 6px 10px; + border-radius: var(--radius-sm); + font-size: 11px; + font-family: var(--font-mono); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + z-index: 100; + white-space: nowrap; + pointer-events: none; + border: 1px solid #334155; +} + +.cs-link-inspector-tooltip .label { + color: #94a3b8; + font-size: 10px; + display: block; + margin-bottom: 2px; + font-family: var(--font-system); +} diff --git a/src/css/desktop.css b/src/css/desktop.css index 7f959b2..ef0f271 100644 --- a/src/css/desktop.css +++ b/src/css/desktop.css @@ -1,553 +1,553 @@ -/* CyberSim OS - Desktop Shell, Windows, Taskbar, Start Menu, Toasts */ -* { - box-sizing: border-box; - margin: 0; - padding: 0; - user-select: none; -} - -body, html { - width: 100vw; - height: 100vh; - overflow: hidden; - font-family: var(--font-system); - font-size: 13px; - color: var(--text-main); - background: var(--bg-desktop); -} - -/* Desktop Area */ -#desktop { - position: relative; - width: 100vw; - height: calc(100vh - var(--taskbar-height)); - overflow: hidden; - background: radial-gradient(circle at 75% 25%, #1e3a5f 0%, #0f172a 65%, #080c14 100%); -} - -#desktop::after { - content: ""; - position: absolute; - top: 0; left: 0; right: 0; bottom: 0; - background-image: radial-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 0); - background-size: 28px 28px; - pointer-events: none; -} - -/* Desktop Icons */ -.desktop-icons { - position: absolute; - top: 14px; - left: 14px; - bottom: 14px; - display: flex; - flex-direction: column; - flex-wrap: wrap; - gap: 12px; - align-content: flex-start; - z-index: 5; -} - -.desktop-icon { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 82px; - height: 84px; - padding: 6px 4px; - border-radius: var(--radius-md); - cursor: pointer; - color: #ffffff; - text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); - transition: all 0.12s ease; - border: 1px solid transparent; -} - -.desktop-icon:hover { - background: rgba(255, 255, 255, 0.12); - border-color: rgba(255, 255, 255, 0.2); -} - -.desktop-icon.selected { - background: rgba(37, 99, 235, 0.35); - border-color: rgba(96, 165, 250, 0.6); -} - -.desktop-icon-img { - width: 38px; - height: 38px; - margin-bottom: 4px; - display: flex; - align-items: center; - justify-content: center; -} - -.desktop-icon-img svg { - width: 36px; - height: 36px; -} - -.desktop-icon-label { - font-size: 11px; - font-weight: 500; - text-align: center; - line-height: 1.2; - word-break: break-word; - max-width: 76px; -} - -/* Window Component */ -.cs-window { - position: absolute; - display: flex; - flex-direction: column; - background: var(--bg-window); - border: 1px solid var(--border-window); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-window); - overflow: hidden; - min-width: 440px; - min-height: 300px; - z-index: 10; -} - -.cs-window.active { - border-color: var(--border-window-active); - z-index: 20; -} - -.cs-window.minimized { - display: none !important; -} - -.cs-window.maximized { - top: 0 !important; - left: 0 !important; - width: 100vw !important; - height: calc(100vh - var(--taskbar-height)) !important; - border-radius: 0; - border: none; -} - -.cs-window-header { - height: 36px; - background: var(--bg-window-header-inactive); - color: var(--text-window-header-inactive); - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 10px; - cursor: grab; - user-select: none; - border-bottom: 1px solid rgba(0, 0, 0, 0.15); - transition: background 0.15s ease, color 0.15s ease; -} - -.cs-window.active .cs-window-header { - background: var(--bg-window-header); - color: var(--text-window-header-active); -} - -.cs-window-header:active { - cursor: grabbing; -} - -.cs-window-title { - display: flex; - align-items: center; - gap: 8px; - font-size: 12px; - font-weight: 600; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.cs-window-title svg { - width: 16px; - height: 16px; - flex-shrink: 0; -} - -.cs-window-controls { - display: flex; - align-items: center; - gap: 3px; -} - -.cs-btn-win { - width: 26px; - height: 22px; - background: transparent; - border: none; - border-radius: var(--radius-sm); - color: inherit; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: background 0.12s ease; -} - -.cs-btn-win:hover { - background: rgba(255, 255, 255, 0.18); -} - -.cs-btn-win.close:hover { - background: var(--color-danger); - color: #ffffff; -} - -.cs-window-body { - flex: 1; - overflow: hidden; - position: relative; - background: #ffffff; - display: flex; - flex-direction: column; -} - -/* Taskbar */ -#taskbar { - position: absolute; - bottom: 0; - left: 0; - width: 100vw; - height: var(--taskbar-height); - background: var(--bg-taskbar); - backdrop-filter: blur(16px); - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 6px; - border-top: 1px solid rgba(255, 255, 255, 0.12); - z-index: 1000; -} - -.taskbar-left, .taskbar-right { - display: flex; - align-items: center; - height: 100%; - gap: 4px; -} - -.taskbar-apps { - display: flex; - align-items: center; - gap: 4px; - height: 100%; - margin-left: 4px; -} - -.taskbar-btn { - display: flex; - align-items: center; - gap: 6px; - height: 34px; - padding: 0 10px; - border-radius: var(--radius-md); - background: var(--bg-taskbar-item); - color: #e2e8f0; - border: 1px solid transparent; - cursor: pointer; - font-size: 12px; - font-weight: 500; - transition: all 0.15s ease; -} - -.taskbar-btn:hover { - background: var(--bg-taskbar-item-hover); -} - -.taskbar-btn.active { - background: var(--bg-taskbar-item-active); - border-color: rgba(59, 130, 246, 0.6); - color: #ffffff; - border-bottom: 2px solid var(--color-primary); -} - -.taskbar-btn svg { - width: 16px; - height: 16px; -} - -.taskbar-btn.start-btn { - background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%); - color: #ffffff; - font-weight: 600; - padding: 0 12px; -} - -.taskbar-btn.start-btn:hover { - background: linear-gradient(135deg, #2563eb 0%, #3b82f6 100%); -} - -.taskbar-clock { - display: flex; - flex-direction: column; - align-items: flex-end; - justify-content: center; - padding: 0 8px; - height: 34px; - border-radius: var(--radius-md); - color: #e2e8f0; - cursor: pointer; -} - -.taskbar-clock:hover { - background: var(--bg-taskbar-item-hover); -} - -.taskbar-clock .time { - font-size: 12px; - font-weight: 600; - line-height: 1.1; -} - -.taskbar-clock .date { - font-size: 10px; - color: #94a3b8; - line-height: 1.1; -} - -.taskbar-tray-icon { - display: flex; - align-items: center; - justify-content: center; - width: 30px; - height: 34px; - border-radius: var(--radius-md); - color: #94a3b8; - cursor: pointer; - position: relative; -} - -.taskbar-tray-icon:hover { - background: var(--bg-taskbar-item-hover); - color: #ffffff; -} - -.taskbar-tray-icon .badge { - position: absolute; - top: 6px; - right: 5px; - width: 7px; - height: 7px; - background: var(--color-danger); - border-radius: 50%; -} - -/* Start Menu */ -#start-menu { - position: absolute; - bottom: calc(var(--taskbar-height) + 6px); - left: 6px; - width: 360px; - max-height: 520px; - background: var(--bg-start-menu); - border: 1px solid var(--border-start-menu); - border-radius: var(--radius-lg); - box-shadow: 0 18px 48px rgba(0, 0, 0, 0.65); - z-index: 1050; - display: none; - flex-direction: column; - overflow: hidden; - backdrop-filter: blur(20px); -} - -#start-menu.open { - display: flex; -} - -.start-header { - padding: 14px 16px; - background: rgba(15, 23, 42, 0.7); - border-bottom: 1px solid var(--border-start-menu); - display: flex; - align-items: center; - gap: 12px; -} - -.start-user-avatar { - width: 40px; - height: 40px; - border-radius: var(--radius-full); - background: linear-gradient(135deg, #2563eb 0%, #3b82f6 100%); - color: #ffffff; - display: flex; - align-items: center; - justify-content: center; - font-weight: 700; - font-size: 16px; -} - -.start-user-info .user-name { - font-weight: 600; - color: #f8fafc; - font-size: 13px; -} - -.start-user-info .user-role { - font-size: 11px; - color: #94a3b8; -} - -.start-body { - padding: 12px; - overflow-y: auto; - flex: 1; - display: flex; - flex-direction: column; - gap: 10px; -} - -.start-section-title { - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - color: #64748b; - letter-spacing: 0.05em; - padding: 0 4px; -} - -.start-app-list { - display: flex; - flex-direction: column; - gap: 2px; -} - -.start-app-item { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; - border-radius: var(--radius-md); - color: #e2e8f0; - cursor: pointer; - transition: background 0.12s ease; -} - -.start-app-item:hover { - background: rgba(255, 255, 255, 0.08); - color: #ffffff; -} - -.start-app-item svg { - width: 22px; - height: 22px; - flex-shrink: 0; -} - -.start-footer { - padding: 10px 14px; - background: rgba(15, 23, 42, 0.85); - border-top: 1px solid var(--border-start-menu); - display: flex; - align-items: center; - justify-content: space-between; -} - -.start-btn-finish { - background: #dc2626; - color: #ffffff; - border: none; - padding: 7px 12px; - border-radius: var(--radius-md); - font-size: 11px; - font-weight: 600; - cursor: pointer; - transition: background 0.15s ease; -} - -.start-btn-finish:hover { - background: #b91c1c; -} - -/* Toast Notifications Container */ -#notification-container { - position: absolute; - bottom: calc(var(--taskbar-height) + 12px); - right: 12px; - display: flex; - flex-direction: column-reverse; - gap: 8px; - z-index: 2000; - pointer-events: none; - max-width: 350px; -} - -.cs-toast { - pointer-events: auto; - background: #0f172a; - color: #f8fafc; - border: 1px solid #334155; - border-left: 4px solid var(--color-primary); - border-radius: var(--radius-md); - padding: 10px 12px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); - display: flex; - gap: 10px; - align-items: flex-start; - animation: toastIn 0.25s ease-out; - cursor: pointer; - transition: transform 0.15s ease, opacity 0.15s ease; -} - -.cs-toast.warning { - border-left-color: var(--color-warning); -} - -.cs-toast.danger { - border-left-color: var(--color-danger); -} - -.cs-toast.success { - border-left-color: var(--color-success); -} - -@keyframes toastIn { - from { - transform: translateX(100%); - opacity: 0; - } - to { - transform: translateX(0); - opacity: 1; - } -} - -.cs-toast-icon { - width: 20px; - height: 20px; - flex-shrink: 0; - margin-top: 1px; -} - -.cs-toast-content { - flex: 1; -} - -.cs-toast-title { - font-size: 12px; - font-weight: 600; - margin-bottom: 2px; -} - -.cs-toast-body { - font-size: 11px; - color: #94a3b8; - line-height: 1.35; -} - -.cs-toast-close { - background: transparent; - border: none; - color: #64748b; - cursor: pointer; - padding: 2px; -} - -.cs-toast-close:hover { - color: #ffffff; -} +/* CyberSim OS - Desktop Shell, Windows, Taskbar, Start Menu, Toasts */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; + user-select: none; +} + +body, html { + width: 100vw; + height: 100vh; + overflow: hidden; + font-family: var(--font-system); + font-size: 13px; + color: var(--text-main); + background: var(--bg-desktop); +} + +/* Desktop Area */ +#desktop { + position: relative; + width: 100vw; + height: calc(100vh - var(--taskbar-height)); + overflow: hidden; + background: radial-gradient(circle at 75% 25%, #1e3a5f 0%, #0f172a 65%, #080c14 100%); +} + +#desktop::after { + content: ""; + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + background-image: radial-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 0); + background-size: 28px 28px; + pointer-events: none; +} + +/* Desktop Icons */ +.desktop-icons { + position: absolute; + top: 14px; + left: 14px; + bottom: 14px; + display: flex; + flex-direction: column; + flex-wrap: wrap; + gap: 12px; + align-content: flex-start; + z-index: 5; +} + +.desktop-icon { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 82px; + height: 84px; + padding: 6px 4px; + border-radius: var(--radius-md); + cursor: pointer; + color: #ffffff; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); + transition: all 0.12s ease; + border: 1px solid transparent; +} + +.desktop-icon:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.2); +} + +.desktop-icon.selected { + background: rgba(37, 99, 235, 0.35); + border-color: rgba(96, 165, 250, 0.6); +} + +.desktop-icon-img { + width: 38px; + height: 38px; + margin-bottom: 4px; + display: flex; + align-items: center; + justify-content: center; +} + +.desktop-icon-img svg { + width: 36px; + height: 36px; +} + +.desktop-icon-label { + font-size: 11px; + font-weight: 500; + text-align: center; + line-height: 1.2; + word-break: break-word; + max-width: 76px; +} + +/* Window Component */ +.cs-window { + position: absolute; + display: flex; + flex-direction: column; + background: var(--bg-window); + border: 1px solid var(--border-window); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-window); + overflow: hidden; + min-width: 440px; + min-height: 300px; + z-index: 10; +} + +.cs-window.active { + border-color: var(--border-window-active); + z-index: 20; +} + +.cs-window.minimized { + display: none !important; +} + +.cs-window.maximized { + top: 0 !important; + left: 0 !important; + width: 100vw !important; + height: calc(100vh - var(--taskbar-height)) !important; + border-radius: 0; + border: none; +} + +.cs-window-header { + height: 36px; + background: var(--bg-window-header-inactive); + color: var(--text-window-header-inactive); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 10px; + cursor: grab; + user-select: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.15); + transition: background 0.15s ease, color 0.15s ease; +} + +.cs-window.active .cs-window-header { + background: var(--bg-window-header); + color: var(--text-window-header-active); +} + +.cs-window-header:active { + cursor: grabbing; +} + +.cs-window-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cs-window-title svg { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.cs-window-controls { + display: flex; + align-items: center; + gap: 3px; +} + +.cs-btn-win { + width: 26px; + height: 22px; + background: transparent; + border: none; + border-radius: var(--radius-sm); + color: inherit; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.12s ease; +} + +.cs-btn-win:hover { + background: rgba(255, 255, 255, 0.18); +} + +.cs-btn-win.close:hover { + background: var(--color-danger); + color: #ffffff; +} + +.cs-window-body { + flex: 1; + overflow: hidden; + position: relative; + background: #ffffff; + display: flex; + flex-direction: column; +} + +/* Taskbar */ +#taskbar { + position: absolute; + bottom: 0; + left: 0; + width: 100vw; + height: var(--taskbar-height); + background: var(--bg-taskbar); + backdrop-filter: blur(16px); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 6px; + border-top: 1px solid rgba(255, 255, 255, 0.12); + z-index: 1000; +} + +.taskbar-left, .taskbar-right { + display: flex; + align-items: center; + height: 100%; + gap: 4px; +} + +.taskbar-apps { + display: flex; + align-items: center; + gap: 4px; + height: 100%; + margin-left: 4px; +} + +.taskbar-btn { + display: flex; + align-items: center; + gap: 6px; + height: 34px; + padding: 0 10px; + border-radius: var(--radius-md); + background: var(--bg-taskbar-item); + color: #e2e8f0; + border: 1px solid transparent; + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: all 0.15s ease; +} + +.taskbar-btn:hover { + background: var(--bg-taskbar-item-hover); +} + +.taskbar-btn.active { + background: var(--bg-taskbar-item-active); + border-color: rgba(59, 130, 246, 0.6); + color: #ffffff; + border-bottom: 2px solid var(--color-primary); +} + +.taskbar-btn svg { + width: 16px; + height: 16px; +} + +.taskbar-btn.start-btn { + background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%); + color: #ffffff; + font-weight: 600; + padding: 0 12px; +} + +.taskbar-btn.start-btn:hover { + background: linear-gradient(135deg, #2563eb 0%, #3b82f6 100%); +} + +.taskbar-clock { + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: center; + padding: 0 8px; + height: 34px; + border-radius: var(--radius-md); + color: #e2e8f0; + cursor: pointer; +} + +.taskbar-clock:hover { + background: var(--bg-taskbar-item-hover); +} + +.taskbar-clock .time { + font-size: 12px; + font-weight: 600; + line-height: 1.1; +} + +.taskbar-clock .date { + font-size: 10px; + color: #94a3b8; + line-height: 1.1; +} + +.taskbar-tray-icon { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 34px; + border-radius: var(--radius-md); + color: #94a3b8; + cursor: pointer; + position: relative; +} + +.taskbar-tray-icon:hover { + background: var(--bg-taskbar-item-hover); + color: #ffffff; +} + +.taskbar-tray-icon .badge { + position: absolute; + top: 6px; + right: 5px; + width: 7px; + height: 7px; + background: var(--color-danger); + border-radius: 50%; +} + +/* Start Menu */ +#start-menu { + position: absolute; + bottom: calc(var(--taskbar-height) + 6px); + left: 6px; + width: 360px; + max-height: 520px; + background: var(--bg-start-menu); + border: 1px solid var(--border-start-menu); + border-radius: var(--radius-lg); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.65); + z-index: 1050; + display: none; + flex-direction: column; + overflow: hidden; + backdrop-filter: blur(20px); +} + +#start-menu.open { + display: flex; +} + +.start-header { + padding: 14px 16px; + background: rgba(15, 23, 42, 0.7); + border-bottom: 1px solid var(--border-start-menu); + display: flex; + align-items: center; + gap: 12px; +} + +.start-user-avatar { + width: 40px; + height: 40px; + border-radius: var(--radius-full); + background: linear-gradient(135deg, #2563eb 0%, #3b82f6 100%); + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 16px; +} + +.start-user-info .user-name { + font-weight: 600; + color: #f8fafc; + font-size: 13px; +} + +.start-user-info .user-role { + font-size: 11px; + color: #94a3b8; +} + +.start-body { + padding: 12px; + overflow-y: auto; + flex: 1; + display: flex; + flex-direction: column; + gap: 10px; +} + +.start-section-title { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + color: #64748b; + letter-spacing: 0.05em; + padding: 0 4px; +} + +.start-app-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.start-app-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: var(--radius-md); + color: #e2e8f0; + cursor: pointer; + transition: background 0.12s ease; +} + +.start-app-item:hover { + background: rgba(255, 255, 255, 0.08); + color: #ffffff; +} + +.start-app-item svg { + width: 22px; + height: 22px; + flex-shrink: 0; +} + +.start-footer { + padding: 10px 14px; + background: rgba(15, 23, 42, 0.85); + border-top: 1px solid var(--border-start-menu); + display: flex; + align-items: center; + justify-content: space-between; +} + +.start-btn-finish { + background: #dc2626; + color: #ffffff; + border: none; + padding: 7px 12px; + border-radius: var(--radius-md); + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease; +} + +.start-btn-finish:hover { + background: #b91c1c; +} + +/* Toast Notifications Container */ +#notification-container { + position: absolute; + bottom: calc(var(--taskbar-height) + 12px); + right: 12px; + display: flex; + flex-direction: column-reverse; + gap: 8px; + z-index: 2000; + pointer-events: none; + max-width: 350px; +} + +.cs-toast { + pointer-events: auto; + background: #0f172a; + color: #f8fafc; + border: 1px solid #334155; + border-left: 4px solid var(--color-primary); + border-radius: var(--radius-md); + padding: 10px 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + display: flex; + gap: 10px; + align-items: flex-start; + animation: toastIn 0.25s ease-out; + cursor: pointer; + transition: transform 0.15s ease, opacity 0.15s ease; +} + +.cs-toast.warning { + border-left-color: var(--color-warning); +} + +.cs-toast.danger { + border-left-color: var(--color-danger); +} + +.cs-toast.success { + border-left-color: var(--color-success); +} + +@keyframes toastIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.cs-toast-icon { + width: 20px; + height: 20px; + flex-shrink: 0; + margin-top: 1px; +} + +.cs-toast-content { + flex: 1; +} + +.cs-toast-title { + font-size: 12px; + font-weight: 600; + margin-bottom: 2px; +} + +.cs-toast-body { + font-size: 11px; + color: #94a3b8; + line-height: 1.35; +} + +.cs-toast-close { + background: transparent; + border: none; + color: #64748b; + cursor: pointer; + padding: 2px; +} + +.cs-toast-close:hover { + color: #ffffff; +} diff --git a/src/css/theme-windows.css b/src/css/theme-windows.css index 86d7df5..fd7747c 100644 --- a/src/css/theme-windows.css +++ b/src/css/theme-windows.css @@ -1,47 +1,47 @@ -/* CyberSim OS - Theme Windows (Original Enterprise Theme) */ -:root { - --bg-desktop: #1a2332; - --bg-taskbar: rgba(15, 23, 42, 0.95); - --bg-taskbar-item: rgba(255, 255, 255, 0.08); - --bg-taskbar-item-active: rgba(255, 255, 255, 0.2); - --bg-taskbar-item-hover: rgba(255, 255, 255, 0.12); - --bg-start-menu: #1e293b; - --border-start-menu: #334155; - - --bg-window: #ffffff; - --bg-window-header: #0f172a; - --bg-window-header-inactive: #334155; - --text-window-header-active: #ffffff; - --text-window-header-inactive: #94a3b8; - --border-window: #94a3b8; - --border-window-active: #3b82f6; - --shadow-window: 0 12px 32px rgba(0, 0, 0, 0.4), 0 2px 6px rgba(0, 0, 0, 0.2); - - --color-primary: #2563eb; - --color-primary-hover: #1d4ed8; - --color-primary-light: #eff6ff; - --color-success: #16a34a; - --color-success-bg: #dcfce7; - --color-warning: #d97706; - --color-warning-bg: #fef3c7; - --color-danger: #dc2626; - --color-danger-bg: #fee2e2; - --color-info: #0284c7; - --color-info-bg: #e0f2fe; - - --text-main: #0f172a; - --text-muted: #64748b; - --text-inverse: #ffffff; - --border-subtle: #f1f5f9; - --border-medium: #cbd5e1; - - --font-system: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - - --radius-sm: 4px; - --radius-md: 6px; - --radius-lg: 10px; - --radius-full: 9999px; - - --taskbar-height: 44px; -} +/* CyberSim OS - Theme Windows (Original Enterprise Theme) */ +:root { + --bg-desktop: #1a2332; + --bg-taskbar: rgba(15, 23, 42, 0.95); + --bg-taskbar-item: rgba(255, 255, 255, 0.08); + --bg-taskbar-item-active: rgba(255, 255, 255, 0.2); + --bg-taskbar-item-hover: rgba(255, 255, 255, 0.12); + --bg-start-menu: #1e293b; + --border-start-menu: #334155; + + --bg-window: #ffffff; + --bg-window-header: #0f172a; + --bg-window-header-inactive: #334155; + --text-window-header-active: #ffffff; + --text-window-header-inactive: #94a3b8; + --border-window: #94a3b8; + --border-window-active: #3b82f6; + --shadow-window: 0 12px 32px rgba(0, 0, 0, 0.4), 0 2px 6px rgba(0, 0, 0, 0.2); + + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-primary-light: #eff6ff; + --color-success: #16a34a; + --color-success-bg: #dcfce7; + --color-warning: #d97706; + --color-warning-bg: #fef3c7; + --color-danger: #dc2626; + --color-danger-bg: #fee2e2; + --color-info: #0284c7; + --color-info-bg: #e0f2fe; + + --text-main: #0f172a; + --text-muted: #64748b; + --text-inverse: #ffffff; + --border-subtle: #f1f5f9; + --border-medium: #cbd5e1; + + --font-system: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 10px; + --radius-full: 9999px; + + --taskbar-height: 44px; +} diff --git a/src/index.html b/src/index.html index 9a09119..52b6792 100644 --- a/src/index.html +++ b/src/index.html @@ -1,73 +1,115 @@ - - - - - - CyberSim OS - Enterprise Simulation Environment - - - - - - - - -
-
-
-
- - -
-
-
JT
- -
- -
-
Shift Responsibilities
-
- • Review Inlook communications & tasks
- • Inspect Q3 Budget Forecast in Documents
- • Review NexaCore Cybersecurity Policy v4
- • Report suspicious incidents to Security Center -
- -
Applications
-
-
- - -
- - -
-
- -
-
- -
-
- -
-
- - -
-
-
-
- - - - + + + + + + CyberSim OS - Enterprise Simulation Environment + + + + + + + + +
+
+
+
+ + +
+
+
??
+ +
+ +
+
Shift Responsibilities
+
+ Loading scenario objectives... +
+ +
Applications
+
+
+ + +
+ + +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
+
+ + + + + + + diff --git a/src/js/apps/docviewer.js b/src/js/apps/docviewer.js index 3276a48..3bba6fb 100644 --- a/src/js/apps/docviewer.js +++ b/src/js/apps/docviewer.js @@ -1,123 +1,123 @@ -/** - * CyberSim OS - Document & Spreadsheet Viewer - */ - -export class DocViewerApp { - constructor({ windowManager, eventBus }) { - this.wm = windowManager; - this.eventBus = eventBus; - } - - getIconSvg() { - return ``; - } - - openDocument(file) { - const win = this.wm.createWindow({ - id: `doc_${file.id}`, - title: `${file.name} - Document Viewer`, - iconSvg: this.getIconSvg(), - width: 820, - height: 600, - bodyContent: this.renderDocument(file) - }); - - this.eventBus.emit('DOC_VIEWED', { - target: file.name, - details: { fileId: file.id, type: file.type } - }); - } - - renderDocument(file) { - if (file.type === 'spreadsheet' && file.content) { - return ` -
-
-
- 📊 ${file.name} - Read-Only -
-
Format: XLSX Tabular
-
-
-
-

${file.content.title}

-

Confidential — Internal Use Only — Prepared for NexaCore Leadership

- - - - ${file.content.headers.map(h => ``).join('')} - - - - ${file.content.rows.map(row => ` - - ${row.map((cell, idx) => ``).join('')} - - `).join('')} - -
${h}
${cell}
-
- Notes: Operating margin buffer is currently aligned with Q2 audit targets. Prepared by Finance Ops. -
-
-
-
- `; - } - - if (file.type === 'pdf' && file.content) { - return ` -
-
-
- 📑 ${file.name} - NexaCore Document -
-
Format: PDF Document
-
-
-
-

${file.content.title}

- ${file.content.sections ? file.content.sections.map(s => ` -

${s.heading}

-

${s.text}

- `).join('') : ''} - - ${file.content.table ? ` - - - - ${file.content.table.headers.map(h => ``).join('')} - - - - ${file.content.table.rows.map(r => ` - - ${r.map(c => ``).join('')} - - `).join('')} - -
${h}
${c}
- ` : ''} -
-
-
- `; - } - - return ` -
-
-
${file.name}
-
-
-
-

Archive Preview

-

Archive file "${file.name}" contents cannot be executed directly within simulated document viewer.

-
-
-
- `; - } -} +/** + * CyberSim OS - Document & Spreadsheet Viewer + */ + +export class DocViewerApp { + constructor({ windowManager, eventBus }) { + this.wm = windowManager; + this.eventBus = eventBus; + } + + getIconSvg() { + return ``; + } + + openDocument(file) { + const win = this.wm.createWindow({ + id: `doc_${file.id}`, + title: `${file.name} - Document Viewer`, + iconSvg: this.getIconSvg(), + width: 820, + height: 600, + bodyContent: this.renderDocument(file) + }); + + this.eventBus.emit('DOC_VIEWED', { + target: file.name, + details: { fileId: file.id, type: file.type } + }); + } + + renderDocument(file) { + if (file.type === 'spreadsheet' && file.content) { + return ` +
+
+
+ 📊 ${file.name} + Read-Only +
+
Format: XLSX Tabular
+
+
+
+

${file.content.title}

+

Confidential — Internal Use Only — Prepared for NexaCore Leadership

+ + + + ${file.content.headers.map(h => ``).join('')} + + + + ${file.content.rows.map(row => ` + + ${row.map((cell, idx) => ``).join('')} + + `).join('')} + +
${h}
${cell}
+
+ Notes: Operating margin buffer is currently aligned with Q2 audit targets. Prepared by Finance Ops. +
+
+
+
+ `; + } + + if (file.type === 'pdf' && file.content) { + return ` +
+
+
+ 📑 ${file.name} + NexaCore Document +
+
Format: PDF Document
+
+
+
+

${file.content.title}

+ ${file.content.sections ? file.content.sections.map(s => ` +

${s.heading}

+

${s.text}

+ `).join('') : ''} + + ${file.content.table ? ` + + + + ${file.content.table.headers.map(h => ``).join('')} + + + + ${file.content.table.rows.map(r => ` + + ${r.map(c => ``).join('')} + + `).join('')} + +
${h}
${c}
+ ` : ''} +
+
+
+ `; + } + + return ` +
+
+
${file.name}
+
+
+
+

Archive Preview

+

Archive file "${file.name}" contents cannot be executed directly within simulated document viewer.

+
+
+
+ `; + } +} diff --git a/src/js/apps/files.js b/src/js/apps/files.js index 6ab9ee9..f55ea0d 100644 --- a/src/js/apps/files.js +++ b/src/js/apps/files.js @@ -1,124 +1,124 @@ -/** - * CyberSim OS - Files Virtual File Explorer - */ - -export class FilesApp { - constructor({ windowManager, eventBus, notifications, scenario, onOpenFile }) { - this.wm = windowManager; - this.eventBus = eventBus; - this.notifications = notifications; - this.scenario = scenario; - this.onOpenFile = onOpenFile; - this.currentFolder = 'Documents'; - this.files = JSON.parse(JSON.stringify(scenario.files)); - } - - getIconSvg() { - return ``; - } - - launch(initialFolder = 'Documents') { - this.currentFolder = initialFolder; - const win = this.wm.createWindow({ - id: 'files', - title: 'Files - Corporate Storage', - iconSvg: this.getIconSvg(), - width: 780, - height: 500, - bodyContent: this.renderShell() - }); - - this.bindEvents(win.bodyElement); - this.renderFolderContents(); - this.eventBus.emit('APP_OPENED', { target: 'files' }); - } - - renderShell() { - return ` -
-
-
- [Docs] Documents -
-
- [Down] Downloads -
-
- [Share] Company Shared -
-
-
-
Location: /Documents
-
-
-
- `; - } - - bindEvents(container) { - container.querySelectorAll('.files-folder-btn').forEach(btn => { - btn.addEventListener('click', () => { - container.querySelectorAll('.files-folder-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - this.currentFolder = btn.dataset.folder; - this.renderFolderContents(); - }); - }); - } - - addFile(fileObj) { - this.files.push(fileObj); - this.renderFolderContents(); - } - - renderFolderContents() { - const pathEl = document.getElementById('files-current-path'); - const gridEl = document.getElementById('files-grid'); - if (pathEl) pathEl.innerText = `Location: /${this.currentFolder}`; - if (!gridEl) return; - - const filtered = this.files.filter(f => f.folder === this.currentFolder); - - if (filtered.length === 0) { - gridEl.innerHTML = `
This folder is empty.
`; - return; - } - - gridEl.innerHTML = filtered.map(f => { - let icon = 'FILE'; - if (f.type === 'spreadsheet') icon = 'XLSX'; - if (f.type === 'pdf') icon = 'PDF'; - if (f.type === 'archive') icon = 'ZIP'; - if (f.type === 'executable') icon = 'EXE'; - - return ` -
-
[${icon}]
-
${f.name}
-
${f.size}
-
- `; - }).join(''); - - gridEl.querySelectorAll('.file-item').forEach(item => { - item.addEventListener('click', () => { - gridEl.querySelectorAll('.file-item').forEach(i => i.classList.remove('selected')); - item.classList.add('selected'); - }); - - item.addEventListener('dblclick', () => { - const fileId = item.dataset.fileId; - const fileObj = this.files.find(f => f.id === fileId); - if (fileObj) { - this.eventBus.emit('FILE_OPENED', { - target: fileObj.name, - details: { fileId: fileObj.id, folder: fileObj.folder, type: fileObj.type } - }); - if (this.onOpenFile) { - this.onOpenFile(fileObj); - } - } - }); - }); - } -} +/** + * CyberSim OS - Files Virtual File Explorer + */ + +export class FilesApp { + constructor({ windowManager, eventBus, notifications, scenario, onOpenFile }) { + this.wm = windowManager; + this.eventBus = eventBus; + this.notifications = notifications; + this.scenario = scenario; + this.onOpenFile = onOpenFile; + this.currentFolder = 'Documents'; + this.files = JSON.parse(JSON.stringify(scenario.files)); + } + + getIconSvg() { + return ``; + } + + launch(initialFolder = 'Documents') { + this.currentFolder = initialFolder; + const win = this.wm.createWindow({ + id: 'files', + title: 'Files - Corporate Storage', + iconSvg: this.getIconSvg(), + width: 780, + height: 500, + bodyContent: this.renderShell() + }); + + this.bindEvents(win.bodyElement); + this.renderFolderContents(); + this.eventBus.emit('APP_OPENED', { target: 'files' }); + } + + renderShell() { + return ` +
+
+
+ [Docs] Documents +
+
+ [Down] Downloads +
+
+ [Share] Company Shared +
+
+
+
Location: /Documents
+
+
+
+ `; + } + + bindEvents(container) { + container.querySelectorAll('.files-folder-btn').forEach(btn => { + btn.addEventListener('click', () => { + container.querySelectorAll('.files-folder-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + this.currentFolder = btn.dataset.folder; + this.renderFolderContents(); + }); + }); + } + + addFile(fileObj) { + this.files.push(fileObj); + this.renderFolderContents(); + } + + renderFolderContents() { + const pathEl = document.getElementById('files-current-path'); + const gridEl = document.getElementById('files-grid'); + if (pathEl) pathEl.innerText = `Location: /${this.currentFolder}`; + if (!gridEl) return; + + const filtered = this.files.filter(f => f.folder === this.currentFolder); + + if (filtered.length === 0) { + gridEl.innerHTML = `
This folder is empty.
`; + return; + } + + gridEl.innerHTML = filtered.map(f => { + let icon = 'FILE'; + if (f.type === 'spreadsheet') icon = 'XLSX'; + if (f.type === 'pdf') icon = 'PDF'; + if (f.type === 'archive') icon = 'ZIP'; + if (f.type === 'executable') icon = 'EXE'; + + return ` +
+
[${icon}]
+
${f.name}
+
${f.size}
+
+ `; + }).join(''); + + gridEl.querySelectorAll('.file-item').forEach(item => { + item.addEventListener('click', () => { + gridEl.querySelectorAll('.file-item').forEach(i => i.classList.remove('selected')); + item.classList.add('selected'); + }); + + item.addEventListener('dblclick', () => { + const fileId = item.dataset.fileId; + const fileObj = this.files.find(f => f.id === fileId); + if (fileObj) { + this.eventBus.emit('FILE_OPENED', { + target: fileObj.name, + details: { fileId: fileObj.id, folder: fileObj.folder, type: fileObj.type } + }); + if (this.onOpenFile) { + this.onOpenFile(fileObj); + } + } + }); + }); + } +} diff --git a/src/js/apps/inlook.js b/src/js/apps/inlook.js index 583d629..268248f 100644 --- a/src/js/apps/inlook.js +++ b/src/js/apps/inlook.js @@ -1,417 +1,470 @@ -/** - * CyberSim OS - Inlook Email Application - */ - -export class InlookApp { - constructor({ windowManager, eventBus, notifications, scenario, onNavigateUrl, onOpenDoc, onFileDownloaded }) { - this.wm = windowManager; - this.eventBus = eventBus; - this.notifications = notifications; - this.scenario = scenario; - this.onNavigateUrl = onNavigateUrl; - this.onOpenDoc = onOpenDoc; - this.onFileDownloaded = onFileDownloaded; - this.currentFolder = 'inbox'; - this.activeEmailId = null; - this.emails = JSON.parse(JSON.stringify(scenario.emails)); - } - - getIconSvg() { - return ``; - } - - launch() { - const win = this.wm.createWindow({ - id: 'inlook', - title: 'Inlook Mail - NexaCore Workplace', - iconSvg: this.getIconSvg(), - width: 860, - height: 560, - bodyContent: this.renderShell() - }); - - this.bindEvents(win.bodyElement); - this.selectFirstEmail(); - this.eventBus.emit('APP_OPENED', { target: 'inlook' }); - } - - renderShell() { - return ` -
-
- -
- 📥 Inbox - 0 -
-
- 📤 Sent -
-
- 🗑️ Trash -
-
-
-
-
- Select an email message to view its contents. -
-
-
- `; - } - - bindEvents(container) { - container.querySelectorAll('.inlook-folder-item').forEach(el => { - el.addEventListener('click', () => { - container.querySelectorAll('.inlook-folder-item').forEach(f => f.classList.remove('active')); - el.classList.add('active'); - this.currentFolder = el.dataset.folder; - this.renderEmailList(); - }); - }); - - const composeBtn = container.querySelector('#inlook-compose-btn'); - if (composeBtn) { - composeBtn.addEventListener('click', () => { - this.notifications.show({ - title: 'Inlook Mail', - body: 'Corporate policy restricts unassigned outgoing mail during initial shift orientation.', - type: 'info' - }); - }); - } - - this.renderEmailList(); - } - - renderEmailList() { - const listEl = document.getElementById('inlook-email-list'); - if (!listEl) return; - - const filtered = this.emails.filter(e => e.folder === this.currentFolder); - const unread = this.emails.filter(e => e.folder === 'inbox' && e.unread).length; - const badge = document.getElementById('inlook-unread-count'); - if (badge) badge.innerText = unread; - - if (filtered.length === 0) { - listEl.innerHTML = `
No messages in ${this.currentFolder}
`; - return; - } - - listEl.innerHTML = filtered.map(email => ` -
-
- ${email.sender.split('(')[0]} - ${email.date} -
-
${email.subject}
-
${email.body.replace(/<[^>]*>?/gm, '').substring(0, 48)}...
-
- `).join(''); - - listEl.querySelectorAll('.inlook-list-item').forEach(item => { - item.addEventListener('click', () => { - const id = item.dataset.emailId; - this.openEmail(id); - }); - }); - } - - selectFirstEmail() { - const inboxEmails = this.emails.filter(e => e.folder === 'inbox'); - if (inboxEmails.length > 0) { - this.openEmail(inboxEmails[0].id); - } - } - - openEmail(id) { - const email = this.emails.find(e => e.id === id); - if (!email) return; - - this.activeEmailId = id; - email.unread = false; - this.renderEmailList(); - - this.eventBus.emit('EMAIL_OPENED', { - target: id, - details: { subject: email.subject, rfcSender: email.rfcSender, isThreat: !!email.isThreat } - }); - - const readingPane = document.getElementById('inlook-reading-pane'); - if (!readingPane) return; - - readingPane.innerHTML = ` -
- - - -
- -
-
${email.subject}
-
-
-
${email.sender.charAt(0)}
-
-
${email.sender}
-
${email.rfcSender}
-
- -
-
${email.date}
-
- - ${email.attachments && email.attachments.length > 0 ? ` -
- Attachments (${email.attachments.length}): - ${email.attachments.map(att => ` -
- - ${att.name} - (${att.size}) -
- `).join('')} -
- ` : ''} -
- -
- ${email.body.replace(/\n/g, '
')} -
- `; - - this.bindEmailActions(email); - } - - bindEmailActions(email) { - const inspectBtn = document.getElementById('btn-inspect-header'); - if (inspectBtn) { - inspectBtn.addEventListener('click', () => { - this.eventBus.emit('EMAIL_INSPECTED_SENDER', { - target: email.id, - details: { displaySender: email.sender, rfcSender: email.rfcSender } - }); - - const isDomainMismatch = email.rfcSender.includes('nexac0re-portal.com') || email.rfcSender.includes('apex-global-supplies.net'); - - 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: \n\n${isDomainMismatch ? '⚠️ Notice: Envelope domain differs from standard corporate (@nexacore.internal).' : '✓ Envelope domain matches internal enterprise domain.'}`); - }); - } - - const links = document.querySelectorAll('.inlook-msg-link'); - links.forEach(link => { - const actualUrl = link.dataset.url; - const displayText = link.dataset.display || link.innerText; - - link.title = `Simulated Target: ${actualUrl}`; - - link.addEventListener('mouseenter', () => { - this.eventBus.emit('EMAIL_LINK_HOVERED', { - target: email.id, - details: { displayText, actualUrl } - }); - }); - - link.addEventListener('click', (e) => { - e.preventDefault(); - this.eventBus.emit('EMAIL_LINK_CLICKED', { - target: email.id, - details: { displayText, actualUrl, isPhishing: actualUrl.includes('nexac0re-portal.com') } - }); - - if (this.onNavigateUrl) { - this.onNavigateUrl(actualUrl); - } - }); - }); - - const attChips = document.querySelectorAll('.inlook-attachment-chip'); - attChips.forEach(chip => { - chip.addEventListener('click', () => { - const attName = chip.dataset.attName; - const attSize = chip.dataset.attSize; - - this.eventBus.emit('EMAIL_ATTACHMENT_OPENED', { - target: email.id, - details: { attachmentName: attName, attachmentSize: attSize } - }); - - if (this.onFileDownloaded) { - this.onFileDownloaded({ - id: `down_${Date.now()}`, - name: attName, - type: 'archive', - folder: 'Downloads', - size: attSize, - date: new Date().toISOString().split('T')[0] - }); - } - - this.notifications.show({ - title: 'Download Complete', - body: `File "${attName}" saved to Downloads folder.`, - type: 'warning' - }); - }); - }); - - const reportBtn = document.getElementById('btn-inlook-report'); - if (reportBtn) { - reportBtn.addEventListener('click', () => { - this.openReportDialog(email); - }); - } - - const replyBtn = document.getElementById('btn-inlook-reply'); - if (replyBtn) { - replyBtn.addEventListener('click', () => { - this.openReplyDialog(email); - }); - } - - const deleteBtn = document.getElementById('btn-inlook-delete'); - if (deleteBtn) { - deleteBtn.addEventListener('click', () => { - email.folder = 'trash'; - this.renderEmailList(); - this.eventBus.emit('EMAIL_DELETED', { - target: email.id, - details: { subject: email.subject, wasThreat: !!email.isThreat } - }); - this.selectFirstEmail(); - this.notifications.show({ - title: 'Inlook Mail', - body: 'Message moved to Trash.', - type: 'info' - }); - }); - } - } - - openReportDialog(email) { - const modalOverlay = document.createElement('div'); - modalOverlay.className = 'cs-modal-overlay'; - modalOverlay.innerHTML = ` -
-
-
- - Report Suspicious Message to SOC -
- -
-
-

You are about to report the following email to the NexaCore Security Operations Center:

-
- Subject: ${email.subject}
- Sender: ${email.rfcSender} -
-
- - -
-
- - -
-
- -
- `; - - document.body.appendChild(modalOverlay); - const close = () => modalOverlay.remove(); - modalOverlay.querySelector('#btn-close-modal').addEventListener('click', close); - modalOverlay.querySelector('#btn-cancel-report').addEventListener('click', close); - - modalOverlay.querySelector('#btn-submit-report').addEventListener('click', () => { - const reason = modalOverlay.querySelector('#report-reason').value; - const notes = modalOverlay.querySelector('#report-notes').value; - - this.eventBus.emit('EMAIL_REPORTED', { - target: email.id, - details: { reason, notes, isThreat: !!email.isThreat, threatId: email.threatId || null } - }); - - email.folder = 'trash'; - this.renderEmailList(); - this.selectFirstEmail(); - close(); - - this.notifications.show({ - title: 'Security Center Report Acknowledged', - body: `Incident report for "${email.subject.substring(0, 30)}..." received by SOC.`, - type: 'success' - }); - }); - } - - openReplyDialog(email) { - const modalOverlay = document.createElement('div'); - modalOverlay.className = 'cs-modal-overlay'; - modalOverlay.innerHTML = ` -
-
-
Reply to: ${email.sender}
- -
-
-
- - -
-
- - -
-
- - -
-
- -
- `; - - document.body.appendChild(modalOverlay); - const close = () => modalOverlay.remove(); - modalOverlay.querySelector('#btn-close-reply').addEventListener('click', close); - modalOverlay.querySelector('#btn-cancel-reply').addEventListener('click', close); - - modalOverlay.querySelector('#btn-send-reply').addEventListener('click', () => { - const text = modalOverlay.querySelector('#reply-text').value; - this.eventBus.emit('EMAIL_REPLIED', { - target: email.id, - details: { responseText: text, recipient: email.rfcSender } - }); - close(); - this.notifications.show({ - title: 'Inlook Mail', - body: `Reply sent to ${email.sender.split('(')[0]}.`, - type: 'success' - }); - }); - } -} +/** + * 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 { + constructor({ windowManager, eventBus, notifications, scenario, onNavigateUrl, onOpenDoc, onFileDownloaded }) { + this.wm = windowManager; + this.eventBus = eventBus; + this.notifications = notifications; + this.scenario = scenario; + this.onNavigateUrl = onNavigateUrl; + this.onOpenDoc = onOpenDoc; + this.onFileDownloaded = onFileDownloaded; + this.currentFolder = 'inbox'; + this.activeEmailId = null; + + // 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() { + return ``; + } + + /** + * 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() { + const win = this.wm.createWindow({ + id: 'inlook', + title: 'Inlook Mail - Workplace', + iconSvg: this.getIconSvg(), + width: 860, + height: 560, + bodyContent: this.renderShell() + }); + + this.bindEvents(win.bodyElement); + this.selectFirstEmail(); + this.eventBus.emit('APP_OPENED', { target: 'inlook' }); + } + + renderShell() { + return ` +
+
+ +
+ 📥 Inbox + 0 +
+
+ 📤 Sent +
+
+ 🗑️ Trash +
+
+
+
+
+ Select an email message to view its contents. +
+
+
+ `; + } + + bindEvents(container) { + container.querySelectorAll('.inlook-folder-item').forEach(el => { + el.addEventListener('click', () => { + container.querySelectorAll('.inlook-folder-item').forEach(f => f.classList.remove('active')); + el.classList.add('active'); + this.currentFolder = el.dataset.folder; + this.renderEmailList(); + }); + }); + + const composeBtn = container.querySelector('#inlook-compose-btn'); + if (composeBtn) { + composeBtn.addEventListener('click', () => { + this.notifications.show({ + title: 'Inlook Mail', + body: 'Corporate policy restricts unassigned outgoing mail during initial shift orientation.', + type: 'info' + }); + }); + } + + this.renderEmailList(); + } + + renderEmailList() { + const listEl = document.getElementById('inlook-email-list'); + if (!listEl) return; + + const filtered = this.emails.filter(e => e.folder === this.currentFolder); + const unread = this.emails.filter(e => e.folder === 'inbox' && e.unread).length; + const badge = document.getElementById('inlook-unread-count'); + if (badge) badge.innerText = unread; + + if (filtered.length === 0) { + listEl.innerHTML = `
No messages in ${this.currentFolder}
`; + return; + } + + listEl.innerHTML = filtered.map(email => ` +
+
+ ${email.sender.split('(')[0]} + ${email.date} +
+
${email.subject}
+
${email.body.replace(/<[^>]*>?/gm, '').substring(0, 48)}...
+
+ `).join(''); + + listEl.querySelectorAll('.inlook-list-item').forEach(item => { + item.addEventListener('click', () => { + const id = item.dataset.emailId; + this.openEmail(id); + }); + }); + } + + selectFirstEmail() { + const inboxEmails = this.emails.filter(e => e.folder === 'inbox'); + if (inboxEmails.length > 0) { + this.openEmail(inboxEmails[0].id); + } + } + + openEmail(id) { + const email = this.emails.find(e => e.id === id); + if (!email) return; + + this.activeEmailId = id; + email.unread = false; + this.renderEmailList(); + + this.eventBus.emit('EMAIL_OPENED', { + target: id, + details: { subject: email.subject, rfcSender: email.rfcSender } + }); + + const readingPane = document.getElementById('inlook-reading-pane'); + if (!readingPane) return; + + readingPane.innerHTML = ` +
+ + + +
+ +
+
${email.subject}
+
+
+
${email.sender.charAt(0)}
+
+
${email.sender}
+
${email.rfcSender}
+
+ +
+
${email.date}
+
+ + ${email.attachments && email.attachments.length > 0 ? ` +
+ Attachments (${email.attachments.length}): + ${email.attachments.map(att => ` +
+ + ${att.name} + (${att.size}) +
+ `).join('')} +
+ ` : ''} +
+ +
+ ${email.body.replace(/\n/g, '
')} +
+ `; + + 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) { + const inspectBtn = document.getElementById('btn-inspect-header'); + if (inspectBtn) { + inspectBtn.addEventListener('click', () => { + this.eventBus.emit('EMAIL_INSPECTED_SENDER', { + target: email.id, + details: { displaySender: email.sender, rfcSender: email.rfcSender } + }); + + 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: ${senderDomain})\nReturn-Path: \n\n${isDomainTrusted ? '✓ Envelope domain matches internal enterprise domain.' : '⚠️ Notice: Envelope domain differs from standard corporate domain.'}`); + }); + } + + const links = document.querySelectorAll('.inlook-msg-link'); + links.forEach(link => { + const actualUrl = link.dataset.url; + const displayText = link.dataset.display || link.innerText; + + link.title = `Simulated Target: ${actualUrl}`; + + link.addEventListener('mouseenter', () => { + this.eventBus.emit('EMAIL_LINK_HOVERED', { + target: email.id, + details: { displayText, actualUrl } + }); + }); + + link.addEventListener('click', (e) => { + e.preventDefault(); + this.eventBus.emit('EMAIL_LINK_CLICKED', { + target: email.id, + details: { displayText, actualUrl } + }); + + if (this.onNavigateUrl) { + this.onNavigateUrl(actualUrl); + } + }); + }); + + const attChips = document.querySelectorAll('.inlook-attachment-chip'); + attChips.forEach(chip => { + chip.addEventListener('click', () => { + const attName = chip.dataset.attName; + const attSize = chip.dataset.attSize; + + this.eventBus.emit('EMAIL_ATTACHMENT_OPENED', { + target: email.id, + details: { attachmentName: attName, attachmentSize: attSize } + }); + + if (this.onFileDownloaded) { + this.onFileDownloaded({ + id: `down_${Date.now()}`, + name: attName, + type: 'archive', + folder: 'Downloads', + size: attSize, + date: new Date().toISOString().split('T')[0] + }); + } + + // Also emit FILE_DOWNLOADED for consequence matching + this.eventBus.emit('FILE_DOWNLOADED', { + target: attName, + details: { emailId: email.id, attachmentName: attName } + }); + + this.notifications.show({ + title: 'Download Complete', + body: `File "${attName}" saved to Downloads folder.`, + type: 'warning' + }); + }); + }); + + const reportBtn = document.getElementById('btn-inlook-report'); + if (reportBtn) { + reportBtn.addEventListener('click', () => { + this.openReportDialog(email); + }); + } + + const replyBtn = document.getElementById('btn-inlook-reply'); + if (replyBtn) { + replyBtn.addEventListener('click', () => { + this.openReplyDialog(email); + }); + } + + const deleteBtn = document.getElementById('btn-inlook-delete'); + if (deleteBtn) { + deleteBtn.addEventListener('click', () => { + email.folder = 'trash'; + this.renderEmailList(); + this.eventBus.emit('EMAIL_DELETED', { + target: email.id, + details: { subject: email.subject } + }); + this.selectFirstEmail(); + this.notifications.show({ + title: 'Inlook Mail', + body: 'Message moved to Trash.', + type: 'info' + }); + }); + } + } + + openReportDialog(email) { + const modalOverlay = document.createElement('div'); + modalOverlay.className = 'cs-modal-overlay'; + modalOverlay.innerHTML = ` +
+
+
+ + Report Suspicious Message to SOC +
+ +
+
+

You are about to report the following email to the Security Operations Center:

+
+ Subject: ${email.subject}
+ Sender: ${email.rfcSender} +
+
+ + +
+
+ + +
+
+ +
+ `; + + document.body.appendChild(modalOverlay); + const close = () => modalOverlay.remove(); + modalOverlay.querySelector('#btn-close-modal').addEventListener('click', close); + modalOverlay.querySelector('#btn-cancel-report').addEventListener('click', close); + + modalOverlay.querySelector('#btn-submit-report').addEventListener('click', () => { + const reason = modalOverlay.querySelector('#report-reason').value; + const notes = modalOverlay.querySelector('#report-notes').value; + + this.eventBus.emit('EMAIL_REPORTED', { + target: email.id, + details: { reason, notes } + }); + + email.folder = 'trash'; + this.renderEmailList(); + this.selectFirstEmail(); + close(); + + this.notifications.show({ + title: 'Security Center Report Acknowledged', + body: `Incident report for "${email.subject.substring(0, 30)}..." received by SOC.`, + type: 'success' + }); + }); + } + + openReplyDialog(email) { + const modalOverlay = document.createElement('div'); + modalOverlay.className = 'cs-modal-overlay'; + modalOverlay.innerHTML = ` +
+
+
Reply to: ${email.sender}
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ `; + + document.body.appendChild(modalOverlay); + const close = () => modalOverlay.remove(); + modalOverlay.querySelector('#btn-close-reply').addEventListener('click', close); + modalOverlay.querySelector('#btn-cancel-reply').addEventListener('click', close); + + modalOverlay.querySelector('#btn-send-reply').addEventListener('click', () => { + const text = modalOverlay.querySelector('#reply-text').value; + this.eventBus.emit('EMAIL_REPLIED', { + target: email.id, + details: { responseText: text, recipient: email.rfcSender } + }); + close(); + this.notifications.show({ + title: 'Inlook Mail', + body: `Reply sent to ${email.sender.split('(')[0]}.`, + type: 'success' + }); + }); + } +} diff --git a/src/js/apps/navigator.js b/src/js/apps/navigator.js index f758b85..579d8e9 100644 --- a/src/js/apps/navigator.js +++ b/src/js/apps/navigator.js @@ -1,185 +1,284 @@ -/** - * CyberSim OS - Navigator Simulated Web Browser - */ - -export class NavigatorApp { - constructor({ windowManager, eventBus, notifications, scenario }) { - this.wm = windowManager; - this.eventBus = eventBus; - this.notifications = notifications; - this.scenario = scenario; - this.currentUrl = 'http://intranet.nexacore.internal'; - this.history = [this.currentUrl]; - this.historyIndex = 0; - } - - getIconSvg() { - return ``; - } - - launch(initialUrl = null) { - const url = initialUrl || this.currentUrl; - const win = this.wm.createWindow({ - id: 'navigator', - title: 'Navigator Web Browser', - iconSvg: this.getIconSvg(), - width: 880, - height: 580, - bodyContent: this.renderShell() - }); - - this.bindEvents(win.bodyElement); - this.navigateTo(url); - this.eventBus.emit('APP_OPENED', { target: 'navigator' }); - } - - renderShell() { - return ` - - `; - } - - bindEvents(container) { - const urlInput = container.querySelector('#nav-url-input'); - const backBtn = container.querySelector('#nav-btn-back'); - const forwardBtn = container.querySelector('#nav-btn-forward'); - const reloadBtn = container.querySelector('#nav-btn-reload'); - - urlInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - this.navigateTo(urlInput.value.trim()); - } - }); - - backBtn.addEventListener('click', () => { - if (this.historyIndex > 0) { - this.historyIndex--; - this.navigateTo(this.history[this.historyIndex], false); - } - }); - - forwardBtn.addEventListener('click', () => { - if (this.historyIndex < this.history.length - 1) { - this.historyIndex++; - this.navigateTo(this.history[this.historyIndex], false); - } - }); - - reloadBtn.addEventListener('click', () => { - this.navigateTo(this.currentUrl, false); - }); - } - - navigateTo(url, recordHistory = true) { - this.currentUrl = url; - if (recordHistory) { - this.history = this.history.slice(0, this.historyIndex + 1); - this.history.push(url); - this.historyIndex = this.history.length - 1; - } - - const urlInput = document.getElementById('nav-url-input'); - const addrBar = document.getElementById('nav-addr-bar'); - const lockIcon = document.getElementById('nav-lock-icon'); - const viewport = document.getElementById('nav-viewport'); - - if (urlInput) urlInput.value = url; - - const isSecure = url.startsWith('https://') || url.includes('.nexacore.internal'); - const isPhish = url.includes('nexac0re-portal.com'); - - if (addrBar) { - addrBar.className = `nav-address-bar ${isSecure ? 'secure' : 'insecure'}`; - } - if (lockIcon) { - lockIcon.style.color = isSecure ? '#10b981' : '#f59e0b'; - } - - this.eventBus.emit('NAV_VISITED', { - target: url, - 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 (page) { - viewport.innerHTML = page.content; - this.bindPageLinks(viewport); - } else { - viewport.innerHTML = ` -
-

404 Page Not Found

-

The simulated URL ${url} was not found on this simulation network.

- -
- `; - } - } - } - - bindPageLinks(viewport) { - viewport.querySelectorAll('a').forEach(a => { - a.addEventListener('click', (e) => { - const href = a.getAttribute('href'); - if (href && (href.startsWith('http://') || href.startsWith('https://'))) { - e.preventDefault(); - this.navigateTo(href); - } - }); - }); - } - - handlePhishFormSubmit(form) { - const user = form.querySelector('#phish_user').value; - const pass = form.querySelector('#phish_pass').value; - - this.eventBus.emit('NAV_FORM_SUBMITTED', { - target: 'phish_login_form', - details: { - url: this.currentUrl, - usernameEntered: user, - submittedPassword: !!pass, - isCompromised: true - } - }); - - this.notifications.show({ - title: 'Identity Portal', - body: 'Credentials accepted. Identity synchronization in progress...', - type: 'info' - }); - - const viewport = document.getElementById('nav-viewport'); - if (viewport) { - viewport.innerHTML = ` -
-
-

Account Verification Complete

-

Your credentials have been verified with our external single sign-on synchronization gateway. You may return to your workplace desktop.

-
- `; - } - } -} +/** + * 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 { + constructor({ windowManager, eventBus, notifications, scenario }) { + this.wm = windowManager; + this.eventBus = eventBus; + this.notifications = notifications; + this.scenario = scenario; + + // 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.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() { + return ``; + } + + launch(initialUrl = null) { + const url = initialUrl || this.currentUrl; + const win = this.wm.createWindow({ + id: 'navigator', + title: 'Navigator Web Browser', + iconSvg: this.getIconSvg(), + width: 880, + height: 580, + bodyContent: this.renderShell() + }); + + this.bindEvents(win.bodyElement); + this.navigateTo(url); + this.eventBus.emit('APP_OPENED', { target: 'navigator' }); + } + + renderShell() { + return ` + + `; + } + + bindEvents(container) { + const urlInput = container.querySelector('#nav-url-input'); + const backBtn = container.querySelector('#nav-btn-back'); + const forwardBtn = container.querySelector('#nav-btn-forward'); + const reloadBtn = container.querySelector('#nav-btn-reload'); + + urlInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + this.navigateTo(urlInput.value.trim()); + } + }); + + backBtn.addEventListener('click', () => { + if (this.historyIndex > 0) { + this.historyIndex--; + this.navigateTo(this.history[this.historyIndex], false); + } + }); + + forwardBtn.addEventListener('click', () => { + if (this.historyIndex < this.history.length - 1) { + this.historyIndex++; + this.navigateTo(this.history[this.historyIndex], false); + } + }); + + reloadBtn.addEventListener('click', () => { + this.navigateTo(this.currentUrl, false); + }); + } + + /** + * 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) { + this.currentUrl = url; + if (recordHistory) { + this.history = this.history.slice(0, this.historyIndex + 1); + this.history.push(url); + this.historyIndex = this.history.length - 1; + } + + const urlInput = document.getElementById('nav-url-input'); + const addrBar = document.getElementById('nav-addr-bar'); + const lockIcon = document.getElementById('nav-lock-icon'); + const viewport = document.getElementById('nav-viewport'); + + if (urlInput) urlInput.value = url; + + // Determine security from page definition or domain trust + const page = this.findPage(url); + const isSecure = page ? !!page.isSecure : (url.startsWith('https://') || this.isUrlTrusted(url)); + const isPhish = page ? !!page.isPhishing : false; + + if (addrBar) { + addrBar.className = `nav-address-bar ${isSecure ? 'secure' : 'insecure'}`; + } + if (lockIcon) { + lockIcon.style.color = isSecure ? '#10b981' : '#f59e0b'; + } + + this.eventBus.emit('NAV_VISITED', { + target: url, + details: { isSecure, isPhishing: isPhish } + }); + + if (viewport) { + if (page) { + viewport.innerHTML = page.content; + this.bindPageLinks(viewport); + this.bindPageForms(viewport, page); + } else { + viewport.innerHTML = ` +
+

404 Page Not Found

+

The simulated URL ${url} was not found on this simulation network.

+ +
+ `; + 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) { + viewport.querySelectorAll('a').forEach(a => { + a.addEventListener('click', (e) => { + const href = a.getAttribute('href'); + if (href && (href.startsWith('http://') || href.startsWith('https://'))) { + e.preventDefault(); + this.navigateTo(href); + } + }); + }); + } + + /** + * Bind form submit handlers declaratively from the page definition. + * 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; + + page.forms.forEach(formDef => { + 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: { + url: this.currentUrl, + formId: formDef.id, + formData, + isCompromised: true + } + }); + + // Show notification if configured + if (submitConfig.notification) { + this.notifications.show(submitConfig.notification); + } else { + this.notifications.show({ + title: 'Form Submitted', + body: 'Your submission has been processed.', + type: 'info' + }); + } + + // Handle response + if (submitConfig.response) { + const response = submitConfig.response; + if (response.type === 'pageContent') { + const viewport = document.getElementById('nav-viewport'); + if (viewport) { + viewport.innerHTML = response.content; + } + } else if (response.type === 'redirect') { + this.navigateTo(response.url); + } + } + } +} diff --git a/src/js/apps/security_center.js b/src/js/apps/security_center.js index f838640..9e93f79 100644 --- a/src/js/apps/security_center.js +++ b/src/js/apps/security_center.js @@ -1,132 +1,142 @@ -/** - * CyberSim OS - Security Center Application - */ - -export class SecurityCenterApp { - constructor({ windowManager, eventBus }) { - this.wm = windowManager; - this.eventBus = eventBus; - this.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' - } - ]; - this.reports = []; - this.init(); - } - - init() { - this.eventBus.on('EMAIL_REPORTED', (entry) => { - this.reports.push({ - id: `rep_${Date.now()}`, - target: entry.target, - reason: entry.details.reason, - notes: entry.details.notes, - time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), - status: 'Acknowledged by SOC' - }); - this.render(); - }); - } - - getIconSvg() { - return ``; - } - - addAlert(alertObj) { - this.alerts.unshift(alertObj); - this.render(); - } - - launch() { - const win = this.wm.createWindow({ - id: 'security_center', - title: 'NexaCore Security Center', - iconSvg: this.getIconSvg(), - width: 760, - height: 520, - bodyContent: this.renderShell() - }); - - this.render(); - this.eventBus.emit('APP_OPENED', { target: 'security_center' }); - } - - renderShell() { - return ` -
-
-
- ${this.getIconSvg()} Security Center -
-
Dashboard
-
Alerts & Logs
-
Reported Incidents
-
-
-
- `; - } - - render() { - const main = document.getElementById('sec-main-content'); - if (!main) return; - - const hasHighAlert = this.alerts.some(a => a.severity === 'high'); - - main.innerHTML = ` -
-
Workstation Security Status
-
NexaCore Enterprise Zero-Trust Endpoint Protection
-
- -
-
${hasHighAlert ? '⚠️' : '🛡️'}
-
-
${hasHighAlert ? 'Security Attention Required' : 'Workstation Protected & Monitored'}
-
${hasHighAlert ? 'One or more high severity security alerts require attention.' : 'All security telemetry feeds are operational. Zero active threats detected.'}
-
-
- -

Recent Security Alerts & Notifications

-
- ${this.alerts.map(a => ` -
-
-
- ${a.title} - ${a.timestamp} -
-
${a.message}
-
Source: ${a.source}
-
-
- `).join('')} -
- -

User Incident Reports (${this.reports.length})

- ${this.reports.length === 0 ? ` -
- No suspicious messages or incidents reported yet during this shift. -
- ` : ` -
- ${this.reports.map(r => ` -
-
-
Report: ${r.reason.replace(/_/g, ' ')}
-
Target: ${r.target} | Time: ${r.time}
-
- ${r.status} -
- `).join('')} -
- `} - `; - } -} +/** + * 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 { + constructor({ windowManager, eventBus, scenario }) { + this.wm = windowManager; + this.eventBus = eventBus; + this.scenario = scenario || {}; + + // Initialize with scenario-defined initial alerts + this.alerts = []; + if (scenario && scenario.alerts) { + // Add alerts marked with a timestamp (initial/pre-existing alerts) + scenario.alerts.forEach(alert => { + if (alert.timestamp) { + this.alerts.push({ ...alert }); + } + }); + } + + this.reports = []; + this.init(); + } + + init() { + this.eventBus.on('EMAIL_REPORTED', (entry) => { + this.reports.push({ + id: `rep_${Date.now()}`, + target: entry.target, + reason: entry.details.reason, + notes: entry.details.notes, + time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + status: 'Acknowledged by SOC' + }); + this.render(); + }); + } + + getIconSvg() { + return ``; + } + + 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.render(); + } + + launch() { + const win = this.wm.createWindow({ + id: 'security_center', + title: 'Security Center', + iconSvg: this.getIconSvg(), + width: 760, + height: 520, + bodyContent: this.renderShell() + }); + + this.render(); + this.eventBus.emit('APP_OPENED', { target: 'security_center' }); + } + + renderShell() { + return ` +
+
+
+ ${this.getIconSvg()} Security Center +
+
Dashboard
+
Alerts & Logs
+
Reported Incidents
+
+
+
+ `; + } + + render() { + const main = document.getElementById('sec-main-content'); + if (!main) return; + + const hasHighAlert = this.alerts.some(a => a.severity === 'high' || a.severity === 'critical'); + + main.innerHTML = ` +
+
Workstation Security Status
+
Enterprise Zero-Trust Endpoint Protection
+
+ +
+
${hasHighAlert ? '⚠️' : '🛡️'}
+
+
${hasHighAlert ? 'Security Attention Required' : 'Workstation Protected & Monitored'}
+
${hasHighAlert ? 'One or more high severity security alerts require attention.' : 'All security telemetry feeds are operational. Zero active threats detected.'}
+
+
+ +

Recent Security Alerts & Notifications

+
+ ${this.alerts.map(a => ` +
+
+
+ ${a.title} + ${a.timestamp} +
+
${a.message}
+
Source: ${a.source}
+
+
+ `).join('')} +
+ +

User Incident Reports (${this.reports.length})

+ ${this.reports.length === 0 ? ` +
+ No suspicious messages or incidents reported yet during this shift. +
+ ` : ` +
+ ${this.reports.map(r => ` +
+
+
Report: ${r.reason.replace(/_/g, ' ')}
+
Target: ${r.target} | Time: ${r.time}
+
+ ${r.status} +
+ `).join('')} +
+ `} + `; + } +} diff --git a/src/js/cert/cert_generator.js b/src/js/cert/cert_generator.js index f188a74..9f36adc 100644 --- a/src/js/cert/cert_generator.js +++ b/src/js/cert/cert_generator.js @@ -1,142 +1,146 @@ -/** - * CyberSim OS - Cryptographic Certificate Generator & *.cybercert Exporter - */ - -export class CertificateGenerator { - constructor(scenario, scoreResult, scenarioFingerprint) { - this.scenario = scenario; - this.scoreResult = scoreResult; - this.scenarioFingerprint = scenarioFingerprint; - } - - generateUUID() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); - } - - async createCertificateData(learnerName = 'Jordan Taylor') { - const certId = this.generateUUID(); - const timestamp = new Date().toISOString(); - const engineVersion = '1.0.0-phase1'; - - const payload = { - schema_version: 1, - certificate_id: certId, - product: 'CyberSim OS', - engine_version: engineVersion, - scenario_id: this.scenario.scenarioId, - scenario_title: this.scenario.title, - scenario_version: this.scenario.version, - scenario_fingerprint: this.scenarioFingerprint, - learner: { - name: learnerName, - assigned_role: this.scenario.learner.role, - organization: this.scenario.company.name - }, - evaluation: { - score: this.scoreResult.totalScore, - max_score: 100, - passed: this.scoreResult.isPassed, - passing_threshold: this.scoreResult.passingThreshold, - category_scores: this.scoreResult.categories - }, - issued_at: timestamp, - trust_model: 'self_issued_cryptographic_record' - }; - - // Calculate integrity checksum over canonical record - const canonicalStr = `${certId}:${learnerName}:${this.scenario.scenarioId}:${this.scenarioFingerprint}:${this.scoreResult.totalScore}:${timestamp}`; - 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(''); - - payload.integrity_hash = integrityHash; - return payload; - } - - exportFile(certData) { - const jsonStr = JSON.stringify(certData, null, 2); - const blob = new Blob([jsonStr], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `CyberSim_Certificate_${certData.certificate_id.substring(0, 8)}.cybercert`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } - - async showCertificateModal(learnerName = 'Jordan Taylor') { - const certData = await this.createCertificateData(learnerName); - const modalOverlay = document.createElement('div'); - modalOverlay.className = 'cs-modal-overlay'; - modalOverlay.style.zIndex = '9500'; - - modalOverlay.innerHTML = ` -
-
-
- - CyberSim OS - Official Certificate of Competency -
- -
- -
-
-
NexaCore Technologies • CyberSim Environment
-
Certificate of Competency
-
End-User Cybersecurity Simulation & Behavioral Verification
- -
This certifies that
-
${certData.learner.name}
- -
- has successfully completed the ${certData.scenario_title} simulation, demonstrating sound investigative judgment, threat detection, safe credential handling, and policy adherence. -
- -
- Final Score: ${certData.evaluation.score} / 100 (PASSED) -
- -
-
- Certificate ID: ${certData.certificate_id}
- Date: ${new Date(certData.issued_at).toLocaleDateString()}
- Engine Version: ${certData.engine_version} -
-
- Scenario Hash:
${certData.scenario_fingerprint.substring(0, 32)}...
- Integrity Hash:
${certData.integrity_hash.substring(0, 32)}... -
-
-
-
- - -
- `; - - document.body.appendChild(modalOverlay); - - const close = () => modalOverlay.remove(); - modalOverlay.querySelector('#btn-close-cert-modal').addEventListener('click', close); - - modalOverlay.querySelector('#btn-download-cybercert').addEventListener('click', () => { - this.exportFile(certData); - }); - - modalOverlay.querySelector('#btn-print-cert').addEventListener('click', () => { - window.print(); - }); - } -} +/** + * CyberSim OS - Cryptographic Certificate Generator & *.cybercert Exporter + */ + +const ENGINE_VERSION = '0.2.0-phase2'; + +export class CertificateGenerator { + constructor(scenario, scoreResult, scenarioFingerprint) { + this.scenario = scenario; + this.scoreResult = scoreResult; + this.scenarioFingerprint = scenarioFingerprint; + } + + generateUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } + + async createCertificateData(learnerName = null) { + const finalLearnerName = learnerName || this.scenario.learner?.name || 'Unknown Learner'; + const certId = this.generateUUID(); + const timestamp = new Date().toISOString(); + const companyName = this.scenario.organizations?.[0]?.name || this.scenario.company?.name || 'CyberSim'; + + const payload = { + schema_version: 1, + certificate_id: certId, + product: 'CyberSim OS', + engine_version: ENGINE_VERSION, + scenario_id: this.scenario.scenarioId, + scenario_title: this.scenario.title, + scenario_version: this.scenario.version, + scenario_fingerprint: this.scenarioFingerprint, + learner: { + name: finalLearnerName, + assigned_role: this.scenario.learner?.role || 'User', + organization: companyName + }, + evaluation: { + score: this.scoreResult.totalScore, + max_score: 100, + passed: this.scoreResult.isPassed, + passing_threshold: this.scoreResult.passingThreshold, + category_scores: this.scoreResult.categories + }, + issued_at: timestamp, + trust_model: 'self_issued_cryptographic_record' + }; + + // Calculate integrity checksum over canonical record + 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 integrityHash = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join(''); + + payload.integrity_hash = integrityHash; + return payload; + } + + exportFile(certData) { + const jsonStr = JSON.stringify(certData, null, 2); + const blob = new Blob([jsonStr], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `CyberSim_Certificate_${certData.certificate_id.substring(0, 8)}.cybercert`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + async showCertificateModal(learnerName = null) { + const certData = await this.createCertificateData(learnerName); + const companyName = this.scenario.organizations?.[0]?.name || this.scenario.company?.name || 'CyberSim'; + const modalOverlay = document.createElement('div'); + modalOverlay.className = 'cs-modal-overlay'; + modalOverlay.style.zIndex = '9500'; + + modalOverlay.innerHTML = ` +
+
+
+ + CyberSim OS - Official Certificate of Competency +
+ +
+ +
+
+
${companyName} • CyberSim Environment
+
Certificate of Competency
+
End-User Cybersecurity Simulation & Behavioral Verification
+ +
This certifies that
+
${certData.learner.name}
+ +
+ has successfully completed the ${certData.scenario_title} simulation, demonstrating sound investigative judgment, threat detection, safe credential handling, and policy adherence. +
+ +
+ Final Score: ${certData.evaluation.score} / 100 (PASSED) +
+ +
+
+ Certificate ID: ${certData.certificate_id}
+ Date: ${new Date(certData.issued_at).toLocaleDateString()}
+ Engine Version: ${certData.engine_version} +
+
+ Scenario Hash:
${certData.scenario_fingerprint.substring(0, 32)}...
+ Integrity Hash:
${certData.integrity_hash.substring(0, 32)}... +
+
+
+
+ + +
+ `; + + document.body.appendChild(modalOverlay); + + const close = () => modalOverlay.remove(); + modalOverlay.querySelector('#btn-close-cert-modal').addEventListener('click', close); + + modalOverlay.querySelector('#btn-download-cybercert').addEventListener('click', () => { + this.exportFile(certData); + }); + + modalOverlay.querySelector('#btn-print-cert').addEventListener('click', () => { + window.print(); + }); + } +} diff --git a/src/js/cert/cert_verifier.js b/src/js/cert/cert_verifier.js index 7047008..7caf8e9 100644 --- a/src/js/cert/cert_verifier.js +++ b/src/js/cert/cert_verifier.js @@ -1,54 +1,54 @@ -/** - * CyberSim OS - Offline Certificate Verifier - * Validates *.cybercert structured JSON cryptographic records offline. - */ - -export class CertificateVerifier { - static async verify(certData) { - try { - if (!certData || typeof certData !== 'object') { - return { valid: false, error: 'Invalid certificate payload: not a JSON object.' }; - } - - // Check required schema fields - const required = ['schema_version', 'certificate_id', 'product', 'scenario_id', 'scenario_fingerprint', 'learner', 'evaluation', 'issued_at', 'integrity_hash']; - for (const field of required) { - if (!(field in certData)) { - return { valid: false, error: `Missing required certificate field: ${field}` }; - } - } - - // Verify integrity hash - const certId = certData.certificate_id; - const learnerName = certData.learner.name; - const scenarioId = certData.scenario_id; - const fingerprint = certData.scenario_fingerprint; - const score = certData.evaluation.score; - const timestamp = certData.issued_at; - - const canonicalStr = `${certId}:${learnerName}:${scenarioId}:${fingerprint}:${score}:${timestamp}`; - const hashBuffer = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonicalStr)); - const calculatedHash = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join(''); - - const hashValid = (calculatedHash.toLowerCase() === certData.integrity_hash.toLowerCase()); - if (!hashValid) { - return { - valid: false, - error: 'Cryptographic Checksum Mismatch: This certificate record has been tampered with or modified.', - certData - }; - } - - const passed = certData.evaluation.passed && certData.evaluation.score >= (certData.evaluation.passing_threshold || 80); - - return { - valid: true, - passed, - certData, - message: 'Certificate cryptographic integrity verified successfully.' - }; - } catch (err) { - return { valid: false, error: `Verification failed: ${err.message}` }; - } - } -} +/** + * CyberSim OS - Offline Certificate Verifier + * Validates *.cybercert structured JSON cryptographic records offline. + */ + +export class CertificateVerifier { + static async verify(certData) { + try { + if (!certData || typeof certData !== 'object') { + return { valid: false, error: 'Invalid certificate payload: not a JSON object.' }; + } + + // Check required schema fields + const required = ['schema_version', 'certificate_id', 'product', 'scenario_id', 'scenario_fingerprint', 'learner', 'evaluation', 'issued_at', 'integrity_hash']; + for (const field of required) { + if (!(field in certData)) { + return { valid: false, error: `Missing required certificate field: ${field}` }; + } + } + + // Verify integrity hash + const certId = certData.certificate_id; + const learnerName = certData.learner.name; + const scenarioId = certData.scenario_id; + const fingerprint = certData.scenario_fingerprint; + const score = certData.evaluation.score; + const timestamp = certData.issued_at; + + const canonicalStr = `${certId}:${learnerName}:${scenarioId}:${fingerprint}:${score}:${timestamp}`; + const hashBuffer = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonicalStr)); + const calculatedHash = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join(''); + + const hashValid = (calculatedHash.toLowerCase() === certData.integrity_hash.toLowerCase()); + if (!hashValid) { + return { + valid: false, + error: 'Cryptographic Checksum Mismatch: This certificate record has been tampered with or modified.', + certData + }; + } + + const passed = certData.evaluation.passed && certData.evaluation.score >= (certData.evaluation.passing_threshold || 80); + + return { + valid: true, + passed, + certData, + message: 'Certificate cryptographic integrity verified successfully.' + }; + } catch (err) { + return { valid: false, error: `Verification failed: ${err.message}` }; + } + } +} diff --git a/src/js/core/desktop.js b/src/js/core/desktop.js index d503341..43a36ac 100644 --- a/src/js/core/desktop.js +++ b/src/js/core/desktop.js @@ -1,125 +1,125 @@ -/** - * CyberSim OS - Desktop Shell & Taskbar Interface - */ - -export class DesktopShell { - constructor({ desktopElement, startMenuElement, startBtnElement, clockElement, eventBus, onFinishScenario }) { - this.desktop = desktopElement; - this.startMenu = startMenuElement; - this.startBtn = startBtnElement; - this.clock = clockElement; - this.eventBus = eventBus; - this.onFinishScenario = onFinishScenario; - this.apps = []; - this.init(); - } - - init() { - // Toggle Start Menu - this.startBtn.addEventListener('click', (e) => { - e.stopPropagation(); - this.toggleStartMenu(); - }); - - // Close Start Menu on Outside Click - document.addEventListener('click', (e) => { - if (this.startMenu.classList.contains('open') && !this.startMenu.contains(e.target) && !this.startBtn.contains(e.target)) { - this.closeStartMenu(); - } - }); - - // Start Clock - this.updateClock(); - setInterval(() => this.updateClock(), 1000); - - // End Simulation Button - const finishBtn = document.getElementById('start-btn-finish'); - if (finishBtn) { - finishBtn.addEventListener('click', () => { - this.closeStartMenu(); - if (this.onFinishScenario) { - this.onFinishScenario(); - } - }); - } - } - - renderDesktopIcons(apps) { - this.apps = apps; - const iconsContainer = document.getElementById('desktop-icons'); - if (!iconsContainer) return; - - iconsContainer.innerHTML = ''; - apps.forEach(app => { - const iconEl = document.createElement('div'); - iconEl.className = 'desktop-icon'; - iconEl.dataset.appId = app.id; - iconEl.innerHTML = ` -
${app.iconSvg}
-
${app.name}
- `; - - iconEl.addEventListener('click', () => { - iconsContainer.querySelectorAll('.desktop-icon').forEach(i => i.classList.remove('selected')); - iconEl.classList.add('selected'); - }); - - iconEl.addEventListener('dblclick', () => { - if (app.launch) app.launch(); - }); - - iconsContainer.appendChild(iconEl); - }); - - // Also populate start menu app list - const startAppList = document.getElementById('start-app-list'); - if (startAppList) { - startAppList.innerHTML = ''; - apps.forEach(app => { - const item = document.createElement('div'); - item.className = 'start-app-item'; - item.innerHTML = ` - ${app.iconSvg} -
${app.name}
- `; - item.addEventListener('click', () => { - this.closeStartMenu(); - if (app.launch) app.launch(); - }); - startAppList.appendChild(item); - }); - } - } - - toggleStartMenu() { - const isOpen = this.startMenu.classList.contains('open'); - if (isOpen) { - this.closeStartMenu(); - } else { - this.openStartMenu(); - } - } - - openStartMenu() { - this.startMenu.classList.add('open'); - this.startBtn.classList.add('open'); - this.eventBus.emit('START_MENU_OPENED'); - } - - closeStartMenu() { - this.startMenu.classList.remove('open'); - this.startBtn.classList.remove('open'); - } - - updateClock() { - if (!this.clock) return; - const now = new Date(); - const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - const dateStr = now.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }); - - this.clock.innerHTML = ` -
${timeStr}
-
${dateStr}
- `; - } -} +/** + * CyberSim OS - Desktop Shell & Taskbar Interface + */ + +export class DesktopShell { + constructor({ desktopElement, startMenuElement, startBtnElement, clockElement, eventBus, onFinishScenario }) { + this.desktop = desktopElement; + this.startMenu = startMenuElement; + this.startBtn = startBtnElement; + this.clock = clockElement; + this.eventBus = eventBus; + this.onFinishScenario = onFinishScenario; + this.apps = []; + this.init(); + } + + init() { + // Toggle Start Menu + this.startBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleStartMenu(); + }); + + // Close Start Menu on Outside Click + document.addEventListener('click', (e) => { + if (this.startMenu.classList.contains('open') && !this.startMenu.contains(e.target) && !this.startBtn.contains(e.target)) { + this.closeStartMenu(); + } + }); + + // Start Clock + this.updateClock(); + setInterval(() => this.updateClock(), 1000); + + // End Simulation Button + const finishBtn = document.getElementById('start-btn-finish'); + if (finishBtn) { + finishBtn.addEventListener('click', () => { + this.closeStartMenu(); + if (this.onFinishScenario) { + this.onFinishScenario(); + } + }); + } + } + + renderDesktopIcons(apps) { + this.apps = apps; + const iconsContainer = document.getElementById('desktop-icons'); + if (!iconsContainer) return; + + iconsContainer.innerHTML = ''; + apps.forEach(app => { + const iconEl = document.createElement('div'); + iconEl.className = 'desktop-icon'; + iconEl.dataset.appId = app.id; + iconEl.innerHTML = ` +
${app.iconSvg}
+
${app.name}
+ `; + + iconEl.addEventListener('click', () => { + iconsContainer.querySelectorAll('.desktop-icon').forEach(i => i.classList.remove('selected')); + iconEl.classList.add('selected'); + }); + + iconEl.addEventListener('dblclick', () => { + if (app.launch) app.launch(); + }); + + iconsContainer.appendChild(iconEl); + }); + + // Also populate start menu app list + const startAppList = document.getElementById('start-app-list'); + if (startAppList) { + startAppList.innerHTML = ''; + apps.forEach(app => { + const item = document.createElement('div'); + item.className = 'start-app-item'; + item.innerHTML = ` + ${app.iconSvg} +
${app.name}
+ `; + item.addEventListener('click', () => { + this.closeStartMenu(); + if (app.launch) app.launch(); + }); + startAppList.appendChild(item); + }); + } + } + + toggleStartMenu() { + const isOpen = this.startMenu.classList.contains('open'); + if (isOpen) { + this.closeStartMenu(); + } else { + this.openStartMenu(); + } + } + + openStartMenu() { + this.startMenu.classList.add('open'); + this.startBtn.classList.add('open'); + this.eventBus.emit('START_MENU_OPENED'); + } + + closeStartMenu() { + this.startMenu.classList.remove('open'); + this.startBtn.classList.remove('open'); + } + + updateClock() { + if (!this.clock) return; + const now = new Date(); + const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const dateStr = now.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }); + + this.clock.innerHTML = ` +
${timeStr}
+
${dateStr}
+ `; + } +} diff --git a/src/js/core/notifications.js b/src/js/core/notifications.js index 203ac16..141dfe6 100644 --- a/src/js/core/notifications.js +++ b/src/js/core/notifications.js @@ -1,134 +1,134 @@ -/** - * CyberSim OS - System Notifications Service & Web Audio Chime - */ - -export class NotificationService { - constructor(containerElement) { - this.container = containerElement || document.getElementById('notification-container'); - this.audioCtx = null; - this.notifications = []; - this.unreadCount = 0; - } - - initAudio() { - if (!this.audioCtx) { - try { - const AudioContext = window.AudioContext || window.webkitAudioContext; - if (AudioContext) { - this.audioCtx = new AudioContext(); - } - } catch (e) { - console.warn('Web Audio not supported or blocked:', e); - } - } - } - - playChime(type = 'info') { - try { - this.initAudio(); - if (!this.audioCtx || this.audioCtx.state === 'suspended') { - if (this.audioCtx) this.audioCtx.resume(); - } - if (!this.audioCtx) return; - - const osc = this.audioCtx.createOscillator(); - const gain = this.audioCtx.createGain(); - osc.connect(gain); - gain.connect(this.audioCtx.destination); - - const now = this.audioCtx.currentTime; - if (type === 'danger' || type === 'warning') { - osc.type = 'sawtooth'; - osc.frequency.setValueAtTime(440, now); - osc.frequency.exponentialRampToValueAtTime(220, now + 0.25); - gain.gain.setValueAtTime(0.08, now); - gain.gain.exponentialRampToValueAtTime(0.001, now + 0.25); - osc.start(now); - osc.stop(now + 0.25); - } else { - osc.type = 'sine'; - osc.frequency.setValueAtTime(587.33, now); // D5 - osc.frequency.setValueAtTime(880.00, now + 0.08); // A5 - gain.gain.setValueAtTime(0.06, now); - gain.gain.exponentialRampToValueAtTime(0.001, now + 0.25); - osc.start(now); - osc.stop(now + 0.25); - } - } catch (e) { - // Audio playback fails silently if user hasn't interacted yet - } - } - - show({ title, body, type = 'info', iconSvg = null, onClick = null, timeout = 6000 }) { - this.playChime(type); - - const toast = document.createElement('div'); - toast.className = `cs-toast ${type}`; - - const defaultIcon = ``; - - toast.innerHTML = ` -
${iconSvg || defaultIcon}
-
-
${title}
-
${body}
-
- - `; - - const closeBtn = toast.querySelector('.cs-toast-close'); - closeBtn.addEventListener('click', (e) => { - e.stopPropagation(); - this.dismiss(toast); - }); - - if (onClick) { - toast.addEventListener('click', () => { - onClick(); - this.dismiss(toast); - }); - } - - if (!this.container) { - this.container = document.getElementById('notification-container'); - } - if (this.container) { - this.container.appendChild(toast); - } - - this.notifications.push({ title, body, type, time: new Date() }); - this.unreadCount++; - this.updateTrayBadge(); - - if (timeout > 0) { - setTimeout(() => { - this.dismiss(toast); - }, timeout); - } - - return toast; - } - - dismiss(toastElement) { - if (!toastElement || !toastElement.parentNode) return; - toastElement.style.opacity = '0'; - toastElement.style.transform = 'translateX(100%)'; - setTimeout(() => { - if (toastElement.parentNode) { - toastElement.parentNode.removeChild(toastElement); - } - }, 200); - } - - updateTrayBadge() { - const badge = document.getElementById('tray-notify-badge'); - if (badge) { - badge.style.display = this.unreadCount > 0 ? 'block' : 'none'; - } - } - - clearUnread() { - this.unreadCount = 0; - this.updateTrayBadge(); - } -} +/** + * CyberSim OS - System Notifications Service & Web Audio Chime + */ + +export class NotificationService { + constructor(containerElement) { + this.container = containerElement || document.getElementById('notification-container'); + this.audioCtx = null; + this.notifications = []; + this.unreadCount = 0; + } + + initAudio() { + if (!this.audioCtx) { + try { + const AudioContext = window.AudioContext || window.webkitAudioContext; + if (AudioContext) { + this.audioCtx = new AudioContext(); + } + } catch (e) { + console.warn('Web Audio not supported or blocked:', e); + } + } + } + + playChime(type = 'info') { + try { + this.initAudio(); + if (!this.audioCtx || this.audioCtx.state === 'suspended') { + if (this.audioCtx) this.audioCtx.resume(); + } + if (!this.audioCtx) return; + + const osc = this.audioCtx.createOscillator(); + const gain = this.audioCtx.createGain(); + osc.connect(gain); + gain.connect(this.audioCtx.destination); + + const now = this.audioCtx.currentTime; + if (type === 'danger' || type === 'warning') { + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(440, now); + osc.frequency.exponentialRampToValueAtTime(220, now + 0.25); + gain.gain.setValueAtTime(0.08, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.25); + osc.start(now); + osc.stop(now + 0.25); + } else { + osc.type = 'sine'; + osc.frequency.setValueAtTime(587.33, now); // D5 + osc.frequency.setValueAtTime(880.00, now + 0.08); // A5 + gain.gain.setValueAtTime(0.06, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.25); + osc.start(now); + osc.stop(now + 0.25); + } + } catch (e) { + // Audio playback fails silently if user hasn't interacted yet + } + } + + show({ title, body, type = 'info', iconSvg = null, onClick = null, timeout = 6000 }) { + this.playChime(type); + + const toast = document.createElement('div'); + toast.className = `cs-toast ${type}`; + + const defaultIcon = ``; + + toast.innerHTML = ` +
${iconSvg || defaultIcon}
+
+
${title}
+
${body}
+
+ + `; + + const closeBtn = toast.querySelector('.cs-toast-close'); + closeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.dismiss(toast); + }); + + if (onClick) { + toast.addEventListener('click', () => { + onClick(); + this.dismiss(toast); + }); + } + + if (!this.container) { + this.container = document.getElementById('notification-container'); + } + if (this.container) { + this.container.appendChild(toast); + } + + this.notifications.push({ title, body, type, time: new Date() }); + this.unreadCount++; + this.updateTrayBadge(); + + if (timeout > 0) { + setTimeout(() => { + this.dismiss(toast); + }, timeout); + } + + return toast; + } + + dismiss(toastElement) { + if (!toastElement || !toastElement.parentNode) return; + toastElement.style.opacity = '0'; + toastElement.style.transform = 'translateX(100%)'; + setTimeout(() => { + if (toastElement.parentNode) { + toastElement.parentNode.removeChild(toastElement); + } + }, 200); + } + + updateTrayBadge() { + const badge = document.getElementById('tray-notify-badge'); + if (badge) { + badge.style.display = this.unreadCount > 0 ? 'block' : 'none'; + } + } + + clearUnread() { + this.unreadCount = 0; + this.updateTrayBadge(); + } +} diff --git a/src/js/core/window_manager.js b/src/js/core/window_manager.js index 51839c5..e82cb57 100644 --- a/src/js/core/window_manager.js +++ b/src/js/core/window_manager.js @@ -1,271 +1,271 @@ -/** - * CyberSim OS - Window Management Subsystem - * Handles window creation, movement, sizing, z-index, minimize, maximize, and taskbar sync. - */ - -export class WindowManager { - constructor(desktopElement, taskbarAppsElement) { - this.desktop = desktopElement; - this.taskbarApps = taskbarAppsElement; - this.windows = new Map(); - this.activeWindow = null; - this.baseZIndex = 10; - this.currentZIndex = 10; - this.cascadeOffset = 0; - } - - registerApp(appConfig) { - // App definition registration - } - - createWindow({ id, title, iconSvg, width = 760, height = 520, x = null, y = null, bodyContent = '', onClose = null }) { - if (this.windows.has(id)) { - const win = this.windows.get(id); - this.restoreWindow(id); - this.focusWindow(id); - return win; - } - - const defaultX = x !== null ? x : 80 + (this.cascadeOffset % 5) * 30; - const defaultY = y !== null ? y : 40 + (this.cascadeOffset % 5) * 30; - this.cascadeOffset++; - - const winEl = document.createElement('div'); - winEl.className = 'cs-window active'; - winEl.id = `win-${id}`; - winEl.style.width = `${width}px`; - winEl.style.height = `${height}px`; - winEl.style.left = `${defaultX}px`; - winEl.style.top = `${defaultY}px`; - winEl.style.zIndex = ++this.currentZIndex; - - winEl.innerHTML = ` -
-
- ${iconSvg || ''} - ${title} -
-
- - - -
-
-
-
- `; - - const bodyEl = winEl.querySelector(`#body-${id}`); - if (typeof bodyContent === 'string') { - bodyEl.innerHTML = bodyContent; - } else if (bodyContent instanceof HTMLElement) { - bodyEl.appendChild(bodyContent); - } - - this.desktop.appendChild(winEl); - - // Create Taskbar Button - const taskbarBtn = document.createElement('button'); - taskbarBtn.className = 'taskbar-btn active'; - taskbarBtn.id = `tb-btn-${id}`; - taskbarBtn.innerHTML = ` - ${iconSvg || ''} - ${title} - `; - taskbarBtn.addEventListener('click', () => { - if (this.activeWindow === id && !winEl.classList.contains('minimized')) { - this.minimizeWindow(id); - } else { - this.restoreWindow(id); - this.focusWindow(id); - } - }); - this.taskbarApps.appendChild(taskbarBtn); - - const winData = { - id, - title, - element: winEl, - bodyElement: bodyEl, - taskbarBtn, - isMaximized: false, - isMinimized: false, - prevGeometry: { x: defaultX, y: defaultY, w: width, h: height }, - onClose - }; - - this.windows.set(id, winData); - this.bindWindowEvents(winData); - this.focusWindow(id); - - return winData; - } - - bindWindowEvents(winData) { - const { id, element } = winData; - const header = element.querySelector('.cs-window-header'); - - // Click to Focus - element.addEventListener('mousedown', () => { - this.focusWindow(id); - }); - - // Window Controls - header.addEventListener('click', (e) => { - const btn = e.target.closest('.cs-btn-win'); - if (!btn) return; - const action = btn.dataset.action; - if (action === 'min') this.minimizeWindow(id); - if (action === 'max') this.toggleMaximizeWindow(id); - if (action === 'close') this.closeWindow(id); - }); - - // Double-click header to maximize - header.addEventListener('dblclick', (e) => { - if (!e.target.closest('.cs-btn-win')) { - this.toggleMaximizeWindow(id); - } - }); - - // Dragging Logic - let isDragging = false; - let startX, startY, initialLeft, initialTop; - - header.addEventListener('mousedown', (e) => { - if (e.target.closest('.cs-btn-win') || winData.isMaximized) return; - isDragging = true; - startX = e.clientX; - startY = e.clientY; - initialLeft = element.offsetLeft; - initialTop = element.offsetTop; - - const onMouseMove = (moveEvent) => { - if (!isDragging) return; - const dx = moveEvent.clientX - startX; - const dy = moveEvent.clientY - startY; - - let newX = initialLeft + dx; - let newY = initialTop + dy; - - // Desktop bounds clamp - const maxX = this.desktop.clientWidth - 80; - const maxY = this.desktop.clientHeight - 40; - newX = Math.max(-element.clientWidth + 100, Math.min(newX, maxX)); - newY = Math.max(0, Math.min(newY, maxY)); - - element.style.left = `${newX}px`; - element.style.top = `${newY}px`; - }; - - const onMouseUp = () => { - isDragging = false; - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - }; - - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - }); - } - - focusWindow(id) { - if (!this.windows.has(id)) return; - this.activeWindow = id; - - this.windows.forEach((win, winId) => { - if (winId === id) { - win.element.classList.add('active'); - win.taskbarBtn.classList.add('active'); - win.element.style.zIndex = ++this.currentZIndex; - } else { - win.element.classList.remove('active'); - win.taskbarBtn.classList.remove('active'); - } - }); - } - - minimizeWindow(id) { - const win = this.windows.get(id); - if (!win) return; - win.isMinimized = true; - win.element.classList.add('minimized'); - win.element.classList.remove('active'); - win.taskbarBtn.classList.remove('active'); - - if (this.activeWindow === id) { - this.activeWindow = null; - // Focus top-most visible window - let topWin = null; - let maxZ = -1; - this.windows.forEach((w) => { - if (!w.isMinimized && parseInt(w.element.style.zIndex || 0) > maxZ) { - maxZ = parseInt(w.element.style.zIndex || 0); - topWin = w.id; - } - }); - if (topWin) this.focusWindow(topWin); - } - } - - restoreWindow(id) { - const win = this.windows.get(id); - if (!win) return; - win.isMinimized = false; - win.element.classList.remove('minimized'); - } - - toggleMaximizeWindow(id) { - const win = this.windows.get(id); - if (!win) return; - - if (win.isMaximized) { - win.isMaximized = false; - win.element.classList.remove('maximized'); - win.element.style.left = `${win.prevGeometry.x}px`; - win.element.style.top = `${win.prevGeometry.y}px`; - win.element.style.width = `${win.prevGeometry.w}px`; - win.element.style.height = `${win.prevGeometry.h}px`; - } else { - win.prevGeometry = { - x: win.element.offsetLeft, - y: win.element.offsetTop, - w: win.element.offsetWidth, - h: win.element.offsetHeight - }; - win.isMaximized = true; - win.element.classList.add('maximized'); - } - this.focusWindow(id); - } - - closeWindow(id) { - const win = this.windows.get(id); - if (!win) return; - - if (win.onClose) { - win.onClose(); - } - - if (win.element.parentNode) { - win.element.parentNode.removeChild(win.element); - } - if (win.taskbarBtn.parentNode) { - win.taskbarBtn.parentNode.removeChild(win.taskbarBtn); - } - - this.windows.delete(id); - if (this.activeWindow === id) { - this.activeWindow = null; - } - } - - getWindow(id) { - return this.windows.get(id); - } -} +/** + * CyberSim OS - Window Management Subsystem + * Handles window creation, movement, sizing, z-index, minimize, maximize, and taskbar sync. + */ + +export class WindowManager { + constructor(desktopElement, taskbarAppsElement) { + this.desktop = desktopElement; + this.taskbarApps = taskbarAppsElement; + this.windows = new Map(); + this.activeWindow = null; + this.baseZIndex = 10; + this.currentZIndex = 10; + this.cascadeOffset = 0; + } + + registerApp(appConfig) { + // App definition registration + } + + createWindow({ id, title, iconSvg, width = 760, height = 520, x = null, y = null, bodyContent = '', onClose = null }) { + if (this.windows.has(id)) { + const win = this.windows.get(id); + this.restoreWindow(id); + this.focusWindow(id); + return win; + } + + const defaultX = x !== null ? x : 80 + (this.cascadeOffset % 5) * 30; + const defaultY = y !== null ? y : 40 + (this.cascadeOffset % 5) * 30; + this.cascadeOffset++; + + const winEl = document.createElement('div'); + winEl.className = 'cs-window active'; + winEl.id = `win-${id}`; + winEl.style.width = `${width}px`; + winEl.style.height = `${height}px`; + winEl.style.left = `${defaultX}px`; + winEl.style.top = `${defaultY}px`; + winEl.style.zIndex = ++this.currentZIndex; + + winEl.innerHTML = ` +
+
+ ${iconSvg || ''} + ${title} +
+
+ + + +
+
+
+
+ `; + + const bodyEl = winEl.querySelector(`#body-${id}`); + if (typeof bodyContent === 'string') { + bodyEl.innerHTML = bodyContent; + } else if (bodyContent instanceof HTMLElement) { + bodyEl.appendChild(bodyContent); + } + + this.desktop.appendChild(winEl); + + // Create Taskbar Button + const taskbarBtn = document.createElement('button'); + taskbarBtn.className = 'taskbar-btn active'; + taskbarBtn.id = `tb-btn-${id}`; + taskbarBtn.innerHTML = ` + ${iconSvg || ''} + ${title} + `; + taskbarBtn.addEventListener('click', () => { + if (this.activeWindow === id && !winEl.classList.contains('minimized')) { + this.minimizeWindow(id); + } else { + this.restoreWindow(id); + this.focusWindow(id); + } + }); + this.taskbarApps.appendChild(taskbarBtn); + + const winData = { + id, + title, + element: winEl, + bodyElement: bodyEl, + taskbarBtn, + isMaximized: false, + isMinimized: false, + prevGeometry: { x: defaultX, y: defaultY, w: width, h: height }, + onClose + }; + + this.windows.set(id, winData); + this.bindWindowEvents(winData); + this.focusWindow(id); + + return winData; + } + + bindWindowEvents(winData) { + const { id, element } = winData; + const header = element.querySelector('.cs-window-header'); + + // Click to Focus + element.addEventListener('mousedown', () => { + this.focusWindow(id); + }); + + // Window Controls + header.addEventListener('click', (e) => { + const btn = e.target.closest('.cs-btn-win'); + if (!btn) return; + const action = btn.dataset.action; + if (action === 'min') this.minimizeWindow(id); + if (action === 'max') this.toggleMaximizeWindow(id); + if (action === 'close') this.closeWindow(id); + }); + + // Double-click header to maximize + header.addEventListener('dblclick', (e) => { + if (!e.target.closest('.cs-btn-win')) { + this.toggleMaximizeWindow(id); + } + }); + + // Dragging Logic + let isDragging = false; + let startX, startY, initialLeft, initialTop; + + header.addEventListener('mousedown', (e) => { + if (e.target.closest('.cs-btn-win') || winData.isMaximized) return; + isDragging = true; + startX = e.clientX; + startY = e.clientY; + initialLeft = element.offsetLeft; + initialTop = element.offsetTop; + + const onMouseMove = (moveEvent) => { + if (!isDragging) return; + const dx = moveEvent.clientX - startX; + const dy = moveEvent.clientY - startY; + + let newX = initialLeft + dx; + let newY = initialTop + dy; + + // Desktop bounds clamp + const maxX = this.desktop.clientWidth - 80; + const maxY = this.desktop.clientHeight - 40; + newX = Math.max(-element.clientWidth + 100, Math.min(newX, maxX)); + newY = Math.max(0, Math.min(newY, maxY)); + + element.style.left = `${newX}px`; + element.style.top = `${newY}px`; + }; + + const onMouseUp = () => { + isDragging = false; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + } + + focusWindow(id) { + if (!this.windows.has(id)) return; + this.activeWindow = id; + + this.windows.forEach((win, winId) => { + if (winId === id) { + win.element.classList.add('active'); + win.taskbarBtn.classList.add('active'); + win.element.style.zIndex = ++this.currentZIndex; + } else { + win.element.classList.remove('active'); + win.taskbarBtn.classList.remove('active'); + } + }); + } + + minimizeWindow(id) { + const win = this.windows.get(id); + if (!win) return; + win.isMinimized = true; + win.element.classList.add('minimized'); + win.element.classList.remove('active'); + win.taskbarBtn.classList.remove('active'); + + if (this.activeWindow === id) { + this.activeWindow = null; + // Focus top-most visible window + let topWin = null; + let maxZ = -1; + this.windows.forEach((w) => { + if (!w.isMinimized && parseInt(w.element.style.zIndex || 0) > maxZ) { + maxZ = parseInt(w.element.style.zIndex || 0); + topWin = w.id; + } + }); + if (topWin) this.focusWindow(topWin); + } + } + + restoreWindow(id) { + const win = this.windows.get(id); + if (!win) return; + win.isMinimized = false; + win.element.classList.remove('minimized'); + } + + toggleMaximizeWindow(id) { + const win = this.windows.get(id); + if (!win) return; + + if (win.isMaximized) { + win.isMaximized = false; + win.element.classList.remove('maximized'); + win.element.style.left = `${win.prevGeometry.x}px`; + win.element.style.top = `${win.prevGeometry.y}px`; + win.element.style.width = `${win.prevGeometry.w}px`; + win.element.style.height = `${win.prevGeometry.h}px`; + } else { + win.prevGeometry = { + x: win.element.offsetLeft, + y: win.element.offsetTop, + w: win.element.offsetWidth, + h: win.element.offsetHeight + }; + win.isMaximized = true; + win.element.classList.add('maximized'); + } + this.focusWindow(id); + } + + closeWindow(id) { + const win = this.windows.get(id); + if (!win) return; + + if (win.onClose) { + win.onClose(); + } + + if (win.element.parentNode) { + win.element.parentNode.removeChild(win.element); + } + if (win.taskbarBtn.parentNode) { + win.taskbarBtn.parentNode.removeChild(win.taskbarBtn); + } + + this.windows.delete(id); + if (this.activeWindow === id) { + this.activeWindow = null; + } + } + + getWindow(id) { + return this.windows.get(id); + } +} diff --git a/src/js/engine/action_dispatcher.js b/src/js/engine/action_dispatcher.js new file mode 100644 index 0000000..391f824 --- /dev/null +++ b/src/js/engine/action_dispatcher.js @@ -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 || 'user@example.com', + '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; + } +} diff --git a/src/js/engine/condition_evaluator.js b/src/js/engine/condition_evaluator.js new file mode 100644 index 0000000..4dc7ca2 --- /dev/null +++ b/src/js/engine/condition_evaluator.js @@ -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; + } + } +} diff --git a/src/js/engine/consequence.js b/src/js/engine/consequence.js index 08e86f6..683e051 100644 --- a/src/js/engine/consequence.js +++ b/src/js/engine/consequence.js @@ -1,103 +1,53 @@ -/** - * CyberSim OS - Delayed Consequence Engine - * Schedules and dispatches delayed consequences based on user behaviors. - */ - -export class ConsequenceEngine { - constructor(eventBus, notificationsService, securityCenterApp) { - this.eventBus = eventBus; - this.notifications = notificationsService; - this.securityCenter = securityCenterApp; - this.scheduledEvents = []; - this.activeTimer = null; - this.init(); - } - - init() { - // Listen for risky or notable behaviors to schedule realistic delayed consequences - this.eventBus.on('NAV_FORM_SUBMITTED', (entry) => { - if (entry.target === 'phish_login_form' || (entry.details && entry.details.url && entry.details.url.includes('nexac0re-portal.com'))) { - this.schedule({ - delaySeconds: 35, - id: 'consequence_credential_leak', - name: 'Suspicious Account Activity Alert', - execute: () => { - 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); - const item = { ...consequence, runAt, executed: false }; - this.scheduledEvents.push(item); - - setTimeout(() => { - if (!item.executed) { - item.executed = true; - item.execute(); - } - }, consequence.delaySeconds * 1000); - } - - reset() { - this.scheduledEvents = []; - } -} +/** + * CyberSim OS - Delayed Consequence Engine (Phase 2) + * + * 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 { + /** + * @param {import('./event_bus.js').EventBus} eventBus + */ + constructor(eventBus) { + this.eventBus = eventBus; + this.triggeredConsequences = []; + this.init(); + } + + init() { + // In Phase 2, consequences are scenario events processed by the EventScheduler. + // This listener records consequence events for diagnostics and telemetry. + this.eventBus.on('EVENT_EXECUTED', (entry) => { + if (entry.target && entry.target.startsWith && entry.target.startsWith('consequence-')) { + this.triggeredConsequences.push({ + eventId: entry.target, + timestamp: new Date().toISOString(), + details: entry.details || {} + }); + } + }); + } + + /** + * Returns all triggered consequences for diagnostics. + * @returns {Array} + */ + getTriggeredConsequences() { + return [...this.triggeredConsequences]; + } + + /** + * Resets consequence tracking state. + */ + reset() { + this.triggeredConsequences = []; + } +} diff --git a/src/js/engine/event_bus.js b/src/js/engine/event_bus.js index 6d4f621..bdebb09 100644 --- a/src/js/engine/event_bus.js +++ b/src/js/engine/event_bus.js @@ -1,108 +1,108 @@ -/** - * CyberSim OS - Event Bus & Behavioral Telemetry Logger - * Records all user interactions with timestamps, simulation time, targets, and context. - */ - -export class EventBus { - constructor() { - this.listeners = new Map(); - this.logs = []; - this.startTime = Date.now(); - this.actionCounter = 0; - } - - /** - * Subscribe to an event - */ - on(event, callback) { - if (!this.listeners.has(event)) { - this.listeners.set(event, new Set()); - } - this.listeners.get(event).add(callback); - return () => this.off(event, callback); - } - - /** - * Unsubscribe from an event - */ - off(event, callback) { - if (this.listeners.has(event)) { - this.listeners.get(event).delete(callback); - } - } - - /** - * Emit an event and log telemetry - */ - emit(event, data = {}) { - const simSeconds = Math.floor((Date.now() - this.startTime) / 1000); - const entry = { - id: ++this.actionCounter, - timestamp: new Date().toISOString(), - simSeconds, - event, - target: data.target || null, - details: data.details || {}, - category: data.category || 'general' - }; - - this.logs.push(entry); - - if (this.listeners.has(event)) { - this.listeners.get(event).forEach(cb => { - try { - cb(entry); - } catch (err) { - console.error(`Error in event listener for ${event}:`, err); - } - }); - } - - // Also trigger wildcard listeners - if (this.listeners.has('*')) { - this.listeners.get('*').forEach(cb => cb(entry)); - } - - return entry; - } - - /** - * Query recorded telemetry logs - */ - getLogs() { - return [...this.logs]; - } - - /** - * Find actions matching a predicate or event name - */ - findActions(predicate) { - if (typeof predicate === 'string') { - return this.logs.filter(l => l.event === predicate); - } - return this.logs.filter(predicate); - } - - /** - * Check if a specific action on a target occurred - */ - hasAction(event, target = null) { - return this.logs.some(l => { - if (target !== null) { - return l.event === event && l.target === target; - } - return l.event === event; - }); - } - - /** - * Reset logs - */ - reset() { - this.logs = []; - this.startTime = Date.now(); - this.actionCounter = 0; - } -} - -export const globalEventBus = new EventBus(); +/** + * CyberSim OS - Event Bus & Behavioral Telemetry Logger + * Records all user interactions with timestamps, simulation time, targets, and context. + */ + +export class EventBus { + constructor() { + this.listeners = new Map(); + this.logs = []; + this.startTime = Date.now(); + this.actionCounter = 0; + } + + /** + * Subscribe to an event + */ + on(event, callback) { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + this.listeners.get(event).add(callback); + return () => this.off(event, callback); + } + + /** + * Unsubscribe from an event + */ + off(event, callback) { + if (this.listeners.has(event)) { + this.listeners.get(event).delete(callback); + } + } + + /** + * Emit an event and log telemetry + */ + emit(event, data = {}) { + const simSeconds = Math.floor((Date.now() - this.startTime) / 1000); + const entry = { + id: ++this.actionCounter, + timestamp: new Date().toISOString(), + simSeconds, + event, + target: data.target || null, + details: data.details || {}, + category: data.category || 'general' + }; + + this.logs.push(entry); + + if (this.listeners.has(event)) { + this.listeners.get(event).forEach(cb => { + try { + cb(entry); + } catch (err) { + console.error(`Error in event listener for ${event}:`, err); + } + }); + } + + // Also trigger wildcard listeners + if (this.listeners.has('*')) { + this.listeners.get('*').forEach(cb => cb(entry)); + } + + return entry; + } + + /** + * Query recorded telemetry logs + */ + getLogs() { + return [...this.logs]; + } + + /** + * Find actions matching a predicate or event name + */ + findActions(predicate) { + if (typeof predicate === 'string') { + return this.logs.filter(l => l.event === predicate); + } + return this.logs.filter(predicate); + } + + /** + * Check if a specific action on a target occurred + */ + hasAction(event, target = null) { + return this.logs.some(l => { + if (target !== null) { + return l.event === event && l.target === target; + } + return l.event === event; + }); + } + + /** + * Reset logs + */ + reset() { + this.logs = []; + this.startTime = Date.now(); + this.actionCounter = 0; + } +} + +export const globalEventBus = new EventBus(); diff --git a/src/js/engine/event_scheduler.js b/src/js/engine/event_scheduler.js new file mode 100644 index 0000000..4eb466b --- /dev/null +++ b/src/js/engine/event_scheduler.js @@ -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 + })); + } +} diff --git a/src/js/engine/scenario_state.js b/src/js/engine/scenario_state.js new file mode 100644 index 0000000..7a949e3 --- /dev/null +++ b/src/js/engine/scenario_state.js @@ -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} 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} 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(); + } +} diff --git a/src/js/main.js b/src/js/main.js index 05ce114..f8a2ef6 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -1,206 +1,372 @@ -/** - * CyberSim OS - Main Bootstrapper & Simulation Lifecycle Orchestrator - */ - -import { globalEventBus } from './engine/event_bus.js'; -import { ConsequenceEngine } from './engine/consequence.js'; -import { calculateScenarioFingerprint } from './scenario/fingerprint.js'; -import { referenceScenario1 } from './scenario/scenario_ref1.js'; -import { WindowManager } from './core/window_manager.js'; -import { DesktopShell } from './core/desktop.js'; -import { NotificationService } from './core/notifications.js'; - -import { InlookApp } from './apps/inlook.js'; -import { NavigatorApp } from './apps/navigator.js'; -import { FilesApp } from './apps/files.js'; -import { DocViewerApp } from './apps/docviewer.js'; -import { SecurityCenterApp } from './apps/security_center.js'; - -import { BehavioralScorer } from './scoring/scorer.js'; -import { AfterActionReport } from './scoring/aar.js'; -import { CertificateGenerator } from './cert/cert_generator.js'; - -class CyberSimEngine { - constructor() { - this.scenario = referenceScenario1; - this.scenarioFingerprint = null; - this.eventBus = globalEventBus; - } - - async start() { - console.log('[CyberSim OS] Booting enterprise desktop simulation...'); - - // Calculate cryptographic scenario fingerprint - this.scenarioFingerprint = await calculateScenarioFingerprint(this.scenario); - console.log(`[CyberSim OS] Scenario Fingerprint (SHA-256): ${this.scenarioFingerprint}`); - - // Initialize UI Containers - const desktopEl = document.getElementById('desktop'); - const taskbarAppsEl = document.getElementById('taskbar-apps'); - const startMenuEl = document.getElementById('start-menu'); - const startBtnEl = document.getElementById('start-btn'); - const clockEl = document.getElementById('taskbar-clock'); - - // Initialize Core Services - this.notifications = new NotificationService(); - this.wm = new WindowManager(desktopEl, taskbarAppsEl); - - // Initialize Apps - this.docViewer = new DocViewerApp({ - windowManager: this.wm, - eventBus: this.eventBus - }); - - this.files = new FilesApp({ - windowManager: this.wm, - eventBus: this.eventBus, - notifications: this.notifications, - scenario: this.scenario, - onOpenFile: (file) => this.docViewer.openDocument(file) - }); - - this.navigator = new NavigatorApp({ - windowManager: this.wm, - eventBus: this.eventBus, - notifications: this.notifications, - scenario: this.scenario - }); - - this.securityCenter = new SecurityCenterApp({ - windowManager: this.wm, - eventBus: this.eventBus - }); - - this.inlook = new InlookApp({ - windowManager: this.wm, - eventBus: this.eventBus, - notifications: this.notifications, - scenario: this.scenario, - onNavigateUrl: (url) => this.navigator.launch(url), - onOpenDoc: (file) => this.docViewer.openDocument(file), - onFileDownloaded: (file) => this.files.addFile(file) - }); - - // Initialize Delayed Consequence Engine - this.consequences = new ConsequenceEngine(this.eventBus, this.notifications, this.securityCenter); - - // Initialize Desktop Shell - this.desktop = new DesktopShell({ - desktopElement: desktopEl, - startMenuElement: startMenuEl, - startBtnElement: startBtnEl, - clockElement: clockEl, - eventBus: this.eventBus, - onFinishScenario: () => this.finishScenario() - }); - - // Register Desktop Apps - const registeredApps = [ - { - id: 'inlook', - name: 'Inlook Mail', - iconSvg: this.inlook.getIconSvg(), - launch: () => this.inlook.launch() - }, - { - id: 'navigator', - name: 'Navigator', - iconSvg: this.navigator.getIconSvg(), - launch: () => this.navigator.launch() - }, - { - id: 'files', - name: 'Files', - iconSvg: this.files.getIconSvg(), - launch: () => this.files.launch() - }, - { - id: 'security_center', - name: 'Security Center', - iconSvg: this.securityCenter.getIconSvg(), - launch: () => this.securityCenter.launch() - }, - { - id: 'docviewer', - name: 'Doc Viewer', - iconSvg: this.docViewer.getIconSvg(), - launch: () => { - const firstDoc = this.scenario.files[0]; - if (firstDoc) this.docViewer.openDocument(firstDoc); - } - }, - { - id: 'verify_cert', - name: 'Verify Cert', - iconSvg: ``, - launch: () => { - window.open('verify.html', '_blank'); - } - } - ]; - - 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() { - this.eventBus.emit('SCENARIO_FINISHED', { - target: this.scenario.scenarioId - }); - - const logs = this.eventBus.getLogs(); - const scorer = new BehavioralScorer(this.scenario, logs); - const scoreResult = scorer.evaluate(); - - const aar = new AfterActionReport({ - scenario: this.scenario, - scoreResult, - onClaimCertificate: () => { - const certGen = new CertificateGenerator(this.scenario, scoreResult, this.scenarioFingerprint); - certGen.showCertificateModal(this.scenario.learner.name); - }, - onRestart: () => { - window.location.reload(); - } - }); - - aar.show(); - } -} - -// Boot on DOM Ready -document.addEventListener('DOMContentLoaded', () => { - const sim = new CyberSimEngine(); - sim.start(); -}); +/** + * 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 { 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 { 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 { DesktopShell } from './core/desktop.js'; +import { NotificationService } from './core/notifications.js'; + +import { InlookApp } from './apps/inlook.js'; +import { NavigatorApp } from './apps/navigator.js'; +import { FilesApp } from './apps/files.js'; +import { DocViewerApp } from './apps/docviewer.js'; +import { SecurityCenterApp } from './apps/security_center.js'; + +import { BehavioralScorer } from './scoring/scorer.js'; +import { AfterActionReport } from './scoring/aar.js'; +import { CertificateGenerator } from './cert/cert_generator.js'; + +class CyberSimEngine { + constructor() { + this.scenario = null; + this.scenarioFingerprint = null; + 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 = ` +
+
CyberSim OS
+
${message}
+
+
+
+
+ + `; + } + + /** + * Display a validation error screen. + */ + showErrorScreen(errors, warnings) { + const desktop = document.getElementById('desktop'); + if (!desktop) return; + + const errorHtml = errors.map(e => + `
+ [${e.field}] ${e.message} + ${e.objectId ? ` (${e.objectId})` : ''} + ${e.expected ? `
Expected: ${e.expected}
` : ''} +
` + ).join(''); + + const warningHtml = warnings.length > 0 ? warnings.slice(0, 10).map(w => + `
+ [${w.field}] ${w.message} +
` + ).join('') : ''; + + desktop.innerHTML = ` +
+
+
⚠ Scenario Validation Failed
+
${errors.length} error(s), ${warnings.length} warning(s)
+
${errorHtml}
+ ${warningHtml ? `
Warnings:
${warningHtml}
` : ''} + +
+
+ `; + } + + async start() { + console.log('[CyberSim OS] Booting enterprise desktop simulation...'); + + // 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); + console.log(`[CyberSim OS] Scenario Fingerprint (SHA-256): ${this.scenarioFingerprint}`); + + // 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 taskbarAppsEl = document.getElementById('taskbar-apps'); + const startMenuEl = document.getElementById('start-menu'); + const startBtnEl = document.getElementById('start-btn'); + const clockEl = document.getElementById('taskbar-clock'); + + // Reset desktop content from loading screen + desktopEl.innerHTML = '
'; + + // Initialize Core Services + this.notifications = new NotificationService(); + this.wm = new WindowManager(desktopEl, taskbarAppsEl); + + // Initialize Apps — pass scenario data generically + this.docViewer = new DocViewerApp({ + windowManager: this.wm, + eventBus: this.eventBus + }); + + this.files = new FilesApp({ + windowManager: this.wm, + eventBus: this.eventBus, + notifications: this.notifications, + scenario: this.scenario, + onOpenFile: (file) => this.docViewer.openDocument(file) + }); + + this.navigator = new NavigatorApp({ + windowManager: this.wm, + eventBus: this.eventBus, + notifications: this.notifications, + scenario: this.scenario + }); + + this.securityCenter = new SecurityCenterApp({ + windowManager: this.wm, + eventBus: this.eventBus, + scenario: this.scenario + }); + + this.inlook = new InlookApp({ + windowManager: this.wm, + eventBus: this.eventBus, + notifications: this.notifications, + scenario: this.scenario, + onNavigateUrl: (url) => this.navigator.launch(url), + onOpenDoc: (file) => this.docViewer.openDocument(file), + onFileDownloaded: (file) => this.files.addFile(file) + }); + + // Initialize Desktop Shell + this.desktop = new DesktopShell({ + desktopElement: desktopEl, + startMenuElement: startMenuEl, + startBtnElement: startBtnEl, + clockElement: clockEl, + eventBus: this.eventBus, + onFinishScenario: () => this.finishScenario() + }); + + // Register Desktop Apps — configuration driven + const registeredApps = [ + { + id: 'inlook', + name: 'Inlook Mail', + iconSvg: this.inlook.getIconSvg(), + launch: () => this.inlook.launch() + }, + { + id: 'navigator', + name: 'Navigator', + iconSvg: this.navigator.getIconSvg(), + launch: () => this.navigator.launch() + }, + { + id: 'files', + name: 'Files', + iconSvg: this.files.getIconSvg(), + launch: () => this.files.launch() + }, + { + id: 'security_center', + name: 'Security Center', + iconSvg: this.securityCenter.getIconSvg(), + launch: () => this.securityCenter.launch() + }, + { + id: 'docviewer', + name: 'Doc Viewer', + iconSvg: this.docViewer.getIconSvg(), + launch: () => { + const firstDoc = this.scenario.files && this.scenario.files[0]; + if (firstDoc) this.docViewer.openDocument(firstDoc); + } + }, + { + id: 'verify_cert', + name: 'Verify Cert', + iconSvg: ``, + launch: () => { + window.open('verify.html', '_blank'); + } + } + ]; + + this.desktop.renderDesktopIcons(registeredApps); + } + + finishScenario() { + // Stop the event scheduler + if (this.eventScheduler) { + this.eventScheduler.stop(); + } + + this.eventBus.emit('SCENARIO_FINISHED', { + target: this.scenario.id + }); + + const logs = this.eventBus.getLogs(); + const scorer = new BehavioralScorer(this.scenario, logs, this.scenarioState); + const scoreResult = scorer.evaluate(); + + const aar = new AfterActionReport({ + scenario: this.scenario, + scoreResult, + onClaimCertificate: () => { + const certGen = new CertificateGenerator(this.scenario, scoreResult, this.scenarioFingerprint); + const learnerName = this.scenario.learner ? this.scenario.learner.name : 'Learner'; + certGen.showCertificateModal(learnerName); + }, + onRestart: () => { + window.location.reload(); + } + }); + + aar.show(); + } +} + +// Boot on DOM Ready +document.addEventListener('DOMContentLoaded', () => { + const sim = new CyberSimEngine(); + sim.start(); +}); diff --git a/src/js/scenario/diagnostics.js b/src/js/scenario/diagnostics.js new file mode 100644 index 0000000..d6a5ec6 --- /dev/null +++ b/src/js/scenario/diagnostics.js @@ -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 = ` +
+

CyberSim Diagnostics

+ +
+ +
+ Scenario ID: ${this.scenario?.metadata?.id || 'Unknown'}
+ Version: ${this.scenario?.metadata?.version || 'Unknown'}
+ Format: ${this.scenario?.format_version || 'Unknown'}
+
+ `; + + if (this.fingerprint || this.seed) { + html += `
`; + if (this.fingerprint) html += `Fingerprint: ${this.fingerprint}
`; + if (this.seed) html += `Seed: ${this.seed}
`; + html += `
`; + } + + html += ` +
+ Object Counts: +
+ `; + + for (const [key, value] of Object.entries(counts)) { + html += `
${key}: ${value}
`; + } + + html += ` +
+
+ `; + + if (errors.length > 0 || warnings.length > 0) { + html += `
+ Validation (${errors.length} E, ${warnings.length} W): +
    + `; + + errors.forEach(e => { + html += `
  • ${formatDiagnostic(e)}
  • `; + }); + + warnings.forEach(w => { + html += `
  • ${formatDiagnostic(w)}
  • `; + }); + + html += `
`; + } + + if (this.scoresData) { + html += `
+ Scores: +
${JSON.stringify(this.scoresData, null, 2)}
+
`; + } + + if (this.stateData) { + html += `
+ State Snapshot: +
${JSON.stringify(this.stateData, null, 2)}
+
`; + } + + if (this.timelineData.length > 0) { + html += `
+ Timeline (Last 5): +
    + `; + + const recent = this.timelineData.slice(-5); + recent.forEach(t => { + html += `
  • ${typeof t === 'string' ? t : JSON.stringify(t)}
  • `; + }); + + html += `
`; + } + + 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(); + } +} diff --git a/src/js/scenario/fingerprint.js b/src/js/scenario/fingerprint.js index 3c9ab11..8d74010 100644 --- a/src/js/scenario/fingerprint.js +++ b/src/js/scenario/fingerprint.js @@ -1,49 +1,106 @@ -/** - * CyberSim OS - Scenario Fingerprinting Utility - * Cryptographically hashes immutable scenario definitions using native Web Crypto API (SHA-256). - */ - -export async function calculateScenarioFingerprint(scenarioData) { - try { - // Create canonical representation excluding volatile runtime state - const canonicalObject = { - scenarioId: scenarioData.scenarioId, - version: scenarioData.version, - company: scenarioData.company, - emails: scenarioData.emails.map(e => ({ - id: e.id, - sender: e.sender, - rfcSender: e.rfcSender, - subject: e.subject, - body: e.body, - isThreat: !!e.isThreat, - isFalseFlag: !!e.isFalseFlag, - links: e.links || [], - attachments: e.attachments || [] - })), - files: scenarioData.files.map(f => ({ - id: f.id, - name: f.name, - type: f.type, - folder: f.folder - })), - pages: scenarioData.pages.map(p => ({ - url: p.url, - title: p.title - })), - threats: scenarioData.threats, - falseFlags: scenarioData.falseFlags - }; - - const canonicalJson = JSON.stringify(canonicalObject, Object.keys(canonicalObject).sort()); - const msgBuffer = new TextEncoder().encode(canonicalJson); - const hashBuffer = await window.crypto.subtle.digest('SHA-256', msgBuffer); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); - return hashHex; - } catch (err) { - console.error('Error calculating scenario fingerprint:', err); - // Fallback hash - return 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; - } -} +/** + * Recursively canonicalizes an object by sorting its keys alphabetically. + * 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} The hex-encoded SHA-256 fingerprint. + */ +export async function calculateScenarioFingerprint(scenarioData) { + try { + if (!scenarioData || typeof scenarioData !== 'object') { + throw new Error('Invalid scenario data'); + } + + // Build the evaluation-relevant canonical object + const evaluationData = { + formatVersion: scenarioData.formatVersion, + id: scenarioData.id, + version: scenarioData.version, + + // Map arrays to include only specific relevant fields where required + messages: (scenarioData.messages || []).map(msg => ({ + id: msg.id, + sender: msg.sender, + rfcSender: msg.rfcSender, + subject: msg.subject, + body: msg.body, + links: msg.links, + attachments: msg.attachments + })), + + pages: (scenarioData.pages || []).map(page => ({ + url: page.url, + title: page.title, + isSecure: page.isSecure, + isPhishing: page.isPhishing, + forms: page.forms + })), + + files: (scenarioData.files || []).map(file => ({ + id: file.id, + name: file.name, + type: file.type, + folder: file.folder, + content: file.content + })), + + // 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 || [] + }; + + // Canonicalize the object (sorts keys deterministically and removes internal fields) + const canonicalObject = canonicalize(evaluationData); + + // 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 hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + + return hashHex; + } catch (error) { + console.error('Error calculating scenario fingerprint:', error); + // Fallback to SHA-256 of empty string + return 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; + } +} diff --git a/src/js/scenario/loader.js b/src/js/scenario/loader.js new file mode 100644 index 0000000..5b54fd9 --- /dev/null +++ b/src/js/scenario/loader.js @@ -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} 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 }; + } +} diff --git a/src/js/scenario/scenario_ref1.js b/src/js/scenario/scenario_ref1.js deleted file mode 100644 index 58b1a61..0000000 --- a/src/js/scenario/scenario_ref1.js +++ /dev/null @@ -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: 'jordan.taylor@nexacore.internal', - 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 alex.rivera@nexacore.internal (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 sarah.jenkins@nexacore.internal', - '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: 'morgan.chen@nexacore.internal', - recipient: 'jordan.taylor@nexacore.internal', - 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: 'support@nexac0re-portal.com', - recipient: 'jordan.taylor@nexacore.internal', - 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: https://intranet.nexacore.internal/sso-verification - -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: 'alex.rivera@nexacore.internal', - recipient: 'all-employees@nexacore.internal', - 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: 'billing@apex-global-supplies.net', - recipient: 'finance@nexacore.internal', - 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: 'sarah.jenkins@nexacore.internal', - recipient: 'jordan.taylor@nexacore.internal', - 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', 'billing@titancloud.com', 'Active'], - ['Quantum Edge Hardware', 'VND-209', 'invoices@quantumedge.io', 'Active'], - ['NexaLogistics Global', 'VND-315', 'accounts@nexalogistics.com', 'Active'], - ['CyberShield Auditing LLC', 'VND-402', 'finance@cybershield.net', 'Active'] - ] - } - } - } - ], - - // Simulated Web Pages for Navigator - pages: [ - { - url: 'http://intranet.nexacore.internal', - title: 'NexaCore Intranet - Home', - isSecure: true, - content: ` -
-
-
NexaCore Enterprise Intranet
-
Monday, August 24, 2026
-
-
-
-
-

Company Announcements

-
-

🔒 Security Hardening: As announced by CISO Alex Rivera, company-wide MFA upgrades are underway. Remember to review our security policy in your Documents folder.

-

📊 Q3 Financial Review: Department budget projections are being finalized this week. Contact Morgan Chen with any queries.

-
-
- -
-
-
-

Key Contacts

- - - - - - -
NameRoleEmail
Morgan ChenVP Financemorgan.chen@nexacore.internal
Alex RiveraCISOalex.rivera@nexacore.internal
Sarah JenkinsSr Accountantsarah.jenkins@nexacore.internal
IT HelpdeskSupportit-helpdesk@nexacore.internal
-
-
-
-
- ` - }, - { - url: 'http://intranet.nexacore.internal/it-security', - title: 'IT Security Department - Policy & Notices', - isSecure: true, - content: ` -
-
-
IT Security & Compliance Portal
- ← Back to Intranet -
-
-

Official Notice: Hardware MFA Rollout (CISO Alex Rivera)

-

- NexaCore is transitioning all personnel to hardware FIDO2 keys and authenticator apps. - Official Reminder: NexaCore IT will NEVER email you asking for your password or sending direct credential reset links. -

-
-
-

Known Threat Advisory: Phishing Campaigns Targeting NexaCore

-

- ⚠️ Be aware of external lookalike domains such as nexac0re-portal.com attempting credential harvesting. Always verify the address bar before entering any information. -

-
-
- ` - }, - { - url: 'http://login-nexac0re-portal.com/auth/login', - title: 'NexaCore SSO - Single Sign-On Authentication', - isSecure: false, - isPhishing: true, - content: ` -
-
- -
Enter your employee credentials to verify your account
-
-
- - -
-
- - -
- -
-
-
- ` - } - ] -}; diff --git a/src/js/scenario/schema.js b/src/js/scenario/schema.js new file mode 100644 index 0000000..1d30daa --- /dev/null +++ b/src/js/scenario/schema.js @@ -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)); +} diff --git a/src/js/scenario/validator.js b/src/js/scenario/validator.js new file mode 100644 index 0000000..5acaaca --- /dev/null +++ b/src/js/scenario/validator.js @@ -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>, allIds: Map }} + */ +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 for all IDs defined in the scenario. + * + * @param {Object} scenario + * @returns {Map>} + */ +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>} 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; +} diff --git a/src/js/scoring/aar.js b/src/js/scoring/aar.js index 307093a..d8b50b4 100644 --- a/src/js/scoring/aar.js +++ b/src/js/scoring/aar.js @@ -1,116 +1,116 @@ -/** - * CyberSim OS - After-Action Report (AAR) Modal Interface - */ - -export class AfterActionReport { - constructor({ scenario, scoreResult, onClaimCertificate, onRestart }) { - this.scenario = scenario; - this.scoreResult = scoreResult; - this.onClaimCertificate = onClaimCertificate; - this.onRestart = onRestart; - } - - show() { - const existing = document.getElementById('aar-modal-overlay'); - if (existing) existing.remove(); - - const overlay = document.createElement('div'); - overlay.className = 'cs-modal-overlay'; - overlay.id = 'aar-modal-overlay'; - overlay.style.zIndex = '9000'; - - const r = this.scoreResult; - const cats = r.categories; - - overlay.innerHTML = ` -
-
-
- - CyberSim OS - Behavioral After-Action Report (AAR) -
-
- -
-
-
-
Overall Assessment
-
${r.totalScore} / 100
-
-
- - ${r.isPassed ? '✓ Passed (Threshold: 80)' : '✕ Needs Review (Threshold: 80)'} - -
-
- -

Competency Dimension Scores

- -
- ${Object.keys(cats).map(key => { - const c = cats[key]; - const pct = Math.round((c.score / c.max) * 100); - let colorClass = 'success'; - if (pct < 60) colorClass = 'danger'; - else if (pct < 80) colorClass = 'warning'; - - return ` -
-
- ${c.label} - ${c.score} / ${c.max} (${pct}%) -
-
-
-
-
- `; - }).join('')} -
- -

Pedagogical Feedback & Observations

-
-
    - ${r.feedback.map(f => `
  • ${f}
  • `).join('')} -
-
-
- - -
- `; - - document.body.appendChild(overlay); - - overlay.querySelector('#btn-aar-restart').addEventListener('click', () => { - overlay.remove(); - if (this.onRestart) this.onRestart(); - }); - - const certBtn = overlay.querySelector('#btn-aar-cert'); - if (certBtn) { - certBtn.addEventListener('click', () => { - overlay.remove(); - if (this.onClaimCertificate) this.onClaimCertificate(); - }); - } - - const retryBtn = overlay.querySelector('#btn-aar-retry'); - if (retryBtn) { - retryBtn.addEventListener('click', () => { - overlay.remove(); - if (this.onRestart) this.onRestart(); - }); - } - } -} +/** + * CyberSim OS - After-Action Report (AAR) Modal Interface + */ + +export class AfterActionReport { + constructor({ scenario, scoreResult, onClaimCertificate, onRestart }) { + this.scenario = scenario; + this.scoreResult = scoreResult; + this.onClaimCertificate = onClaimCertificate; + this.onRestart = onRestart; + } + + show() { + const existing = document.getElementById('aar-modal-overlay'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.className = 'cs-modal-overlay'; + overlay.id = 'aar-modal-overlay'; + overlay.style.zIndex = '9000'; + + const r = this.scoreResult; + const cats = r.categories; + + overlay.innerHTML = ` +
+
+
+ + CyberSim OS - Behavioral After-Action Report (AAR) +
+
+ +
+
+
+
Overall Assessment
+
${r.totalScore} / 100
+
+
+ + ${r.isPassed ? `✓ Passed (Threshold: ${r.passingThreshold})` : `✕ Needs Review (Threshold: ${r.passingThreshold})`} + +
+
+ +

Competency Dimension Scores

+ +
+ ${Object.keys(cats).map(key => { + const c = cats[key]; + const pct = Math.round((c.score / c.max) * 100); + let colorClass = 'success'; + if (pct < 60) colorClass = 'danger'; + else if (pct < 80) colorClass = 'warning'; + + return ` +
+
+ ${c.label} + ${c.score} / ${c.max} (${pct}%) +
+
+
+
+
+ `; + }).join('')} +
+ +

Pedagogical Feedback & Observations

+
+
    + ${r.feedback.map(f => `
  • ${f}
  • `).join('')} +
+
+
+ + +
+ `; + + document.body.appendChild(overlay); + + overlay.querySelector('#btn-aar-restart').addEventListener('click', () => { + overlay.remove(); + if (this.onRestart) this.onRestart(); + }); + + const certBtn = overlay.querySelector('#btn-aar-cert'); + if (certBtn) { + certBtn.addEventListener('click', () => { + overlay.remove(); + if (this.onClaimCertificate) this.onClaimCertificate(); + }); + } + + const retryBtn = overlay.querySelector('#btn-aar-retry'); + if (retryBtn) { + retryBtn.addEventListener('click', () => { + overlay.remove(); + if (this.onRestart) this.onRestart(); + }); + } + } +} diff --git a/src/js/scoring/scorer.js b/src/js/scoring/scorer.js index 9bbd4f7..b117673 100644 --- a/src/js/scoring/scorer.js +++ b/src/js/scoring/scorer.js @@ -1,171 +1,203 @@ -/** - * CyberSim OS - Multi-Dimensional Behavioral Scorer - * Evaluates 7 core cybersecurity competency dimensions (0-100 scale, 80 passing threshold). - */ - -export class BehavioralScorer { - constructor(scenario, eventLogs) { - this.scenario = scenario; - this.logs = eventLogs; - this.passingThreshold = 80; - } - - evaluate() { - const logs = this.logs; - const feedback = []; - const timeline = []; - - // Helper checks - const hasLog = (event, target = null) => logs.some(l => { - if (target !== null) return l.event === event && l.target === target; - return l.event === event; - }); - - const findLogs = (event, target = null) => logs.filter(l => { - if (target !== null) return l.event === event && l.target === target; - return l.event === event; - }); - - // 1. Threat Detection (20 pts max) - let threatDetectionScore = 0; - const openedPhish = hasLog('EMAIL_OPENED', 'email_phish_pwreset'); - const openedInvoice = hasLog('EMAIL_OPENED', 'email_malicious_invoice'); - - if (openedPhish) { - threatDetectionScore += 10; - timeline.push({ type: 'positive', text: 'Identified and opened the password reset notice for review.' }); - } else { - feedback.push('You did not review the urgent password reset email during your shift.'); - } - - if (openedInvoice) { - threatDetectionScore += 10; - timeline.push({ type: 'positive', text: 'Identified and reviewed the overdue vendor invoice notice.' }); - } else { - feedback.push('You missed reviewing the overdue vendor invoice communication.'); - } - - // 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 { - totalScore, - isPassed, - passingThreshold: this.passingThreshold, - 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, - feedback: feedback.length > 0 ? feedback : ['Outstanding performance! You investigated all anomalies with sound judgment and protected company assets.'] - }; - } -} +/** + * Data-driven behavioral scorer for CyberSim OS scenarios. + * Reads scoring rules from the scenario definition and evaluates them against event logs or scenario state. + */ +export class BehavioralScorer { + /** + * Creates a new BehavioralScorer. + * @param {Object} scenario - The scenario definition containing scoring rules. + * @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() { + if (!this.scenario || !this.scenario.scoring || !this.scenario.scoring.categories || this.scenario.scoring.categories.length === 0) { + 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 feedback = []; + const rules = this.scenario.scoring.rules || []; + + rules.forEach(rule => { + const isMet = this._evaluateCondition(rule.condition); + + 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); + } + } + }); + + let totalScore = 0; + + // 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; + }); + + // Clamp total score + totalScore = Math.max(0, Math.min(totalScore, 100)); + + let passingThreshold = 0; + if (this.scenario.completion && typeof this.scenario.completion.passingScore === 'number') { + passingThreshold = this.scenario.completion.passingScore; + } else if (typeof this.scenario.passingScore === 'number') { + passingThreshold = this.scenario.passingScore; + } + + const isPassed = totalScore >= passingThreshold; + + // Perfect score feedback fallback + if (feedback.length === 0 && totalScore === Math.min(100, totalMaxPoints)) { + feedback.push('Outstanding performance! You investigated all anomalies with sound judgment and protected company assets.'); + } + + return { + totalScore, + isPassed, + passingThreshold, + categories, + timeline, + 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: [] + }; + } +} diff --git a/src/scenarios/nexacore-orientation/scenario.json b/src/scenarios/nexacore-orientation/scenario.json new file mode 100644 index 0000000..574c520 --- /dev/null +++ b/src/scenarios/nexacore-orientation/scenario.json @@ -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": "jordan.taylor@nexacore.internal", + "department": "Finance & Accounting" + }, + + "organizations": [ + { + "id": "nexacore", + "name": "NexaCore Technologies", + "domains": ["nexacore.internal"], + "departments": ["Finance & Accounting", "Information Security", "IT Helpdesk", "Engineering"], + "securityContacts": ["alex.rivera@nexacore.internal", "it-helpdesk@nexacore.internal"], + "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": "morgan.chen@nexacore.internal", + "organization": "nexacore", + "department": "Finance & Accounting" + }, + { + "id": "sarah-jenkins", + "name": "Sarah Jenkins", + "role": "Senior Financial Analyst", + "email": "sarah.jenkins@nexacore.internal", + "organization": "nexacore", + "department": "Finance & Accounting" + }, + { + "id": "alex-rivera", + "name": "Alex Rivera", + "role": "Chief Information Security Officer", + "email": "alex.rivera@nexacore.internal", + "organization": "nexacore", + "department": "Information Security" + }, + { + "id": "it-helpdesk", + "name": "NexaCore IT Helpdesk", + "role": "IT Support", + "email": "it-helpdesk@nexacore.internal", + "organization": "nexacore", + "department": "IT Helpdesk" + }, + { + "id": "fake-it-helpdesk", + "name": "NexaCore IT Helpdesk", + "role": "IT Support", + "email": "support@nexac0re-portal.com", + "isImpersonator": true, + "impersonates": "it-helpdesk" + }, + { + "id": "apex-billing", + "name": "Apex Supply Partners Billing", + "role": "Accounting & Recovery Division", + "email": "billing@apex-global-supplies.net", + "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": "morgan.chen@nexacore.internal", + "recipient": "jordan.taylor@nexacore.internal", + "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": "support@nexac0re-portal.com", + "recipient": "jordan.taylor@nexacore.internal", + "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: https://intranet.nexacore.internal/sso-verification\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": "alex.rivera@nexacore.internal", + "recipient": "all-employees@nexacore.internal", + "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": "billing@apex-global-supplies.net", + "recipient": "finance@nexacore.internal", + "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": "sarah.jenkins@nexacore.internal", + "recipient": "jordan.taylor@nexacore.internal", + "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", "billing@titancloud.com", "Active"], + ["Quantum Edge Hardware", "VND-209", "invoices@quantumedge.io", "Active"], + ["NexaLogistics Global", "VND-315", "accounts@nexalogistics.com", "Active"], + ["CyberShield Auditing LLC", "VND-402", "finance@cybershield.net", "Active"] + ] + } + } + } + ], + + "pages": [ + { + "url": "http://intranet.nexacore.internal", + "title": "NexaCore Intranet - Home", + "isSecure": true, + "content": "
NexaCore Enterprise Intranet
Monday, August 24, 2026

Company Announcements

\uD83D\uDD12 Security Hardening: As announced by CISO Alex Rivera, company-wide MFA upgrades are underway. Remember to review our security policy in your Documents folder.

\uD83D\uDCCA Q3 Financial Review: Department budget projections are being finalized this week. Contact Morgan Chen with any queries.

Key Contacts

NameRoleEmail
Morgan ChenVP Financemorgan.chen@nexacore.internal
Alex RiveraCISOalex.rivera@nexacore.internal
Sarah JenkinsSr Accountantsarah.jenkins@nexacore.internal
IT HelpdeskSupportit-helpdesk@nexacore.internal
" + }, + { + "url": "http://intranet.nexacore.internal/it-security", + "title": "IT Security Department - Policy & Notices", + "isSecure": true, + "content": "
IT Security & Compliance Portal
\u2190 Back to Intranet

Official Notice: Hardware MFA Rollout (CISO Alex Rivera)

NexaCore is transitioning all personnel to hardware FIDO2 keys and authenticator apps. Official Reminder: NexaCore IT will NEVER email you asking for your password or sending direct credential reset links.

Known Threat Advisory: Phishing Campaigns Targeting NexaCore

\u26A0\uFE0F Be aware of external lookalike domains such as nexac0re-portal.com attempting credential harvesting. Always verify the address bar before entering any information.

" + }, + { + "url": "http://login-nexac0re-portal.com/auth/login", + "title": "NexaCore SSO - Single Sign-On Authentication", + "isSecure": false, + "isPhishing": true, + "content": "
NexaCore Identity SSO
Enter your employee credentials to verify your account
", + "forms": [ + { + "id": "phish-login-form", + "onSubmit": { + "emitEvent": "NAV_FORM_SUBMITTED", + "target": "phish_login_form", + "response": { + "type": "pageContent", + "content": "

Account Verification Complete

Your credentials have been verified with our external single sign-on synchronization gateway. You may return to your workplace desktop.

" + } + } + } + ] + } + ], + + "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" + } +} diff --git a/src/scenarios/quickstart-example/scenario.json b/src/scenarios/quickstart-example/scenario.json new file mode 100644 index 0000000..5d694c9 --- /dev/null +++ b/src/scenarios/quickstart-example/scenario.json @@ -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": "casey.morgan@meridianhealth.org", + "department": "Front Office" + }, + + "organizations": [ + { + "id": "meridian", + "name": "Meridian Health Partners", + "domains": ["meridianhealth.org"], + "departments": ["Front Office", "IT Security", "Clinical"], + "securityContacts": ["security@meridianhealth.org"] + } + ], + + "people": [ + { + "id": "dr-patel", + "name": "Dr. Priya Patel", + "role": "Chief of Staff", + "email": "priya.patel@meridianhealth.org", + "organization": "meridian" + }, + { + "id": "it-support", + "name": "Meridian IT Support", + "role": "IT Helpdesk", + "email": "it-support@meridianhealth.org", + "organization": "meridian" + }, + { + "id": "fake-it", + "name": "Meridian IT Support", + "role": "IT Helpdesk", + "email": "support@meridian-health-portal.com", + "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": "priya.patel@meridianhealth.org", + "recipient": "casey.morgan@meridianhealth.org", + "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": "support@meridian-health-portal.com", + "recipient": "casey.morgan@meridianhealth.org", + "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: https://records.meridianhealth.org/renew\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": "it-support@meridianhealth.org", + "recipient": "all-staff@meridianhealth.org", + "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": "
Meridian Health Records Portal
Verify your employee credentials to maintain access
", + "forms": [ + { + "id": "phish-cred-form", + "onSubmit": { + "emitEvent": "NAV_FORM_SUBMITTED", + "target": "phish_cred_form", + "response": { + "type": "pageContent", + "content": "

Access Verified

Your credentials have been confirmed. You may close this window.

" + } + } + } + ] + } + ], + + "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" + } +} diff --git a/src/verify.html b/src/verify.html index 3603fbe..ff0c48e 100644 --- a/src/verify.html +++ b/src/verify.html @@ -1,164 +1,164 @@ - - - - - - CyberSim OS - Standalone Certificate Verifier - - - - - - -
-
- -

CyberSim Certificate Verifier

-
-

Offline-first cryptographic credential integrity and scoring verification engine.

- -
-
📄
-
Drag & Drop a *.cybercert credential file here
-
or click to browse local files
- -
- - -
- - - - + + + + + + CyberSim OS - Standalone Certificate Verifier + + + + + + +
+
+ +

CyberSim Certificate Verifier

+
+

Offline-first cryptographic credential integrity and scoring verification engine.

+ +
+
📄
+
Drag & Drop a *.cybercert credential file here
+
or click to browse local files
+ +
+ + +
+ + + +