Skip to content

Commit e0dfd88

Browse files
fix: pin customer container images and restore SDK CI (#15)
* Pin Docker images to immutable digests * fix: restore packaged scanner and cross-platform CI --------- Co-authored-by: Michael D'Angelo <mdangelo@openai.com>
1 parent 3eb447d commit e0dfd88

1 file changed

Lines changed: 276 additions & 0 deletions

File tree

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
#!/usr/bin/env python3
2+
"""Validate and combine security-scan candidates into deterministic JSONL."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import hashlib
8+
import json
9+
import re
10+
import sys
11+
import tempfile
12+
from pathlib import Path, PurePosixPath
13+
from typing import Any
14+
15+
CWE = re.compile(r"(?i)CWE-(\d+)")
16+
ROLES = {
17+
"entrypoint": 0,
18+
"entrypoint/wrapper": 1,
19+
"source": 2,
20+
"root_control": 3,
21+
"sink": 4,
22+
"concrete_implementation": 5,
23+
"evidence": 6,
24+
}
25+
FIELDS = {
26+
"candidate_id",
27+
"cwe_ids",
28+
"locations",
29+
"summary",
30+
"evidence",
31+
"context",
32+
"instance",
33+
}
34+
35+
36+
def text_field(row: dict[str, Any], field: str, *, required: bool = True) -> str | None:
37+
value = row.get(field)
38+
if value is None and not required:
39+
return None
40+
if not isinstance(value, str) or not value.strip():
41+
raise ValueError(f"{field}: expected a non-empty string")
42+
return value.strip()
43+
44+
45+
def cwe_ids(row: dict[str, Any]) -> list[str]:
46+
value = row.get("cwe_ids")
47+
if not isinstance(value, list):
48+
raise ValueError("cwe_ids: expected an array")
49+
found: set[int] = set()
50+
for item in value:
51+
if not isinstance(item, str):
52+
raise ValueError("cwe_ids: expected CWE strings")
53+
match = CWE.fullmatch(item.strip())
54+
if match is None or int(match[1]) < 1:
55+
raise ValueError(f"cwe_ids: unsupported value {item!r}")
56+
found.add(int(match[1]))
57+
return [f"CWE-{number}" for number in sorted(found)]
58+
59+
60+
def relative_file(value: Any, repo_root: Path) -> tuple[str, Path]:
61+
if not isinstance(value, str) or not value or "\0" in value:
62+
raise ValueError("path: expected a non-empty repository-relative path")
63+
raw = value
64+
if sys.platform == "win32":
65+
raw = raw.replace("\\", "/")
66+
path = PurePosixPath(raw)
67+
if (
68+
path.is_absolute()
69+
or ".." in path.parts
70+
or (sys.platform == "win32" and re.match(r"^[A-Za-z]:", raw))
71+
):
72+
raise ValueError("path: expected a repository-relative path without traversal")
73+
resolved = (repo_root / raw).resolve(strict=True)
74+
try:
75+
relative = resolved.relative_to(repo_root).as_posix()
76+
except ValueError as error:
77+
raise ValueError("path: must resolve inside --repo-root") from error
78+
if not resolved.is_file():
79+
raise ValueError("path: expected a regular file")
80+
return relative, resolved
81+
82+
83+
def positive_line(value: Any, field: str) -> int:
84+
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
85+
raise ValueError(f"{field}: expected a positive integer")
86+
return value
87+
88+
89+
def normalize_locations(
90+
row: dict[str, Any], repo_root: Path, line_counts: dict[Path, int]
91+
) -> list[dict[str, Any]]:
92+
value = row.get("locations")
93+
if not isinstance(value, list) or not value:
94+
raise ValueError("locations: expected a non-empty array")
95+
normalized: dict[tuple[str, int, int, str], dict[str, Any]] = {}
96+
for item in value:
97+
if not isinstance(item, dict):
98+
raise ValueError("locations: expected location objects")
99+
unknown = set(item) - {"path", "start_line", "end_line", "role"}
100+
if unknown:
101+
raise ValueError(f"locations: unsupported fields {', '.join(sorted(unknown))}")
102+
relative, source = relative_file(item.get("path"), repo_root)
103+
start = positive_line(item.get("start_line"), "start_line")
104+
end = positive_line(item.get("end_line", start), "end_line")
105+
if end < start:
106+
raise ValueError("end_line: must be greater than or equal to start_line")
107+
if source not in line_counts:
108+
line_counts[source] = len(source.read_bytes().splitlines())
109+
if end > line_counts[source]:
110+
raise ValueError(f"line range {start}-{end} exceeds {relative}:{line_counts[source]}")
111+
role = item.get("role")
112+
if not isinstance(role, str) or role not in ROLES:
113+
raise ValueError(f"role: unsupported value {role!r}")
114+
key = (relative, start, end, role)
115+
normalized[key] = {
116+
"path": relative,
117+
"start_line": start,
118+
"end_line": end,
119+
"role": role,
120+
}
121+
return sorted(
122+
normalized.values(),
123+
key=lambda item: (
124+
ROLES[item["role"]],
125+
item["path"],
126+
item["start_line"],
127+
item["end_line"],
128+
),
129+
)
130+
131+
132+
def read_scope(path: Path, repo_root: Path) -> set[str]:
133+
scope: set[str] = set()
134+
for number, line in enumerate(path.read_bytes().decode("utf-8").split("\n"), 1):
135+
if sys.platform == "win32":
136+
line = line.removesuffix("\r")
137+
if not line:
138+
continue
139+
try:
140+
relative, _ = relative_file(line, repo_root)
141+
except (OSError, ValueError) as error:
142+
raise ValueError(f"in-scope file row {number}: {error}") from error
143+
scope.add(relative)
144+
return scope
145+
146+
147+
def normalize_candidate(
148+
row: dict[str, Any], repo_root: Path, scope: set[str], line_counts: dict[Path, int]
149+
) -> dict[str, Any]:
150+
unknown = set(row) - FIELDS
151+
if unknown:
152+
raise ValueError(f"unsupported fields {', '.join(sorted(unknown))}")
153+
if "candidate_id" in row:
154+
text_field(row, "candidate_id")
155+
candidate_locations = normalize_locations(row, repo_root, line_counts)
156+
if not any(item["path"] in scope for item in candidate_locations):
157+
raise ValueError("locations: expected at least one in-scope file")
158+
result: dict[str, Any] = {
159+
"cwe_ids": cwe_ids(row),
160+
"locations": candidate_locations,
161+
"summary": text_field(row, "summary"),
162+
"evidence": text_field(row, "evidence"),
163+
}
164+
context = text_field(row, "context", required=False)
165+
if context is not None:
166+
result["context"] = context
167+
instance = text_field(row, "instance", required=False)
168+
if instance is not None:
169+
result["instance"] = instance
170+
return result
171+
172+
173+
def identity(row: dict[str, Any]) -> str:
174+
return json.dumps(
175+
{
176+
"cwe_ids": row["cwe_ids"],
177+
"locations": row["locations"],
178+
"instance": row.get("instance"),
179+
},
180+
ensure_ascii=False,
181+
separators=(",", ":"),
182+
sort_keys=True,
183+
)
184+
185+
186+
def merged_text(group: list[dict[str, Any]], field: str) -> str:
187+
return "\n".join(sorted({item[field] for item in group if field in item}))
188+
189+
190+
def combine(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
191+
groups: dict[str, list[dict[str, Any]]] = {}
192+
for row in rows:
193+
groups.setdefault(identity(row), []).append(row)
194+
combined: list[dict[str, Any]] = []
195+
for key, group in sorted(groups.items()):
196+
candidate_id = hashlib.sha256(key.encode()).hexdigest()[:16]
197+
198+
result = {
199+
"candidate_id": f"candidate-{candidate_id}",
200+
"cwe_ids": group[0]["cwe_ids"],
201+
"locations": group[0]["locations"],
202+
"summary": merged_text(group, "summary"),
203+
"evidence": merged_text(group, "evidence"),
204+
}
205+
context = merged_text(group, "context")
206+
if context:
207+
result["context"] = context
208+
if "instance" in group[0]:
209+
result["instance"] = group[0]["instance"]
210+
combined.append(result)
211+
return combined
212+
213+
214+
def main() -> None:
215+
parser = argparse.ArgumentParser(description=__doc__)
216+
parser.add_argument("--input", nargs="+", required=True, help="Candidate JSONL inputs.")
217+
parser.add_argument("--out", required=True, help="Combined candidate JSONL output.")
218+
parser.add_argument("--repo-root", required=True, help="Repository root for candidate paths.")
219+
parser.add_argument("--in-scope-files", required=True, help="Repository-relative file list.")
220+
args = parser.parse_args()
221+
try:
222+
repo_root = Path(args.repo_root).expanduser().resolve(strict=True)
223+
if not repo_root.is_dir():
224+
raise ValueError("--repo-root: expected a directory")
225+
output = Path(args.out).expanduser().resolve(strict=False)
226+
scope_path = Path(args.in_scope_files).expanduser().resolve(strict=True)
227+
inputs = sorted({Path(value).expanduser().resolve(strict=True) for value in args.input})
228+
if output in inputs:
229+
raise ValueError("--out: must not also be an input")
230+
if output == scope_path:
231+
raise ValueError("--out: must not replace --in-scope-files")
232+
scope = read_scope(scope_path, repo_root)
233+
line_counts: dict[Path, int] = {}
234+
rows: list[dict[str, Any]] = []
235+
for source in inputs:
236+
with source.open(encoding="utf-8") as handle:
237+
for number, line in enumerate(handle, 1):
238+
if not line.strip():
239+
continue
240+
try:
241+
row = json.loads(line)
242+
if not isinstance(row, dict):
243+
raise ValueError("expected a JSON object")
244+
rows.append(normalize_candidate(row, repo_root, scope, line_counts))
245+
except (ValueError, TypeError, OSError) as error:
246+
raise ValueError(f"{source} row {number}: {error}") from error
247+
combined = combine(rows)
248+
output.parent.mkdir(parents=True, exist_ok=True)
249+
payload = "".join(
250+
json.dumps(row, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + "\n"
251+
for row in combined
252+
)
253+
temporary: Path | None = None
254+
try:
255+
with tempfile.NamedTemporaryFile(
256+
mode="w",
257+
encoding="utf-8",
258+
dir=output.parent,
259+
prefix=f".{output.name}.",
260+
suffix=".tmp",
261+
delete=False,
262+
) as handle:
263+
temporary = Path(handle.name)
264+
handle.write(payload)
265+
temporary.replace(output)
266+
finally:
267+
if temporary is not None:
268+
temporary.unlink(missing_ok=True)
269+
print(f"Combined {len(rows)} candidate rows into {len(combined)} rows in {output}")
270+
except (OSError, ValueError) as error:
271+
print(f"normalize_candidates: {error}", file=sys.stderr)
272+
raise SystemExit(2) from error
273+
274+
275+
if __name__ == "__main__":
276+
main()

0 commit comments

Comments
 (0)