cyborg-hunter 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -0
- package/bin/cyborg-hunter.js +20 -0
- package/dist/cyborg-hunter.esm.js +1534 -0
- package/dist/cyborg-hunter.min.js +6 -0
- package/dist/jspsych-cyborg-hunter.js +1 -0
- package/package.json +56 -0
- package/src/cli/analyzers/edge-exit.js +56 -0
- package/src/cli/analyzers/summary.js +99 -0
- package/src/cli/analyzers/triage.js +104 -0
- package/src/cli/config.js +99 -0
- package/src/cli/ingest.js +343 -0
- package/src/cli/init.js +31 -0
- package/src/cli/renderers/event-log.js +66 -0
- package/src/cli/renderers/extensions.js +43 -0
- package/src/cli/renderers/html-index.js +329 -0
- package/src/cli/renderers/summary-csv.js +70 -0
- package/src/cli/renderers/tab-timeline.js +149 -0
- package/src/cli/renderers/trajectories.js +607 -0
- package/src/cli/renderers/triage-md.js +29 -0
- package/src/cli/renderers/typing-profile.js +200 -0
- package/src/cli/report.js +100 -0
- package/src/core/index.js +13 -0
- package/src/core/monitor.js +404 -0
- package/src/core/scoring.js +153 -0
- package/src/core/signals/browser.js +303 -0
- package/src/core/signals/clipboard.js +91 -0
- package/src/core/signals/dom-protection.js +285 -0
- package/src/core/signals/focus.js +101 -0
- package/src/core/signals/mouse.js +117 -0
- package/src/core/signals/typing.js +110 -0
- package/src/core/state-machine.js +88 -0
- package/src/jspsych/extension.js +199 -0
- package/src/shared/constants.js +162 -0
- package/src/shared/schema.js +56 -0
- package/src/shared/validation.js +73 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// src/shared/constants.js
|
|
2
|
+
// Default thresholds and preset configurations for Cyborg Hunter.
|
|
3
|
+
// Single source of truth — used by both the browser library and CLI report tool.
|
|
4
|
+
|
|
5
|
+
export const VERSION = "0.3.0";
|
|
6
|
+
|
|
7
|
+
// Default detection thresholds shared across presets.
|
|
8
|
+
// Researchers can override any value at init() time.
|
|
9
|
+
export const DEFAULT_THRESHOLDS = {
|
|
10
|
+
pasteMinChars: 0, // record ALL pastes (was 20; now 0 by default)
|
|
11
|
+
dropMinChars: 0, // record ALL drops
|
|
12
|
+
sidebarGapPx: 100, // outerWidth - innerWidth above this = sidebar
|
|
13
|
+
layoutCompressionPx: 20, // innerWidth - clientWidth above scrollbar width
|
|
14
|
+
syntheticGapMs: 100, // keydown-to-input gap above this = synthetic insertion
|
|
15
|
+
idleGapMs: 10000, // no input for this long = idle gap
|
|
16
|
+
idleCheckIntervalMs: 5000, // how often to check for idle gaps
|
|
17
|
+
mouseThrottleMs: 50, // 20 Hz mouse sampling
|
|
18
|
+
mouseMaxEvents: 2000, // safety cap per trial (configurable)
|
|
19
|
+
mouseBotMinEvents: 3, // minimum move events for bot metrics
|
|
20
|
+
sidebarPollMs: 2000, // sidebar + zoom check interval
|
|
21
|
+
extensionScanMs: 30000, // AI extension DOM re-scan interval
|
|
22
|
+
windowPositionPollMs: 2000, // screenX/screenY polling interval
|
|
23
|
+
elementTraceHz: 500, // elementsFromPoint sampling interval (ms)
|
|
24
|
+
tabAwayDurationMs: 3000, // tab-away longer than this counts toward soft score (and is classified as "long" in reports)
|
|
25
|
+
typingSpeedCps: 10 // chars/sec above this counts toward soft score
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Named detection + scoring configurations.
|
|
29
|
+
// Architecture:
|
|
30
|
+
// signals — what to COLLECT (always-on by default; disable for GDPR-sensitive signals)
|
|
31
|
+
// thresholds — detection parameters (when does an event "count"?)
|
|
32
|
+
// scoring — two-tier screenout system:
|
|
33
|
+
// hard: count-based, any one crossing its threshold = screenout
|
|
34
|
+
// soft: weighted accumulation with a separate threshold
|
|
35
|
+
// screenout — master switch + grace period
|
|
36
|
+
export const PRESETS = {
|
|
37
|
+
permissive: {
|
|
38
|
+
// Collect everything, screen out nobody. For calibration/pilot studies.
|
|
39
|
+
signals: {
|
|
40
|
+
paste: true, copy: true, tabAway: true, typingSpeed: true,
|
|
41
|
+
devTools: true, aiExtensions: true, sidebarGap: true,
|
|
42
|
+
keyboardShortcuts: true, mouseTracking: true, idleGaps: true,
|
|
43
|
+
windowPosition: true, clipboardManager: true,
|
|
44
|
+
keystrokeDynamics: false // GDPR: off by default
|
|
45
|
+
},
|
|
46
|
+
thresholds: {
|
|
47
|
+
typingSpeedCps: 15 // more lenient typing speed threshold
|
|
48
|
+
},
|
|
49
|
+
scoring: {
|
|
50
|
+
hard: {
|
|
51
|
+
paste: { countThreshold: 3 },
|
|
52
|
+
drop: { countThreshold: 3 }
|
|
53
|
+
},
|
|
54
|
+
soft: {
|
|
55
|
+
copy: { weight: 1, maxPerTrial: 2 },
|
|
56
|
+
tabAway: { weight: 1, maxPerTrial: 2 },
|
|
57
|
+
typingSpeed: { weight: 1 },
|
|
58
|
+
sidebarEvent: { weight: 1 },
|
|
59
|
+
devTools: { weight: 1 },
|
|
60
|
+
foreignInput: { weight: 1 }
|
|
61
|
+
},
|
|
62
|
+
softScoreThreshold: 10
|
|
63
|
+
},
|
|
64
|
+
screenout: { enabled: false, gracePeriodTrials: 5 }
|
|
65
|
+
},
|
|
66
|
+
standard: {
|
|
67
|
+
// Balanced detection. Default for most studies.
|
|
68
|
+
signals: {
|
|
69
|
+
paste: true, copy: true, tabAway: true, typingSpeed: true,
|
|
70
|
+
devTools: true, aiExtensions: true, sidebarGap: true,
|
|
71
|
+
keyboardShortcuts: true, mouseTracking: true, idleGaps: true,
|
|
72
|
+
windowPosition: true, clipboardManager: true,
|
|
73
|
+
keystrokeDynamics: false // GDPR: off by default
|
|
74
|
+
},
|
|
75
|
+
thresholds: {}, // uses DEFAULT_THRESHOLDS as-is
|
|
76
|
+
scoring: {
|
|
77
|
+
hard: {
|
|
78
|
+
paste: { countThreshold: 2 },
|
|
79
|
+
drop: { countThreshold: 2 }
|
|
80
|
+
},
|
|
81
|
+
soft: {
|
|
82
|
+
copy: { weight: 2, maxPerTrial: 2 },
|
|
83
|
+
tabAway: { weight: 1, maxPerTrial: 2 },
|
|
84
|
+
typingSpeed: { weight: 2 },
|
|
85
|
+
sidebarEvent: { weight: 3 },
|
|
86
|
+
devTools: { weight: 1 },
|
|
87
|
+
foreignInput: { weight: 2 }
|
|
88
|
+
},
|
|
89
|
+
softScoreThreshold: 6
|
|
90
|
+
},
|
|
91
|
+
screenout: { enabled: true, gracePeriodTrials: 3 }
|
|
92
|
+
},
|
|
93
|
+
strict: {
|
|
94
|
+
// Low thresholds, all signals. For high-stakes studies.
|
|
95
|
+
signals: {
|
|
96
|
+
paste: true, copy: true, tabAway: true, typingSpeed: true,
|
|
97
|
+
devTools: true, aiExtensions: true, sidebarGap: true,
|
|
98
|
+
keyboardShortcuts: true, mouseTracking: true, idleGaps: true,
|
|
99
|
+
windowPosition: true, clipboardManager: true,
|
|
100
|
+
keystrokeDynamics: true
|
|
101
|
+
},
|
|
102
|
+
thresholds: {
|
|
103
|
+
typingSpeedCps: 8, // stricter typing speed
|
|
104
|
+
tabAwayDurationMs: 5000, // shorter tab-away counts
|
|
105
|
+
mouseMaxEvents: 5000 // larger cap for detailed analysis
|
|
106
|
+
},
|
|
107
|
+
scoring: {
|
|
108
|
+
hard: {
|
|
109
|
+
paste: { countThreshold: 1 },
|
|
110
|
+
copy: { countThreshold: 2 },
|
|
111
|
+
drop: { countThreshold: 1 }
|
|
112
|
+
},
|
|
113
|
+
soft: {
|
|
114
|
+
tabAway: { weight: 2, maxPerTrial: 1 },
|
|
115
|
+
typingSpeed: { weight: 3 },
|
|
116
|
+
sidebarEvent: { weight: 2 },
|
|
117
|
+
devTools: { weight: 2 },
|
|
118
|
+
foreignInput: { weight: 3 }
|
|
119
|
+
},
|
|
120
|
+
softScoreThreshold: 4
|
|
121
|
+
},
|
|
122
|
+
screenout: { enabled: true, gracePeriodTrials: 2 }
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// AI extension selectors — used by browser detection to find known AI tools
|
|
127
|
+
// in the DOM. Each entry has a human-readable name and a CSS selector.
|
|
128
|
+
export const AI_SELECTORS = [
|
|
129
|
+
// ChatGPT extensions
|
|
130
|
+
{ name: "ChatGPT sidebar", sel: "chatgpt-sidebar" },
|
|
131
|
+
{ name: "ChatGPT extension", sel: "[data-chatgpt]" },
|
|
132
|
+
// Copilot browser extension (not Edge's built-in)
|
|
133
|
+
{ name: "Copilot extension", sel: "copilot-suggestions" },
|
|
134
|
+
{ name: "Copilot extension", sel: "[data-copilot]" },
|
|
135
|
+
// Gemini browser extension (not Chrome's built-in)
|
|
136
|
+
{ name: "Gemini extension", sel: "gemini-panel" },
|
|
137
|
+
// Claude browser extension
|
|
138
|
+
{ name: "Claude extension", sel: "[data-claude-extension]" },
|
|
139
|
+
// Popular AI extensions
|
|
140
|
+
{ name: "Monica AI", sel: "monica-root" },
|
|
141
|
+
{ name: "Merlin AI", sel: "merlin-root" },
|
|
142
|
+
{ name: "Sider AI", sel: "[data-sider-extension]" },
|
|
143
|
+
{ name: "MaxAI", sel: "[data-maxai]" },
|
|
144
|
+
// Generic AI assistant marker
|
|
145
|
+
{ name: "AI assistant", sel: "[data-ai-assistant]" },
|
|
146
|
+
// AI iframes (extensions embedding AI services)
|
|
147
|
+
{ name: "AI iframe (OpenAI)", sel: 'iframe[src*="chat.openai.com"]' },
|
|
148
|
+
{ name: "AI iframe (Copilot)", sel: 'iframe[src*="copilot.microsoft.com"]' },
|
|
149
|
+
{ name: "AI iframe (Gemini)", sel: 'iframe[src*="gemini.google.com"]' },
|
|
150
|
+
{ name: "AI iframe (Claude)", sel: 'iframe[src*="claude.ai"]' },
|
|
151
|
+
{ name: "AI iframe (Perplexity)", sel: 'iframe[src*="perplexity.ai"]' }
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
// Benign extension tags to exclude from MutationObserver alerts.
|
|
155
|
+
// These are known non-AI extensions that commonly inject DOM elements.
|
|
156
|
+
export const BENIGN_TAGS = [
|
|
157
|
+
"grammarly", "lastpass", "1password", "bitwarden",
|
|
158
|
+
"dashlane", "roboform", "honey", "ublock", "adblock"
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
// Storage key prefix for localStorage/sessionStorage persistence
|
|
162
|
+
export const STORAGE_PREFIX = "integrity_monitor_";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// src/shared/schema.js
|
|
2
|
+
// Defines the expected shape of integrity data for validation.
|
|
3
|
+
// Used by the CLI ingest step and for documentation generation.
|
|
4
|
+
|
|
5
|
+
// The fields that appear in a trial report from endTrial().
|
|
6
|
+
// 'required' means the field must be present for valid integrity data.
|
|
7
|
+
export const TRIAL_REPORT_FIELDS = {
|
|
8
|
+
trialId: { type: 'string', required: true },
|
|
9
|
+
phase: { type: 'string', required: false },
|
|
10
|
+
libraryVersion: { type: 'string', required: true },
|
|
11
|
+
participantId: { type: 'string', required: true },
|
|
12
|
+
startTime: { type: 'number', required: true },
|
|
13
|
+
duration_ms: { type: 'number', required: true },
|
|
14
|
+
pasteEvents: { type: 'array', required: true },
|
|
15
|
+
copyEvents: { type: 'array', required: true },
|
|
16
|
+
dropEvents: { type: 'array', required: true },
|
|
17
|
+
editTimestamps: { type: 'array', required: false },
|
|
18
|
+
charsPerSec: { type: 'number', required: false },
|
|
19
|
+
tabAwayEvents: { type: 'array', required: true },
|
|
20
|
+
idleGaps: { type: 'array', required: false },
|
|
21
|
+
mouseEvents: { type: 'array', required: false },
|
|
22
|
+
mouseMetrics: { type: 'object', required: false },
|
|
23
|
+
extensionsDetected: { type: 'array', required: false },
|
|
24
|
+
sidebarGapPx: { type: 'number', required: false },
|
|
25
|
+
foreignInputEvents: { type: 'array', required: false },
|
|
26
|
+
syntheticInsertions: { type: 'array', required: false },
|
|
27
|
+
decoy: { type: 'object', required: false },
|
|
28
|
+
trialSoftScore: { type: 'number', required: true },
|
|
29
|
+
trialSignals: { type: 'object', required: true }
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Default config for the CLI report tool.
|
|
33
|
+
// All fields have sensible defaults — minimal config is just dataDir + filePattern.
|
|
34
|
+
export const DEFAULT_CLI_CONFIG = {
|
|
35
|
+
dataDir: "./data",
|
|
36
|
+
filePattern: "*.json",
|
|
37
|
+
participantIdField: "participantId",
|
|
38
|
+
trialIdField: "trialId",
|
|
39
|
+
trialOrderField: "trialIndex",
|
|
40
|
+
integrityField: "integrity",
|
|
41
|
+
trialsPerParticipant: null,
|
|
42
|
+
platformIdField: null,
|
|
43
|
+
conditionField: null,
|
|
44
|
+
groupField: null,
|
|
45
|
+
typingSpeedThreshold_cps: 10,
|
|
46
|
+
tabAwayMinDuration_ms: 3000,
|
|
47
|
+
idleGapThreshold_ms: 10000,
|
|
48
|
+
suspiciouslyFastRT_ms: 2000,
|
|
49
|
+
scoring: null, // uses library defaults
|
|
50
|
+
signals: null, // all enabled
|
|
51
|
+
trajectoryGrid: "auto",
|
|
52
|
+
trajectoryTrialLabel: "trialId",
|
|
53
|
+
trajectoryResponseField: null,
|
|
54
|
+
outputDir: "./cyborg-hunter-report",
|
|
55
|
+
outputFormat: "html"
|
|
56
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// src/shared/validation.js
|
|
2
|
+
// Config validation with "did you mean?" suggestions using Levenshtein distance.
|
|
3
|
+
// Used by both library init() and CLI config loading to catch typos early.
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_CLI_CONFIG } from './schema.js';
|
|
6
|
+
import { DEFAULT_THRESHOLDS } from './constants.js';
|
|
7
|
+
|
|
8
|
+
// All recognized config keys across both browser library and CLI.
|
|
9
|
+
// Unknown keys trigger a warning with the closest match suggestion.
|
|
10
|
+
const ALL_KNOWN_KEYS = [
|
|
11
|
+
...Object.keys(DEFAULT_CLI_CONFIG),
|
|
12
|
+
...Object.keys(DEFAULT_THRESHOLDS),
|
|
13
|
+
'preset', 'participantId', 'signals', 'thresholds', 'scoring',
|
|
14
|
+
'domProtection', 'collectForPostHoc', 'onSignal', 'screenout',
|
|
15
|
+
'experimentContainer', 'knownInputs', 'decoyAnswers', 'decoyMap',
|
|
16
|
+
'decoyVisibility', 'decoyFraming', 'decoyExcludeButtons',
|
|
17
|
+
'autoMonitor', 'excludeTrialTypes'
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validates a config object against known keys.
|
|
22
|
+
* Returns an array of warning strings for any unrecognized keys.
|
|
23
|
+
* Each warning includes a "did you mean?" suggestion if a close match exists.
|
|
24
|
+
*/
|
|
25
|
+
export function validateConfig(config, knownKeys = ALL_KNOWN_KEYS) {
|
|
26
|
+
const warnings = [];
|
|
27
|
+
Object.keys(config).forEach(key => {
|
|
28
|
+
if (!knownKeys.includes(key)) {
|
|
29
|
+
const suggestion = findClosestKey(key, knownKeys);
|
|
30
|
+
const msg = suggestion
|
|
31
|
+
? `Unknown config key "${key}" — did you mean "${suggestion}"?`
|
|
32
|
+
: `Unknown config key "${key}"`;
|
|
33
|
+
warnings.push(msg);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
return warnings;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Finds the closest matching key using Levenshtein distance.
|
|
41
|
+
* Returns null if no key is within edit distance 3 (too different to suggest).
|
|
42
|
+
*/
|
|
43
|
+
function findClosestKey(input, keys) {
|
|
44
|
+
let best = null;
|
|
45
|
+
let bestDist = Infinity;
|
|
46
|
+
for (const key of keys) {
|
|
47
|
+
const dist = levenshtein(input.toLowerCase(), key.toLowerCase());
|
|
48
|
+
if (dist < bestDist && dist <= 3) {
|
|
49
|
+
bestDist = dist;
|
|
50
|
+
best = key;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return best;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Computes the Levenshtein (edit) distance between two strings.
|
|
58
|
+
* Used for "did you mean?" suggestions on misspelled config keys.
|
|
59
|
+
*/
|
|
60
|
+
function levenshtein(a, b) {
|
|
61
|
+
const m = a.length, n = b.length;
|
|
62
|
+
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
63
|
+
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
|
64
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
65
|
+
for (let i = 1; i <= m; i++) {
|
|
66
|
+
for (let j = 1; j <= n; j++) {
|
|
67
|
+
dp[i][j] = a[i-1] === b[j-1]
|
|
68
|
+
? dp[i-1][j-1]
|
|
69
|
+
: 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return dp[m][n];
|
|
73
|
+
}
|