Track flashcard views from learn #198
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Security checks | |
| on: | |
| pull_request: | |
| push: | |
| branches: | |
| - main | |
| permissions: | |
| contents: read | |
| jobs: | |
| static-security: | |
| name: Static security checks | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Check out repository | |
| uses: actions/checkout@v4 | |
| - name: Run static checks | |
| shell: bash | |
| run: | | |
| python - <<'PY' | |
| from pathlib import Path | |
| import re | |
| import sys | |
| ROOT = Path('.') | |
| TEXT_EXTENSIONS = {'.html', '.css', '.js', '.md'} | |
| SKIP_DIRS = {'.git'} | |
| CONFLICT_MARKER_RE = re.compile(r'^(<<<<<<<|=======|>>>>>>>)', re.MULTILINE) | |
| INLINE_HANDLER_FREE_HTML = { | |
| 'index.html', | |
| 'learn.html', | |
| 'exercises.html', | |
| 'exam.html', | |
| 'tense-rules.html', | |
| 'condition.html', | |
| 'cookies.html', | |
| 'confidentialite.html', | |
| 'mention.html', | |
| } | |
| INLINE_SCRIPT_FREE_HTML = { | |
| 'index.html', | |
| 'tense-rules.html', | |
| 'condition.html', | |
| 'cookies.html', | |
| 'confidentialite.html', | |
| 'mention.html', | |
| } | |
| STRICT_SCRIPT_CSP_HTML = { | |
| 'index.html', | |
| 'tense-rules.html', | |
| } | |
| REQUIRED_CSP_DIRECTIVES = [ | |
| "default-src 'self'", | |
| "object-src 'none'", | |
| "base-uri 'self'", | |
| "form-action 'self'", | |
| "connect-src 'none'", | |
| "upgrade-insecure-requests", | |
| ] | |
| HTML_ONLY_PATTERNS = [ | |
| (r'<img\b[^>]*\bsrc=["\']https?://', 'external image URL'), | |
| (r'<img\b[^>]*\bsrc=["\']//', 'protocol-relative image URL'), | |
| (r'\bsrcset=["\'][^"\']*https?://', 'external srcset URL'), | |
| (r'\bsrcset=["\'][^"\']*(?<!:)//', 'protocol-relative srcset URL'), | |
| (r'<(?:audio|video|source|track)\b[^>]*\bsrc=["\']https?://', 'external media URL'), | |
| (r'<(?:audio|video|source|track)\b[^>]*\bsrc=["\']//', 'protocol-relative media URL'), | |
| (r'<video\b[^>]*\bposter=["\']https?://', 'external video poster URL'), | |
| (r'<video\b[^>]*\bposter=["\']//', 'protocol-relative video poster URL'), | |
| (r'<link\b(?=[^>]*\brel=["\'][^"\']*(?:stylesheet|preload|modulepreload|prefetch|preconnect|dns-prefetch|manifest|icon|apple-touch-icon)[^"\']*["\'])(?=[^>]*\bhref=["\']https?://)', 'external link resource URL'), | |
| (r'<link\b(?=[^>]*\brel=["\'][^"\']*(?:stylesheet|preload|modulepreload|prefetch|preconnect|dns-prefetch|manifest|icon|apple-touch-icon)[^"\']*["\'])(?=[^>]*\bhref=["\']//)', 'protocol-relative link resource URL'), | |
| (r'\sping=', 'link ping attribute'), | |
| (r'\ssrcdoc=', 'srcdoc attribute'), | |
| (r'\sformaction=', 'formaction attribute'), | |
| ] | |
| CSS_RESOURCE_PATTERNS = [ | |
| (r'url\(\s*["\']?https?://', 'external CSS url()'), | |
| (r'url\(\s*["\']?//', 'protocol-relative CSS url()'), | |
| ] | |
| failures = [] | |
| def tracked_text_files(): | |
| for path in ROOT.rglob('*'): | |
| if not path.is_file(): | |
| continue | |
| if any(part in SKIP_DIRS for part in path.parts): | |
| continue | |
| if path.suffix.lower() in TEXT_EXTENSIONS: | |
| yield path | |
| for path in tracked_text_files(): | |
| text = path.read_text(encoding='utf-8', errors='replace') | |
| if path.suffix.lower() == '.html': | |
| if 'Content-Security-Policy' not in text: | |
| failures.append(f'{path}: missing Content-Security-Policy meta tag') | |
| if 'name="referrer"' not in text: | |
| failures.append(f'{path}: missing referrer meta tag') | |
| for directive in REQUIRED_CSP_DIRECTIVES: | |
| if directive not in text: | |
| failures.append(f'{path}: missing CSP directive {directive}') | |
| for pattern, label in HTML_ONLY_PATTERNS: | |
| if re.search(pattern, text, re.IGNORECASE): | |
| failures.append(f'{path}: uses {label}') | |
| if path.suffix.lower() in {'.html', '.css'}: | |
| for pattern, label in CSS_RESOURCE_PATTERNS: | |
| if re.search(pattern, text, re.IGNORECASE): | |
| failures.append(f'{path}: uses {label}') | |
| if path.as_posix() in INLINE_HANDLER_FREE_HTML and re.search(r'\son\w+=', text, re.IGNORECASE): | |
| failures.append(f'{path}: uses inline event handlers') | |
| if path.as_posix() in INLINE_SCRIPT_FREE_HTML: | |
| if re.search(r'<script(?![^>]*\bsrc=)[^>]*>', text, re.IGNORECASE): | |
| failures.append(f'{path}: uses inline script tags') | |
| if path.as_posix() in STRICT_SCRIPT_CSP_HTML and re.search(r"script-src[^;]*'unsafe-inline'", text, re.IGNORECASE): | |
| failures.append(f'{path}: allows unsafe-inline scripts in CSP') | |
| if CONFLICT_MARKER_RE.search(text): | |
| failures.append(f'{path}: contains Git conflict markers') | |
| for pattern, label in [ | |
| (r'\binnerHTML\b', 'innerHTML'), | |
| (r'\binsertAdjacentHTML\b', 'insertAdjacentHTML'), | |
| (r'\beval\s*\(', 'eval()'), | |
| (r'\bnew\s+Function\s*\(', 'new Function()'), | |
| (r'\bsetTimeout\s*\(\s*["\']', 'string-based setTimeout()'), | |
| (r'\bsetInterval\s*\(\s*["\']', 'string-based setInterval()'), | |
| (r'\bdocument\.write\s*\(', 'document.write()'), | |
| (r'\blocalStorage\.getItem\s*\(', 'direct localStorage.getItem()'), | |
| (r'\bsessionStorage\.getItem\s*\(', 'direct sessionStorage.getItem()'), | |
| (r'\bfetch\s*\(', 'fetch()'), | |
| (r'\bXMLHttpRequest\b', 'XMLHttpRequest'), | |
| (r'\bWebSocket\s*\(', 'WebSocket'), | |
| (r'\bEventSource\s*\(', 'EventSource'), | |
| (r'\bnavigator\.sendBeacon\s*\(', 'navigator.sendBeacon()'), | |
| (r'\bimportScripts\s*\(', 'importScripts()'), | |
| (r'\bdocument\.cookie\b', 'document.cookie'), | |
| (r'\bindexedDB\b', 'indexedDB'), | |
| (r'\bwindow\.open\s*\(', 'window.open()'), | |
| (r'\bpostMessage\s*\(', 'postMessage()'), | |
| (r'\b(?:Worker|SharedWorker)\s*\(', 'web workers'), | |
| (r'\bserviceWorker\b', 'service workers'), | |
| (r'http://', 'insecure http:// reference'), | |
| (r'<script\b[^>]*\bsrc=["\']https?://', 'external script URL'), | |
| (r'<script\b[^>]*\bsrc=["\']//', 'protocol-relative script URL'), | |
| (r'<link\b[^>]*\brel=["\']stylesheet["\'][^>]*\bhref=["\']https?://', 'external stylesheet URL'), | |
| (r'<link\b[^>]*\brel=["\']stylesheet["\'][^>]*\bhref=["\']//', 'protocol-relative stylesheet URL'), | |
| (r'@import\s+(?:url\()?\s*["\']?https?://', 'external CSS import'), | |
| (r'@import\s+(?:url\()?\s*["\']?//', 'protocol-relative CSS import'), | |
| (r'javascript:', 'javascript: URL'), | |
| (r'vbscript:', 'vbscript: URL'), | |
| (r'data:text/html', 'data:text/html URL'), | |
| (r'<base\b', 'base tag'), | |
| (r'<form\b', 'form tag'), | |
| (r'<iframe\b', 'iframe tag'), | |
| (r'<object\b', 'object tag'), | |
| (r'<embed\b', 'embed tag'), | |
| (r'<meta\b[^>]*http-equiv=["\']?refresh', 'meta refresh redirect'), | |
| ]: | |
| if re.search(pattern, text, re.IGNORECASE): | |
| failures.append(f'{path}: uses {label}') | |
| for match in re.finditer(r'<a\b[^>]*target=["\']_blank["\'][^>]*>', text, re.IGNORECASE): | |
| tag = match.group(0) | |
| rel_match = re.search(r'rel=["\']([^"\']*)["\']', tag, re.IGNORECASE) | |
| rel_values = rel_match.group(1).lower().split() if rel_match else [] | |
| if 'noopener' not in rel_values or 'noreferrer' not in rel_values: | |
| failures.append(f'{path}: target="_blank" link without rel="noopener noreferrer"') | |
| if failures: | |
| print('Static security checks failed:') | |
| for failure in failures: | |
| print(f'- {failure}') | |
| sys.exit(1) | |
| print('Static security checks passed.') | |
| PY | |
| - name: Validate irregular verb data | |
| shell: bash | |
| run: | | |
| node <<'JS' | |
| const fs = require('fs'); | |
| const vm = require('vm'); | |
| const source = fs.readFileSync('data/irregular-verbs.js', 'utf8'); | |
| const sandbox = { window: {} }; | |
| vm.createContext(sandbox); | |
| vm.runInContext(source, sandbox, { filename: 'data/irregular-verbs.js' }); | |
| const verbs = sandbox.window.IRREGULAR_VERBS; | |
| const failures = []; | |
| const allowedDifficulties = new Set(['easy', 'medium', 'hard']); | |
| const requiredFields = ['base', 'past', 'pp', 'difficulty']; | |
| const requiredSentences = ['base', 'past', 'pp']; | |
| if (!Array.isArray(verbs)) { | |
| failures.push('IRREGULAR_VERBS must be an array'); | |
| } else { | |
| const seenBaseVerbs = new Set(); | |
| verbs.forEach((verb, index) => { | |
| const label = verb && verb.base ? `verb "${verb.base}"` : `entry ${index + 1}`; | |
| if (!verb || typeof verb !== 'object') { | |
| failures.push(`${label}: must be an object`); | |
| return; | |
| } | |
| requiredFields.forEach((field) => { | |
| if (typeof verb[field] !== 'string' || verb[field].trim() === '') { | |
| failures.push(`${label}: missing ${field}`); | |
| } | |
| }); | |
| if (typeof verb.difficulty === 'string' && !allowedDifficulties.has(verb.difficulty)) { | |
| failures.push(`${label}: invalid difficulty "${verb.difficulty}"`); | |
| } | |
| if (typeof verb.base === 'string') { | |
| const baseKey = verb.base.trim().toLowerCase(); | |
| if (seenBaseVerbs.has(baseKey)) { | |
| failures.push(`${label}: duplicate base verb`); | |
| } | |
| seenBaseVerbs.add(baseKey); | |
| } | |
| if (!verb.sentences || typeof verb.sentences !== 'object') { | |
| failures.push(`${label}: missing sentences`); | |
| } else { | |
| requiredSentences.forEach((form) => { | |
| const sentence = verb.sentences[form]; | |
| if (typeof sentence !== 'string' || sentence.trim() === '') { | |
| failures.push(`${label}: missing sentences.${form}`); | |
| } else if (!sentence.includes('____')) { | |
| failures.push(`${label}: sentences.${form} must contain ____`); | |
| } | |
| }); | |
| } | |
| }); | |
| } | |
| if (failures.length) { | |
| console.log('Irregular verb data checks failed:'); | |
| failures.forEach((failure) => console.log(`- ${failure}`)); | |
| process.exit(1); | |
| } | |
| console.log(`Irregular verb data checks passed (${verbs.length} verbs).`); | |
| JS | |
| - name: Validate verb utilities | |
| shell: bash | |
| run: | | |
| node <<'JS' | |
| const fs = require('fs'); | |
| const vm = require('vm'); | |
| const source = fs.readFileSync('verb-utils.js', 'utf8'); | |
| const sandbox = { | |
| window: {}, | |
| localStorage: { selectedDifficulty: 'medium' }, | |
| safeGet(storage, key) { | |
| return storage[key]; | |
| }, | |
| }; | |
| vm.createContext(sandbox); | |
| vm.runInContext(source, sandbox, { filename: 'verb-utils.js' }); | |
| const utils = sandbox.window.VerbUtils; | |
| const failures = []; | |
| const requiredFunctions = [ | |
| 'getSelectedDifficulty', | |
| 'getVerbsForDifficulty', | |
| 'shuffle', | |
| 'correctAnswer', | |
| ]; | |
| if (!utils || typeof utils !== 'object') { | |
| failures.push('window.VerbUtils must be defined'); | |
| } else { | |
| if (!Array.isArray(utils.templates) || utils.templates.length !== 3) { | |
| failures.push('VerbUtils.templates must contain three verb forms'); | |
| } else { | |
| const forms = utils.templates.map((template) => template.form).join(','); | |
| if (forms !== 'base,past,pp') { | |
| failures.push('VerbUtils.templates must use base,past,pp forms'); | |
| } | |
| } | |
| requiredFunctions.forEach((name) => { | |
| if (typeof utils[name] !== 'function') { | |
| failures.push(`VerbUtils.${name} must be a function`); | |
| } | |
| }); | |
| if (typeof utils.getSelectedDifficulty === 'function') { | |
| if (utils.getSelectedDifficulty() !== 'medium') { | |
| failures.push('getSelectedDifficulty should return a valid stored difficulty'); | |
| } | |
| sandbox.localStorage.selectedDifficulty = 'unknown'; | |
| if (utils.getSelectedDifficulty() !== 'easy') { | |
| failures.push('getSelectedDifficulty should fall back to easy'); | |
| } | |
| } | |
| if (typeof utils.getVerbsForDifficulty === 'function') { | |
| const filtered = utils.getVerbsForDifficulty([ | |
| { base: 'go', difficulty: 'easy' }, | |
| { base: 'write', difficulty: 'hard' }, | |
| ], 'easy'); | |
| if (!Array.isArray(filtered) || filtered.length !== 1 || filtered[0].base !== 'go') { | |
| failures.push('getVerbsForDifficulty should filter by difficulty'); | |
| } | |
| } | |
| if (typeof utils.correctAnswer === 'function') { | |
| const answer = utils.correctAnswer({ past: 'went' }, { form: 'past' }); | |
| if (answer !== 'went') { | |
| failures.push('correctAnswer should return the requested verb form'); | |
| } | |
| } | |
| if (typeof utils.shuffle === 'function') { | |
| const shuffled = utils.shuffle(['a', 'b', 'c']); | |
| if (!Array.isArray(shuffled) || shuffled.length !== 3 || shuffled.slice().sort().join(',') !== 'a,b,c') { | |
| failures.push('shuffle should keep the same values'); | |
| } | |
| } | |
| } | |
| if (failures.length) { | |
| console.log('Verb utility checks failed:'); | |
| failures.forEach((failure) => console.log(`- ${failure}`)); | |
| process.exit(1); | |
| } | |
| console.log('Verb utility checks passed.'); | |
| JS |