Skip to content

Track flashcard views from learn #198

Track flashcard views from learn

Track flashcard views from learn #198

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