@zivis/mcp 0.1.10 → 0.1.11
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/dist/http-probe/capture.d.ts +51 -0
- package/dist/http-probe/capture.js +96 -0
- package/dist/http-probe/compare.d.ts +43 -0
- package/dist/http-probe/compare.js +117 -0
- package/dist/http-probe/index.d.ts +2 -0
- package/dist/http-probe/index.js +2 -0
- package/dist/pattern-packs/zivis-public-0.2.0/manifest.json +1 -1
- package/dist/redact.d.ts +30 -0
- package/dist/redact.js +164 -0
- package/dist/scanners/index.d.ts +3 -0
- package/dist/scanners/index.js +2 -0
- package/dist/scanners/normalize.d.ts +6 -0
- package/dist/scanners/normalize.js +181 -0
- package/dist/scanners/run.d.ts +27 -0
- package/dist/scanners/run.js +142 -0
- package/dist/scanners/types.d.ts +40 -0
- package/dist/scanners/types.js +1 -0
- package/package.json +4 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface CaptureRequestSpec {
|
|
2
|
+
url: string;
|
|
3
|
+
method?: string;
|
|
4
|
+
headers?: Record<string, string>;
|
|
5
|
+
body?: string | Record<string, unknown> | unknown[];
|
|
6
|
+
label?: string;
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface CapturedRequest {
|
|
10
|
+
method: string;
|
|
11
|
+
url: string;
|
|
12
|
+
headers: Record<string, string>;
|
|
13
|
+
body?: string;
|
|
14
|
+
label?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface CapturedResponse {
|
|
17
|
+
status: number;
|
|
18
|
+
status_text?: string;
|
|
19
|
+
headers: Record<string, string>;
|
|
20
|
+
body?: string;
|
|
21
|
+
body_json?: unknown;
|
|
22
|
+
}
|
|
23
|
+
export interface HttpCapture {
|
|
24
|
+
request: CapturedRequest;
|
|
25
|
+
response: CapturedResponse;
|
|
26
|
+
meta: {
|
|
27
|
+
captured_at: string;
|
|
28
|
+
duration_ms: number;
|
|
29
|
+
request_fingerprint: string;
|
|
30
|
+
redacted: boolean;
|
|
31
|
+
error?: string;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export type FetchLike = (url: string, init: {
|
|
35
|
+
method: string;
|
|
36
|
+
headers: Record<string, string>;
|
|
37
|
+
body?: string;
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
}) => Promise<{
|
|
40
|
+
status: number;
|
|
41
|
+
statusText?: string;
|
|
42
|
+
headers: {
|
|
43
|
+
forEach(cb: (value: string, key: string) => void): void;
|
|
44
|
+
};
|
|
45
|
+
text(): Promise<string>;
|
|
46
|
+
}>;
|
|
47
|
+
export declare function requestFingerprint(req: CapturedRequest): string;
|
|
48
|
+
export declare function captureRequest(spec: CaptureRequestSpec, deps?: {
|
|
49
|
+
fetchImpl?: FetchLike;
|
|
50
|
+
}): Promise<HttpCapture>;
|
|
51
|
+
export declare function redactCapture(capture: HttpCapture): HttpCapture;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import { redactValue } from "../redact.js";
|
|
3
|
+
function normalizeHeaders(headers) {
|
|
4
|
+
const out = {};
|
|
5
|
+
for (const [k, v] of Object.entries(headers ?? {}))
|
|
6
|
+
out[k.toLowerCase()] = String(v);
|
|
7
|
+
return Object.fromEntries(Object.entries(out).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
8
|
+
}
|
|
9
|
+
function bodyToString(body) {
|
|
10
|
+
if (body === undefined || body === null)
|
|
11
|
+
return undefined;
|
|
12
|
+
if (typeof body === "string")
|
|
13
|
+
return body;
|
|
14
|
+
try {
|
|
15
|
+
return JSON.stringify(body);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return String(body);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function requestFingerprint(req) {
|
|
22
|
+
const headerLines = Object.entries(req.headers)
|
|
23
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
24
|
+
.join("\n");
|
|
25
|
+
const canonical = `${req.method} ${req.url}\n${headerLines}\n\n${req.body ?? ""}`;
|
|
26
|
+
return crypto.createHash("sha256").update(canonical, "utf8").digest("hex");
|
|
27
|
+
}
|
|
28
|
+
function tryJson(text) {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(text);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function captureRequest(spec, deps = {}) {
|
|
37
|
+
const method = (spec.method ?? "GET").toUpperCase();
|
|
38
|
+
const headers = normalizeHeaders(spec.headers);
|
|
39
|
+
const body = bodyToString(spec.body);
|
|
40
|
+
const request = { method, url: spec.url, headers, body, label: spec.label };
|
|
41
|
+
const fingerprint = requestFingerprint(request);
|
|
42
|
+
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
const timeout = setTimeout(() => controller.abort(), spec.timeoutMs ?? 30_000);
|
|
45
|
+
const started = Date.now();
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetchImpl(spec.url, { method, headers, body, signal: controller.signal });
|
|
48
|
+
const respHeaders = {};
|
|
49
|
+
res.headers.forEach((value, key) => {
|
|
50
|
+
respHeaders[key.toLowerCase()] = value;
|
|
51
|
+
});
|
|
52
|
+
const text = await res.text();
|
|
53
|
+
const bodyJson = tryJson(text);
|
|
54
|
+
return {
|
|
55
|
+
request,
|
|
56
|
+
response: {
|
|
57
|
+
status: res.status,
|
|
58
|
+
status_text: res.statusText,
|
|
59
|
+
headers: Object.fromEntries(Object.entries(respHeaders).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))),
|
|
60
|
+
body: text,
|
|
61
|
+
body_json: bodyJson,
|
|
62
|
+
},
|
|
63
|
+
meta: {
|
|
64
|
+
captured_at: new Date().toISOString(),
|
|
65
|
+
duration_ms: Date.now() - started,
|
|
66
|
+
request_fingerprint: fingerprint,
|
|
67
|
+
redacted: false,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
return {
|
|
73
|
+
request,
|
|
74
|
+
response: { status: 0, headers: {} },
|
|
75
|
+
meta: {
|
|
76
|
+
captured_at: new Date().toISOString(),
|
|
77
|
+
duration_ms: Date.now() - started,
|
|
78
|
+
request_fingerprint: fingerprint,
|
|
79
|
+
redacted: false,
|
|
80
|
+
error: err instanceof Error ? err.message : String(err),
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
clearTimeout(timeout);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function redactCapture(capture) {
|
|
89
|
+
const request = redactValue(capture.request).value;
|
|
90
|
+
const response = redactValue(capture.response).value;
|
|
91
|
+
return {
|
|
92
|
+
request,
|
|
93
|
+
response,
|
|
94
|
+
meta: { ...capture.meta, redacted: true },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { HttpCapture } from "./capture.js";
|
|
2
|
+
export interface StatusComparison {
|
|
3
|
+
a: number;
|
|
4
|
+
b: number;
|
|
5
|
+
changed: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface JsonPathDiff {
|
|
8
|
+
path: string;
|
|
9
|
+
kind: "added" | "removed" | "changed";
|
|
10
|
+
a?: unknown;
|
|
11
|
+
b?: unknown;
|
|
12
|
+
}
|
|
13
|
+
export interface BodyComparison {
|
|
14
|
+
equal: boolean;
|
|
15
|
+
json_diffs?: JsonPathDiff[];
|
|
16
|
+
opaque_change?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface HeaderDiff {
|
|
19
|
+
header: string;
|
|
20
|
+
kind: "added" | "removed" | "changed";
|
|
21
|
+
a?: string;
|
|
22
|
+
b?: string;
|
|
23
|
+
}
|
|
24
|
+
export type AccessSignal = "same_status" | "both_success" | "both_denied" | "access_differential" | "one_error" | "mixed";
|
|
25
|
+
export interface CaptureComparison {
|
|
26
|
+
equal: boolean;
|
|
27
|
+
labels: {
|
|
28
|
+
a?: string;
|
|
29
|
+
b?: string;
|
|
30
|
+
};
|
|
31
|
+
status: StatusComparison;
|
|
32
|
+
headers: HeaderDiff[];
|
|
33
|
+
body: BodyComparison;
|
|
34
|
+
access_signal: AccessSignal;
|
|
35
|
+
summary: string;
|
|
36
|
+
same_request: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface CompareOptions {
|
|
39
|
+
headerAllowlist?: string[];
|
|
40
|
+
allHeaders?: boolean;
|
|
41
|
+
}
|
|
42
|
+
export declare function diffJson(a: unknown, b: unknown, basePath?: string): JsonPathDiff[];
|
|
43
|
+
export declare function compareCaptures(a: HttpCapture, b: HttpCapture, opts?: CompareOptions): CaptureComparison;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const VOLATILE_HEADERS = new Set([
|
|
2
|
+
"date",
|
|
3
|
+
"age",
|
|
4
|
+
"etag",
|
|
5
|
+
"last-modified",
|
|
6
|
+
"expires",
|
|
7
|
+
"set-cookie",
|
|
8
|
+
"x-request-id",
|
|
9
|
+
"x-correlation-id",
|
|
10
|
+
"cf-ray",
|
|
11
|
+
"x-amz-request-id",
|
|
12
|
+
"x-amz-id-2",
|
|
13
|
+
"x-served-by",
|
|
14
|
+
"x-timer",
|
|
15
|
+
"x-runtime",
|
|
16
|
+
"server-timing",
|
|
17
|
+
"content-length",
|
|
18
|
+
]);
|
|
19
|
+
function isDenied(status) {
|
|
20
|
+
return status === 401 || status === 403;
|
|
21
|
+
}
|
|
22
|
+
function isSuccess(status) {
|
|
23
|
+
return status >= 200 && status < 300;
|
|
24
|
+
}
|
|
25
|
+
function isError(status) {
|
|
26
|
+
return status === 0 || status >= 500;
|
|
27
|
+
}
|
|
28
|
+
function classifyAccess(a, b) {
|
|
29
|
+
if (isError(a) || isError(b))
|
|
30
|
+
return "one_error";
|
|
31
|
+
if (isSuccess(a) && isSuccess(b))
|
|
32
|
+
return "both_success";
|
|
33
|
+
if (isDenied(a) && isDenied(b))
|
|
34
|
+
return "both_denied";
|
|
35
|
+
if ((isSuccess(a) && isDenied(b)) || (isDenied(a) && isSuccess(b)))
|
|
36
|
+
return "access_differential";
|
|
37
|
+
if (a === b)
|
|
38
|
+
return "same_status";
|
|
39
|
+
return "mixed";
|
|
40
|
+
}
|
|
41
|
+
function diffHeaders(a, b, opts) {
|
|
42
|
+
const allow = opts.headerAllowlist ? new Set(opts.headerAllowlist.map((h) => h.toLowerCase())) : null;
|
|
43
|
+
const keep = (h) => (allow ? allow.has(h) : opts.allHeaders ? true : !VOLATILE_HEADERS.has(h));
|
|
44
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)].filter(keep));
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const h of [...keys].sort()) {
|
|
47
|
+
const av = a[h];
|
|
48
|
+
const bv = b[h];
|
|
49
|
+
if (av === bv)
|
|
50
|
+
continue;
|
|
51
|
+
if (av === undefined)
|
|
52
|
+
out.push({ header: h, kind: "added", b: bv });
|
|
53
|
+
else if (bv === undefined)
|
|
54
|
+
out.push({ header: h, kind: "removed", a: av });
|
|
55
|
+
else
|
|
56
|
+
out.push({ header: h, kind: "changed", a: av, b: bv });
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
export function diffJson(a, b, basePath = "$") {
|
|
61
|
+
const diffs = [];
|
|
62
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
63
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
64
|
+
const max = Math.max(a.length, b.length);
|
|
65
|
+
for (let i = 0; i < max; i++) {
|
|
66
|
+
const p = `${basePath}[${i}]`;
|
|
67
|
+
if (i >= a.length)
|
|
68
|
+
diffs.push({ path: p, kind: "added", b: b[i] });
|
|
69
|
+
else if (i >= b.length)
|
|
70
|
+
diffs.push({ path: p, kind: "removed", a: a[i] });
|
|
71
|
+
else
|
|
72
|
+
diffs.push(...diffJson(a[i], b[i], p));
|
|
73
|
+
}
|
|
74
|
+
return diffs;
|
|
75
|
+
}
|
|
76
|
+
if (isObj(a) && isObj(b)) {
|
|
77
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
78
|
+
for (const k of [...keys].sort()) {
|
|
79
|
+
const p = `${basePath}.${k}`;
|
|
80
|
+
if (!(k in a))
|
|
81
|
+
diffs.push({ path: p, kind: "added", b: b[k] });
|
|
82
|
+
else if (!(k in b))
|
|
83
|
+
diffs.push({ path: p, kind: "removed", a: a[k] });
|
|
84
|
+
else
|
|
85
|
+
diffs.push(...diffJson(a[k], b[k], p));
|
|
86
|
+
}
|
|
87
|
+
return diffs;
|
|
88
|
+
}
|
|
89
|
+
if (JSON.stringify(a) !== JSON.stringify(b)) {
|
|
90
|
+
diffs.push({ path: basePath, kind: "changed", a, b });
|
|
91
|
+
}
|
|
92
|
+
return diffs;
|
|
93
|
+
}
|
|
94
|
+
function compareBodies(a, b) {
|
|
95
|
+
const aHasJson = a.body_json !== undefined;
|
|
96
|
+
const bHasJson = b.body_json !== undefined;
|
|
97
|
+
if (aHasJson && bHasJson) {
|
|
98
|
+
const json_diffs = diffJson(a.body_json, b.body_json);
|
|
99
|
+
return { equal: json_diffs.length === 0, json_diffs };
|
|
100
|
+
}
|
|
101
|
+
const equal = (a.body ?? "") === (b.body ?? "");
|
|
102
|
+
return { equal, opaque_change: !equal };
|
|
103
|
+
}
|
|
104
|
+
export function compareCaptures(a, b, opts = {}) {
|
|
105
|
+
const status = { a: a.response.status, b: b.response.status, changed: a.response.status !== b.response.status };
|
|
106
|
+
const headers = diffHeaders(a.response.headers, b.response.headers, opts);
|
|
107
|
+
const body = compareBodies(a.response, b.response);
|
|
108
|
+
const access_signal = classifyAccess(a.response.status, b.response.status);
|
|
109
|
+
const same_request = a.meta.request_fingerprint === b.meta.request_fingerprint;
|
|
110
|
+
const equal = !status.changed && headers.length === 0 && body.equal;
|
|
111
|
+
const la = a.request.label ?? "A";
|
|
112
|
+
const lb = b.request.label ?? "B";
|
|
113
|
+
const summary = equal
|
|
114
|
+
? `${la} and ${lb} are equivalent (status ${status.a}, no header/body differences).`
|
|
115
|
+
: `${la}=${status.a} vs ${lb}=${status.b}; ${headers.length} header diff(s), body ${body.equal ? "identical" : "differs"}; access_signal=${access_signal}.`;
|
|
116
|
+
return { equal, labels: { a: a.request.label, b: b.request.label }, status, headers, body, access_signal, summary, same_request };
|
|
117
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { captureRequest, redactCapture, requestFingerprint, type CaptureRequestSpec, type CapturedRequest, type CapturedResponse, type HttpCapture, type FetchLike, } from "./capture.js";
|
|
2
|
+
export { compareCaptures, diffJson, type CaptureComparison, type CompareOptions, type StatusComparison, type BodyComparison, type HeaderDiff, type JsonPathDiff, type AccessSignal, } from "./compare.js";
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"pack_id": "zivis-public",
|
|
4
4
|
"pack_name": "ZIVIS Public Pattern Pack",
|
|
5
5
|
"version": "0.2.0",
|
|
6
|
-
"built_at": "2026-08-
|
|
6
|
+
"built_at": "2026-08-31T22:32:10.810Z",
|
|
7
7
|
"tier": "customer_safe",
|
|
8
8
|
"description": "ZIVIS-curated public pattern pack — capsules + inference prompts evaluated locally on the user's machine.",
|
|
9
9
|
"capsules": [
|
package/dist/redact.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface RedactionFinding {
|
|
2
|
+
rule: string;
|
|
3
|
+
path: string;
|
|
4
|
+
}
|
|
5
|
+
export interface RedactionReport {
|
|
6
|
+
redacted: boolean;
|
|
7
|
+
count: number;
|
|
8
|
+
byRule: Record<string, number>;
|
|
9
|
+
findings: RedactionFinding[];
|
|
10
|
+
}
|
|
11
|
+
interface RedactRule {
|
|
12
|
+
id: string;
|
|
13
|
+
regex: RegExp;
|
|
14
|
+
secretGroup?: number;
|
|
15
|
+
}
|
|
16
|
+
export declare const REDACTION_RULES: readonly RedactRule[];
|
|
17
|
+
export declare function redactString(input: string, report?: RedactionReport, path?: string): {
|
|
18
|
+
text: string;
|
|
19
|
+
report: RedactionReport;
|
|
20
|
+
};
|
|
21
|
+
export declare const SENSITIVE_KEY_NAMES: ReadonlySet<string>;
|
|
22
|
+
export interface RedactOptions {
|
|
23
|
+
maxDepth?: number;
|
|
24
|
+
}
|
|
25
|
+
export declare function redactValue<T>(value: T, opts?: RedactOptions): {
|
|
26
|
+
value: T;
|
|
27
|
+
report: RedactionReport;
|
|
28
|
+
};
|
|
29
|
+
export declare function redactEvidence<T>(value: T): T;
|
|
30
|
+
export {};
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
function emptyReport() {
|
|
2
|
+
return { redacted: false, count: 0, byRule: {}, findings: [] };
|
|
3
|
+
}
|
|
4
|
+
function record(report, rule, path) {
|
|
5
|
+
report.redacted = true;
|
|
6
|
+
report.count += 1;
|
|
7
|
+
report.byRule[rule] = (report.byRule[rule] ?? 0) + 1;
|
|
8
|
+
report.findings.push({ rule, path });
|
|
9
|
+
}
|
|
10
|
+
const MASK = (id) => `[REDACTED:${id}]`;
|
|
11
|
+
export const REDACTION_RULES = [
|
|
12
|
+
{
|
|
13
|
+
id: "auth-header",
|
|
14
|
+
regex: /^([ \t]*(?:authorization|proxy-authorization|www-authenticate|cookie|set-cookie|x-api-key|x-apikey|x-auth-token|x-access-token|x-session-token|x-refresh-token|x-amz-security-token|api-key|apikey)[ \t]*:[ \t]*)(.+)$/gim,
|
|
15
|
+
secretGroup: 2,
|
|
16
|
+
},
|
|
17
|
+
{ id: "bearer-token", regex: /\b(Bearer[ \t]+)([A-Za-z0-9._~+/=-]{8,})/gi, secretGroup: 2 },
|
|
18
|
+
{ id: "basic-auth", regex: /\b(Basic[ \t]+)([A-Za-z0-9+/=]{8,})/gi, secretGroup: 2 },
|
|
19
|
+
{
|
|
20
|
+
id: "private-key",
|
|
21
|
+
regex: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/g,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "private-key",
|
|
25
|
+
regex: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/g,
|
|
26
|
+
},
|
|
27
|
+
{ id: "jwt", regex: /\beyJ[A-Za-z0-9_-]{5,}\.eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/g },
|
|
28
|
+
{ id: "aws-access-key-id", regex: /\b(?:A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}\b/g },
|
|
29
|
+
{
|
|
30
|
+
id: "aws-secret-access-key",
|
|
31
|
+
regex: /(aws_?secret_?access_?key["' ]*[=:]["' ]*)([A-Za-z0-9/+=]{40})/gi,
|
|
32
|
+
secretGroup: 2,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "azure-storage-key",
|
|
36
|
+
regex: /(AccountKey=)([A-Za-z0-9+/=]{40,})/gi,
|
|
37
|
+
secretGroup: 2,
|
|
38
|
+
},
|
|
39
|
+
{ id: "google-api-key", regex: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
40
|
+
{ id: "github-token", regex: /\bgh[pousr]_[A-Za-z0-9_]{36,}\b/g },
|
|
41
|
+
{ id: "slack-token", regex: /\bxox[baprs]-[0-9A-Za-z-]{10,}/g },
|
|
42
|
+
{ id: "slack-webhook", regex: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/g },
|
|
43
|
+
{ id: "stripe-key", regex: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
|
|
44
|
+
{ id: "sendgrid-key", regex: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g },
|
|
45
|
+
{ id: "npm-token", regex: /\bnpm_[A-Za-z0-9]{36}\b/g },
|
|
46
|
+
{ id: "anthropic-key", regex: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
|
|
47
|
+
{ id: "openai-key", regex: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g },
|
|
48
|
+
{
|
|
49
|
+
id: "connection-string-password",
|
|
50
|
+
regex: /\b((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|amqps?|https?):\/\/[^:@/\s]+:)([^@/\s]+)(@)/gi,
|
|
51
|
+
secretGroup: 2,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "generic-secret-assignment",
|
|
55
|
+
regex: /\b(password|passwd|pwd|secret|client[_-]?secret|api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|auth[_-]?token|session[_-]?token|private[_-]?key|passphrase)\b(["' ]*[=:]["' ]*)([^\s"',;&})]{6,})/gi,
|
|
56
|
+
secretGroup: 3,
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
export function redactString(input, report = emptyReport(), path = "") {
|
|
60
|
+
if (typeof input !== "string" || input.length === 0) {
|
|
61
|
+
return { text: input, report };
|
|
62
|
+
}
|
|
63
|
+
let text = input;
|
|
64
|
+
for (const rule of REDACTION_RULES) {
|
|
65
|
+
if (rule.secretGroup === undefined) {
|
|
66
|
+
text = text.replace(rule.regex, () => {
|
|
67
|
+
record(report, rule.id, path);
|
|
68
|
+
return MASK(rule.id);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
const g = rule.secretGroup;
|
|
73
|
+
text = text.replace(rule.regex, (match, ...groups) => {
|
|
74
|
+
const secret = groups[g - 1];
|
|
75
|
+
if (secret === undefined || secret === "")
|
|
76
|
+
return match;
|
|
77
|
+
record(report, rule.id, path);
|
|
78
|
+
const idx = match.lastIndexOf(String(secret));
|
|
79
|
+
if (idx < 0)
|
|
80
|
+
return MASK(rule.id);
|
|
81
|
+
return match.slice(0, idx) + MASK(rule.id) + match.slice(idx + String(secret).length);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { text, report };
|
|
86
|
+
}
|
|
87
|
+
export const SENSITIVE_KEY_NAMES = new Set([
|
|
88
|
+
"authorization",
|
|
89
|
+
"proxyauthorization",
|
|
90
|
+
"cookie",
|
|
91
|
+
"setcookie",
|
|
92
|
+
"xapikey",
|
|
93
|
+
"xauthtoken",
|
|
94
|
+
"xaccesstoken",
|
|
95
|
+
"xsessiontoken",
|
|
96
|
+
"xrefreshtoken",
|
|
97
|
+
"xamzsecuritytoken",
|
|
98
|
+
"apikey",
|
|
99
|
+
"apitoken",
|
|
100
|
+
"password",
|
|
101
|
+
"passwd",
|
|
102
|
+
"pwd",
|
|
103
|
+
"passphrase",
|
|
104
|
+
"secret",
|
|
105
|
+
"clientsecret",
|
|
106
|
+
"secretkey",
|
|
107
|
+
"token",
|
|
108
|
+
"accesstoken",
|
|
109
|
+
"refreshtoken",
|
|
110
|
+
"idtoken",
|
|
111
|
+
"authtoken",
|
|
112
|
+
"sessiontoken",
|
|
113
|
+
"sessionid",
|
|
114
|
+
"session",
|
|
115
|
+
"privatekey",
|
|
116
|
+
"credential",
|
|
117
|
+
"credentials",
|
|
118
|
+
"bearer",
|
|
119
|
+
"auth",
|
|
120
|
+
].map((k) => k.replace(/[-_]/g, "")));
|
|
121
|
+
function normalizeKey(key) {
|
|
122
|
+
return key.toLowerCase().replace(/[-_]/g, "");
|
|
123
|
+
}
|
|
124
|
+
export function redactValue(value, opts = {}) {
|
|
125
|
+
const report = emptyReport();
|
|
126
|
+
const maxDepth = opts.maxDepth ?? 64;
|
|
127
|
+
const seen = new WeakSet();
|
|
128
|
+
const walk = (node, path, depth, underSensitiveKey) => {
|
|
129
|
+
if (node === null || node === undefined)
|
|
130
|
+
return node;
|
|
131
|
+
if (typeof node === "string") {
|
|
132
|
+
if (underSensitiveKey) {
|
|
133
|
+
if (node.length > 0) {
|
|
134
|
+
record(report, "sensitive-key", path);
|
|
135
|
+
return MASK("sensitive-key");
|
|
136
|
+
}
|
|
137
|
+
return node;
|
|
138
|
+
}
|
|
139
|
+
return redactString(node, report, path).text;
|
|
140
|
+
}
|
|
141
|
+
if (typeof node !== "object")
|
|
142
|
+
return node;
|
|
143
|
+
if (seen.has(node))
|
|
144
|
+
return "[CYCLE]";
|
|
145
|
+
if (depth >= maxDepth)
|
|
146
|
+
return node;
|
|
147
|
+
seen.add(node);
|
|
148
|
+
if (Array.isArray(node)) {
|
|
149
|
+
return node.map((item, i) => walk(item, `${path}[${i}]`, depth + 1, underSensitiveKey));
|
|
150
|
+
}
|
|
151
|
+
const out = {};
|
|
152
|
+
for (const [key, v] of Object.entries(node)) {
|
|
153
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
154
|
+
const sensitive = underSensitiveKey || SENSITIVE_KEY_NAMES.has(normalizeKey(key));
|
|
155
|
+
out[key] = walk(v, childPath, depth + 1, sensitive);
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
};
|
|
159
|
+
const redacted = walk(value, "", 0, false);
|
|
160
|
+
return { value: redacted, report };
|
|
161
|
+
}
|
|
162
|
+
export function redactEvidence(value) {
|
|
163
|
+
return redactValue(value).value;
|
|
164
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export type { ScannerId, LeadSeverity, LeadLocation, NormalizedLead, ScannerRunResult, LocalScanReport, } from "./types.js";
|
|
2
|
+
export { normalizeScannerOutput, normalizeGitleaks, normalizeTrivy, normalizeSemgrep, normalizeOsvScanner, } from "./normalize.js";
|
|
3
|
+
export { SUPPORTED_SCANNERS, SCANNER_BINARIES, scannerCommand, runOneScanner, runLocalScanners, aggregate, defaultExecEnv, type ScannerCommand, type ScannerExecEnv, type RunLocalScannersOptions, } from "./run.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { NormalizedLead, ScannerId } from "./types.js";
|
|
2
|
+
export declare function normalizeGitleaks(parsed: unknown): NormalizedLead[];
|
|
3
|
+
export declare function normalizeTrivy(parsed: unknown): NormalizedLead[];
|
|
4
|
+
export declare function normalizeSemgrep(parsed: unknown): NormalizedLead[];
|
|
5
|
+
export declare function normalizeOsvScanner(parsed: unknown): NormalizedLead[];
|
|
6
|
+
export declare function normalizeScannerOutput(source: ScannerId, parsed: unknown): NormalizedLead[];
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { redactString } from "../redact.js";
|
|
2
|
+
function redact(text) {
|
|
3
|
+
if (text === undefined || text === null || text === "")
|
|
4
|
+
return undefined;
|
|
5
|
+
return redactString(String(text)).text;
|
|
6
|
+
}
|
|
7
|
+
function normalizeSeverity(raw) {
|
|
8
|
+
const s = String(raw ?? "").toLowerCase();
|
|
9
|
+
if (s.startsWith("crit"))
|
|
10
|
+
return "critical";
|
|
11
|
+
if (s === "high" || s === "error")
|
|
12
|
+
return "high";
|
|
13
|
+
if (s === "medium" || s === "moderate" || s === "warning" || s === "warn")
|
|
14
|
+
return "medium";
|
|
15
|
+
if (s === "low")
|
|
16
|
+
return "low";
|
|
17
|
+
if (s === "info" || s === "informational" || s === "unknown" || s === "none" || s === "") {
|
|
18
|
+
return s === "" || s === "unknown" || s === "none" ? "unknown" : "info";
|
|
19
|
+
}
|
|
20
|
+
return "unknown";
|
|
21
|
+
}
|
|
22
|
+
function asArray(v) {
|
|
23
|
+
return Array.isArray(v) ? v : [];
|
|
24
|
+
}
|
|
25
|
+
function lead(base) {
|
|
26
|
+
return { lane: "lead", ...base };
|
|
27
|
+
}
|
|
28
|
+
export function normalizeGitleaks(parsed) {
|
|
29
|
+
const findings = asArray(parsed);
|
|
30
|
+
return findings.map((f) => {
|
|
31
|
+
const tags = Array.isArray(f.Tags) ? f.Tags : [];
|
|
32
|
+
const sevTag = tags.find((t) => ["critical", "high", "medium", "low"].includes(String(t).toLowerCase()));
|
|
33
|
+
return lead({
|
|
34
|
+
source: "gitleaks",
|
|
35
|
+
rule_id: f.RuleID ?? "gitleaks-secret",
|
|
36
|
+
title: f.Description ?? f.RuleID ?? "Potential secret",
|
|
37
|
+
severity: sevTag ? normalizeSeverity(sevTag) : "high",
|
|
38
|
+
location: { file: f.File, start_line: f.StartLine, end_line: f.EndLine },
|
|
39
|
+
excerpt: redact(f.Match),
|
|
40
|
+
metadata: tags.length ? { tags } : undefined,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
export function normalizeTrivy(parsed) {
|
|
45
|
+
const results = asArray(parsed?.Results);
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const r of results) {
|
|
48
|
+
for (const v of asArray(r.Vulnerabilities)) {
|
|
49
|
+
out.push(lead({
|
|
50
|
+
source: "trivy",
|
|
51
|
+
rule_id: v.VulnerabilityID ?? "trivy-vuln",
|
|
52
|
+
title: v.Title ?? `${v.PkgName ?? "dependency"} ${v.VulnerabilityID ?? ""}`.trim(),
|
|
53
|
+
severity: normalizeSeverity(v.Severity),
|
|
54
|
+
location: { file: r.Target },
|
|
55
|
+
package: v.PkgName,
|
|
56
|
+
cwe: Array.isArray(v.CweIDs) && v.CweIDs.length ? v.CweIDs : undefined,
|
|
57
|
+
references: dedupeRefs(v.PrimaryURL, v.References),
|
|
58
|
+
metadata: {
|
|
59
|
+
installed_version: v.InstalledVersion,
|
|
60
|
+
fixed_version: v.FixedVersion,
|
|
61
|
+
},
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
for (const s of asArray(r.Secrets)) {
|
|
65
|
+
out.push(lead({
|
|
66
|
+
source: "trivy",
|
|
67
|
+
rule_id: s.RuleID ?? "trivy-secret",
|
|
68
|
+
title: s.Title ?? "Potential secret",
|
|
69
|
+
severity: normalizeSeverity(s.Severity ?? "high"),
|
|
70
|
+
location: { file: r.Target, start_line: s.StartLine, end_line: s.EndLine },
|
|
71
|
+
excerpt: redact(s.Match),
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
for (const m of asArray(r.Misconfigurations)) {
|
|
75
|
+
out.push(lead({
|
|
76
|
+
source: "trivy",
|
|
77
|
+
rule_id: m.ID ?? "trivy-misconfig",
|
|
78
|
+
title: m.Title ?? m.ID ?? "Misconfiguration",
|
|
79
|
+
severity: normalizeSeverity(m.Severity),
|
|
80
|
+
location: { file: r.Target },
|
|
81
|
+
references: dedupeRefs(m.PrimaryURL, m.References),
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
export function normalizeSemgrep(parsed) {
|
|
88
|
+
const results = asArray(parsed?.results);
|
|
89
|
+
return results.map((r) => {
|
|
90
|
+
const meta = r.extra?.metadata ?? {};
|
|
91
|
+
return lead({
|
|
92
|
+
source: "semgrep",
|
|
93
|
+
rule_id: r.check_id ?? "semgrep-rule",
|
|
94
|
+
title: r.extra?.message ?? r.check_id ?? "Semgrep match",
|
|
95
|
+
severity: normalizeSeverity(r.extra?.severity),
|
|
96
|
+
location: { file: r.path, start_line: r.start?.line, end_line: r.end?.line },
|
|
97
|
+
cwe: toStringArray(meta.cwe),
|
|
98
|
+
owasp: toStringArray(meta.owasp),
|
|
99
|
+
references: Array.isArray(meta.references) ? meta.references : undefined,
|
|
100
|
+
excerpt: redact(r.extra?.lines),
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
export function normalizeOsvScanner(parsed) {
|
|
105
|
+
const results = asArray(parsed?.results);
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const src of results) {
|
|
108
|
+
const file = src.source?.path;
|
|
109
|
+
for (const pkg of asArray(src.packages)) {
|
|
110
|
+
const pkgName = pkg.package?.name;
|
|
111
|
+
const groupSeverity = new Map();
|
|
112
|
+
for (const g of asArray(pkg.groups)) {
|
|
113
|
+
for (const id of asArray(g.ids)) {
|
|
114
|
+
if (g.max_severity)
|
|
115
|
+
groupSeverity.set(id, g.max_severity);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const v of asArray(pkg.vulnerabilities)) {
|
|
119
|
+
out.push(lead({
|
|
120
|
+
source: "osv-scanner",
|
|
121
|
+
rule_id: v.id ?? "osv-vuln",
|
|
122
|
+
title: v.summary ?? `${pkgName ?? "dependency"} ${v.id ?? ""}`.trim(),
|
|
123
|
+
severity: cvssScoreToSeverity(groupSeverity.get(v.id ?? "")),
|
|
124
|
+
location: { file },
|
|
125
|
+
package: pkgName,
|
|
126
|
+
references: asArray(v.references)
|
|
127
|
+
.map((r) => r.url)
|
|
128
|
+
.filter((u) => typeof u === "string"),
|
|
129
|
+
metadata: { version: pkg.package?.version, ecosystem: pkg.package?.ecosystem },
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
function toStringArray(v) {
|
|
137
|
+
if (v === undefined || v === null)
|
|
138
|
+
return undefined;
|
|
139
|
+
const arr = Array.isArray(v) ? v : [v];
|
|
140
|
+
const out = arr.map((x) => String(x)).filter(Boolean);
|
|
141
|
+
return out.length ? out : undefined;
|
|
142
|
+
}
|
|
143
|
+
function dedupeRefs(primary, refs) {
|
|
144
|
+
const set = new Set();
|
|
145
|
+
if (primary)
|
|
146
|
+
set.add(primary);
|
|
147
|
+
for (const r of Array.isArray(refs) ? refs : [])
|
|
148
|
+
if (r)
|
|
149
|
+
set.add(r);
|
|
150
|
+
return set.size ? [...set] : undefined;
|
|
151
|
+
}
|
|
152
|
+
function cvssScoreToSeverity(score) {
|
|
153
|
+
if (!score)
|
|
154
|
+
return "unknown";
|
|
155
|
+
const n = Number.parseFloat(score);
|
|
156
|
+
if (Number.isNaN(n))
|
|
157
|
+
return "unknown";
|
|
158
|
+
if (n >= 9.0)
|
|
159
|
+
return "critical";
|
|
160
|
+
if (n >= 7.0)
|
|
161
|
+
return "high";
|
|
162
|
+
if (n >= 4.0)
|
|
163
|
+
return "medium";
|
|
164
|
+
if (n > 0)
|
|
165
|
+
return "low";
|
|
166
|
+
return "unknown";
|
|
167
|
+
}
|
|
168
|
+
export function normalizeScannerOutput(source, parsed) {
|
|
169
|
+
switch (source) {
|
|
170
|
+
case "gitleaks":
|
|
171
|
+
return normalizeGitleaks(parsed);
|
|
172
|
+
case "trivy":
|
|
173
|
+
return normalizeTrivy(parsed);
|
|
174
|
+
case "semgrep":
|
|
175
|
+
return normalizeSemgrep(parsed);
|
|
176
|
+
case "osv-scanner":
|
|
177
|
+
return normalizeOsvScanner(parsed);
|
|
178
|
+
default:
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { LocalScanReport, ScannerId, ScannerRunResult } from "./types.js";
|
|
2
|
+
export declare const SUPPORTED_SCANNERS: readonly ScannerId[];
|
|
3
|
+
export declare const SCANNER_BINARIES: Record<ScannerId, string>;
|
|
4
|
+
export interface ScannerCommand {
|
|
5
|
+
bin: string;
|
|
6
|
+
args: string[];
|
|
7
|
+
}
|
|
8
|
+
export declare function scannerCommand(source: ScannerId, dir: string, outPath: string): ScannerCommand;
|
|
9
|
+
export interface ScannerExecEnv {
|
|
10
|
+
isAvailable(bin: string): Promise<boolean>;
|
|
11
|
+
execute(cmd: ScannerCommand, opts: {
|
|
12
|
+
timeoutMs: number;
|
|
13
|
+
}): Promise<{
|
|
14
|
+
reportText: string | null;
|
|
15
|
+
error?: string;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
export declare const defaultExecEnv: ScannerExecEnv;
|
|
19
|
+
export interface RunLocalScannersOptions {
|
|
20
|
+
sources?: ScannerId[];
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
env?: ScannerExecEnv;
|
|
23
|
+
tmpDir?: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function runOneScanner(source: ScannerId, dir: string, opts?: RunLocalScannersOptions): Promise<ScannerRunResult>;
|
|
26
|
+
export declare function runLocalScanners(dir: string, opts?: RunLocalScannersOptions): Promise<LocalScanReport>;
|
|
27
|
+
export declare function aggregate(results: ScannerRunResult[]): LocalScanReport;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import * as os from "node:os";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as fs from "node:fs/promises";
|
|
4
|
+
import * as crypto from "node:crypto";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { normalizeScannerOutput } from "./normalize.js";
|
|
7
|
+
export const SUPPORTED_SCANNERS = ["gitleaks", "trivy", "semgrep", "osv-scanner"];
|
|
8
|
+
export const SCANNER_BINARIES = {
|
|
9
|
+
gitleaks: "gitleaks",
|
|
10
|
+
trivy: "trivy",
|
|
11
|
+
semgrep: "semgrep",
|
|
12
|
+
"osv-scanner": "osv-scanner",
|
|
13
|
+
};
|
|
14
|
+
export function scannerCommand(source, dir, outPath) {
|
|
15
|
+
switch (source) {
|
|
16
|
+
case "gitleaks":
|
|
17
|
+
return {
|
|
18
|
+
bin: "gitleaks",
|
|
19
|
+
args: ["detect", "--source", dir, "--no-banner", "--report-format", "json", "--report-path", outPath, "--exit-code", "0"],
|
|
20
|
+
};
|
|
21
|
+
case "trivy":
|
|
22
|
+
return {
|
|
23
|
+
bin: "trivy",
|
|
24
|
+
args: ["fs", "--quiet", "--format", "json", "--output", outPath, "--scanners", "vuln,secret,misconfig", dir],
|
|
25
|
+
};
|
|
26
|
+
case "semgrep":
|
|
27
|
+
return {
|
|
28
|
+
bin: "semgrep",
|
|
29
|
+
args: ["scan", "--json", "--output", outPath, "--quiet", "--config", process.env.ZIVIS_SEMGREP_CONFIG || "auto", dir],
|
|
30
|
+
};
|
|
31
|
+
case "osv-scanner":
|
|
32
|
+
return {
|
|
33
|
+
bin: "osv-scanner",
|
|
34
|
+
args: ["--format", "json", "--output", outPath, "--recursive", dir],
|
|
35
|
+
};
|
|
36
|
+
default:
|
|
37
|
+
throw new Error(`unknown scanner: ${source}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const execFileAsync = (bin, args, timeoutMs) => new Promise((resolve) => {
|
|
41
|
+
execFile(bin, args, { timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 }, (err) => {
|
|
42
|
+
const code = err && typeof err.code === "number" ? (err.code) : err ? 1 : 0;
|
|
43
|
+
resolve({ code, stderr: err ? String(err.message ?? "") : "" });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
export const defaultExecEnv = {
|
|
47
|
+
async isAvailable(bin) {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
execFile(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [bin] : ["-v", bin], { shell: true, timeout: 5000 }, (err) => {
|
|
50
|
+
resolve(!err);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
async execute(cmd, opts) {
|
|
55
|
+
const outIdx = cmd.args.findIndex((a) => a === "--output" || a === "--report-path");
|
|
56
|
+
const outPath = outIdx >= 0 ? cmd.args[outIdx + 1] : undefined;
|
|
57
|
+
if (!outPath)
|
|
58
|
+
return { reportText: null, error: "no output path in command" };
|
|
59
|
+
try {
|
|
60
|
+
await execFileAsync(cmd.bin, cmd.args, opts.timeoutMs);
|
|
61
|
+
const text = await fs.readFile(outPath, "utf8").catch(() => null);
|
|
62
|
+
return { reportText: text };
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
return { reportText: null, error: err instanceof Error ? err.message : String(err) };
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
if (outPath)
|
|
69
|
+
await fs.rm(outPath, { force: true }).catch(() => { });
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
export async function runOneScanner(source, dir, opts = {}) {
|
|
74
|
+
const env = opts.env ?? defaultExecEnv;
|
|
75
|
+
const bin = SCANNER_BINARIES[source];
|
|
76
|
+
const started = Date.now();
|
|
77
|
+
if (!(await env.isAvailable(bin))) {
|
|
78
|
+
return { source, status: "not_installed", leads: [] };
|
|
79
|
+
}
|
|
80
|
+
const tmpDir = opts.tmpDir ?? os.tmpdir();
|
|
81
|
+
const outPath = path.join(tmpDir, `zivis-${source}-${crypto.randomUUID()}.json`);
|
|
82
|
+
const cmd = scannerCommand(source, dir, outPath);
|
|
83
|
+
try {
|
|
84
|
+
const { reportText, error } = await env.execute(cmd, { timeoutMs: opts.timeoutMs ?? 120_000 });
|
|
85
|
+
if (error && reportText === null) {
|
|
86
|
+
return { source, status: "error", leads: [], error, duration_ms: Date.now() - started };
|
|
87
|
+
}
|
|
88
|
+
if (reportText === null || reportText.trim() === "") {
|
|
89
|
+
return { source, status: "ok", leads: [], duration_ms: Date.now() - started };
|
|
90
|
+
}
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(reportText);
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
return { source, status: "error", leads: [], error: `unparseable ${source} JSON: ${e instanceof Error ? e.message : String(e)}`, duration_ms: Date.now() - started };
|
|
97
|
+
}
|
|
98
|
+
const leads = normalizeScannerOutput(source, parsed);
|
|
99
|
+
return { source, status: "ok", leads, duration_ms: Date.now() - started };
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
return { source, status: "error", leads: [], error: err instanceof Error ? err.message : String(err), duration_ms: Date.now() - started };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export async function runLocalScanners(dir, opts = {}) {
|
|
106
|
+
const sources = opts.sources ?? [...SUPPORTED_SCANNERS];
|
|
107
|
+
const results = [];
|
|
108
|
+
for (const source of sources) {
|
|
109
|
+
results.push(await runOneScanner(source, dir, opts));
|
|
110
|
+
}
|
|
111
|
+
return aggregate(results);
|
|
112
|
+
}
|
|
113
|
+
export function aggregate(results) {
|
|
114
|
+
const leads = [];
|
|
115
|
+
const bySource = {};
|
|
116
|
+
const bySeverity = {};
|
|
117
|
+
const scannersRun = [];
|
|
118
|
+
const scannersMissing = [];
|
|
119
|
+
for (const r of results) {
|
|
120
|
+
if (r.status === "not_installed")
|
|
121
|
+
scannersMissing.push(r.source);
|
|
122
|
+
else
|
|
123
|
+
scannersRun.push(r.source);
|
|
124
|
+
for (const l of r.leads) {
|
|
125
|
+
leads.push(l);
|
|
126
|
+
bySource[l.source] = (bySource[l.source] ?? 0) + 1;
|
|
127
|
+
bySeverity[l.severity] = (bySeverity[l.severity] ?? 0) + 1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
ran_at: new Date().toISOString(),
|
|
132
|
+
results,
|
|
133
|
+
leads,
|
|
134
|
+
summary: {
|
|
135
|
+
total_leads: leads.length,
|
|
136
|
+
by_source: bySource,
|
|
137
|
+
by_severity: bySeverity,
|
|
138
|
+
scanners_run: scannersRun,
|
|
139
|
+
scanners_missing: scannersMissing,
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type ScannerId = "gitleaks" | "trivy" | "semgrep" | "osv-scanner";
|
|
2
|
+
export type LeadSeverity = "critical" | "high" | "medium" | "low" | "info" | "unknown";
|
|
3
|
+
export interface LeadLocation {
|
|
4
|
+
file?: string;
|
|
5
|
+
start_line?: number;
|
|
6
|
+
end_line?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface NormalizedLead {
|
|
9
|
+
lane: "lead";
|
|
10
|
+
source: ScannerId;
|
|
11
|
+
rule_id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
severity: LeadSeverity;
|
|
14
|
+
location?: LeadLocation;
|
|
15
|
+
package?: string;
|
|
16
|
+
cwe?: string[];
|
|
17
|
+
owasp?: string[];
|
|
18
|
+
references?: string[];
|
|
19
|
+
excerpt?: string;
|
|
20
|
+
metadata?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
export interface ScannerRunResult {
|
|
23
|
+
source: ScannerId;
|
|
24
|
+
status: "ok" | "not_installed" | "error";
|
|
25
|
+
leads: NormalizedLead[];
|
|
26
|
+
error?: string;
|
|
27
|
+
duration_ms?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface LocalScanReport {
|
|
30
|
+
ran_at: string;
|
|
31
|
+
results: ScannerRunResult[];
|
|
32
|
+
leads: NormalizedLead[];
|
|
33
|
+
summary: {
|
|
34
|
+
total_leads: number;
|
|
35
|
+
by_source: Record<string, number>;
|
|
36
|
+
by_severity: Record<string, number>;
|
|
37
|
+
scanners_run: ScannerId[];
|
|
38
|
+
scanners_missing: ScannerId[];
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zivis/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "ZIVIS MCP server — threat modeling, security scans, and AI red team tools for IDE integration",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://zivis.ai",
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
"./server": "./dist/server.js",
|
|
25
25
|
"./types": "./dist/types.js",
|
|
26
26
|
"./cli": "./dist/cli.js",
|
|
27
|
+
"./redact": "./dist/redact.js",
|
|
28
|
+
"./scanners": "./dist/scanners/index.js",
|
|
29
|
+
"./http-probe": "./dist/http-probe/index.js",
|
|
27
30
|
"./pattern-pack": "./dist/pattern-pack/index.js",
|
|
28
31
|
"./matcher": "./dist/matcher/index.js"
|
|
29
32
|
},
|