@mcrescenzo/opencode-workflows 0.1.0
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 +14 -0
- package/CODE_OF_CONDUCT.md +134 -0
- package/CONTRIBUTING.md +95 -0
- package/LICENSE +21 -0
- package/README.md +746 -0
- package/SECURITY.md +38 -0
- package/commands/repo-bughunt.md +94 -0
- package/commands/repo-review.md +148 -0
- package/commands/workflow-live-gates-release-check.md +143 -0
- package/docs/workflow-plugin.md +400 -0
- package/opencode-workflows.js +5 -0
- package/package.json +86 -0
- package/skills/opencode-workflow-authoring/SKILL.md +180 -0
- package/skills/repo-review-command-protocol/SKILL.md +56 -0
- package/skills/workflow-model-tiering/SKILL.md +57 -0
- package/skills/workflow-plan-review/SKILL.md +91 -0
- package/workflow-kernel/approval-hashing.js +39 -0
- package/workflow-kernel/async-util.js +33 -0
- package/workflow-kernel/audited-shell-policy.js +200 -0
- package/workflow-kernel/authority-policy.js +670 -0
- package/workflow-kernel/budget-accounting.js +142 -0
- package/workflow-kernel/capability-adapter.js +753 -0
- package/workflow-kernel/child-agent-runner.js +1264 -0
- package/workflow-kernel/constants.js +117 -0
- package/workflow-kernel/diagnostics.js +152 -0
- package/workflow-kernel/drain-runtime.js +421 -0
- package/workflow-kernel/errors.js +181 -0
- package/workflow-kernel/event-journal.js +487 -0
- package/workflow-kernel/extension-registry.js +144 -0
- package/workflow-kernel/free-text-redactor.js +91 -0
- package/workflow-kernel/gate-shapes.js +82 -0
- package/workflow-kernel/git-util.js +45 -0
- package/workflow-kernel/index.js +72 -0
- package/workflow-kernel/integration-mode.js +155 -0
- package/workflow-kernel/lane-effort-policy.js +134 -0
- package/workflow-kernel/lifecycle-control.js +608 -0
- package/workflow-kernel/live-gate-probes.js +916 -0
- package/workflow-kernel/notification-toast-cards.js +393 -0
- package/workflow-kernel/notification-toast-policy.js +179 -0
- package/workflow-kernel/notification-toast-scope.js +100 -0
- package/workflow-kernel/notification-toast.js +287 -0
- package/workflow-kernel/path-policy.js +219 -0
- package/workflow-kernel/result-readback.js +106 -0
- package/workflow-kernel/role-template-loading.js +606 -0
- package/workflow-kernel/run-context.js +139 -0
- package/workflow-kernel/run-observability.js +43 -0
- package/workflow-kernel/run-store-fs.js +231 -0
- package/workflow-kernel/run-store-locks.js +180 -0
- package/workflow-kernel/run-store-projections.js +421 -0
- package/workflow-kernel/run-store-rehydrate.js +68 -0
- package/workflow-kernel/run-store-state.js +147 -0
- package/workflow-kernel/run-store-status-format.js +1154 -0
- package/workflow-kernel/run-store-status.js +107 -0
- package/workflow-kernel/sandbox-executor.js +1131 -0
- package/workflow-kernel/session-access.js +56 -0
- package/workflow-kernel/structured-output.js +143 -0
- package/workflow-kernel/test-fix-drain-adapter.js +119 -0
- package/workflow-kernel/text-json.js +136 -0
- package/workflow-kernel/workflow-plugin.js +3017 -0
- package/workflow-kernel/workflow-source.js +444 -0
- package/workflow-kernel/worktree-adapter.js +256 -0
- package/workflow-kernel/worktree-git.js +147 -0
- package/workflows/repo-bughunt.js +362 -0
- package/workflows/repo-cleanup.js +383 -0
- package/workflows/repo-complexity.js +404 -0
- package/workflows/repo-deps.js +457 -0
- package/workflows/repo-modernize.js +395 -0
- package/workflows/repo-perf.js +394 -0
- package/workflows/repo-review.js +831 -0
- package/workflows/repo-security-audit.js +466 -0
- package/workflows/repo-test-gaps.js +377 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
// LEAF workflow — repo-security-audit engine (OpenCode port).
|
|
2
|
+
//
|
|
3
|
+
// Conforms to docs/repo-review-leaf-contract.md (the shared leaf contract).
|
|
4
|
+
// Canonical exemplar: workflows/repo-bughunt.js + tests/repo-bughunt.test.mjs.
|
|
5
|
+
// Port lineage (DOMAIN LOGIC only): internal Claude workflow source,
|
|
6
|
+
// adapted to the OpenCode QuickJS guest: no fs/Bash/git, tier-based models
|
|
7
|
+
// (fast/deep) instead of sonnet/opus, PURE-JS synthesis (no model) instead of an
|
|
8
|
+
// opus synthesizer agent, onFailure:"returnNull", arity-1 parallel thunks for
|
|
9
|
+
// concurrency, and reportMarkdown returned in the envelope (the guest cannot
|
|
10
|
+
// write files, so reportPath is always null — the command wrapper persists the
|
|
11
|
+
// report).
|
|
12
|
+
//
|
|
13
|
+
// Domain stance: this is the ONLY leaf allowed to populate the `critical`
|
|
14
|
+
// severity tier (counts.critical is the REAL count of critical findings).
|
|
15
|
+
// Security finds credential/secret RISKS by LOCATION without ever surfacing raw
|
|
16
|
+
// secret values; the SAFETY directive below forbids embedding secret values and
|
|
17
|
+
// forbids reading credential files. In-guest value masking of model prose is
|
|
18
|
+
// applied during synthesis (maskFindingSecrets; docs/repo-review-leaf-contract.md
|
|
19
|
+
// §15) so no raw secret enters the returned envelope/report; the prompt layer and
|
|
20
|
+
// the guest sandbox (no fs) are defense-in-depth.
|
|
21
|
+
export const meta = {
|
|
22
|
+
name: "repo-security-audit",
|
|
23
|
+
description: "Report-only whole-repo security audit (injection, authz, secrets, unsafe deserialization, SSRF, crypto misuse, input validation, dependency CVEs, insecure defaults, sensitive logging) with adversarial exploitability verification. Finds credential/secret risks by location without surfacing secret values. Returns ranked structured findings; the workflow writes no files.",
|
|
24
|
+
profile: "read-only-review",
|
|
25
|
+
maxAgents: 4096,
|
|
26
|
+
concurrency: 16,
|
|
27
|
+
phases: ["recon", "find", "verify", "synthesize"],
|
|
28
|
+
category: "repo-review-leaf",
|
|
29
|
+
notes: "Read-only report-only security leaf. Finds secret risks by location without surfacing raw values. Finder lanes use fast tier, exploitability verification uses deep tier.",
|
|
30
|
+
examples: [
|
|
31
|
+
{ label: "normal security scan", args: { depth: "normal", paths: ["src"] } },
|
|
32
|
+
{ label: "focused secrets/authz scan", args: { depth: "quick", categories: ["secrets", "authz"], paths: ["src"] } },
|
|
33
|
+
],
|
|
34
|
+
argsSchema: {
|
|
35
|
+
type: ["object", "string", "null"],
|
|
36
|
+
properties: {
|
|
37
|
+
paths: { type: "array", items: { type: "string" } },
|
|
38
|
+
exclude: { type: "array", items: { type: "string" } },
|
|
39
|
+
depth: { type: "string", enum: ["quick", "normal", "thorough"] },
|
|
40
|
+
categories: { type: "array", items: { type: "string", enum: ["injection", "authz", "secrets", "unsafe-deserialization", "ssrf", "crypto-misuse", "input-validation", "dep-cve", "insecure-default", "sensitive-logging"] } },
|
|
41
|
+
maxReturnFindings: { type: "integer", minimum: 1 },
|
|
42
|
+
recon: { type: ["object", "string"] },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ---- suite identity ----
|
|
48
|
+
const DOMAIN = "security";
|
|
49
|
+
const SCHEMA_VERSION = 1;
|
|
50
|
+
|
|
51
|
+
// args may arrive as an object (workflow_run args) or, defensively, a JSON string.
|
|
52
|
+
let RT = args;
|
|
53
|
+
if (typeof RT === "string") { try { RT = RT.trim() ? JSON.parse(RT) : {}; } catch (error) { throw new Error(`Invalid repo-security-audit runtime args JSON: ${error.message}`); } }
|
|
54
|
+
if (!RT || typeof RT !== "object" || Array.isArray(RT)) RT = {};
|
|
55
|
+
|
|
56
|
+
const paths = Array.isArray(RT.paths) && RT.paths.length ? RT.paths : ["."];
|
|
57
|
+
const exclude = Array.isArray(RT.exclude) ? RT.exclude : ["node_modules", "dist", "build", ".git", "vendor", "target", "*.min.*", "*.map"];
|
|
58
|
+
const depth = ["quick", "normal", "thorough"].includes(RT.depth) ? RT.depth : "thorough";
|
|
59
|
+
|
|
60
|
+
// Model selection is intent-based: lanes declare a tier; the kernel resolves tier -> concrete model from
|
|
61
|
+
// run.modelTiers (set by the planning agent), falling back to the session-inherited default.
|
|
62
|
+
// recon + finders = bulk work (fast); skeptics = subtle exploitability correctness (deep); synth = pure JS.
|
|
63
|
+
const TIER_RECON = "fast";
|
|
64
|
+
const TIER_FINDER = "fast";
|
|
65
|
+
const TIER_VERIFY = "deep";
|
|
66
|
+
|
|
67
|
+
const MAX_RETURN_FINDINGS = Number.isInteger(RT.maxReturnFindings) && RT.maxReturnFindings > 0 ? RT.maxReturnFindings : 1000000;
|
|
68
|
+
|
|
69
|
+
const ALL_CATEGORIES = [
|
|
70
|
+
"injection", "authz", "secrets", "unsafe-deserialization", "ssrf",
|
|
71
|
+
"crypto-misuse", "input-validation", "dep-cve", "insecure-default", "sensitive-logging",
|
|
72
|
+
];
|
|
73
|
+
const requestedCategories = Array.isArray(RT.categories) ? RT.categories.filter((c) => typeof c === "string" && c.trim()) : [];
|
|
74
|
+
const categories = requestedCategories.length > 0 ? requestedCategories.filter((c) => ALL_CATEGORIES.includes(c)) : ALL_CATEGORIES;
|
|
75
|
+
if (requestedCategories.length > 0 && categories.length === 0) {
|
|
76
|
+
throw new Error(`No valid repo-security-audit categories supplied. Valid categories: ${ALL_CATEGORIES.join(", ")}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// DOMAIN-SPECIFIC SCOPE (iui1.3): security inspects the highest-signal config/CI/infra/auth surface
|
|
80
|
+
// in addition to source. *.lock is intentionally NOT in this domain's default exclude (lockfiles pin
|
|
81
|
+
// vulnerable transitive versions). The shared meta exclude no longer drops *.lock globally; config/CI/
|
|
82
|
+
// Docker/deploy files are a hard include for security because that is where insecure defaults, leaked
|
|
83
|
+
// secrets, and over-broad permissions most often live.
|
|
84
|
+
const DOMAIN_SCOPE_INCLUDES = "In scope for security (always inspect): CI pipelines (.github/workflows, .gitlab-ci.yml, .circleci), Docker/container files (Dockerfile, docker-compose.yml), deploy/infra manifests (k8s, terraform, serverless), non-secret application config, and auth/session code (token handling, session middleware, RBAC) — these carry the highest security signal alongside lockfiles (pin vulnerable transitive versions). Secret files themselves remain OFF-LIMITS for reading (report by location only, never paste raw values).";
|
|
85
|
+
const scope = `Scope: paths = ${JSON.stringify(paths)}. Exclude (do not scan/report): ${JSON.stringify(exclude)}. ${DOMAIN_SCOPE_INCLUDES}`;
|
|
86
|
+
|
|
87
|
+
// ---- lane coverage telemetry (iui1.2) ----
|
|
88
|
+
// Tracks expected/completed/dropped lane counts per phase so a dropped finder/verifier lane
|
|
89
|
+
// (onFailure returnNull + filter(Boolean)) surfaces as degraded coverage instead of a silent
|
|
90
|
+
// null. The meta reads laneCoverage.dropped to block materialization on partial coverage.
|
|
91
|
+
const laneCoverage = { expected: 0, completed: 0, dropped: 0, byPhase: {}, droppedLabels: [] };
|
|
92
|
+
function tallyPhase(name, results, labelOf) {
|
|
93
|
+
const expected = results.length;
|
|
94
|
+
let completed = 0;
|
|
95
|
+
for (let i = 0; i < results.length; i++) {
|
|
96
|
+
if (results[i] === null || results[i] === undefined) laneCoverage.droppedLabels.push(labelOf ? labelOf(i) : `${name}:${i + 1}`);
|
|
97
|
+
else completed++;
|
|
98
|
+
}
|
|
99
|
+
const dropped = expected - completed;
|
|
100
|
+
laneCoverage.expected += expected;
|
|
101
|
+
laneCoverage.completed += completed;
|
|
102
|
+
laneCoverage.dropped += dropped;
|
|
103
|
+
const prev = laneCoverage.byPhase[name] || { expected: 0, completed: 0, dropped: 0 };
|
|
104
|
+
laneCoverage.byPhase[name] = { expected: prev.expected + expected, completed: prev.completed + completed, dropped: prev.dropped + dropped };
|
|
105
|
+
return results;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- standardized return envelope ----
|
|
109
|
+
function envelope(status, extra) {
|
|
110
|
+
return { domain: DOMAIN, schemaVersion: SCHEMA_VERSION, status, abortReason: null, reportPath: null, laneCoverage, ...extra };
|
|
111
|
+
}
|
|
112
|
+
const emptyCounts = { total: 0, critical: 0, high: 0, medium: 0, low: 0 };
|
|
113
|
+
|
|
114
|
+
// ---- coverage disclosure ----
|
|
115
|
+
// Security runs NO dynamic tooling in-guest (read-only-review profile, no shell):
|
|
116
|
+
// no automated vulnerability scanners, no dynamic analysis, no penetration tests.
|
|
117
|
+
// Dependency CVEs reflect reviewer knowledge of manifests/lockfiles and may miss
|
|
118
|
+
// unpublished advisories. shellCoverage:"none" + a stated limitation (contract §2
|
|
119
|
+
// optional extension fields; population for this domain is directed by its bead).
|
|
120
|
+
const SHELL_COVERAGE = "none";
|
|
121
|
+
const COVERAGE_LIMITATIONS =
|
|
122
|
+
"Static read-only review only. No automated vulnerability scanners (npm audit, pip-audit, Semgrep, etc.), dynamic analysis, or penetration tests were run in-guest (read-only-review profile, no shell). Dependency CVEs reflect reviewer knowledge of manifests/lockfiles and may miss unpublished advisories.";
|
|
123
|
+
|
|
124
|
+
// ---- shared recon schema (tolerates a prose string via formatRecon) ----
|
|
125
|
+
const RECON_SCHEMA = {
|
|
126
|
+
type: "object", additionalProperties: false,
|
|
127
|
+
properties: {
|
|
128
|
+
languages: { type: "array", items: { type: "string" } },
|
|
129
|
+
frameworks: { type: "array", items: { type: "string" } },
|
|
130
|
+
packageManagers: { type: "array", items: { type: "string" } },
|
|
131
|
+
entryPoints: { type: "array", items: { type: "string" } },
|
|
132
|
+
testLayout: { type: "string" },
|
|
133
|
+
buildTooling: { type: "string" },
|
|
134
|
+
concurrencyModel: { type: "string" },
|
|
135
|
+
errorHandling: { type: "string" },
|
|
136
|
+
externalResources: { type: "array", items: { type: "string" } },
|
|
137
|
+
notes: { type: "string" },
|
|
138
|
+
},
|
|
139
|
+
required: ["languages", "notes"],
|
|
140
|
+
};
|
|
141
|
+
function formatRecon(r) {
|
|
142
|
+
if (typeof r === "string") return r;
|
|
143
|
+
if (!r || typeof r !== "object") return "No recon available.";
|
|
144
|
+
const L = (k, v) => (v && (Array.isArray(v) ? v.length : true) ? `${k}: ${Array.isArray(v) ? v.join(", ") : v}` : null);
|
|
145
|
+
return [
|
|
146
|
+
L("Languages", r.languages), L("Frameworks", r.frameworks), L("Package managers", r.packageManagers),
|
|
147
|
+
L("Entry points", r.entryPoints), L("Test layout", r.testLayout), L("Build tooling", r.buildTooling),
|
|
148
|
+
L("Concurrency model", r.concurrencyModel), L("Error handling", r.errorHandling),
|
|
149
|
+
L("External resources", r.externalResources), L("Notes", r.notes),
|
|
150
|
+
].filter(Boolean).join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---- stable content fingerprint (djb2; deterministic, no crypto) ----
|
|
154
|
+
// <suite:fingerprintOf>
|
|
155
|
+
function fingerprintOf(f) {
|
|
156
|
+
const norm = (s) => (s || "").toString().toLowerCase().replace(/\s+/g, " ").trim();
|
|
157
|
+
const basis = `${DOMAIN}|${norm(f.file)}|${norm(f.category)}|${norm(f.description).slice(0, 160)}`;
|
|
158
|
+
let h = 5381;
|
|
159
|
+
for (let i = 0; i < basis.length; i++) h = ((h * 33) ^ basis.charCodeAt(i)) >>> 0;
|
|
160
|
+
return `${DOMAIN}-${h.toString(16)}`;
|
|
161
|
+
}
|
|
162
|
+
// </suite:fingerprintOf>
|
|
163
|
+
|
|
164
|
+
// ---- SAFETY directive (secret-value containment; prompt-layer enforcement) ----
|
|
165
|
+
// Security detects credential/secret RISKS by LOCATION only. Findings must never
|
|
166
|
+
// embed raw secret values, and lanes must not read credential files. (The guest
|
|
167
|
+
// sandbox also structurally forbids fs, so this is defense-in-depth at the prompt
|
|
168
|
+
// layer; in-guest value masking of model prose is applied during synthesis.)
|
|
169
|
+
const SAFETY = [
|
|
170
|
+
"SAFETY (security domain): detect credential/secret RISKS by LOCATION only.",
|
|
171
|
+
"NEVER paste or embed raw secret values (API keys, passwords, tokens, private keys, connection strings) in any field of a finding — use file:line plus a REDACTED placeholder.",
|
|
172
|
+
"Credential files (.env, ~/.ssh/*, *.pem, *.key, secrets.*) are OFF-LIMITS for reading: reference their path, never their contents.",
|
|
173
|
+
].join("\n");
|
|
174
|
+
|
|
175
|
+
// ---- secret-value containment helper (in-guest masking; contract §15) ----
|
|
176
|
+
// The kernel also masks credential-shaped string values at durable/display boundaries, but
|
|
177
|
+
// security findings must not rely on a downstream scrubber. Leaves mask detected secret
|
|
178
|
+
// patterns here, during synthesis, BEFORE ranking/rendering and before the envelope is
|
|
179
|
+
// returned. Replacement is non-reversible: a length-aware short prefix + short suffix with
|
|
180
|
+
// the identifying middle removed, so the revealed portion never exceeds a small fraction of
|
|
181
|
+
// the total length (short secrets are fully redacted). This keeps low-entropy tokens — e.g. a
|
|
182
|
+
// 10-char AKIA match or an sk-/pk-/ghp- token — from leaking most of their entropy past the mask.
|
|
183
|
+
// <suite:maskSecrets>
|
|
184
|
+
function maskSecret(token) {
|
|
185
|
+
const s = String(token);
|
|
186
|
+
if (s.length <= 20) return "***";
|
|
187
|
+
const keep = Math.min(4, Math.floor(s.length * 0.15));
|
|
188
|
+
return `${s.slice(0, keep)}***${s.slice(-keep)}`;
|
|
189
|
+
}
|
|
190
|
+
function maskSecretsInText(s) {
|
|
191
|
+
if (typeof s !== "string" || !s) return s;
|
|
192
|
+
let out = s;
|
|
193
|
+
// Well-known credential shapes.
|
|
194
|
+
const patterns = [
|
|
195
|
+
/\bAKIA[A-Za-z0-9_-]{6,}\b/g, // AWS access key id (and AKIA-prefixed tokens)
|
|
196
|
+
/\b(sk|pk|ghp|gho|github_pat|xox[baprs])[-_][-_A-Za-z0-9]{8,}\b/g, // provider tokens
|
|
197
|
+
/\b-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g, // PEM private keys
|
|
198
|
+
];
|
|
199
|
+
for (const re of patterns) out = out.replace(re, (m) => maskSecret(m));
|
|
200
|
+
// Generic key=value / token: value assignments.
|
|
201
|
+
out = out.replace(
|
|
202
|
+
/\b(api[_-]?key|access[_-]?token|refresh[_-]?token|secret|password|passwd|authorization|auth[_-]?token|token)\b\s*[:=]\s*["']?[^\s"'`,;]{8,}/gi,
|
|
203
|
+
(m) => maskSecret(m),
|
|
204
|
+
);
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
function maskFindingSecrets(f) {
|
|
208
|
+
if (!f || typeof f !== "object") return f;
|
|
209
|
+
const mask = (v) => (typeof v === "string" ? maskSecretsInText(v) : v);
|
|
210
|
+
return { ...f, description: mask(f.description), attackVector: mask(f.attackVector), proposedChange: mask(f.proposedChange), docImpact: mask(f.docImpact) };
|
|
211
|
+
}
|
|
212
|
+
// </suite:maskSecrets>
|
|
213
|
+
|
|
214
|
+
// ---- schemas ----
|
|
215
|
+
const FINDINGS_SCHEMA = {
|
|
216
|
+
type: "object", additionalProperties: false,
|
|
217
|
+
properties: {
|
|
218
|
+
findings: {
|
|
219
|
+
type: "array",
|
|
220
|
+
items: {
|
|
221
|
+
type: "object", additionalProperties: false,
|
|
222
|
+
properties: {
|
|
223
|
+
category: { type: "string" },
|
|
224
|
+
file: { type: "string" },
|
|
225
|
+
line: { type: "integer" },
|
|
226
|
+
// security is the ONE domain allowed to emit critical severity (4-tier real).
|
|
227
|
+
severity: { type: "string", enum: ["critical", "high", "medium", "low"] },
|
|
228
|
+
description: { type: "string" },
|
|
229
|
+
cwe: { type: "string" },
|
|
230
|
+
attackVector: { type: "string" },
|
|
231
|
+
exploitability: { type: "string", enum: ["high", "medium", "low"] },
|
|
232
|
+
proposedChange: { type: "string" },
|
|
233
|
+
confidence: { type: "integer" },
|
|
234
|
+
effort: { type: "string", enum: ["small", "medium", "large"] },
|
|
235
|
+
docImpact: { type: "string" },
|
|
236
|
+
},
|
|
237
|
+
required: ["category", "file", "line", "severity", "description", "cwe", "attackVector", "exploitability", "proposedChange", "confidence", "effort", "docImpact"],
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
required: ["findings"],
|
|
242
|
+
};
|
|
243
|
+
const VERDICT_SCHEMA = {
|
|
244
|
+
type: "object", additionalProperties: false,
|
|
245
|
+
properties: {
|
|
246
|
+
refuted: { type: "boolean" },
|
|
247
|
+
reasoning: { type: "string" },
|
|
248
|
+
adjustedConfidence: { type: "integer" },
|
|
249
|
+
},
|
|
250
|
+
required: ["refuted", "reasoning", "adjustedConfidence"],
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// ---- lenses (one parallel finder per vuln class) ----
|
|
254
|
+
const LENS = {
|
|
255
|
+
"injection": "Injection: SQL/NoSQL, OS command, path traversal, template/SSTI, LDAP, XPath. Trace untrusted input to a sink without proper parameterization/escaping.",
|
|
256
|
+
"authz": "Authn/authz flaws: missing access checks, IDOR, broken object-level/function-level authorization, privilege escalation, trusting client-supplied identity/role.",
|
|
257
|
+
"secrets": "Secrets in code: hardcoded API keys, passwords, tokens, private keys, connection strings; secrets committed to config; secrets in logs. Report the RISK by location; never paste the value.",
|
|
258
|
+
"unsafe-deserialization": "Unsafe deserialization / dynamic execution: eval, pickle, yaml.load, native deserialization of untrusted data, prototype pollution, unsafe reflection.",
|
|
259
|
+
"ssrf": "SSRF and unsafe outbound requests: user-controlled URLs fetched server-side, missing allowlist, metadata-endpoint exposure, open redirects.",
|
|
260
|
+
"crypto-misuse": "Crypto misuse: weak/broken algorithms (MD5/SHA1 for security, ECB), hardcoded IVs/keys, predictable randomness for security, missing TLS verification, improper password hashing.",
|
|
261
|
+
"input-validation": "Missing/weak input validation & output encoding: XSS (stored/reflected/DOM), missing size/type bounds, mass assignment, unsafe file upload.",
|
|
262
|
+
"dep-cve": "Dependency CVEs: known-vulnerable package versions found in manifests/lockfiles. Cross-check package versions against known advisories from your knowledge (an automated audit tool is NOT available in this read-only profile — do not attempt to run one).",
|
|
263
|
+
"insecure-default": "Insecure defaults/config: debug mode on, permissive CORS, missing security headers, default credentials, overly broad permissions, verbose error exposure.",
|
|
264
|
+
"sensitive-logging": "Sensitive data exposure: PII/secrets/tokens written to logs, error responses leaking internals, sensitive data in URLs/caches. Report the exposure by location; do not surface the logged value.",
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
function finderPrompt(cat, recon, roundNote) {
|
|
268
|
+
return [
|
|
269
|
+
`You are the "${cat}" security finder. REPORT-ONLY — do NOT modify files.`,
|
|
270
|
+
scope,
|
|
271
|
+
SAFETY,
|
|
272
|
+
`Repo profile (recon):\n${formatRecon(recon)}`,
|
|
273
|
+
`Your lens: ${LENS[cat]}`,
|
|
274
|
+
roundNote || "",
|
|
275
|
+
`For EVERY finding set category to exactly "${cat}". Fill cwe (CWE id or empty), attackVector (how an attacker reaches it), and exploitability. Trace from an untrusted source to the dangerous sink — only flag reachable issues. A false alarm wastes triage; be precise.`,
|
|
276
|
+
`Return findings via the structured output.`,
|
|
277
|
+
].filter(Boolean).join("\n\n");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function skepticPrompt(f) {
|
|
281
|
+
return [
|
|
282
|
+
"You are a skeptic security reviewer. Try to REFUTE the finding — prove it is NOT exploitable.",
|
|
283
|
+
SAFETY,
|
|
284
|
+
scope,
|
|
285
|
+
`Finding (${f.category}) at ${f.file}:${f.line}: ${f.description}`,
|
|
286
|
+
`Attack vector: ${f.attackVector}`,
|
|
287
|
+
"Check: is the source actually untrusted/attacker-controlled? is it sanitized/validated/parameterized upstream? is the sink reachable in practice? is there a mitigating control?",
|
|
288
|
+
"Set refuted=true if it is not a real, reachable vulnerability. Default to refuted=true when genuinely uncertain.",
|
|
289
|
+
].join("\n\n");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---- 1. Recon (use injected recon if present, else profile once) ----
|
|
293
|
+
await phase("recon");
|
|
294
|
+
const recon = RT.recon
|
|
295
|
+
? RT.recon
|
|
296
|
+
: await agent(
|
|
297
|
+
["Profile this repository for a security audit.", scope,
|
|
298
|
+
"Report: languages/frameworks; entry points & trust boundaries (HTTP handlers, CLIs, message consumers); where untrusted input enters; auth/session model; data stores & external calls; how secrets are managed (approach only — never surface secret values); dependency manifests/lockfiles.",
|
|
299
|
+
"Explore with your tools."].join("\n\n"),
|
|
300
|
+
{ label: "recon", schema: RECON_SCHEMA, tier: TIER_RECON, onFailure: "returnNull" },
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
// ---- 2. Find + dedup ----
|
|
304
|
+
function dedup(findings) {
|
|
305
|
+
const seen = new Set(); const out = [];
|
|
306
|
+
for (const f of findings) {
|
|
307
|
+
if (!f) continue;
|
|
308
|
+
const key = `${f.category}::${(f.file || "").trim()}::${f.line || 0}`;
|
|
309
|
+
if (seen.has(key)) continue;
|
|
310
|
+
seen.add(key); out.push(f);
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function findRound(roundNote) {
|
|
316
|
+
await phase("find");
|
|
317
|
+
// arity-1 thunks (api) => ... so parallel() runs them CONCURRENTLY (zero-arg thunks fail unless { sequential: true } is explicit).
|
|
318
|
+
const results = await parallel(categories.map((cat) => (api) =>
|
|
319
|
+
api.agent(finderPrompt(cat, recon, roundNote), { label: `find:${cat}`, schema: FINDINGS_SCHEMA, tier: TIER_FINDER, onFailure: "returnNull" })));
|
|
320
|
+
tallyPhase("find", results, (i) => `find:${categories[i]}`);
|
|
321
|
+
return results.filter(Boolean).flatMap((r) => (r.findings || []).map((f) => ({ ...f, category: f.category || "unknown" })));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let findings = dedup(await findRound(null));
|
|
325
|
+
await log(`Round 1: ${findings.length} candidate vulnerabilities across ${categories.length} lenses`);
|
|
326
|
+
|
|
327
|
+
if (depth === "thorough") {
|
|
328
|
+
const known = findings.map((f) => `- ${f.category} ${f.file}:${f.line} — ${f.description}`).join("\n");
|
|
329
|
+
const round2 = await findRound(`SECOND pass. Already found below — find only NEW issues, do not repeat:\n${known}`);
|
|
330
|
+
findings = dedup(findings.concat(round2));
|
|
331
|
+
await log(`After round 2: ${findings.length} candidates`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// positional id (in-run reference) + stable content fingerprint (cross-run dedupe key)
|
|
335
|
+
findings = findings.map((f, i) => ({ ...f, id: `${f.category}-${i + 1}`, fingerprint: fingerprintOf(f) }));
|
|
336
|
+
|
|
337
|
+
if (findings.length === 0) {
|
|
338
|
+
return envelope("empty", { summary: "No vulnerabilities found.", counts: emptyCounts, findings: [], truncatedFindings: false, reportMarkdown: null, shellCoverage: SHELL_COVERAGE, coverageLimitations: COVERAGE_LIMITATIONS });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---- 3. Verify (high-FP profile) ----
|
|
342
|
+
// quick: verify critical+high only, 1 skeptic. normal: verify ALL, 1 skeptic.
|
|
343
|
+
// thorough: verify ALL, 3-skeptic majority (keep unless >=2 refute).
|
|
344
|
+
let toVerify, votes;
|
|
345
|
+
if (depth === "quick") { toVerify = findings.filter((f) => f.severity === "critical" || f.severity === "high"); votes = 1; }
|
|
346
|
+
else if (depth === "thorough") { toVerify = findings; votes = 3; }
|
|
347
|
+
else { toVerify = findings; votes = 1; }
|
|
348
|
+
const verifyIds = new Set(toVerify.map((f) => f.id));
|
|
349
|
+
const passThrough = findings.filter((f) => !verifyIds.has(f.id));
|
|
350
|
+
|
|
351
|
+
let verified = passThrough;
|
|
352
|
+
if (toVerify.length > 0) {
|
|
353
|
+
await phase("verify");
|
|
354
|
+
const checked = await parallel(toVerify.map((f) => (api) => {
|
|
355
|
+
if (votes === 1) {
|
|
356
|
+
return api.agent(skepticPrompt(f), { label: `verify:${f.id}`, schema: VERDICT_SCHEMA, tier: TIER_VERIFY, onFailure: "returnNull" })
|
|
357
|
+
.then((v) => ({ f, keep: !!(v && !v.refuted), conf: v ? v.adjustedConfidence : undefined }));
|
|
358
|
+
}
|
|
359
|
+
return api.parallel([0, 1, 2].map((n) => (inner) =>
|
|
360
|
+
inner.agent(`${skepticPrompt(f)}\n\n(Independent reviewer #${n + 1}.)`, { label: `verify:${f.id}#${n + 1}`, schema: VERDICT_SCHEMA, tier: TIER_VERIFY, onFailure: "returnNull" })))
|
|
361
|
+
.then((vs) => {
|
|
362
|
+
const ok = vs.filter(Boolean);
|
|
363
|
+
const refutedCount = ok.filter((v) => v.refuted).length;
|
|
364
|
+
const avg = ok.length ? Math.round(ok.reduce((s, v) => s + (v.adjustedConfidence || 0), 0) / ok.length) : f.confidence;
|
|
365
|
+
return { f, keep: ok.length > 0 && refutedCount < 2, conf: avg };
|
|
366
|
+
});
|
|
367
|
+
}));
|
|
368
|
+
tallyPhase("verify", checked, (i) => `verify:${toVerify[i].id}`);
|
|
369
|
+
const survivors = checked.filter(Boolean).filter((c) => c.keep)
|
|
370
|
+
.map((c) => ({ ...c.f, confidence: c.conf != null ? c.conf : c.f.confidence }));
|
|
371
|
+
verified = passThrough.concat(survivors);
|
|
372
|
+
await log(`Verified: ${survivors.length}/${toVerify.length} survived; ${verified.length} total`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (verified.length === 0) {
|
|
376
|
+
return envelope("empty", { summary: "No vulnerabilities survived verification.", counts: emptyCounts, findings: [], truncatedFindings: false, reportMarkdown: null, shellCoverage: SHELL_COVERAGE, coverageLimitations: COVERAGE_LIMITATIONS });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---- 4. Synthesize (PURE JS — dedup, rank, render; the host persists the returned object) ----
|
|
380
|
+
await phase("synthesize");
|
|
381
|
+
// Secret-value containment (docs/repo-review-leaf-contract.md §15): mask credential values
|
|
382
|
+
// embedded in free-text prose (description/attackVector/proposedChange) BEFORE ranking and
|
|
383
|
+
// rendering reportMarkdown. No raw secret should enter the returned envelope or the report.
|
|
384
|
+
verified = verified.map(maskFindingSecrets);
|
|
385
|
+
// Security ranks by severity weighted by exploitability and confidence (port of the
|
|
386
|
+
// Claude synthesizer's intent; here it is pure JS, never an agent).
|
|
387
|
+
const SEVW = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
388
|
+
const EXPW = { high: 1.15, medium: 1.0, low: 0.85 };
|
|
389
|
+
const EFFD = { small: 1, medium: 0.8, large: 0.6 };
|
|
390
|
+
function score(f) { return (SEVW[f.severity] || 1) * (EXPW[f.exploitability] || 1) * ((f.confidence || 0) / 100) * (EFFD[f.effort] || 0.8); }
|
|
391
|
+
|
|
392
|
+
const ranked = verified.map((f) => ({ ...f })).sort((a, b) => score(b) - score(a));
|
|
393
|
+
ranked.forEach((f, i) => { f.rank = i + 1; });
|
|
394
|
+
|
|
395
|
+
// counts are NORMALIZED from the actual surviving findings — never trusted from an
|
|
396
|
+
// agent. security is the one domain that populates the critical tier for real.
|
|
397
|
+
const counts = {
|
|
398
|
+
total: ranked.length,
|
|
399
|
+
critical: ranked.filter((f) => f.severity === "critical").length,
|
|
400
|
+
high: ranked.filter((f) => f.severity === "high").length,
|
|
401
|
+
medium: ranked.filter((f) => f.severity === "medium").length,
|
|
402
|
+
low: ranked.filter((f) => f.severity === "low").length,
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
function mdCell(s) { return String(s == null ? "" : s).replace(/\|/g, "\\|").replace(/\n+/g, " "); }
|
|
406
|
+
function renderMarkdown(rows, c) {
|
|
407
|
+
const lines = [];
|
|
408
|
+
lines.push(`# Security Audit Report (${DOMAIN})`, "");
|
|
409
|
+
lines.push("> Report-only. No files were modified and nothing was applied.", "");
|
|
410
|
+
lines.push("## Summary", `- Total: ${c.total} (critical: ${c.critical}, high: ${c.high}, medium: ${c.medium}, low: ${c.low})`, "");
|
|
411
|
+
lines.push("## Ranked findings", "");
|
|
412
|
+
lines.push("| Rank | Category | Severity | Exploitability | CWE | Confidence | Location | Description |");
|
|
413
|
+
lines.push("| ---- | -------- | -------- | -------------- | --- | ---------- | -------- | ----------- |");
|
|
414
|
+
for (const f of rows) {
|
|
415
|
+
lines.push(`| ${f.rank} | ${mdCell(f.category)} | ${mdCell(f.severity)} | ${mdCell(f.exploitability)} | ${mdCell(f.cwe)} | ${f.confidence} | ${mdCell(f.file)}:${f.line || 0} | ${mdCell(f.description).slice(0, 120)} |`);
|
|
416
|
+
}
|
|
417
|
+
lines.push("", "## Detail");
|
|
418
|
+
for (const f of rows) {
|
|
419
|
+
lines.push("", `### ${f.rank}. ${mdCell(f.category)} — ${mdCell(f.file)}:${f.line || 0} (${f.severity}, exploitability ${f.exploitability}, conf ${f.confidence})`);
|
|
420
|
+
lines.push(`- **What:** ${f.description}`);
|
|
421
|
+
if (f.cwe) lines.push(`- **CWE:** ${f.cwe}`);
|
|
422
|
+
lines.push(`- **Attack vector:** ${f.attackVector}`);
|
|
423
|
+
lines.push(`- **Proposed change:** ${f.proposedChange}`);
|
|
424
|
+
if (f.docImpact) lines.push(`- **Doc impact:** ${f.docImpact}`);
|
|
425
|
+
lines.push(`- **Fingerprint:** \`${f.fingerprint}\``);
|
|
426
|
+
}
|
|
427
|
+
return lines.join("\n");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Size-fit to the 256 KB host cap: drop reportMarkdown first, then halve findings, until it fits.
|
|
431
|
+
function utf8ByteLength(value) {
|
|
432
|
+
const s = String(value ?? "");
|
|
433
|
+
let bytes = 0;
|
|
434
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
435
|
+
const code = s.charCodeAt(i);
|
|
436
|
+
if (code <= 0x7f) bytes += 1;
|
|
437
|
+
else if (code <= 0x7ff) bytes += 2;
|
|
438
|
+
else if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) {
|
|
439
|
+
const next = s.charCodeAt(i + 1);
|
|
440
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
441
|
+
bytes += 4;
|
|
442
|
+
i += 1;
|
|
443
|
+
} else bytes += 3;
|
|
444
|
+
} else bytes += 3;
|
|
445
|
+
}
|
|
446
|
+
return bytes;
|
|
447
|
+
}
|
|
448
|
+
function jsonUtf8ByteLength(value) {
|
|
449
|
+
return utf8ByteLength(JSON.stringify(value));
|
|
450
|
+
}
|
|
451
|
+
function fitWithinBudget(status, summary) {
|
|
452
|
+
const LIMIT = 230000; // headroom under MAX_RESULT_BYTES (262144) for the {output:...} wrapper + envelope fields
|
|
453
|
+
let returned = ranked.slice(0, MAX_RETURN_FINDINGS);
|
|
454
|
+
let truncated = ranked.length > returned.length;
|
|
455
|
+
let reportMarkdown = renderMarkdown(ranked, counts);
|
|
456
|
+
const sizeOf = () => jsonUtf8ByteLength(envelope(status, { summary, counts, findings: returned, truncatedFindings: truncated, reportMarkdown, shellCoverage: SHELL_COVERAGE, coverageLimitations: COVERAGE_LIMITATIONS }));
|
|
457
|
+
if (sizeOf() > LIMIT) reportMarkdown = null;
|
|
458
|
+
while (sizeOf() > LIMIT && returned.length > 10) {
|
|
459
|
+
returned = returned.slice(0, Math.ceil(returned.length / 2));
|
|
460
|
+
truncated = true;
|
|
461
|
+
}
|
|
462
|
+
return envelope(status, { summary, counts, findings: returned, truncatedFindings: truncated, reportMarkdown, shellCoverage: SHELL_COVERAGE, coverageLimitations: COVERAGE_LIMITATIONS });
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const summary = `Found ${counts.total} vulnerability(s): ${counts.critical} critical, ${counts.high} high, ${counts.medium} medium, ${counts.low} low. Report-only — nothing applied.`;
|
|
466
|
+
return fitWithinBudget("ok", summary);
|