CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

fix: the cross-engine diff's findings — one real eval, one self-portrait #5619

MergedXSccantynz wants to mergefix/cross-engine-findingsmainopened 1d ago
4 changed files+108−53
Modifiedscripts/first-run-journey.mjs+5−1View fileUnifiedSplit
3939import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4040import { tmpdir } from "node:os";
4141import { join } from "node:path";
42import crypto from "node:crypto";
4243
4344const argv = process.argv.slice(2);
4445const arg = (name, fallback = null) => {
5152const stamp = new Date().toISOString().slice(2, 19).replace(/[:T-]/g, "");
5253const USER = `journey-${stamp}`;
5354const EMAIL = `journey+${stamp}@example.invalid`;
54const PASSWORD = `Jr!${stamp}x${Math.random().toString(36).slice(2, 8)}`;
55// crypto, not Math.random: this creates a REAL account on production, and a
56// timestamp-plus-Math.random password is guessable by anyone who can read a
57// clock. The account is throwaway, but throwaway is a policy, not a defence.
58const PASSWORD = `Jr!${stamp}x${crypto.randomBytes(9).toString("base64url")}`;
5559const REPO = `first-run-${stamp}`;
5660
5761// Same hard guard as agent-journey: the scratch repo must never be able to
Modifiedscripts/interaction-audit.mjs+54−48View fileUnifiedSplit
6464 * In-page check for one opened disclosure panel.
6565 * Returns null when healthy, or a failure description.
6666 */
67const PANEL_CHECK = `(panel) => {
68 const r = panel.getBoundingClientRect();
69 if (r.width < 2 || r.height < 2) {
70 return 'panel has no size after opening (' + Math.round(r.width) + 'x' + Math.round(r.height) + ')';
71 }
72 const style = getComputedStyle(panel);
73 if (style.visibility === 'hidden' || style.display === 'none' || Number(style.opacity) === 0) {
74 return 'panel is invisible after opening (visibility/display/opacity)';
75 }
76 // Clipping: any ancestor with overflow != visible whose client box does
77 // not contain the panel. This is the More-menu bug, detected generically.
78 for (let a = panel.parentElement; a && a !== document.body; a = a.parentElement) {
79 const as = getComputedStyle(a);
80 const clips = [as.overflow, as.overflowX, as.overflowY].some(
81 (v) => v && v !== 'visible'
82 );
83 if (!clips) continue;
84 const ar = a.getBoundingClientRect();
85 const visTop = Math.max(r.top, ar.top);
86 const visBottom = Math.min(r.bottom, ar.bottom);
87 const visLeft = Math.max(r.left, ar.left);
88 const visRight = Math.min(r.right, ar.right);
89 const visibleFraction =
90 Math.max(0, visBottom - visTop) * Math.max(0, visRight - visLeft) /
91 (r.width * r.height);
92 if (visibleFraction < 0.85) {
93 return (
94 'panel clipped to ' + Math.round(visibleFraction * 100) + '% by overflow ancestor <' +
95 a.tagName.toLowerCase() + ' class="' + String(a.className).slice(0, 60) + '">'
96 );
97 }
98 }
99 // Coverage: the centre of the panel must hit-test to the panel itself.
100 const cx = r.left + r.width / 2;
101 const cy = Math.min(r.top + r.height / 2, window.innerHeight - 2);
102 const hit = document.elementFromPoint(cx, cy);
103 if (hit && !panel.contains(hit) && hit !== panel) {
104 return (
105 'panel centre is covered by <' + hit.tagName.toLowerCase() +
106 ' class="' + String(hit.className).slice(0, 60) + '">'
107 );
108 }
109 return null;
110}`;
67// The in-page panel check lives INSIDE the evaluate callback below, as a
68// real function. It used to be a source STRING passed in and `eval`ed in the
69// page — the only live eval in this repository, found by a peer engine in a
70// cross-engine diff (both engines' scanners had missed it; theirs never
71// opened .mjs files, ours suppressed nothing but nobody read the report).
72// The string form had no reason to exist: evaluate() serialises its callback
73// anyway, so the function can simply be written where it runs.
11174
11275async function main() {
11376 const browser = await chromium.launch();
174137 // The revealed content: first element child after summary.
175138 const verdict = await details
176139 .evaluate(
177 (d, checkSrc) => {
140 (d) => {
178141 const panel = Array.from(d.children).find((c) => c.tagName !== 'SUMMARY');
179142 if (!panel) return 'disclosure has no panel content at all';
180 return eval(checkSrc)(panel);
181 },
182 PANEL_CHECK
143 const check = (panel) => {
144 const r = panel.getBoundingClientRect();
145 if (r.width < 2 || r.height < 2) {
146 return 'panel has no size after opening (' + Math.round(r.width) + 'x' + Math.round(r.height) + ')';
147 }
148 const style = getComputedStyle(panel);
149 if (style.visibility === 'hidden' || style.display === 'none' || Number(style.opacity) === 0) {
150 return 'panel is invisible after opening (visibility/display/opacity)';
151 }
152 // Clipping: any ancestor with overflow != visible whose client box does
153 // not contain the panel. This is the More-menu bug, detected generically.
154 for (let a = panel.parentElement; a && a !== document.body; a = a.parentElement) {
155 const as = getComputedStyle(a);
156 const clips = [as.overflow, as.overflowX, as.overflowY].some(
157 (v) => v && v !== 'visible'
158 );
159 if (!clips) continue;
160 const ar = a.getBoundingClientRect();
161 const visTop = Math.max(r.top, ar.top);
162 const visBottom = Math.min(r.bottom, ar.bottom);
163 const visLeft = Math.max(r.left, ar.left);
164 const visRight = Math.min(r.right, ar.right);
165 const visibleFraction =
166 Math.max(0, visBottom - visTop) * Math.max(0, visRight - visLeft) /
167 (r.width * r.height);
168 if (visibleFraction < 0.85) {
169 return (
170 'panel clipped to ' + Math.round(visibleFraction * 100) + '% by overflow ancestor <' +
171 a.tagName.toLowerCase() + ' class="' + String(a.className).slice(0, 60) + '">'
172 );
173 }
174 }
175 // Coverage: the centre of the panel must hit-test to the panel itself.
176 const cx = r.left + r.width / 2;
177 const cy = Math.min(r.top + r.height / 2, window.innerHeight - 2);
178 const hit = document.elementFromPoint(cx, cy);
179 if (hit && !panel.contains(hit) && hit !== panel) {
180 return (
181 'panel centre is covered by <' + hit.tagName.toLowerCase() +
182 ' class="' + String(hit.className).slice(0, 60) + '">'
183 );
184 }
185 return null;
186 };
187 return check(panel);
188 }
183189 )
184190 .catch((e) => `panel check crashed: ${String(e).slice(0, 80)}`);
185191
Modifiedsrc/__tests__/mention-is-not-use.test.ts+23−0View fileUnifiedSplit
9191 );
9292 });
9393});
94
95describe("no-inner-html: comments are mentions, strings are code carriers", () => {
96 // The cross-engine diff at e168803: this scanner's four remaining
97 // innerHTML findings were all comments inside its OWN safety predicate in
98 // intelligence.ts — the rule reporting the documentation of its own fix.
99 // The fix is the weaker exclusion (notInComments), NOT codeOnly: an admin
100 // page served as a template literal carries real innerHTML assignments
101 // inside a string, and suppressing those would undo the ops-dashboard
102 // class this rule was tuned on.
103
104 it("does not report its own predicate's doc comment", () => {
105 expect(rules("src/lib/intelligence.ts", " * Is this `innerHTML =` assignment demonstrably safe from the line alone?")).toEqual([]);
106 expect(rules("src/a.ts", "// never assign el.innerHTML = raw here")).toEqual([]);
107 });
108
109 it("still reports an assignment inside a served template literal", () => {
110 // The ops-dashboard shape: inline page JS inside a backtick string.
111 expect(rules("src/routes/page.ts", " card.innerHTML = raw;")).toContain("no-inner-html");
112 expect(
113 rules("src/routes/page.ts", ' const page = `<script>el.innerHTML = data;</script>`;')
114 ).toContain("no-inner-html");
115 });
116});
Modifiedsrc/lib/intelligence.ts+26−4View fileUnifiedSplit
515515 * `security-todo` is nothing but comments.
516516 */
517517 codeOnly?: boolean;
518 /**
519 * Fire from code OR strings, but never from comments.
520 *
521 * The innerHTML rule needs this weaker exclusion, not codeOnly: an admin
522 * page served as a template literal carries REAL innerHTML assignments
523 * inside a string (the whole ops-dashboard class this rule was tuned on),
524 * so string interiors must stay eligible. Comments must not — the
525 * cross-engine diff found this scanner's four remaining innerHTML findings
526 * were all comments inside its own safety predicate, in this file. The
527 * rule was reporting the documentation of its own fix.
528 */
529 notInComments?: boolean;
518530}> = [
519531 // Hardcoded secrets
520532 { pattern: /(?:password|passwd|pwd)\s*[:=]\s*["']([^"']{4,})/i, severity: "critical", message: "Possible hardcoded password", rule: "no-hardcoded-secrets", appliesTo: NOT_PROSE },
590602 { pattern: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/, severity: "critical", message: "SendGrid API key committed", rule: "no-vendor-credentials" },
591603 // Injection vulnerabilities
592604 { pattern: /eval\s*\(/, severity: "high", message: "eval call — potential code injection", rule: "no-eval", appliesTo: EVAL_LANGS, codeOnly: true },
593 { pattern: /innerHTML\s*=/, severity: "medium", message: "Direct innerHTML assignment — potential XSS", rule: "no-inner-html", appliesTo: JS_TS },
605 { pattern: /innerHTML\s*=/, severity: "medium", message: "Direct innerHTML assignment — potential XSS", rule: "no-inner-html", appliesTo: JS_TS, notInComments: true },
594606 { pattern: /document\.write\s*\(/, severity: "medium", message: "document.write usage — potential XSS", rule: "no-document-write", appliesTo: JS_TS, codeOnly: true },
595607 { pattern: /exec\s*\(\s*[`"'].*\$\{/, severity: "high", message: "Shell command with template literal — potential injection", rule: "no-shell-injection", appliesTo: JS_TS },
596608 // SQL injection
10071019 *
10081020 * Exported and asserted directly, per this module's convention.
10091021 */
1010export function patternMatchesInCode(line: string, pattern: RegExp): boolean {
1022export function patternMatchesInCode(
1023 line: string,
1024 pattern: RegExp,
1025 opts: { stringsAreEligible?: boolean } = {}
1026): boolean {
10111027 const trimmed = line.trimStart();
10121028 if (trimmed.startsWith("*") || trimmed.startsWith("/*")) return false;
10131029
10171033 for (let i = 0; i < line.length; i++) {
10181034 const ch = line[i];
10191035 if (quote) {
1020 kinds[i] = false;
1036 kinds[i] = opts.stringsAreEligible === true;
10211037 if (ch === "\\") {
10221038 if (i + 1 < line.length) kinds[++i] = false;
10231039 continue;
10271043 }
10281044 if (ch === '"' || ch === "'" || ch === "`") {
10291045 quote = ch;
1030 kinds[i] = false;
1046 kinds[i] = opts.stringsAreEligible === true;
10311047 continue;
10321048 }
10331049 if (ch === "/" && line[i + 1] === "/") {
10781094
10791095 // Mention is not use: a call-shaped rule only fires from code.
10801096 if (rule.codeOnly && !patternMatchesInCode(line, rule.pattern)) continue;
1097 if (
1098 rule.notInComments &&
1099 !patternMatchesInCode(line, rule.pattern, { stringsAreEligible: true })
1100 ) {
1101 continue;
1102 }
10811103
10821104 if (rule.rule === "no-hardcoded-secrets" || rule.rule === "no-unit-inline-credentials") {
10831105 // Prefer the rule's own capture when it has one. The env-fallback rule
10841106
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts