Files
reverse-engineering-anything/src/domain/releaseExposure.ts
T
Matt Van Horn fa91268045 fix(audit): keep an unresolved Electron host's fuses unknown
fuseCheck decided from whatever outcomes the images reported, so a host
with no fuse answer read not_observed as soon as any other image in the
directory reported safe fuses.

Pass the images named as the host into the report and leave a check
unknown when one of them has no answer for it. Other images stay free to
have no fuses, because helpers in a real Electron install have none.
2026-11-02 02:33:01 -08:00

935 lines
31 KiB
TypeScript

import { z } from "zod";
import { digestSchema } from "./digests.js";
import { evidenceLocationSchema, type EvidenceLocation } from "./evidence.js";
import {
absoluteLocalPathStringSchema,
localPathStringSchema,
} from "./localPath.js";
/** Selected-file ceiling for one release-exposure audit. */
export const MAX_RELEASE_EXPOSURE_FILE_BYTES = 256 * 1024 * 1024;
/** Members inventoried from one installation or package. */
export const MAX_RELEASE_EXPOSURE_INVENTORY_ENTRIES = 65_536;
/** Bytes hashed while inventorying one installation or package. */
export const MAX_RELEASE_EXPOSURE_INVENTORY_BYTES = 2 * 1024 * 1024 * 1024;
/** Bytes read from one text member. */
export const MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES = 256 * 1024;
/** Bytes read across all text members. */
export const MAX_RELEASE_EXPOSURE_TEXT_BYTES = 4 * 1024 * 1024;
/** Resolved paths retained for HTML script targets across one audit. */
export const MAX_RELEASE_EXPOSURE_SCRIPT_TARGET_BYTES = 1024 * 1024;
/** Source maps read from one artifact. */
export const MAX_RELEASE_EXPOSURE_SOURCE_MAPS = 32;
/** Bytes read from one source map. */
export const MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES = 1024 * 1024;
/** Bytes read to identify a package member that is neither text nor a map. */
export const MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES = 4096;
/** Bytes read from one PE stored inside a package. */
export const MAX_RELEASE_EXPOSURE_PACKAGED_PE_BYTES = 256 * 1024;
/** PE images stored inside packages that are read for debug records. */
export const MAX_RELEASE_EXPOSURE_PACKAGED_PE_READS = 64;
/** Native images passed to Go, Rust, and Electron fuse readers. */
export const MAX_RELEASE_EXPOSURE_NATIVE_IMAGES = 4;
/** Paths, rule ids, and other samples kept on one check. */
export const MAX_RELEASE_EXPOSURE_SAMPLES = 8;
export const RELEASE_EXPOSURE_OPERATION = "audit_release_exposure";
export const RELEASE_EXPOSURE_LIMITATION =
"This report states what static analysis of the selected artifact recovered. It does not establish that the application is secure.";
export const RELEASE_EXPOSURE_ALLOWANCES = [
`Selected file reads stop at ${String(MAX_RELEASE_EXPOSURE_FILE_BYTES)} bytes.`,
`Text member scans read at most ${String(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES)} bytes each and ${String(MAX_RELEASE_EXPOSURE_TEXT_BYTES)} bytes in total.`,
`HTML script targets keep at most ${String(MAX_RELEASE_EXPOSURE_SCRIPT_TARGET_BYTES)} bytes of resolved paths. Later targets stay unassessed.`,
`Source map scans read at most ${String(MAX_RELEASE_EXPOSURE_SOURCE_MAPS)} maps and ${String(MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES)} bytes each. sourcesContent text is counted and then dropped.`,
`Other files and package members are identified from their first ${String(MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES)} bytes, and those without a NUL byte in that prefix are scanned as text. Up to ${String(MAX_RELEASE_EXPOSURE_PACKAGED_PE_READS)} packaged PE images are read for debug records, ${String(MAX_RELEASE_EXPOSURE_PACKAGED_PE_BYTES)} bytes each.`,
`Go, Rust, and Electron fuse readers run on at most ${String(MAX_RELEASE_EXPOSURE_NATIVE_IMAGES)} native images.`,
] as const;
export const RELEASE_EXPOSURE_STATUSES = [
"exposed",
"not_observed",
"not_applicable",
"unknown",
"not_assessed",
] as const;
export const RELEASE_EXPOSURE_SEVERITIES = [
"high",
"medium",
"low",
"info",
] as const;
export const RELEASE_EXPOSURE_FAIL_LEVELS = ["high", "medium", "low"] as const;
export const RELEASE_EXPOSURE_CHECK_IDS = [
"javascript.source_map_sources",
"javascript.source_maps",
"electron.run_as_node",
"electron.asar_integrity",
"electron.only_load_from_asar",
"electron.node_cli_inspect",
"packaging.extractable_payload",
"native.debug_companions",
"native.pdb_path",
"go.build_paths",
"rust.dependency_metadata",
"text.credential_markers",
"native.hardening",
"macho.hardened_runtime",
"android.manifest",
] as const;
const statusSchema = z.enum(RELEASE_EXPOSURE_STATUSES);
const severitySchema = z.enum(RELEASE_EXPOSURE_SEVERITIES);
type Status = z.infer<typeof statusSchema>;
type Severity = z.infer<typeof severitySchema>;
export type ReleaseExposureCheckId =
(typeof RELEASE_EXPOSURE_CHECK_IDS)[number];
export type ReleaseExposureFailLevel =
(typeof RELEASE_EXPOSURE_FAIL_LEVELS)[number];
const CHECK_TABLE = {
"javascript.source_map_sources": {
severity: "high",
remediation:
"Ship the release without sourcesContent. Remove the source maps or omit sourcesContent from maps that ship.",
next_probe: null,
},
"javascript.source_maps": {
severity: "medium",
remediation: "Do not ship source maps beside the release.",
next_probe: null,
},
"electron.run_as_node": {
severity: "high",
remediation:
"Set FuseV1Options.RunAsNode to false with @electron/fuses before packaging.",
next_probe: "inspect_electron_fuses",
},
"electron.asar_integrity": {
severity: "medium",
remediation:
"Set FuseV1Options.EnableEmbeddedAsarIntegrityValidation to true with @electron/fuses. Electron compiles this check for macOS and Windows; an ELF byte stays unknown.",
next_probe: "inspect_electron_fuses",
},
"electron.only_load_from_asar": {
severity: "medium",
remediation:
"Set FuseV1Options.OnlyLoadAppFromAsar to true with @electron/fuses so the packaged app loads application code from app.asar.",
next_probe: "inspect_electron_fuses",
},
"electron.node_cli_inspect": {
severity: "medium",
remediation:
"Set FuseV1Options.EnableNodeCliInspectArguments to false with @electron/fuses so the packaged app ignores --inspect.",
next_probe: "inspect_electron_fuses",
},
"packaging.extractable_payload": {
severity: "low",
remediation:
"Readable members are part of this package format. Treat packaged sources as public. This check does not rewrite the artifact.",
next_probe: "inspect_artifact",
},
"native.debug_companions": {
severity: "medium",
remediation:
"Do not ship .pdb, .dSYM, .debug, or linker map files beside the release.",
next_probe: null,
},
"native.pdb_path": {
severity: "low",
remediation:
"Rebuild without a CodeView PDB path, or accept that the path string is recoverable from the PE. The PDB file itself is a separate check.",
next_probe: null,
},
"go.build_paths": {
severity: "low",
remediation:
"Rebuild with -trimpath so file paths are not written into the binary. Module names can remain.",
next_probe: "inspect_go_binary",
},
"rust.dependency_metadata": {
severity: "low",
remediation:
"Omit cargo-auditable metadata from the release if the crate list should not ship.",
next_probe: "inspect_rust_binary",
},
"text.credential_markers": {
severity: "high",
remediation:
"Remove the provider-prefixed credential from the shipped text and rotate it if it is real. This check reports the rule id and location only.",
next_probe: null,
},
"native.hardening": {
severity: "info",
remediation:
"This audit does not assess native hardening. Use inspect_binary_hardening when that tool is available.",
next_probe: "inspect_binary_hardening",
},
"macho.hardened_runtime": {
severity: "info",
remediation:
"This audit does not assess the Mach-O hardened runtime. inspect_signature reads code signatures and would change this tool's effects, so it stays a next probe.",
next_probe: "inspect_signature",
},
"android.manifest": {
severity: "info",
remediation:
"This audit does not read Android manifests. Use inspect_android_package, which needs JADX.",
next_probe: "inspect_android_package",
},
} as const satisfies Record<
ReleaseExposureCheckId,
{
readonly severity: Severity;
readonly remediation: string;
readonly next_probe: string | null;
}
>;
const observationSchema = z.strictObject({
counts: z.record(z.string().min(1), z.int().nonnegative()),
samples: z.array(z.string().min(1)).max(MAX_RELEASE_EXPOSURE_SAMPLES),
});
const releaseExposureCheckSchema = z.strictObject({
id: z.enum(RELEASE_EXPOSURE_CHECK_IDS),
status: statusSchema,
severity: severitySchema.nullable(),
confidence: z.enum(["observed", "inferred"]),
observation: observationSchema,
locations: z.array(evidenceLocationSchema).max(MAX_RELEASE_EXPOSURE_SAMPLES),
remediation: z.string().min(1),
next_probe: z.string().min(1).nullable(),
});
const countSchema = z.strictObject({
exposed: z.int().nonnegative(),
not_observed: z.int().nonnegative(),
not_applicable: z.int().nonnegative(),
unknown: z.int().nonnegative(),
not_assessed: z.int().nonnegative(),
});
const severityCountSchema = z.strictObject({
high: z.int().nonnegative(),
medium: z.int().nonnegative(),
low: z.int().nonnegative(),
info: z.int().nonnegative(),
});
export const releaseExposureReportSchema = z
.strictObject({
target: z.strictObject({
selected_path: localPathStringSchema,
kind: z.enum(["app", "directory", "asar", "executable", "file"]),
format: z.string().min(1),
sha256: digestSchema.nullable(),
}),
checks: z.array(releaseExposureCheckSchema),
summary: z.strictObject({
by_status: countSchema,
by_severity: severityCountSchema,
highest_exposed_severity: severitySchema.nullable(),
no_exposure_observed_in_assessed_checks: z.boolean(),
}),
coverage: z.strictObject({
allowances: z.array(z.string().min(1)),
omitted: z.array(
z.strictObject({
subject: z.string().min(1),
count: z.int().nonnegative(),
reason: z.string().min(1),
}),
),
}),
limitations: z.array(z.string().min(1)),
})
.superRefine((report, context) => {
if (
report.checks.length !== RELEASE_EXPOSURE_CHECK_IDS.length ||
report.checks.some(
(check, index) => check.id !== RELEASE_EXPOSURE_CHECK_IDS[index],
)
)
context.addIssue({
code: "custom",
path: ["checks"],
message: "Checks must be the fixed release-exposure table, in order.",
});
for (const [index, check] of report.checks.entries()) {
const policy = CHECK_TABLE[check.id];
if (
check.remediation !== policy.remediation ||
check.next_probe !== policy.next_probe
)
context.addIssue({
code: "custom",
path: ["checks", index],
message: "Remediation and next probe are fixed per check.",
});
if (check.status === "exposed" && check.severity !== policy.severity)
context.addIssue({
code: "custom",
path: ["checks", index, "severity"],
message: "Exposed severity comes from the check policy.",
});
if (check.status !== "exposed" && check.severity !== null)
context.addIssue({
code: "custom",
path: ["checks", index, "severity"],
message: "Severity is null unless the check is exposed.",
});
}
const summary = summarizeReleaseExposureChecks(report.checks);
if (
JSON.stringify(summary) !== JSON.stringify(report.summary) ||
report.limitations[0] !== RELEASE_EXPOSURE_LIMITATION
)
context.addIssue({
code: "custom",
path: ["summary"],
message: "Summary and the leading limitation do not match the checks.",
});
});
export const auditReleaseExposureInputSchema = z.strictObject({
path: absoluteLocalPathStringSchema.describe(
"Absolute path to a shipped .app bundle, install directory, ASAR, or executable",
),
max_file_bytes: z
.int()
.positive()
.max(MAX_RELEASE_EXPOSURE_FILE_BYTES)
.default(MAX_RELEASE_EXPOSURE_FILE_BYTES)
.describe(
"Selected-file and on-disk PE read ceiling. Text, source-map, and package members use the smaller allowances in coverage.allowances.",
),
});
export type ReleaseExposureReport = z.infer<typeof releaseExposureReportSchema>;
export type ReleaseExposureCheck = z.infer<typeof releaseExposureCheckSchema>;
export type AuditReleaseExposureInput = z.infer<
typeof auditReleaseExposureInputSchema
>;
export interface ReleaseExposureTargetFact {
readonly selected_path: string;
readonly kind: ReleaseExposureReport["target"]["kind"];
readonly format: string;
readonly sha256: string | null;
}
export interface ReleaseExposureNativeImage {
readonly path: string;
readonly format: "elf" | "mach-o" | "mach-o-universal" | "pe";
/** Digest from inventory, when the audit has one to bind owner reads to. */
readonly sha256?: string;
}
export interface ReleaseExposureFuseCandidate {
readonly path: string;
readonly role: "app-bundle" | "native-image";
}
/** Omission subject for native images stored inside a package. */
export const PACKAGED_NATIVE_IMAGES = "packaged native images";
export interface ReleaseExposureMarkerHit {
readonly rule_id: string;
readonly path: string;
readonly line: number;
}
export interface ReleaseExposureScan {
readonly target: ReleaseExposureTargetFact;
readonly subject: {
readonly path: string;
readonly sha256: string;
readonly format: string;
};
readonly native_images: readonly ReleaseExposureNativeImage[];
readonly fuse_candidates: readonly ReleaseExposureFuseCandidate[];
readonly source_maps: {
readonly embedded: number;
readonly without_sources: number;
readonly unparsed: number;
readonly skipped: number;
readonly samples: readonly string[];
readonly embedded_paths?: readonly string[];
readonly plain_paths?: readonly string[];
};
readonly extractable: {
readonly formats: readonly string[];
readonly readable_members: number;
readonly unreadable_members: number;
readonly samples: readonly string[];
};
readonly debug_companions: {
readonly paths: readonly string[];
readonly skipped: number;
};
readonly pdb: {
readonly examined: number;
readonly paths: readonly string[];
readonly image_paths?: readonly string[];
readonly failed: number;
};
readonly text_members: {
readonly scanned: number;
readonly skipped: number;
};
readonly coverage: "complete" | "unknown";
readonly omitted: readonly {
readonly subject: string;
readonly count: number;
readonly reason: string;
}[];
readonly limitations: readonly string[];
}
export interface FuseImageFact {
readonly kind: "unsupported" | "failed" | "report";
readonly role: "app-bundle" | "native-image";
readonly path?: string;
readonly reason?: string;
readonly implications?: readonly {
readonly subject: string;
readonly outcome: string;
}[];
}
export type MetadataImageFact = (
| { readonly kind: "unsupported" }
| { readonly kind: "failed"; readonly reason: string }
| { readonly kind: "absent" }
| {
readonly kind: "paths";
readonly paths: readonly string[];
readonly complete?: boolean;
readonly limitations?: readonly string[];
}
| { readonly kind: "packages"; readonly packages: readonly string[] }
) & { readonly path?: string };
export interface ReleaseExposureOwnerFacts {
readonly electron_family:
| "electron"
| "not_electron"
| "unknown"
| "not_a_directory";
readonly electron_family_reason?: string | null;
readonly electron_executable_included?: boolean;
/** Images named as the Electron host. Others may be helpers with no fuses. */
readonly electron_host_paths?: readonly string[];
readonly fuse_images: readonly FuseImageFact[];
readonly go_images: readonly MetadataImageFact[];
readonly rust_images: readonly MetadataImageFact[];
readonly credential_markers: {
readonly hits: readonly ReleaseExposureMarkerHit[];
readonly omitted_hits: number;
};
}
const SEVERITY_RANK: Record<Severity, number> = {
info: 1,
low: 2,
medium: 3,
high: 4,
};
const FAIL_RANK: Record<ReleaseExposureFailLevel, number> = {
low: 2,
medium: 3,
high: 4,
};
const boundSamples = (samples: readonly string[]): string[] => {
const unique: string[] = [];
for (const sample of samples) {
if (sample.length === 0 || unique.includes(sample)) continue;
unique.push(sample);
if (unique.length === MAX_RELEASE_EXPOSURE_SAMPLES) break;
}
return unique;
};
const boundLocations = (
locations: readonly EvidenceLocation[],
): EvidenceLocation[] => locations.slice(0, MAX_RELEASE_EXPOSURE_SAMPLES);
const makeCheck = (
id: ReleaseExposureCheckId,
status: Status,
confidence: "observed" | "inferred",
counts: Record<string, number>,
samples: readonly string[],
locations: readonly EvidenceLocation[],
): ReleaseExposureCheck => {
const policy = CHECK_TABLE[id];
return {
id,
status,
severity: status === "exposed" ? policy.severity : null,
confidence,
observation: { counts, samples: boundSamples(samples) },
locations: boundLocations(locations),
remediation: policy.remediation,
next_probe: policy.next_probe,
};
};
const targetLocation = (path: string): EvidenceLocation[] => [
{ kind: "artifact-path", path },
];
const findingLocations = (
paths: readonly string[],
fallback: string,
): EvidenceLocation[] => {
const locations = [...new Set(paths.filter((path) => path.length > 0))]
.slice(0, MAX_RELEASE_EXPOSURE_SAMPLES)
.map((path) => ({ kind: "artifact-path" as const, path }));
return locations.length > 0 ? locations : targetLocation(fallback);
};
const sourceMapCheck = (
id: ReleaseExposureCheckId,
present: number,
scan: ReleaseExposureScan,
): ReleaseExposureCheck => {
const { source_maps: maps, coverage, target } = scan;
const unresolved = maps.unparsed + maps.skipped > 0 || coverage === "unknown";
const status: Status =
present > 0 ? "exposed" : unresolved ? "unknown" : "not_observed";
const findingPaths =
id === "javascript.source_map_sources"
? (maps.embedded_paths ?? (present > 0 ? maps.samples : []))
: (maps.plain_paths ?? (present > 0 ? maps.samples : []));
return makeCheck(
id,
status,
status === "unknown" ? "inferred" : "observed",
{
embedded: maps.embedded,
without_sources: maps.without_sources,
unparsed: maps.unparsed,
skipped: maps.skipped,
},
maps.samples,
status === "exposed"
? findingLocations(findingPaths, target.selected_path)
: targetLocation(target.selected_path),
);
};
const FUSE_SUBJECTS = {
"electron.run_as_node": {
subject: "electron_run_as_node",
exposed: "honored",
safe: "not_honored",
},
"electron.node_cli_inspect": {
subject: "node_cli_inspect_arguments",
exposed: "honored",
safe: "not_honored",
},
"electron.asar_integrity": {
subject: "embedded_asar_integrity_validation",
exposed: "not_enforced",
safe: "enforced",
},
"electron.only_load_from_asar": {
subject: "only_load_app_from_asar",
exposed: "not_restricted_to_app_asar",
safe: "restricted_to_app_asar",
},
} as const;
/** Fuse implication subjects that decide an Electron check. */
export const RELEASE_EXPOSURE_FUSE_SUBJECTS = Object.values(FUSE_SUBJECTS).map(
(spec) => spec.subject,
);
/**
* Whether a native image may have gone unread: inventory stopped early, the
* sample dropped one, or one sits inside a package no owner reader opens.
*/
export const nativeImagesMayBeUnread = (scan: ReleaseExposureScan): boolean =>
scan.coverage === "unknown" ||
scan.omitted.some(
(item) =>
(item.subject === "native images" ||
item.subject === PACKAGED_NATIVE_IMAGES) &&
item.count > 0,
);
const fuseCheck = (
id: keyof typeof FUSE_SUBJECTS,
scan: ReleaseExposureScan,
owners: ReleaseExposureOwnerFacts,
): ReleaseExposureCheck => {
const path = scan.target.selected_path;
const spec = FUSE_SUBJECTS[id];
const images = owners.fuse_images;
const outcomes = images.flatMap((image) =>
image.kind === "report"
? (image.implications ?? [])
.filter((item) => item.subject === spec.subject)
.map((item) => item.outcome)
: [],
);
const failed = images.some((image) => image.kind === "failed");
const hostUnresolved = images.some(
(image) =>
image.kind === "report" &&
image.path !== undefined &&
owners.electron_host_paths?.includes(image.path) === true &&
!(image.implications ?? []).some((item) => item.subject === spec.subject),
);
const partialFuseSample =
nativeImagesMayBeUnread(scan) &&
owners.electron_executable_included !== true;
let status: Status;
if (outcomes.includes(spec.exposed)) status = "exposed";
else if (owners.electron_family === "not_electron") status = "not_applicable";
else if (outcomes.includes("unknown") || failed || hostUnresolved)
status = "unknown";
else if (outcomes.length > 0 && outcomes.every((item) => item === spec.safe))
status = partialFuseSample ? "unknown" : "not_observed";
else if (owners.electron_family === "not_a_directory")
status = "not_applicable";
else status = "unknown";
const familyReason = owners.electron_family_reason;
const exposedPaths = images.flatMap((image) => {
if (image.kind !== "report" || image.path === undefined) return [];
const hit = (image.implications ?? []).some(
(item) => item.subject === spec.subject && item.outcome === spec.exposed,
);
return hit ? [image.path] : [];
});
return makeCheck(
id,
status,
"inferred",
{
images: images.length,
implications: outcomes.length,
},
[
...images.flatMap((image) =>
image.reason === undefined ? [] : [image.reason],
),
...(status === "unknown" &&
familyReason !== undefined &&
familyReason !== null
? [familyReason]
: []),
],
status === "exposed"
? findingLocations(exposedPaths, path)
: targetLocation(path),
);
};
const extractableCheck = (scan: ReleaseExposureScan): ReleaseExposureCheck => {
const { extractable, coverage, target } = scan;
let status: Status;
if (extractable.readable_members > 0) status = "exposed";
else if (
extractable.unreadable_members > 0 ||
extractable.formats.length > 0 ||
coverage !== "complete"
)
status = "unknown";
else status = "not_observed";
return makeCheck(
"packaging.extractable_payload",
status,
status === "unknown" ? "inferred" : "observed",
{
readable_members: extractable.readable_members,
unreadable_members: extractable.unreadable_members,
formats: extractable.formats.length,
},
[...extractable.formats, ...extractable.samples],
status === "exposed"
? findingLocations(extractable.samples, target.selected_path)
: targetLocation(target.selected_path),
);
};
const companionCheck = (scan: ReleaseExposureScan): ReleaseExposureCheck => {
const { debug_companions: companions, coverage, target } = scan;
const status: Status =
companions.paths.length > 0
? "exposed"
: companions.skipped > 0 || coverage === "unknown"
? "unknown"
: "not_observed";
return makeCheck(
"native.debug_companions",
status,
// A companion's identity comes from its name or an unparseable .map body.
status === "not_observed" ? "observed" : "inferred",
{ companions: companions.paths.length, skipped: companions.skipped },
companions.paths,
status === "exposed"
? findingLocations(companions.paths, target.selected_path)
: targetLocation(target.selected_path),
);
};
const pdbCheck = (scan: ReleaseExposureScan): ReleaseExposureCheck => {
const { pdb, coverage, target } = scan;
let status: Status;
if (pdb.paths.length > 0) status = "exposed";
else if (pdb.failed > 0 || coverage !== "complete") status = "unknown";
else if (pdb.examined === 0) status = "not_applicable";
else status = "not_observed";
const imagePaths = pdb.image_paths ?? [];
return makeCheck(
"native.pdb_path",
status,
status === "unknown" ? "inferred" : "observed",
{ examined: pdb.examined, paths: pdb.paths.length, failed: pdb.failed },
pdb.paths,
status === "exposed"
? findingLocations(imagePaths, target.selected_path)
: targetLocation(target.selected_path),
);
};
const metadataStatus = (
images: readonly MetadataImageFact[],
present: (image: MetadataImageFact) => boolean,
unresolved: boolean,
): Status => {
if (images.some(present)) return "exposed";
if (
unresolved ||
images.some((image) => image.kind === "failed" || image.kind === "absent")
)
return "unknown";
if (
images.length === 0 ||
images.every((image) => image.kind === "unsupported")
)
return "not_applicable";
return "not_observed";
};
const retainsBuildPaths = (image: MetadataImageFact): boolean =>
image.kind === "paths" && image.paths.length > 0;
const goCheck = (
scan: ReleaseExposureScan,
images: readonly MetadataImageFact[],
): ReleaseExposureCheck => {
const path = scan.target.selected_path;
const paths = images.flatMap((image) =>
image.kind === "paths" ? image.paths : [],
);
const reasons = images.flatMap((image) =>
image.kind === "failed" ? [image.reason] : [],
);
const imagePaths = images.flatMap((image) =>
retainsBuildPaths(image) && image.path !== undefined ? [image.path] : [],
);
const status = metadataStatus(
images,
retainsBuildPaths,
nativeImagesMayBeUnread(scan) ||
images.some(
(image) => image.kind === "paths" && image.complete === false,
),
);
return makeCheck(
"go.build_paths",
status,
status === "unknown" ? "inferred" : "observed",
{ images: images.length, paths: paths.length },
[...paths, ...reasons],
status === "exposed"
? findingLocations(imagePaths, path)
: targetLocation(path),
);
};
const rustCheck = (
scan: ReleaseExposureScan,
images: readonly MetadataImageFact[],
): ReleaseExposureCheck => {
const path = scan.target.selected_path;
const packages = images.flatMap((image) =>
image.kind === "packages" ? image.packages : [],
);
const reasons = images.flatMap((image) =>
image.kind === "failed" ? [image.reason] : [],
);
const imagePaths = images.flatMap((image) =>
image.kind === "packages" && image.path !== undefined ? [image.path] : [],
);
const status = metadataStatus(
images,
(image) => image.kind === "packages",
nativeImagesMayBeUnread(scan),
);
return makeCheck(
"rust.dependency_metadata",
status,
status === "unknown" ? "inferred" : "observed",
{ images: images.length, packages: packages.length },
[...packages, ...reasons],
status === "exposed"
? findingLocations(imagePaths, path)
: targetLocation(path),
);
};
const credentialCheck = (
scan: ReleaseExposureScan,
markers: ReleaseExposureOwnerFacts["credential_markers"],
): ReleaseExposureCheck => {
const { text_members: members, coverage, target } = scan;
const counts: Record<string, number> = {
scanned_members: members.scanned,
skipped_members: members.skipped,
omitted_hits: markers.omitted_hits,
};
for (const hit of markers.hits)
counts[hit.rule_id] = (counts[hit.rule_id] ?? 0) + 1;
const status: Status =
markers.hits.length > 0
? "exposed"
: members.skipped > 0 || coverage === "unknown"
? "unknown"
: members.scanned === 0
? "not_applicable"
: "not_observed";
return makeCheck(
"text.credential_markers",
status,
status === "unknown" ? "inferred" : "observed",
counts,
markers.hits.map((hit) => `${hit.rule_id} ${hit.path}:${String(hit.line)}`),
status === "exposed"
? findingLocations(
markers.hits.map((hit) => hit.path),
target.selected_path,
)
: targetLocation(target.selected_path),
);
};
const notAssessed = (
id: "native.hardening" | "macho.hardened_runtime" | "android.manifest",
path: string,
): ReleaseExposureCheck =>
makeCheck(
id,
"not_assessed",
"inferred",
{ assessed: 0 },
[],
targetLocation(path),
);
/** Build the fixed check list from reader facts and owner results. */
export const assembleReleaseExposureReport = (
scan: ReleaseExposureScan,
owners: ReleaseExposureOwnerFacts,
): ReleaseExposureReport => {
const path = scan.target.selected_path;
const checks = [
sourceMapCheck(
"javascript.source_map_sources",
scan.source_maps.embedded,
scan,
),
sourceMapCheck(
"javascript.source_maps",
scan.source_maps.without_sources,
scan,
),
fuseCheck("electron.run_as_node", scan, owners),
fuseCheck("electron.asar_integrity", scan, owners),
fuseCheck("electron.only_load_from_asar", scan, owners),
fuseCheck("electron.node_cli_inspect", scan, owners),
extractableCheck(scan),
companionCheck(scan),
pdbCheck(scan),
goCheck(scan, owners.go_images),
rustCheck(scan, owners.rust_images),
credentialCheck(scan, owners.credential_markers),
notAssessed("native.hardening", path),
notAssessed("macho.hardened_runtime", path),
notAssessed("android.manifest", path),
];
return releaseExposureReportSchema.parse({
target: scan.target,
checks,
summary: summarizeReleaseExposureChecks(checks),
coverage: {
allowances: [...RELEASE_EXPOSURE_ALLOWANCES],
omitted: scan.omitted.filter((item) => item.count > 0),
},
limitations: [
RELEASE_EXPOSURE_LIMITATION,
...scan.limitations.filter(
(item) => item !== RELEASE_EXPOSURE_LIMITATION,
),
...owners.go_images.flatMap((image) =>
image.kind === "paths" ? (image.limitations ?? []) : [],
),
],
});
};
/** Count check outcomes. Severity counts include exposed checks only. */
export const summarizeReleaseExposureChecks = (
checks: readonly ReleaseExposureCheck[],
): ReleaseExposureReport["summary"] => {
const byStatus = {
exposed: 0,
not_observed: 0,
not_applicable: 0,
unknown: 0,
not_assessed: 0,
};
const bySeverity = { high: 0, medium: 0, low: 0, info: 0 };
let highest: Severity | null = null;
for (const check of checks) {
byStatus[check.status] += 1;
if (check.status !== "exposed" || check.severity === null) continue;
bySeverity[check.severity] += 1;
if (
highest === null ||
SEVERITY_RANK[check.severity] > SEVERITY_RANK[highest]
)
highest = check.severity;
}
return {
by_status: byStatus,
by_severity: bySeverity,
highest_exposed_severity: highest,
no_exposure_observed_in_assessed_checks: byStatus.exposed === 0,
};
};
/**
* Whether an exposed check meets a CI threshold.
* `high` matches high. `medium` matches high and medium. `low` matches high,
* medium, and low. Unknown and not_assessed checks do not match.
*/
export const exposureMeetsFailOn = (
report: ReleaseExposureReport,
level: ReleaseExposureFailLevel,
): boolean =>
report.checks.some(
(check) =>
check.status === "exposed" &&
check.severity !== null &&
SEVERITY_RANK[check.severity] >= FAIL_RANK[level],
);