Skip to content

Commit ccaefc6

Browse files
feat(enrichment): add hardcoded-URL / raw-endpoint heuristic analyzer (#3474)
Fixes #2027 — flags absolute HTTP(S) URLs and IP:port endpoints in non-test source. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 5312a56 commit ccaefc6

9 files changed

Lines changed: 340 additions & 14 deletions

File tree

.env.example

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -63,28 +63,29 @@ GITTENSORY_REVIEW_ENRICHMENT=false
6363
# # Unknown names warn and are ignored; a typo-only list runs no analyzers.
6464
# BEGIN GENERATED REES ANALYZERS
6565
# Current analyzer names:
66-
# dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol,redos
67-
# provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature,iacMisconfig,nativeBuild
68-
# history,docCommentDrift,duplication,churnHotspot,blameLink,approvalIntegrity,ciCheckSignals
69-
# undocumentedExport,staleBranch,commitHygiene,pendingReviewRequests,testRatio,migrationSafety
70-
# looseRange,terminology,todoMarker,magicNumber,conflictMarker,debugLeftover,commitLint
66+
# dependency,lockfileDrift,secret,license,installScript,heavyDependency,hardcodedUrl,actionPin
67+
# eol,redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature,iacMisconfig
68+
# nativeBuild,history,docCommentDrift,duplication,churnHotspot,blameLink,approvalIntegrity
69+
# ciCheckSignals,undocumentedExport,staleBranch,commitHygiene,pendingReviewRequests,testRatio
70+
# migrationSafety,looseRange,terminology,todoMarker,magicNumber,conflictMarker,debugLeftover
71+
# commitLint
7172
#
7273
# Profile defaults:
73-
# fast: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol
74-
# redos,provenance,secretLog,typosquat,iacMisconfig,nativeBuild,testRatio,migrationSafety
75-
# looseRange,terminology,todoMarker,magicNumber,conflictMarker,debugLeftover
74+
# fast: dependency,lockfileDrift,secret,license,installScript,heavyDependency,hardcodedUrl
75+
# actionPin,eol,redos,provenance,secretLog,typosquat,iacMisconfig,nativeBuild,testRatio
76+
# migrationSafety,looseRange,terminology,todoMarker,magicNumber,conflictMarker,debugLeftover
7677
# balanced (default): dependency,lockfileDrift,secret,license,installScript,heavyDependency
78+
# hardcodedUrl,actionPin,eol,redos,provenance,codeowners,secretLog,assetWeight,typosquat
79+
# commitSignature,iacMisconfig,nativeBuild,history,docCommentDrift,duplication,churnHotspot
80+
# blameLink,approvalIntegrity,ciCheckSignals,undocumentedExport,staleBranch,commitHygiene
81+
# pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology,todoMarker,magicNumber
82+
# conflictMarker,debugLeftover,commitLint
83+
# deep: dependency,lockfileDrift,secret,license,installScript,heavyDependency,hardcodedUrl
7784
# actionPin,eol,redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature
7885
# iacMisconfig,nativeBuild,history,docCommentDrift,duplication,churnHotspot,blameLink
7986
# approvalIntegrity,ciCheckSignals,undocumentedExport,staleBranch,commitHygiene
8087
# pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology,todoMarker,magicNumber
8188
# conflictMarker,debugLeftover,commitLint
82-
# deep: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol
83-
# redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature,iacMisconfig
84-
# nativeBuild,history,docCommentDrift,duplication,churnHotspot,blameLink,approvalIntegrity
85-
# ciCheckSignals,undocumentedExport,staleBranch,commitHygiene,pendingReviewRequests,testRatio
86-
# migrationSafety,looseRange,terminology,todoMarker,magicNumber,conflictMarker,debugLeftover
87-
# commitLint
8889
# END GENERATED REES ANALYZERS
8990

9091
# Submitter-reputation spend control (internal-only): downgrades new/burst/low-rep

apps/gittensory-ui/src/lib/rees-analyzers.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,31 @@ export const REES_ANALYZERS = [
219219
notes: "Only reports packages with trivial direct usage so the finding stays actionable.",
220220
},
221221
},
222+
{
223+
name: "hardcodedUrl",
224+
title: "Hardcoded URLs and endpoints",
225+
category: "config",
226+
cost: "local",
227+
defaultEnabled: true,
228+
profiles: ["fast", "balanced", "deep"],
229+
requires: ["files"],
230+
limits: {
231+
maxFindings: 25,
232+
maxLineChars: 2000,
233+
maxHostChars: 40,
234+
},
235+
docs: {
236+
summary:
237+
"Flags absolute HTTP(S) URLs and raw IP:port endpoints newly added in non-test, non-config source.",
238+
looksAt:
239+
"Added lines in changed source files, excluding tests, config manifests, and comment/import lines.",
240+
reports:
241+
"File, line, kind (http-url or ip-endpoint), and a redacted host — never full paths or queries.",
242+
network: "Pure local analyzer. No external network call.",
243+
notes:
244+
"localhost, 127.0.0.1, and example.com are allowlisted. Distinct from the secret scanner — no credential detection.",
245+
},
246+
},
222247
{
223248
name: "actionPin",
224249
title: "Unpinned GitHub Actions",

review-enrichment/analyzer-metadata.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,33 @@
236236
"notes": "Only reports packages with trivial direct usage so the finding stays actionable."
237237
}
238238
},
239+
{
240+
"name": "hardcodedUrl",
241+
"title": "Hardcoded URLs and endpoints",
242+
"category": "config",
243+
"cost": "local",
244+
"defaultEnabled": true,
245+
"profiles": [
246+
"fast",
247+
"balanced",
248+
"deep"
249+
],
250+
"requires": [
251+
"files"
252+
],
253+
"limits": {
254+
"maxFindings": 25,
255+
"maxLineChars": 2000,
256+
"maxHostChars": 40
257+
},
258+
"docs": {
259+
"summary": "Flags absolute HTTP(S) URLs and raw IP:port endpoints newly added in non-test, non-config source.",
260+
"looksAt": "Added lines in changed source files, excluding tests, config manifests, and comment/import lines.",
261+
"reports": "File, line, kind (http-url or ip-endpoint), and a redacted host — never full paths or queries.",
262+
"network": "Pure local analyzer. No external network call.",
263+
"notes": "localhost, 127.0.0.1, and example.com are allowlisted. Distinct from the secret scanner — no credential detection."
264+
}
265+
},
239266
{
240267
"name": "actionPin",
241268
"title": "Unpinned GitHub Actions",
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Hardcoded-URL / raw-endpoint analyzer (#2027). Flags absolute HTTP(S) URLs and IP:port endpoints newly
2+
// added in non-test, non-config source — often environment leakage or a value that should come from config.
3+
// Distinct from the secret scanner (no credential); this is a portability/config-hygiene signal. Pure compute
4+
// over added lines, no network. Hostnames are redacted/truncated in findings — never full paths or queries.
5+
import type { EnrichRequest, HardcodedUrlFinding } from "../types.js";
6+
import { isMagicNumberSourcePath } from "./magic-number.js";
7+
8+
const MAX_FINDINGS = 25;
9+
const MAX_LINE_CHARS = 2000;
10+
const MAX_HOST_CHARS = 40;
11+
12+
const CONFIG_PATH_RE =
13+
/(?:^|\/)(?:docker-compose[^/]*\.ya?ml|compose[^/]*\.ya?ml|values(?:\.[^/]+)?\.ya?ml|\.env(?:\.[^/]+)?|.*\.(?:tf|tfvars|hcl|ya?ml|json|toml|ini|conf|env)|Dockerfile(?:\.[^/]+)?|nginx[^/]*\.conf)$/i;
14+
15+
const HTTP_URL_RE = /https?:\/\/[^\s'"\`<>]+/gi;
16+
const IP_ENDPOINT_RE = /\b(?:\d{1,3}\.){3}\d{1,3}:\d{1,5}\b/g;
17+
18+
const ALLOWLISTED_HOSTS = new Set(["localhost", "127.0.0.1", "example.com"]);
19+
20+
function isConfigPath(path: string): boolean {
21+
return CONFIG_PATH_RE.test(path);
22+
}
23+
24+
function isScannablePath(path: string): boolean {
25+
return isMagicNumberSourcePath(path) && !isConfigPath(path);
26+
}
27+
28+
function redactHost(host: string): string {
29+
const lower = host.toLowerCase();
30+
if (lower.length <= MAX_HOST_CHARS) return lower;
31+
return `${lower.slice(0, MAX_HOST_CHARS - 3)}...`;
32+
}
33+
34+
function isAllowlistedHost(host: string): boolean {
35+
const lower = host.toLowerCase();
36+
if (ALLOWLISTED_HOSTS.has(lower)) return true;
37+
if (lower.endsWith(".example.com")) return true;
38+
return false;
39+
}
40+
41+
function hostFromHttpUrl(url: string): string {
42+
const match = /^https?:\/\/([^/?#:]+)(?::\d+)?/i.exec(url);
43+
return match?.[1] ?? url;
44+
}
45+
46+
function isCommentLine(line: string): boolean {
47+
const trimmed = line.trimStart();
48+
return /^(?:\/\/|#|\/\*|\*|<!--)/.test(trimmed);
49+
}
50+
51+
function isImportLine(line: string): boolean {
52+
const trimmed = line.trimStart();
53+
return /^(?:import\b|from\b|#include\b|require\s*\(|use\s+\w+::)/.test(trimmed);
54+
}
55+
56+
/** Classify one added line for a hardcoded URL or IP endpoint, or null. Pure. */
57+
export function detectHardcodedUrl(
58+
line: string,
59+
): { kind: HardcodedUrlFinding["kind"]; host: string } | null {
60+
if (isCommentLine(line) || isImportLine(line)) return null;
61+
62+
HTTP_URL_RE.lastIndex = 0;
63+
const urlMatch = HTTP_URL_RE.exec(line);
64+
if (urlMatch) {
65+
const host = hostFromHttpUrl(urlMatch[0]);
66+
if (!isAllowlistedHost(host)) {
67+
return { kind: "http-url", host: redactHost(host) };
68+
}
69+
}
70+
71+
IP_ENDPOINT_RE.lastIndex = 0;
72+
const ipMatch = IP_ENDPOINT_RE.exec(line);
73+
if (ipMatch) {
74+
const host = ipMatch[0].split(":")[0] ?? ipMatch[0];
75+
if (!isAllowlistedHost(host)) {
76+
return { kind: "ip-endpoint", host: redactHost(host) };
77+
}
78+
}
79+
80+
return null;
81+
}
82+
83+
type ScanLimits = {
84+
maxFindings?: number;
85+
signal?: AbortSignal;
86+
};
87+
88+
/** Scan one file patch's added lines for hardcoded URLs/endpoints, line-cited via hunk headers. Pure. */
89+
export function scanPatchForHardcodedUrl(
90+
path: string,
91+
patch: string,
92+
limits: ScanLimits = {},
93+
): HardcodedUrlFinding[] {
94+
const maxFindings = limits.maxFindings ?? MAX_FINDINGS;
95+
if (maxFindings <= 0 || !isScannablePath(path)) return [];
96+
const findings: HardcodedUrlFinding[] = [];
97+
let newLine = 0;
98+
let inHunk = false;
99+
for (const line of patch.split("\n")) {
100+
if (limits.signal?.aborted) throw new Error("analyzer_aborted");
101+
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
102+
if (hunk) {
103+
newLine = Number(hunk[1]);
104+
inHunk = true;
105+
continue;
106+
}
107+
if (!inHunk) continue;
108+
if (line.startsWith("+")) {
109+
const body = line.slice(1);
110+
if (body.length <= MAX_LINE_CHARS) {
111+
const hit = detectHardcodedUrl(body);
112+
if (hit) {
113+
findings.push({ file: path, line: newLine, kind: hit.kind, host: hit.host });
114+
if (findings.length >= maxFindings) return findings;
115+
}
116+
}
117+
newLine++;
118+
} else if (!line.startsWith("-") && !line.startsWith("\\")) {
119+
newLine++;
120+
}
121+
}
122+
return findings;
123+
}
124+
125+
/** Analyzer entrypoint: scan every changed scannable file's added lines for hardcoded endpoints. */
126+
export async function scanHardcodedUrl(
127+
req: EnrichRequest,
128+
signal?: AbortSignal,
129+
): Promise<HardcodedUrlFinding[]> {
130+
const findings: HardcodedUrlFinding[] = [];
131+
for (const file of req.files ?? []) {
132+
if (signal?.aborted) throw new Error("analyzer_aborted");
133+
if (!file.patch) continue;
134+
for (const finding of scanPatchForHardcodedUrl(file.path, file.patch, {
135+
maxFindings: MAX_FINDINGS - findings.length,
136+
signal,
137+
})) {
138+
findings.push(finding);
139+
if (findings.length >= MAX_FINDINGS) return findings;
140+
}
141+
}
142+
return findings;
143+
}

review-enrichment/src/analyzers/registry.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { dependencyAnalyzer } from "./dependency/descriptor.js";
1111
import { scanDocCommentDrift } from "./doc-comment-drift.js";
1212
import { scanDuplication } from "./duplication-scan.js";
1313
import { scanEol } from "./eol-check.js";
14+
import { scanHardcodedUrl } from "./hardcoded-url.js";
1415
import { scanHeavyDependencies } from "./heavy-dependency.js";
1516
import { scanHistory } from "./history.js";
1617
import { scanIacMisconfig } from "./iac-misconfig.js";
@@ -136,6 +137,35 @@ export const ANALYZER_DESCRIPTORS = [
136137
run: (req, { signal, analysis, diagnostics }) =>
137138
scanHeavyDependencies(req, fetch, { signal, analysis, diagnostics }),
138139
}),
140+
descriptor({
141+
name: "hardcodedUrl",
142+
title: "Hardcoded URLs and endpoints",
143+
category: "config",
144+
cost: "local",
145+
defaultEnabled: true,
146+
requires: ["files"],
147+
limits: { maxFindings: 25, maxLineChars: 2000, maxHostChars: 40 },
148+
docs: {
149+
summary:
150+
"Flags absolute HTTP(S) URLs and raw IP:port endpoints newly added in non-test, non-config source.",
151+
looksAt: "Added lines in changed source files, excluding tests, config manifests, and comment/import lines.",
152+
reports: "File, line, kind (http-url or ip-endpoint), and a redacted host — never full paths or queries.",
153+
network: "Pure local analyzer. No external network call.",
154+
notes:
155+
"localhost, 127.0.0.1, and example.com are allowlisted. Distinct from the secret scanner — no credential detection.",
156+
},
157+
render: (findings, helpers) => {
158+
if (!findings.length) return [];
159+
const lines = ["### Hardcoded URLs/endpoints (should usually come from config)"];
160+
for (const item of findings) {
161+
lines.push(
162+
`- ${helpers.safeCodeSpan(`${item.file}:${item.line}`)}${helpers.safeCodeSpan(item.kind)} host ${helpers.safeCodeSpan(item.host)}`,
163+
);
164+
}
165+
return lines;
166+
},
167+
run: (req, { signal }) => scanHardcodedUrl(req, signal),
168+
}),
139169
descriptor({
140170
name: "actionPin",
141171
title: "Unpinned GitHub Actions",

review-enrichment/src/render.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,7 @@ export function renderBrief(
481481
lines.push(...renderDescriptorSection("magicNumber", findings.magicNumber));
482482
lines.push(...renderDescriptorSection("conflictMarker", findings.conflictMarker));
483483
lines.push(...renderDescriptorSection("debugLeftover", findings.debugLeftover));
484+
lines.push(...renderDescriptorSection("hardcodedUrl", findings.hardcodedUrl));
484485
lines.push(...renderDescriptorSection("commitLint", findings.commitLint));
485486

486487
if (!lines.length) return { promptSection: "", systemSuffix: "" };

review-enrichment/src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,15 @@ export interface DebugLeftoverFinding {
473473
kind: "debugger" | "console" | "print";
474474
}
475475

476+
/** An absolute HTTP(S) URL or raw IP:port endpoint hardcoded in non-test, non-config source (#2027, part of #1499).
477+
* Reports location, kind, and a redacted/truncated host — never full paths or query strings. */
478+
export interface HardcodedUrlFinding {
479+
file: string;
480+
line: number;
481+
kind: "http-url" | "ip-endpoint";
482+
host: string;
483+
}
484+
476485
/** A PR commit subject that does not conform to the Conventional Commits spec (#2021, part of #1499). Reports a
477486
* short SHA prefix, the subject, and the failing reason — never author/email. */
478487
export interface CommitLintFinding {
@@ -519,6 +528,7 @@ export interface BriefFindings {
519528
magicNumber?: MagicNumberFinding[];
520529
conflictMarker?: ConflictMarkerFinding[];
521530
debugLeftover?: DebugLeftoverFinding[];
531+
hardcodedUrl?: HardcodedUrlFinding[];
522532
commitLint?: CommitLintFinding[];
523533
}
524534

review-enrichment/test/analyzer-registry.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const EXPECTED_ANALYZERS = [
1616
"license",
1717
"installScript",
1818
"heavyDependency",
19+
"hardcodedUrl",
1920
"actionPin",
2021
"eol",
2122
"redos",

0 commit comments

Comments
 (0)