Files
polinrider-cleanup/src/jsonc.js
T
jodosjodos 39f03add71 Add initial implementation of PolinRider malware scanner
- Introduced core files including Dockerfile, action.yml, and entry point script (bin/polinrider.js) for the PolinRider malware scanner.
- Created configuration files: .env.example, .gitignore, and .npmrc for environment setup and dependency management.
- Added CI workflows for testing, building, and releasing the action, along with examples for usage in GitHub Actions.
- Implemented a README.md to provide documentation on usage, features, and setup instructions.
- Included necessary scripts for building the action and handling security measures during execution.
- Established a structured directory for reports and workspace management within Docker.
- Ensured all files are set up for a secure and efficient scanning process of GitHub repositories for PolinRider malware.
2026-08-11 13:59:03 +02:00

232 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Tolerant JSONC parser for VS Code config files (which legitimately contain
* // and /* *\/ comments and trailing commas), plus an offset-based array
* splicer used for surgical removal of malicious task/launch entries.
*
* SAFETY: this parser is hand-written and uses ONLY string scanning + the
* built-in JSON.parse (on isolated string literals). It NEVER uses eval, the
* Function constructor, or node:vm. Parsing an attacker-controlled .vscode file
* therefore cannot execute anything.
*
* The AST records source offsets so the remediator can remove only the
* malicious entries and leave the surrounding bytes (and other entries) intact.
*/
/**
* @typedef {Object} Node
* @property {'object'|'array'|'string'|'number'|'boolean'|'null'} type
* @property {*} value Decoded JS value of this node.
* @property {number} start Offset of the first character (inclusive).
* @property {number} end Offset just past the last character (exclusive).
* @property {Array} [elements] For arrays: child value Nodes, in order.
* @property {Array} [members] For objects: { key, keyNode, valueNode } entries.
*/
/**
* Parse JSONC text.
* @returns {{ ok: boolean, value: *, ast: Node|null, error: Error|null }}
*/
export function parseJsonc(text) {
let i = 0;
const n = text.length;
const fail = (msg) => {
const e = new Error(`JSONC parse error at offset ${i}: ${msg}`);
e.offset = i;
return e;
};
function skipWs() {
while (i < n) {
const c = text[i];
if (c === " " || c === "\t" || c === "\n" || c === "\r" || c === "") {
i++;
continue;
}
if (c === "/" && text[i + 1] === "/") {
i += 2;
while (i < n && text[i] !== "\n") i++;
continue;
}
if (c === "/" && text[i + 1] === "*") {
i += 2;
while (i < n && !(text[i] === "*" && text[i + 1] === "/")) i++;
i += 2;
continue;
}
break;
}
}
function parseString() {
const start = i;
if (text[i] !== '"') throw fail("expected string");
i++;
while (i < n) {
const c = text[i];
if (c === "\\") {
i += 2;
continue;
}
if (c === '"') {
i++;
break;
}
i++;
}
const raw = text.slice(start, i);
let value;
try {
value = JSON.parse(raw); // decode escapes safely; no code execution
} catch {
throw fail("invalid string literal");
}
return { type: "string", value, start, end: i };
}
function parseNumber() {
const start = i;
const m = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i));
if (!m) throw fail("invalid number");
i += m[0].length;
return { type: "number", value: Number(m[0]), start, end: i };
}
function parseObject() {
const start = i;
i++; // {
const members = [];
const value = {};
skipWs();
while (text[i] !== "}") {
if (i >= n) throw fail("unterminated object");
const keyNode = parseString();
skipWs();
if (text[i] !== ":") throw fail("expected ':'");
i++;
const valueNode = parseValue();
members.push({ key: keyNode.value, keyNode, valueNode });
value[keyNode.value] = valueNode.value;
skipWs();
if (text[i] === ",") {
i++;
skipWs();
} else break;
}
if (text[i] !== "}") throw fail("expected '}'");
i++;
return { type: "object", members, value, start, end: i };
}
function parseArray() {
const start = i;
i++; // [
const elements = [];
const value = [];
skipWs();
while (text[i] !== "]") {
if (i >= n) throw fail("unterminated array");
const el = parseValue();
elements.push(el);
value.push(el.value);
skipWs();
if (text[i] === ",") {
i++;
skipWs();
} else break;
}
if (text[i] !== "]") throw fail("expected ']'");
i++;
return { type: "array", elements, value, start, end: i };
}
function parseValue() {
skipWs();
if (i >= n) throw fail("unexpected end of input");
const c = text[i];
if (c === "{") return parseObject();
if (c === "[") return parseArray();
if (c === '"') return parseString();
if (c === "-" || (c >= "0" && c <= "9")) return parseNumber();
if (text.startsWith("true", i)) {
const s = i;
i += 4;
return { type: "boolean", value: true, start: s, end: i };
}
if (text.startsWith("false", i)) {
const s = i;
i += 5;
return { type: "boolean", value: false, start: s, end: i };
}
if (text.startsWith("null", i)) {
const s = i;
i += 4;
return { type: "null", value: null, start: s, end: i };
}
throw fail(`unexpected character ${JSON.stringify(c)}`);
}
try {
skipWs();
const ast = parseValue();
skipWs();
if (i < n) throw fail("trailing content after top-level value");
return { ok: true, value: ast.value, ast, error: null };
} catch (error) {
return { ok: false, value: undefined, ast: null, error };
}
}
/**
* Return the member's value Node for `key` on an object Node, or undefined.
*/
export function getMember(objectNode, key) {
if (!objectNode || objectNode.type !== "object") return undefined;
const m = objectNode.members.find((mem) => mem.key === key);
return m ? m.valueNode : undefined;
}
/**
* Remove the array elements at `indicesToRemove` from `text`, given the array's
* AST Node. Removes each element together with one adjacent separator (the
* comma + surrounding whitespace) by deleting the span up to the next element's
* start — so the result stays valid JSON and the surviving elements keep their
* exact source bytes. Overlapping spans (e.g. removing adjacent elements) are
* merged before applying.
*
* @returns {string} the edited text
*/
export function removeArrayElements(text, arrayNode, indicesToRemove) {
if (!arrayNode || arrayNode.type !== "array") {
throw new Error("removeArrayElements: arrayNode must be an array Node");
}
const remove = new Set(indicesToRemove);
const els = arrayNode.elements;
const spans = [];
for (let k = 0; k < els.length; k++) {
if (!remove.has(k)) continue;
if (k < els.length - 1) {
spans.push([els[k].start, els[k + 1].start]); // element + trailing comma/ws
} else if (k > 0) {
spans.push([els[k - 1].end, els[k].end]); // leading comma/ws + last element
} else {
spans.push([els[k].start, els[k].end]); // sole element
}
}
if (spans.length === 0) return text;
// Merge overlapping/adjacent spans, then delete from the end backward so
// earlier offsets stay valid.
spans.sort((a, b) => a[0] - b[0]);
const merged = [spans[0].slice()];
for (let s = 1; s < spans.length; s++) {
const last = merged[merged.length - 1];
if (spans[s][0] <= last[1]) last[1] = Math.max(last[1], spans[s][1]);
else merged.push(spans[s].slice());
}
merged.sort((a, b) => b[0] - a[0]);
let out = text;
for (const [start, end] of merged) out = out.slice(0, start) + out.slice(end);
return out;
}