Files
santifer--career-ops/providers/local-parser.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

212 lines
7.1 KiB
JavaScript

// @ts-check
/** @typedef {import('./_types.js').Provider} Provider */
import { execFile } from 'child_process';
import { realpathSync } from 'fs';
import { resolve, sep } from 'path';
import { fileURLToPath } from 'url';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
const LOCAL_PARSER_TIMEOUT_MS = 20_000;
const LOCAL_PARSER_MAX_BUFFER_BYTES = 2_000_000;
// `parser.command` / `parser.script` come from portals.yml, which on a shared or
// template config is not fully trusted. The command must be a known interpreter
// or a file inside this project — never an arbitrary binary like `rm` or `curl`.
const PROJECT_ROOT = realpathSync(resolve(fileURLToPath(new URL('..', import.meta.url))));
const ALLOWED_INTERPRETERS = new Set(['python3', 'python', 'node', 'deno', 'bun', 'sh', 'bash']);
// `{careers_url}` and `{company}` are interpolated into the parser's argv. Validate
// them so an interpolated value can never be read as a CLI flag (argument injection).
function safeCareersUrl(value) {
if (!value) return '';
let url;
try {
url = new URL(String(value));
} catch {
throw new Error(`local-parser: careers_url is not a valid URL: ${value}`);
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(`local-parser: careers_url must be http(s): ${value}`);
}
return url.href;
}
function safeCompany(value) {
if (!value) return '';
const name = String(value).trim();
// execFile passes args verbatim (no shell), so the only injection risk is a
// value that begins like a CLI flag.
if (name.startsWith('-')) {
throw new Error(`local-parser: company name cannot start with '-': ${value}`);
}
return name;
}
// Only validate a placeholder's value when the arg actually uses it — a fixed
// `parser.script` must not be rejected because some unrelated `{company}` value
// has punctuation it never sees.
function expandParserArg(value, entry) {
let out = String(value);
if (out.includes('{careers_url}')) out = out.replaceAll('{careers_url}', safeCareersUrl(entry.careers_url));
if (out.includes('{company}')) out = out.replaceAll('{company}', safeCompany(entry.name));
return out;
}
function getParserScriptPath(entry) {
const parser = entry.parser || {};
if (parser.script) return expandParserArg(parser.script, entry);
const args = Array.isArray(parser.args) ? parser.args : [];
const scriptArg = args.find(arg => {
const value = String(arg);
return !value.startsWith('-') && /\.(py|mjs|js|sh)$/.test(value);
});
return scriptArg ? expandParserArg(scriptArg, entry) : null;
}
function buildParserArgs(entry) {
const parser = entry.parser || {};
const args = [];
if (parser.script) args.push(parser.script);
if (Array.isArray(parser.args)) args.push(...parser.args);
return args.map(arg => expandParserArg(arg, entry));
}
// Resolve a configured path and confirm it stays inside the project tree.
function resolveInsideRoot(rawPath) {
const resolved = realpathSync(resolve(PROJECT_ROOT, String(rawPath)));
if (resolved !== PROJECT_ROOT && !resolved.startsWith(PROJECT_ROOT + sep)) {
throw new Error(`local-parser: path escapes the project root: ${rawPath}`);
}
return resolved;
}
// The command is either a whitelisted interpreter (resolved via PATH) or a script
// that lives inside the repo. Anything else is rejected.
function resolveCommand(command) {
const value = String(command || '');
if (!value) throw new Error('local-parser: parser.command is required');
if (!value.includes('/') && ALLOWED_INTERPRETERS.has(value)) return value;
return resolveInsideRoot(value);
}
// Validate the whole invocation and return what to spawn. Throws on anything unsafe.
function resolveInvocation(entry) {
const rawCommand = String(entry.parser?.command || '');
const command = resolveCommand(rawCommand);
const args = buildParserArgs(entry);
const scriptPath = getParserScriptPath(entry);
const usesInterpreter = !rawCommand.includes('/') && ALLOWED_INTERPRETERS.has(rawCommand);
if (usesInterpreter) {
// A whitelisted interpreter must run an in-repo script as its FIRST argument.
// Anything before the script is an interpreter option (node --eval / --require,
// python -c, …) that could execute arbitrary code, so require the script to lead.
if (!scriptPath) throw new Error('local-parser: interpreter command requires an in-repo parser script');
resolveInsideRoot(scriptPath);
if (args[0] !== scriptPath) {
throw new Error('local-parser: the parser script must be the interpreter\'s first argument');
}
} else if (scriptPath) {
// command is an in-repo file; keep any detected script path inside the repo too.
resolveInsideRoot(scriptPath);
}
return { command, args };
}
function normalizeJobUrl(rawUrl, baseUrl) {
if (!rawUrl) return '';
try {
return new URL(String(rawUrl).trim(), baseUrl || undefined).href;
} catch {
return '';
}
}
function normalizeLocation(value) {
if (!value) return '';
if (Array.isArray(value)) return value.map(normalizeLocation).filter(Boolean).join(', ');
if (typeof value === 'object') return value.name || value.text || '';
return String(value).trim();
}
function normalizeParserJob(job, entry) {
if (!job || typeof job !== 'object') return null;
const title = String(job.title || job.name || '').trim();
const url = normalizeJobUrl(
job.url || job.jobUrl || job.job_url || job.applyUrl || job.apply_url,
entry.careers_url,
);
if (!title || !url) return null;
return {
title,
url,
company: String(job.company || entry.name || '').trim(),
location: normalizeLocation(job.location || job.locations),
};
}
async function runLocalParser(entry) {
const parser = entry.parser || {};
const { command, args } = resolveInvocation(entry);
const timeout = Number(parser.timeout_ms || LOCAL_PARSER_TIMEOUT_MS);
const maxBuffer = Number(parser.max_buffer_bytes || LOCAL_PARSER_MAX_BUFFER_BYTES);
// cwd is pinned to the project root so a relative script arg resolves to the
// same file resolveInvocation() validated, regardless of the caller's cwd.
const { stdout } = await execFileAsync(command, args, {
cwd: PROJECT_ROOT,
timeout,
maxBuffer,
windowsHide: true,
});
let payload;
try {
payload = JSON.parse(stdout);
} catch {
throw new Error('local parser returned invalid JSON');
}
const rawJobs = Array.isArray(payload) ? payload : payload.jobs || payload.results;
if (!Array.isArray(rawJobs)) {
throw new Error('local parser JSON must be an array or contain jobs[]/results[]');
}
return rawJobs
.map(job => normalizeParserJob(job, entry))
.filter(Boolean);
}
/** @type {Provider} */
export default {
id: 'local-parser',
detect(entry) {
if (!entry.parser?.command) return null;
// An invocation we can't safely resolve (unknown command, out-of-repo or
// missing script, inline-code flags) is not runnable — skip it.
try {
resolveInvocation(entry);
} catch {
return null;
}
return { url: entry.careers_url || 'local-parser' };
},
async fetch(entry) {
return runLocalParser(entry);
},
};