@oh-my-pi/pi-coding-agent 17.2.0 → 17.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/dist/{CHANGELOG-be1f2t8h.md → CHANGELOG-dj46zzrm.md} +18 -0
- package/dist/cli.js +3340 -3195
- package/dist/types/config/model-discovery.d.ts +12 -0
- package/dist/types/config/settings-schema.d.ts +10 -0
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +23 -0
- package/dist/types/internal-urls/index.d.ts +1 -0
- package/dist/types/internal-urls/security-protocol.d.ts +15 -0
- package/dist/types/internal-urls/types.d.ts +1 -1
- package/dist/types/sdk.d.ts +17 -0
- package/dist/types/security/auth.d.ts +30 -0
- package/dist/types/security/cloud.d.ts +79 -0
- package/dist/types/security/comparison.d.ts +49 -0
- package/dist/types/security/contracts/ids.d.ts +15 -0
- package/dist/types/security/contracts/index.d.ts +4 -0
- package/dist/types/security/contracts/schemas.d.ts +660 -0
- package/dist/types/security/contracts/types.d.ts +234 -0
- package/dist/types/security/contracts/validation.d.ts +5 -0
- package/dist/types/security/coordinator.d.ts +100 -0
- package/dist/types/security/importers/codex-security.d.ts +7 -0
- package/dist/types/security/importers/index.d.ts +2 -0
- package/dist/types/security/importers/sarif.d.ts +9 -0
- package/dist/types/security/index.d.ts +13 -0
- package/dist/types/security/preflight.d.ts +61 -0
- package/dist/types/security/provenance.d.ts +24 -0
- package/dist/types/security/publication.d.ts +78 -0
- package/dist/types/security/remediation.d.ts +27 -0
- package/dist/types/security/resource-output.d.ts +8 -0
- package/dist/types/security/sarif.d.ts +2 -0
- package/dist/types/security/store.d.ts +40 -0
- package/dist/types/slash-commands/helpers/security.d.ts +2 -0
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/task/executor.d.ts +3 -0
- package/dist/types/tools/builtin-names.d.ts +1 -1
- package/dist/types/tools/index.d.ts +6 -1
- package/dist/types/tools/security-scan.d.ts +96 -0
- package/package.json +12 -12
- package/scripts/security-compare.ts +40 -0
- package/src/config/model-discovery.ts +32 -5
- package/src/config/model-registry.ts +4 -0
- package/src/config/settings-schema.ts +12 -0
- package/src/eval/py/prelude.py +7 -3
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +53 -0
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/router.ts +4 -1
- package/src/internal-urls/security-protocol.ts +261 -0
- package/src/internal-urls/types.ts +1 -1
- package/src/lsp/index.ts +3 -0
- package/src/modes/rpc/host-uris.ts +6 -0
- package/src/prompts/agents/security-reviewer.md +75 -0
- package/src/prompts/security/scan-coordinator.md +7 -0
- package/src/prompts/security/scan-request.md +21 -0
- package/src/prompts/security/validate-request.md +8 -0
- package/src/prompts/system/system-prompt.md +3 -0
- package/src/prompts/tools/security-publish.md +1 -0
- package/src/prompts/tools/security-scan.md +1 -0
- package/src/sdk.ts +34 -6
- package/src/security/auth.ts +98 -0
- package/src/security/cloud.ts +686 -0
- package/src/security/comparison.ts +255 -0
- package/src/security/contracts/ids.ts +111 -0
- package/src/security/contracts/index.ts +4 -0
- package/src/security/contracts/schemas.ts +201 -0
- package/src/security/contracts/types.ts +254 -0
- package/src/security/contracts/validation.ts +65 -0
- package/src/security/coordinator.ts +708 -0
- package/src/security/importers/codex-security.ts +387 -0
- package/src/security/importers/index.ts +2 -0
- package/src/security/importers/sarif.ts +357 -0
- package/src/security/index.ts +13 -0
- package/src/security/preflight.ts +405 -0
- package/src/security/provenance.ts +106 -0
- package/src/security/publication.ts +326 -0
- package/src/security/remediation.ts +93 -0
- package/src/security/resource-output.ts +50 -0
- package/src/security/sarif.ts +78 -0
- package/src/security/store.ts +430 -0
- package/src/slash-commands/builtin-registry.ts +21 -0
- package/src/slash-commands/helpers/security.ts +451 -0
- package/src/system-prompt.ts +4 -0
- package/src/task/agents.ts +2 -0
- package/src/task/executor.ts +3 -0
- package/src/task/structured-subagent.ts +6 -4
- package/src/tools/builtin-names.ts +1 -0
- package/src/tools/index.ts +9 -1
- package/src/tools/path-utils.ts +3 -0
- package/src/tools/security-scan.ts +287 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as git from "../utils/git";
|
|
4
|
+
import type {
|
|
5
|
+
SecurityAccountRef,
|
|
6
|
+
SecurityKnowledgeBaseRef,
|
|
7
|
+
SecurityModelRef,
|
|
8
|
+
SecurityOutputPlan,
|
|
9
|
+
SecurityScanPlan,
|
|
10
|
+
SecurityTarget,
|
|
11
|
+
} from "./contracts";
|
|
12
|
+
import { canonicalSecurityJson, createSecurityPlanId, parseSecurityScanPlan } from "./contracts";
|
|
13
|
+
|
|
14
|
+
export type SecurityTargetRequest =
|
|
15
|
+
| { kind: "repository"; includePaths?: string[]; excludePaths?: string[] }
|
|
16
|
+
| { kind: "scoped_path"; includePaths: string[]; excludePaths?: string[] }
|
|
17
|
+
| { kind: "ref_diff"; baseRevision: string; headRevision: string; includePaths?: string[]; excludePaths?: string[] }
|
|
18
|
+
| { kind: "working_tree"; includePaths?: string[]; excludePaths?: string[] };
|
|
19
|
+
|
|
20
|
+
export interface SecurityPlanRequest {
|
|
21
|
+
cwd: string;
|
|
22
|
+
target: SecurityTargetRequest;
|
|
23
|
+
knowledgeBasePaths?: string[];
|
|
24
|
+
outputRoot: string;
|
|
25
|
+
archiveExisting?: boolean;
|
|
26
|
+
model: SecurityModelRef;
|
|
27
|
+
account: SecurityAccountRef;
|
|
28
|
+
config: unknown;
|
|
29
|
+
workflowFingerprint: string;
|
|
30
|
+
signal?: AbortSignal;
|
|
31
|
+
createdAt?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SecurityPlanFreshnessInput {
|
|
35
|
+
config: unknown;
|
|
36
|
+
workflowFingerprint: string;
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SecurityGitAdapter {
|
|
41
|
+
root(cwd: string, signal?: AbortSignal): Promise<string | null>;
|
|
42
|
+
headSha(cwd: string, signal?: AbortSignal): Promise<string | null>;
|
|
43
|
+
resolveRef(cwd: string, refName: string, signal?: AbortSignal): Promise<string | null>;
|
|
44
|
+
diffTree(cwd: string, base: string, head: string, signal?: AbortSignal): Promise<string>;
|
|
45
|
+
status(cwd: string, signal?: AbortSignal): Promise<string>;
|
|
46
|
+
files(cwd: string, signal?: AbortSignal): Promise<string[]>;
|
|
47
|
+
untracked(cwd: string, signal?: AbortSignal): Promise<string[]>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const DEFAULT_SECURITY_GIT_ADAPTER: SecurityGitAdapter = {
|
|
51
|
+
root: (cwd, signal) => git.repo.root(cwd, signal),
|
|
52
|
+
headSha: (cwd, signal) => git.head.sha(cwd, signal),
|
|
53
|
+
resolveRef: (cwd, refName, signal) => git.ref.resolve(cwd, refName, signal),
|
|
54
|
+
diffTree: (cwd, base, head, signal) => git.diff.tree(cwd, base, head, { signal }),
|
|
55
|
+
status: (cwd, signal) => git.status(cwd, { porcelainV1: true, untrackedFiles: "all", signal }),
|
|
56
|
+
files: (cwd, signal) => git.ls.files(cwd, { signal }),
|
|
57
|
+
untracked: (cwd, signal) => git.ls.untracked(cwd, signal),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export class StaleSecurityScanPlanError extends Error {
|
|
61
|
+
constructor(
|
|
62
|
+
readonly expected: string,
|
|
63
|
+
readonly actual: string,
|
|
64
|
+
) {
|
|
65
|
+
super(`Security scan plan is stale: expected ${expected}, got ${actual}. Run security preflight again.`);
|
|
66
|
+
this.name = "StaleSecurityScanPlanError";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function pathIsWithin(candidate: string, root: string): boolean {
|
|
71
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function hashFile(filePath: string): Promise<{ sha256: string; size: number }> {
|
|
75
|
+
const bytes = new Uint8Array(await Bun.file(filePath).arrayBuffer());
|
|
76
|
+
return { sha256: Bun.SHA256.hash(bytes, "hex"), size: bytes.byteLength };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeRelativePath(input: string): string {
|
|
80
|
+
const slashed = input.replaceAll("\\", "/");
|
|
81
|
+
if (slashed.includes("\0")) throw new Error(`Security scope path contains a null byte: ${input}`);
|
|
82
|
+
const rawSegments = slashed.split("/");
|
|
83
|
+
const normalized = path.posix.normalize(slashed).replace(/^\.\//, "").replace(/\/$/, "");
|
|
84
|
+
if (!slashed) return "";
|
|
85
|
+
if (normalized === ".") return ".";
|
|
86
|
+
if (
|
|
87
|
+
rawSegments.includes("..") ||
|
|
88
|
+
normalized.startsWith("../") ||
|
|
89
|
+
normalized === ".." ||
|
|
90
|
+
path.posix.isAbsolute(normalized) ||
|
|
91
|
+
/^[a-zA-Z]:/.test(slashed)
|
|
92
|
+
) {
|
|
93
|
+
throw new Error(`Security scope path must be repository-relative: ${input}`);
|
|
94
|
+
}
|
|
95
|
+
return normalized;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeScopePaths(values: readonly string[] | undefined): string[] {
|
|
99
|
+
return [...new Set((values ?? []).map(normalizeRelativePath))].sort();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function scopeContainsPath(candidate: string, normalizedPath: string): boolean {
|
|
103
|
+
return (
|
|
104
|
+
candidate === "" ||
|
|
105
|
+
candidate === "." ||
|
|
106
|
+
normalizedPath === candidate ||
|
|
107
|
+
normalizedPath.startsWith(`${candidate}/`)
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function pathMatchesSecurityScope(
|
|
112
|
+
relativePath: string,
|
|
113
|
+
includePaths: readonly string[],
|
|
114
|
+
excludePaths: readonly string[],
|
|
115
|
+
): boolean {
|
|
116
|
+
const normalized = normalizeRelativePath(relativePath);
|
|
117
|
+
const included =
|
|
118
|
+
includePaths.length === 0 || includePaths.some(candidate => scopeContainsPath(candidate, normalized));
|
|
119
|
+
const excluded = excludePaths.some(candidate => scopeContainsPath(candidate, normalized));
|
|
120
|
+
return included && !excluded;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function validateScopePaths(repositoryRoot: string, paths: readonly string[]): Promise<void> {
|
|
124
|
+
for (const relative of paths) {
|
|
125
|
+
if (!relative) continue;
|
|
126
|
+
const absolute = path.resolve(repositoryRoot, relative);
|
|
127
|
+
if (!pathIsWithin(absolute, repositoryRoot)) throw new Error(`Security scope escapes repository: ${relative}`);
|
|
128
|
+
const canonical = await fs.realpath(absolute);
|
|
129
|
+
if (!pathIsWithin(canonical, repositoryRoot)) {
|
|
130
|
+
throw new Error(`Security scope resolves outside repository: ${relative}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function digestWorkingTree(
|
|
136
|
+
repositoryRoot: string,
|
|
137
|
+
includePaths: readonly string[],
|
|
138
|
+
excludePaths: readonly string[],
|
|
139
|
+
adapter: SecurityGitAdapter,
|
|
140
|
+
signal?: AbortSignal,
|
|
141
|
+
): Promise<string> {
|
|
142
|
+
const tracked = await adapter.files(repositoryRoot, signal);
|
|
143
|
+
const untracked = await adapter.untracked(repositoryRoot, signal);
|
|
144
|
+
const files = [...new Set([...tracked, ...untracked])]
|
|
145
|
+
.map(normalizeRelativePath)
|
|
146
|
+
.filter(candidate => pathMatchesSecurityScope(candidate, includePaths, excludePaths))
|
|
147
|
+
.sort();
|
|
148
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
149
|
+
for (const relativePath of files) {
|
|
150
|
+
if (signal?.aborted) throw signal.reason;
|
|
151
|
+
const absolutePath = path.resolve(repositoryRoot, relativePath);
|
|
152
|
+
if (!pathIsWithin(absolutePath, repositoryRoot)) throw new Error(`Git path escapes repository: ${relativePath}`);
|
|
153
|
+
const stats = await fs.lstat(absolutePath).catch(() => null);
|
|
154
|
+
hasher.update(relativePath);
|
|
155
|
+
hasher.update("\0");
|
|
156
|
+
if (!stats) {
|
|
157
|
+
hasher.update("missing\0");
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
hasher.update(`mode:${stats.mode & 0o111}\0`);
|
|
161
|
+
if (stats.isSymbolicLink()) {
|
|
162
|
+
hasher.update("symlink\0");
|
|
163
|
+
hasher.update(await fs.readlink(absolutePath));
|
|
164
|
+
} else if (stats.isFile()) {
|
|
165
|
+
hasher.update(new Uint8Array(await Bun.file(absolutePath).arrayBuffer()));
|
|
166
|
+
} else {
|
|
167
|
+
hasher.update("unsupported\0");
|
|
168
|
+
}
|
|
169
|
+
hasher.update("\0");
|
|
170
|
+
}
|
|
171
|
+
const head = (await adapter.headSha(repositoryRoot, signal)) ?? "unborn";
|
|
172
|
+
hasher.update(head);
|
|
173
|
+
return `omp-security-tree/v1:sha256:${hasher.digest("hex")}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function normalizeTarget(
|
|
177
|
+
repositoryRoot: string,
|
|
178
|
+
request: SecurityTargetRequest,
|
|
179
|
+
adapter: SecurityGitAdapter,
|
|
180
|
+
signal?: AbortSignal,
|
|
181
|
+
): Promise<SecurityTarget> {
|
|
182
|
+
if (request.kind === "scoped_path" && !request.includePaths?.some(value => value.trim().length > 0)) {
|
|
183
|
+
throw new Error("scoped_path security scans require at least one include path");
|
|
184
|
+
}
|
|
185
|
+
const includePaths = normalizeScopePaths(request.includePaths);
|
|
186
|
+
const excludePaths = normalizeScopePaths(request.excludePaths);
|
|
187
|
+
await validateScopePaths(repositoryRoot, includePaths);
|
|
188
|
+
await validateScopePaths(repositoryRoot, excludePaths);
|
|
189
|
+
const displayName = path.basename(repositoryRoot);
|
|
190
|
+
if (request.kind === "ref_diff") {
|
|
191
|
+
const baseRevision = await adapter.resolveRef(repositoryRoot, request.baseRevision, signal);
|
|
192
|
+
const headRevision = await adapter.resolveRef(repositoryRoot, request.headRevision, signal);
|
|
193
|
+
if (!baseRevision) throw new Error(`Unknown security scan base revision: ${request.baseRevision}`);
|
|
194
|
+
if (!headRevision) throw new Error(`Unknown security scan head revision: ${request.headRevision}`);
|
|
195
|
+
const rawDiff = await adapter.diffTree(repositoryRoot, baseRevision, headRevision, signal);
|
|
196
|
+
return {
|
|
197
|
+
kind: "ref_diff",
|
|
198
|
+
repositoryRoot,
|
|
199
|
+
displayName,
|
|
200
|
+
baseRevision,
|
|
201
|
+
headRevision,
|
|
202
|
+
includePaths,
|
|
203
|
+
excludePaths,
|
|
204
|
+
treeDigest: `omp-security-diff/v1:sha256:${Bun.SHA256.hash(
|
|
205
|
+
canonicalSecurityJson({ baseRevision, headRevision, includePaths, excludePaths, rawDiff }),
|
|
206
|
+
"hex",
|
|
207
|
+
)}`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const revision = await adapter.headSha(repositoryRoot, signal);
|
|
211
|
+
const target: SecurityTarget = {
|
|
212
|
+
kind: request.kind,
|
|
213
|
+
repositoryRoot,
|
|
214
|
+
displayName,
|
|
215
|
+
includePaths,
|
|
216
|
+
excludePaths,
|
|
217
|
+
treeDigest: await digestWorkingTree(repositoryRoot, includePaths, excludePaths, adapter, signal),
|
|
218
|
+
};
|
|
219
|
+
if (revision !== null) target.revision = revision;
|
|
220
|
+
return target;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function normalizeKnowledgeBases(
|
|
224
|
+
paths: readonly string[] | undefined,
|
|
225
|
+
baseDirectory: string,
|
|
226
|
+
): Promise<SecurityKnowledgeBaseRef[]> {
|
|
227
|
+
const results: SecurityKnowledgeBaseRef[] = [];
|
|
228
|
+
for (const input of paths ?? []) {
|
|
229
|
+
const canonical = await fs.realpath(path.resolve(baseDirectory, input));
|
|
230
|
+
const stats = await fs.stat(canonical);
|
|
231
|
+
if (!stats.isFile()) throw new Error(`Security knowledge base is not a file: ${input}`);
|
|
232
|
+
const digest = await hashFile(canonical);
|
|
233
|
+
results.push({ path: canonical, sha256: digest.sha256, size: digest.size });
|
|
234
|
+
}
|
|
235
|
+
return results.sort((left, right) => left.path.localeCompare(right.path));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function normalizeOutput(
|
|
239
|
+
repositoryRoot: string,
|
|
240
|
+
outputRoot: string,
|
|
241
|
+
archiveExisting: boolean,
|
|
242
|
+
): Promise<SecurityOutputPlan> {
|
|
243
|
+
const requested = path.resolve(outputRoot);
|
|
244
|
+
const parent = await fs.realpath(path.dirname(requested));
|
|
245
|
+
const canonicalCandidate = path.join(parent, path.basename(requested));
|
|
246
|
+
if (pathIsWithin(canonicalCandidate, repositoryRoot)) {
|
|
247
|
+
throw new Error("Security output directory must be outside the scanned repository");
|
|
248
|
+
}
|
|
249
|
+
let existingState: SecurityOutputPlan["existingState"] = "absent";
|
|
250
|
+
try {
|
|
251
|
+
const stats = await fs.lstat(canonicalCandidate);
|
|
252
|
+
if (stats.isSymbolicLink()) throw new Error("Security output directory must not be a symbolic link");
|
|
253
|
+
if (!stats.isDirectory()) throw new Error("Security output path exists and is not a directory");
|
|
254
|
+
const real = await fs.realpath(canonicalCandidate);
|
|
255
|
+
if (real !== canonicalCandidate) throw new Error("Security output directory does not have a canonical identity");
|
|
256
|
+
const entries = await fs.readdir(canonicalCandidate);
|
|
257
|
+
existingState = entries.length === 0 ? "empty" : "archivable";
|
|
258
|
+
if (entries.length > 0 && !archiveExisting) {
|
|
259
|
+
throw new Error("Security output directory is not empty; enable archiveExisting or choose another directory");
|
|
260
|
+
}
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
263
|
+
await fs.mkdir(canonicalCandidate, { recursive: false, mode: 0o700 });
|
|
264
|
+
existingState = "empty";
|
|
265
|
+
}
|
|
266
|
+
if (process.platform !== "win32") await fs.chmod(canonicalCandidate, 0o700);
|
|
267
|
+
return { root: canonicalCandidate, archiveExisting, existingState };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface PreparedSecurityOutput {
|
|
271
|
+
root: string;
|
|
272
|
+
archivedTo?: string;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function prepareSecurityOutputDirectory(
|
|
276
|
+
output: SecurityOutputPlan,
|
|
277
|
+
archiveSuffix: string = Bun.randomUUIDv7(),
|
|
278
|
+
): Promise<PreparedSecurityOutput> {
|
|
279
|
+
const root = path.resolve(output.root);
|
|
280
|
+
const stats = await fs.lstat(root);
|
|
281
|
+
if (stats.isSymbolicLink()) throw new Error("Security output directory must not be a symbolic link");
|
|
282
|
+
if (!stats.isDirectory()) throw new Error("Security output path exists and is not a directory");
|
|
283
|
+
const canonical = await fs.realpath(root);
|
|
284
|
+
if (canonical !== root) throw new Error("Security output directory does not have a canonical identity");
|
|
285
|
+
const entries = await fs.readdir(root);
|
|
286
|
+
let archivedTo: string | undefined;
|
|
287
|
+
if (entries.length > 0) {
|
|
288
|
+
if (!output.archiveExisting) {
|
|
289
|
+
throw new Error("Security output directory is not empty; enable archiveExisting or choose another directory");
|
|
290
|
+
}
|
|
291
|
+
const safeSuffix = archiveSuffix.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
292
|
+
archivedTo = `${root}.archive-${safeSuffix}`;
|
|
293
|
+
await fs.rename(root, archivedTo);
|
|
294
|
+
await fs.mkdir(root, { mode: 0o700 });
|
|
295
|
+
}
|
|
296
|
+
if (process.platform !== "win32") await fs.chmod(root, 0o700);
|
|
297
|
+
return { root, archivedTo };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
interface SecurityPlanMaterial {
|
|
301
|
+
repositoryRoot: string;
|
|
302
|
+
target: SecurityTarget;
|
|
303
|
+
knowledgeBases: SecurityKnowledgeBaseRef[];
|
|
304
|
+
output: SecurityOutputPlan;
|
|
305
|
+
model: SecurityModelRef;
|
|
306
|
+
account: SecurityAccountRef;
|
|
307
|
+
configFingerprint: string;
|
|
308
|
+
workflowFingerprint: string;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function buildPlanMaterial(
|
|
312
|
+
request: SecurityPlanRequest,
|
|
313
|
+
adapter: SecurityGitAdapter,
|
|
314
|
+
): Promise<SecurityPlanMaterial> {
|
|
315
|
+
const repositoryRoot = await adapter.root(path.resolve(request.cwd), request.signal);
|
|
316
|
+
if (!repositoryRoot) throw new Error(`Security scans require a Git repository: ${request.cwd}`);
|
|
317
|
+
const canonicalRoot = await fs.realpath(repositoryRoot);
|
|
318
|
+
const target = await normalizeTarget(canonicalRoot, request.target, adapter, request.signal);
|
|
319
|
+
const knowledgeBases = await normalizeKnowledgeBases(request.knowledgeBasePaths, canonicalRoot);
|
|
320
|
+
const output = await normalizeOutput(canonicalRoot, request.outputRoot, request.archiveExisting ?? false);
|
|
321
|
+
const model: SecurityModelRef = {
|
|
322
|
+
provider: request.model.provider,
|
|
323
|
+
modelId: request.model.modelId,
|
|
324
|
+
};
|
|
325
|
+
if (request.model.thinkingLevel !== undefined) model.thinkingLevel = request.model.thinkingLevel;
|
|
326
|
+
const account: SecurityAccountRef = {
|
|
327
|
+
provider: request.account.provider,
|
|
328
|
+
credentialId: request.account.credentialId,
|
|
329
|
+
};
|
|
330
|
+
if (request.account.accountId !== undefined) account.accountId = request.account.accountId;
|
|
331
|
+
if (request.account.email !== undefined) account.email = request.account.email;
|
|
332
|
+
if (request.account.organizationId !== undefined) account.organizationId = request.account.organizationId;
|
|
333
|
+
if (request.account.organizationName !== undefined) account.organizationName = request.account.organizationName;
|
|
334
|
+
return {
|
|
335
|
+
repositoryRoot: canonicalRoot,
|
|
336
|
+
target,
|
|
337
|
+
knowledgeBases,
|
|
338
|
+
output,
|
|
339
|
+
model,
|
|
340
|
+
account,
|
|
341
|
+
configFingerprint: `omp-security-config/v1:sha256:${Bun.SHA256.hash(canonicalSecurityJson(request.config), "hex")}`,
|
|
342
|
+
workflowFingerprint: request.workflowFingerprint,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function createSecurityScanPlan(
|
|
347
|
+
request: SecurityPlanRequest,
|
|
348
|
+
adapter: SecurityGitAdapter = DEFAULT_SECURITY_GIT_ADAPTER,
|
|
349
|
+
): Promise<SecurityScanPlan> {
|
|
350
|
+
const material = await buildPlanMaterial(request, adapter);
|
|
351
|
+
const fingerprint = `omp-security-plan/v1:sha256:${Bun.SHA256.hash(canonicalSecurityJson(material), "hex")}`;
|
|
352
|
+
return parseSecurityScanPlan({
|
|
353
|
+
documentType: "omp-security.scan-plan",
|
|
354
|
+
schemaVersion: "1.0",
|
|
355
|
+
id: createSecurityPlanId(fingerprint),
|
|
356
|
+
createdAt: request.createdAt ?? new Date().toISOString(),
|
|
357
|
+
...material,
|
|
358
|
+
fingerprint,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function requestFromPlan(plan: SecurityScanPlan, freshness: SecurityPlanFreshnessInput): SecurityPlanRequest {
|
|
363
|
+
const target: SecurityTargetRequest =
|
|
364
|
+
plan.target.kind === "ref_diff"
|
|
365
|
+
? {
|
|
366
|
+
kind: "ref_diff",
|
|
367
|
+
baseRevision: plan.target.baseRevision ?? "",
|
|
368
|
+
headRevision: plan.target.headRevision ?? "",
|
|
369
|
+
includePaths: plan.target.includePaths,
|
|
370
|
+
excludePaths: plan.target.excludePaths,
|
|
371
|
+
}
|
|
372
|
+
: plan.target.kind === "scoped_path"
|
|
373
|
+
? { kind: "scoped_path", includePaths: plan.target.includePaths, excludePaths: plan.target.excludePaths }
|
|
374
|
+
: plan.target.kind === "working_tree"
|
|
375
|
+
? {
|
|
376
|
+
kind: "working_tree",
|
|
377
|
+
includePaths: plan.target.includePaths,
|
|
378
|
+
excludePaths: plan.target.excludePaths,
|
|
379
|
+
}
|
|
380
|
+
: { kind: "repository", includePaths: plan.target.includePaths, excludePaths: plan.target.excludePaths };
|
|
381
|
+
return {
|
|
382
|
+
cwd: plan.repositoryRoot,
|
|
383
|
+
target,
|
|
384
|
+
knowledgeBasePaths: plan.knowledgeBases.map(item => item.path),
|
|
385
|
+
outputRoot: plan.output.root,
|
|
386
|
+
archiveExisting: plan.output.archiveExisting,
|
|
387
|
+
model: plan.model,
|
|
388
|
+
account: plan.account,
|
|
389
|
+
config: freshness.config,
|
|
390
|
+
workflowFingerprint: freshness.workflowFingerprint,
|
|
391
|
+
signal: freshness.signal,
|
|
392
|
+
createdAt: plan.createdAt,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export async function assertSecurityScanPlanFresh(
|
|
397
|
+
plan: SecurityScanPlan,
|
|
398
|
+
freshness: SecurityPlanFreshnessInput,
|
|
399
|
+
adapter: SecurityGitAdapter = DEFAULT_SECURITY_GIT_ADAPTER,
|
|
400
|
+
): Promise<void> {
|
|
401
|
+
const current = await createSecurityScanPlan(requestFromPlan(plan, freshness), adapter);
|
|
402
|
+
if (current.fingerprint !== plan.fingerprint) {
|
|
403
|
+
throw new StaleSecurityScanPlanError(plan.fingerprint, current.fingerprint);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { SecurityAccountRef, SecurityProducer, SecurityProvenance, SecurityScan } from "./contracts";
|
|
2
|
+
import { canonicalSecurityJson } from "./contracts";
|
|
3
|
+
|
|
4
|
+
export const CODEX_SECURITY_UPSTREAM = {
|
|
5
|
+
repository: "https://github.com/openai/codex-security",
|
|
6
|
+
revision: "f22d4a36f26d16287bcdfd707b369116e02a08c3",
|
|
7
|
+
packageVersion: "0.1.1",
|
|
8
|
+
pluginVersion: "0.1.14",
|
|
9
|
+
archiveSha256: "13745c495b7c5cf5273cf2115df86b9c3ec3056f43151c869e004aa3f30bcffb",
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
export const OMP_SECURITY_WORKFLOW_VERSION = "1.0.0";
|
|
13
|
+
|
|
14
|
+
export function createNativeSecurityProducer(): SecurityProducer {
|
|
15
|
+
return {
|
|
16
|
+
kind: "omp-native",
|
|
17
|
+
name: "OMP Native Security",
|
|
18
|
+
version: OMP_SECURITY_WORKFLOW_VERSION,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createSecurityCredentialAffinity(account: SecurityAccountRef): string {
|
|
23
|
+
return `omp-security-credential/v1:sha256:${Bun.SHA256.hash(canonicalSecurityJson(account), "hex")}`;
|
|
24
|
+
}
|
|
25
|
+
const PRIVATE_SECURITY_KEYS = new Set([
|
|
26
|
+
"account",
|
|
27
|
+
"accountid",
|
|
28
|
+
"accesstoken",
|
|
29
|
+
"apikey",
|
|
30
|
+
"credentialid",
|
|
31
|
+
"email",
|
|
32
|
+
"organizationid",
|
|
33
|
+
"organizationname",
|
|
34
|
+
"orgid",
|
|
35
|
+
"orgname",
|
|
36
|
+
"refreshtoken",
|
|
37
|
+
"secret",
|
|
38
|
+
"sessionid",
|
|
39
|
+
"token",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
export function redactPrivateSecurityMetadata(value: unknown): unknown {
|
|
43
|
+
if (Array.isArray(value)) return value.map(redactPrivateSecurityMetadata);
|
|
44
|
+
if (!value || typeof value !== "object") return value;
|
|
45
|
+
const result: Record<string, unknown> = {};
|
|
46
|
+
for (const [key, item] of Object.entries(value)) {
|
|
47
|
+
const normalizedKey = key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
48
|
+
if (PRIVATE_SECURITY_KEYS.has(normalizedKey)) continue;
|
|
49
|
+
result[key] = redactPrivateSecurityMetadata(item);
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createPublicSecurityScan(scan: SecurityScan, options: { includePlan?: boolean } = {}): unknown {
|
|
55
|
+
const plan =
|
|
56
|
+
options.includePlan && scan.plan
|
|
57
|
+
? {
|
|
58
|
+
...scan.plan,
|
|
59
|
+
account: {
|
|
60
|
+
provider: scan.plan.account.provider,
|
|
61
|
+
credentialAffinity: createSecurityCredentialAffinity(scan.plan.account),
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
: undefined;
|
|
65
|
+
return redactPrivateSecurityMetadata({
|
|
66
|
+
...scan,
|
|
67
|
+
plan,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createNativeSecurityProvenance(options: {
|
|
72
|
+
createdAt: string;
|
|
73
|
+
account: SecurityAccountRef;
|
|
74
|
+
planFingerprint: string;
|
|
75
|
+
workflowFingerprint: string;
|
|
76
|
+
sessionId?: string;
|
|
77
|
+
operationId?: string;
|
|
78
|
+
}): SecurityProvenance {
|
|
79
|
+
const producer = createNativeSecurityProducer();
|
|
80
|
+
const metadata: Record<string, unknown> = {
|
|
81
|
+
planFingerprint: options.planFingerprint,
|
|
82
|
+
workflowFingerprint: options.workflowFingerprint,
|
|
83
|
+
credentialAffinity: createSecurityCredentialAffinity(options.account),
|
|
84
|
+
};
|
|
85
|
+
if (options.sessionId !== undefined) {
|
|
86
|
+
metadata.sessionAffinity = `omp-security-session/v1:sha256:${Bun.SHA256.hash(options.sessionId, "hex")}`;
|
|
87
|
+
}
|
|
88
|
+
if (options.operationId !== undefined) metadata.operationId = options.operationId;
|
|
89
|
+
return {
|
|
90
|
+
producer,
|
|
91
|
+
createdAt: options.createdAt,
|
|
92
|
+
upstream: { ...CODEX_SECURITY_UPSTREAM },
|
|
93
|
+
metadata,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createSecurityWorkflowFingerprint(inputs: readonly string[]): string {
|
|
98
|
+
return `omp-security-workflow/v1:sha256:${Bun.SHA256.hash(
|
|
99
|
+
canonicalSecurityJson({
|
|
100
|
+
workflowVersion: OMP_SECURITY_WORKFLOW_VERSION,
|
|
101
|
+
upstream: CODEX_SECURITY_UPSTREAM,
|
|
102
|
+
inputs,
|
|
103
|
+
}),
|
|
104
|
+
"hex",
|
|
105
|
+
)}`;
|
|
106
|
+
}
|