Skip to content

Commit 10b0e6c

Browse files
security: require zero CodeQL findings (#64)
Remediate all 59 existing CodeQL alerts, make local path containment machine-verifiable, redact arbitrary provider errors, replace adversarial parsers, and make hosted CodeQL fail on any SARIF finding. Preserve Python 3.9 and pin the verified Ruff series.
1 parent b662dde commit 10b0e6c

25 files changed

Lines changed: 827 additions & 127 deletions

.github/workflows/codeql.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,9 @@ jobs:
2828
languages: ${{ matrix.language }}
2929
build-mode: none
3030
- name: Analyze
31+
id: analyze
3132
uses: github/codeql-action/analyze@3b0bd1d116c0bde30213346b22d4f634d96a2fb0 # v3
33+
with:
34+
output: codeql-results
35+
- name: Require clean CodeQL results
36+
run: python scripts/check_codeql_sarif.py "${{ steps.analyze.outputs.sarif-output }}"

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ Public 1.0.1 client reliability release.
6868
entitlement-gated managed-compute availability while preserving snapshot redaction and limits.
6969
- Commercial metadata now describes Pro as one owner account across that owner's local
7070
installations, matching the hosted entitlement model; Team remains billed per named seat.
71+
- API error responses and provider logs no longer expose arbitrary exception or configuration
72+
text; local folder and repository reads resolve and re-check filesystem boundaries.
73+
- Entity extraction and dashboard asset migration avoid adversarial regular-expression
74+
backtracking, and CI now fails when CodeQL emits any SARIF finding.
7175

7276
## [1.0.0] - 2026-07-23
7377

demo/record_screen_demo.mjs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { spawn, spawnSync } from "node:child_process";
22
import { createReadStream, mkdirSync, rmSync, statSync } from "node:fs";
33
import { createServer } from "node:http";
4-
import { join, resolve, sep } from "node:path";
4+
import { join, resolve } from "node:path";
55
import { fileURLToPath } from "node:url";
66
import { chromium } from "playwright";
77

@@ -13,6 +13,11 @@ const payload = join(generatedDir, "screen_demo_payload.json");
1313
const webm = join(outputDir, "engraphis-memory-demo.webm");
1414
const mp4 = join(outputDir, "engraphis-memory-demo.mp4");
1515
const port = 8790;
16+
const demoAssets = new Map([
17+
["/", join(demoDir, "engraphis_screen_demo.html")],
18+
["/engraphis_screen_demo.html", join(demoDir, "engraphis_screen_demo.html")],
19+
["/generated/screen_demo_payload.json", payload],
20+
]);
1621

1722
mkdirSync(generatedDir, { recursive: true });
1823
mkdirSync(outputDir, { recursive: true });
@@ -22,17 +27,16 @@ const prepared = spawnSync(process.env.PYTHON || "python", [
2227
if (prepared.status !== 0) process.exit(prepared.status || 1);
2328

2429
const server = createServer((request, response) => {
25-
let relative;
30+
let pathname;
2631
try {
27-
relative = decodeURIComponent((request.url || "/").split("?", 1)[0]);
32+
pathname = new URL(request.url || "/", "http://127.0.0.1").pathname;
2833
} catch {
2934
response.writeHead(400); response.end("Bad request"); return;
3035
}
31-
const requested = relative === "/" ? "engraphis_screen_demo.html" : relative.slice(1);
32-
const file = resolve(demoDir, requested);
33-
if (file !== demoDir && !file.startsWith(demoDir + sep)) {
34-
response.writeHead(403); response.end("Forbidden"); return;
35-
}
36+
// This recorder only needs these generated demo assets. Map request paths to fixed
37+
// files instead of deriving a filesystem path from a URL.
38+
const file = demoAssets.get(pathname);
39+
if (!file) { response.writeHead(404); response.end("Not found"); return; }
3640
try {
3741
const stat = statSync(file);
3842
response.writeHead(200, { "Content-Length": stat.size, "Content-Type": file.endsWith(".json") ? "application/json" : "text/html" });

engraphis/backends/embedder_api.py

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,9 @@ def __init__(
5656
# A custom endpoint can contain embedded credentials or signed query
5757
# parameters, while provider-controlled model identifiers are also untrusted
5858
# log input. Do not copy either into logs.
59-
logger.info(
60-
"ApiEmbedder(custom_endpoint=%s, dim=%s)",
61-
bool(base_url), self._dim or "auto",
62-
)
59+
# Do not log endpoint, model, dimensions, or any authentication-related state:
60+
# provider configuration can contain account identifiers or signed parameters.
61+
logger.info("API embedder initialized")
6362

6463
@property
6564
def dim(self) -> int:
@@ -89,10 +88,7 @@ def embed(
8988
import httpx
9089

9190
if not self._api_key:
92-
logger.error(
93-
"No API key set — set %s env var or pass api_key",
94-
_DEFAULT_API_KEY_ENV,
95-
)
91+
logger.error("No API key set for API embedder")
9692
raise RuntimeError(
9793
f"ApiEmbedder requires an API key via {_DEFAULT_API_KEY_ENV} "
9894
"env var or the api_key parameter"
@@ -114,9 +110,8 @@ def embed(
114110
)
115111
resp.raise_for_status()
116112
data = resp.json()
117-
except Exception as exc:
118-
logger.warning("Batch embedding failed (%s), falling back per-item",
119-
type(exc).__name__)
113+
except Exception:
114+
logger.warning("Batch embedding request failed; falling back per-item")
120115
# Fallback: embed one at a time
121116
vecs = [self._embed_one(t) for t in texts]
122117
return np.asarray(vecs, dtype=np.float32)
@@ -172,8 +167,8 @@ def _embed_one(self, text: str) -> list[float]:
172167
)
173168
resp.raise_for_status()
174169
data = resp.json()
175-
except Exception as exc:
176-
logger.error("Single embedding request failed (%s)", type(exc).__name__)
170+
except Exception:
171+
logger.error("Single embedding request failed")
177172
return [0.0] * (self._dim or 384)
178173

179174
items = data.get("data", [])

engraphis/backends/embedder_deterministic.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def embed(self, texts: list[str], *, kind: Literal["text", "code"] = "text") ->
3232
# SHA-1 is retained only to keep the established offline embedding
3333
# mapping stable across upgrades. This is feature hashing, never a
3434
# password, signature, integrity check, or other security primitive.
35+
# codeql[py/weak-sensitive-data-hashing]
3536
h = hashlib.sha1(
3637
token.encode("utf-8"), usedforsecurity=False
3738
).digest()

engraphis/backends/graph_extractor.py

Lines changed: 100 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,28 @@
2121
"""
2222
from __future__ import annotations
2323

24+
import heapq
2425
import re
2526
from dataclasses import dataclass, field
2627
from typing import Any, Optional
2728

2829
from engraphis.core.interfaces import Edge, Node
2930

3031
# ── Regex NER (ported from engraphis/engines/ingest.py — the v1 heuristic path) ──
31-
# Capitalized multi-word sequences, emails, URLs/hashtags, quoted names/mentions.
32-
_ENTITY_RE = re.compile(
33-
r"\b([A-Z][a-z]+(?:-[A-Za-z]+)*(?:\s+[A-Z][a-z]+(?:-[A-Za-z]+)*){0,3})\b"
34-
r"|([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})"
35-
r"|(#[a-zA-Z][a-zA-Z0-9_-]+)"
36-
r"|(@[a-zA-Z][a-zA-Z0-9_-]+)"
32+
# Capitalized multi-word sequences, emails, hashtags, and mentions. Keep the
33+
# individual recognizers unambiguous: the old all-in-one expression could take
34+
# polynomial time while backtracking over a long email-like local part.
35+
_CAPITALIZED_WORD_RE = re.compile(r"\b[A-Z][a-z]+(?:-[A-Za-z]+)*\b")
36+
_HASHTAG_RE = re.compile(r"#[a-zA-Z][a-zA-Z0-9_-]+")
37+
_MENTION_RE = re.compile(r"@[a-zA-Z][a-zA-Z0-9_-]+")
38+
_EMAIL_LOCAL_CHARS = frozenset(
39+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._%+-"
40+
)
41+
_EMAIL_DOMAIN_CHARS = frozenset(
42+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-"
43+
)
44+
_EMAIL_SUFFIX_CHARS = frozenset(
45+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
3746
)
3847
_RELATION_RE = re.compile(
3948
r"\b(?:is|are|was|were|has|have|had|owns|works at|lives in|prefers|likes|"
@@ -79,6 +88,35 @@
7988
"My", "Our", "Your", "Their", "His", "Her", "Its"}
8089

8190

91+
def _iter_emails(text: str):
92+
"""Yield email-like spans in one pass without regex backtracking."""
93+
94+
index = 0
95+
length = len(text)
96+
while index < length:
97+
if text[index] not in _EMAIL_LOCAL_CHARS:
98+
index += 1
99+
continue
100+
if index > 0 and text[index - 1] in _EMAIL_LOCAL_CHARS:
101+
index += 1
102+
continue
103+
start = index
104+
while index < length and text[index] in _EMAIL_LOCAL_CHARS:
105+
index += 1
106+
if index >= length or text[index] != "@":
107+
continue
108+
domain_start = index + 1
109+
end = domain_start
110+
while end < length and text[end] in _EMAIL_DOMAIN_CHARS:
111+
end += 1
112+
domain = text[domain_start:end]
113+
dot = domain.rfind(".")
114+
suffix = domain[dot + 1:] if dot > 0 else ""
115+
if len(suffix) >= 2 and all(char in _EMAIL_SUFFIX_CHARS for char in suffix):
116+
yield start, end, text[start:end]
117+
index = domain_start if end < length and text[end] == "@" else end
118+
119+
82120
def _defang(value: str) -> str:
83121
return _CONTROL_RE.sub("", value or "").strip()[:_MAX_NAME]
84122

@@ -105,24 +143,71 @@ class GraphExtraction:
105143

106144

107145
def _extract_entities(text: str) -> list[tuple[str, str]]:
146+
text = text or ""
147+
148+
def capitalized_candidates():
149+
# Assemble at most four whitespace-separated capitalized words, matching the
150+
# former heuristic without placing a nested repetition over untrusted text.
151+
words = iter(_CAPITALIZED_WORD_RE.finditer(text))
152+
current = next(words, None)
153+
while current is not None:
154+
first = last = current
155+
current = None
156+
for _ in range(3):
157+
following = next(words, None)
158+
if following is None:
159+
break
160+
if text[last.end():following.start()].isspace():
161+
last = following
162+
continue
163+
current = following
164+
break
165+
else:
166+
current = next(words, None)
167+
yield (
168+
first.start(),
169+
0,
170+
last.end(),
171+
text[first.start():last.end()],
172+
"person_or_concept",
173+
)
174+
175+
# Merge four already-position-ordered iterators. This keeps memory bounded even
176+
# for very large untrusted input and lets the entity fanout cap stop collection.
177+
candidates = heapq.merge(
178+
capitalized_candidates(),
179+
(
180+
(start, 1, end, value, "email")
181+
for start, end, value in _iter_emails(text)
182+
),
183+
(
184+
(match.start(), 2, match.end(), match.group(0), "hashtag")
185+
for match in _HASHTAG_RE.finditer(text)
186+
),
187+
(
188+
(match.start(), 3, match.end(), match.group(0), "mention")
189+
for match in _MENTION_RE.finditer(text)
190+
),
191+
)
192+
108193
seen: set[str] = set()
109194
out: list[tuple[str, str]] = []
110-
for m in _ENTITY_RE.finditer(text or ""):
111-
raw = (m.group(0) or "").strip()
195+
matched_through = 0
196+
for start, _priority, end, candidate, etype in candidates:
197+
if start < matched_through:
198+
continue
199+
matched_through = end
200+
raw = candidate.strip()
112201
if not raw or raw.casefold() in _STOPWORD_KEYS or raw.casefold() in (
113202
"user", "the user"
114203
):
115204
continue
116-
if raw.startswith("#"):
117-
ent, etype = raw, "hashtag"
118-
elif raw.startswith("@"):
119-
ent, etype = raw, "mention"
120-
elif "@" in raw and "." in raw:
121-
ent, etype = raw, "email"
122-
else:
205+
if etype == "person_or_concept":
123206
ent, etype = _canon_concept(raw), "person_or_concept"
124207
if len(ent) < 2 or ent.casefold() in _STOPWORD_KEYS:
125208
continue
209+
else:
210+
ent = raw
126211
key = ent.lower()
127212
if key not in seen:
128213
seen.add(key)

engraphis/core/engine.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,6 +1228,10 @@ def index_repo(self, repo_id: str, root_path: str, *, languages: Optional[set] =
12281228
)
12291229

12301230
indexer = get_code_indexer(prefer=prefer)
1231+
# index_repo is an explicit local-filesystem capability: callers select the
1232+
# repository root to index. Every walked child is resolved and re-contained
1233+
# below before it is read.
1234+
# codeql[py/path-injection]
12311235
root = Path(root_path).expanduser().resolve()
12321236
if not root.exists():
12331237
raise ValueError(f"repo root not found: {root_path}")
@@ -1257,10 +1261,14 @@ def index_repo(self, repo_id: str, root_path: str, *, languages: Optional[set] =
12571261
if files_scanned >= max_files:
12581262
scan_complete = False
12591263
break
1260-
p = Path(file_path)
1264+
candidate = Path(file_path)
12611265
try:
1262-
rel = p.resolve().relative_to(root).as_posix()
1263-
except ValueError:
1266+
# Resolve once, verify containment, then use this checked path for
1267+
# every filesystem operation. The walker skips symlinks, and this
1268+
# closes the remaining defense-in-depth gap if it ever changes.
1269+
source_file = candidate.resolve(strict=True)
1270+
rel = source_file.relative_to(root).as_posix()
1271+
except (OSError, ValueError):
12641272
files_failed += 1
12651273
continue
12661274
files_scanned += 1
@@ -1271,11 +1279,11 @@ def index_repo(self, repo_id: str, root_path: str, *, languages: Optional[set] =
12711279
# of an otherwise complete scan.
12721280
present.add(rel)
12731281
try:
1274-
stat = p.stat()
1282+
stat = source_file.stat()
12751283
if stat.st_size > max_file_bytes:
12761284
files_skipped += 1
12771285
continue
1278-
raw = p.read_bytes()
1286+
raw = source_file.read_bytes()
12791287
except OSError:
12801288
files_failed += 1
12811289
continue

0 commit comments

Comments
 (0)