@xagents_in/unmask 0.1.2 → 0.1.4
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/README.md +301 -85
- package/dist/cli/annotations.mjs +145 -0
- package/dist/cli/audit.mjs +223 -0
- package/dist/cli/batch.mjs +218 -0
- package/dist/cli/cache.mjs +488 -0
- package/dist/cli/compare.mjs +261 -0
- package/dist/cli/config.mjs +141 -0
- package/dist/cli/discovery.mjs +372 -0
- package/dist/cli/doctor.mjs +354 -0
- package/dist/cli/exceptions.mjs +170 -0
- package/dist/cli/file.mjs +271 -0
- package/dist/cli/files.mjs +141 -0
- package/dist/cli/history.mjs +232 -0
- package/dist/cli/hosted.mjs +251 -43
- package/dist/cli/info.mjs +125 -0
- package/dist/cli/init.mjs +57 -0
- package/dist/cli/install-guard.mjs +896 -0
- package/dist/cli/lockfile.mjs +330 -0
- package/dist/cli/main.mjs +101 -14
- package/dist/cli/preflight.mjs +388 -0
- package/dist/cli/sarif.mjs +100 -0
- package/dist/cli/scan.mjs +128 -0
- package/dist/cli/shell-integration.mjs +122 -0
- package/dist/cli/terminal.mjs +396 -0
- package/dist/cli/watch.mjs +233 -0
- package/dist/cli/why.mjs +256 -0
- package/dist/core/advisories.mjs +101 -24
- package/dist/core/advisory-cache.mjs +295 -0
- package/dist/core/behavior-signals.mjs +1088 -0
- package/dist/core/dependency-graph.mjs +332 -0
- package/dist/core/evidence-exports.mjs +469 -0
- package/dist/core/guard-config.mjs +463 -0
- package/dist/core/guard-policy.mjs +308 -0
- package/dist/core/package-details.mjs +42 -6
- package/dist/core/registry-facts.mjs +342 -0
- package/dist/core/report-exploration.mjs +328 -0
- package/dist/core/report.mjs +7 -0
- package/dist/core/sarif.mjs +556 -0
- package/dist/core/syntax.mjs +296 -15
- package/dist/model/client.mjs +204 -56
- package/dist/model/diff-evidence.mjs +177 -0
- package/dist/model/summary.mjs +183 -0
- package/dist/server/advisory-result.mjs +137 -0
- package/dist/server/app.mjs +66 -2
- package/dist/server/dependency-screening.mjs +386 -0
- package/dist/server/exports.mjs +63 -0
- package/dist/server/guard-decision.mjs +37 -0
- package/dist/server/main.mjs +25 -7
- package/dist/server/queue.mjs +95 -53
- package/dist/server/registry-facts.mjs +118 -0
- package/dist/server/report-exploration.mjs +87 -0
- package/dist/server/store.mjs +21 -1
- package/dist/version.json +1 -1
- package/dist/viewer/render.mjs +10 -2
- package/dist/viewer/shell.mjs +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export const SHELL_MARKER = "# unmask-shell-integration v1";
|
|
4
|
+
export const SHELL_ENV = "UNMASK_SHELL_INTEGRATION";
|
|
5
|
+
export const SHELL_ENV_VALUE = "npm-guard-v1";
|
|
6
|
+
export const SHELLS = Object.freeze(["bash", "zsh", "fish"]);
|
|
7
|
+
const INSTALL = [
|
|
8
|
+
"install",
|
|
9
|
+
"i",
|
|
10
|
+
"in",
|
|
11
|
+
"ins",
|
|
12
|
+
"inst",
|
|
13
|
+
"insta",
|
|
14
|
+
"instal",
|
|
15
|
+
"isnt",
|
|
16
|
+
"isnta",
|
|
17
|
+
"isntal",
|
|
18
|
+
"isntall",
|
|
19
|
+
"add",
|
|
20
|
+
];
|
|
21
|
+
const CI = ["ci", "clean-install", "ic", "install-clean", "isntall-clean"];
|
|
22
|
+
// Ownership is the exact definition recorded at enable time, never a text match inside some other npm function.
|
|
23
|
+
const OWNED = "__UNMASK_NPM_FUNCTION";
|
|
24
|
+
|
|
25
|
+
const posix = {
|
|
26
|
+
enable: () =>
|
|
27
|
+
[
|
|
28
|
+
SHELL_MARKER,
|
|
29
|
+
`if alias npm >/dev/null 2>&1 || { typeset -f npm >/dev/null 2>&1 && [ "$(typeset -f npm)" != "\${${OWNED}-}" ]; }; then`,
|
|
30
|
+
" echo 'unmask: an existing npm alias or function was left unchanged; guarded npm was not enabled' >&2",
|
|
31
|
+
"else",
|
|
32
|
+
"function npm {",
|
|
33
|
+
' if [ -z "${UNMASK_GUARD_ACTIVE:-}" ]; then',
|
|
34
|
+
' case "${1-}" in',
|
|
35
|
+
` ${INSTALL.join("|")}) shift; command unmask install "$@"; return $? ;;`,
|
|
36
|
+
` ${CI.join("|")}) shift; command unmask ci "$@"; return $? ;;`,
|
|
37
|
+
" esac",
|
|
38
|
+
" fi",
|
|
39
|
+
' command npm "$@"',
|
|
40
|
+
"}",
|
|
41
|
+
`${OWNED}="$(typeset -f npm)"`,
|
|
42
|
+
`export ${SHELL_ENV}=${SHELL_ENV_VALUE}`,
|
|
43
|
+
"fi",
|
|
44
|
+
].join("\n"),
|
|
45
|
+
disable: () =>
|
|
46
|
+
[
|
|
47
|
+
SHELL_MARKER,
|
|
48
|
+
`if [ -n "\${${OWNED}-}" ] && [ "$(typeset -f npm 2>/dev/null)" = "$${OWNED}" ]; then unset -f npm; fi`,
|
|
49
|
+
`unset ${OWNED} ${SHELL_ENV}`,
|
|
50
|
+
].join("\n"),
|
|
51
|
+
};
|
|
52
|
+
const fish = {
|
|
53
|
+
enable: () =>
|
|
54
|
+
[
|
|
55
|
+
SHELL_MARKER,
|
|
56
|
+
"set -l __unmask_current",
|
|
57
|
+
"functions -q npm; and set __unmask_current (functions npm | string collect)",
|
|
58
|
+
`if functions -q npm; and test "$__unmask_current" != "$${OWNED}"`,
|
|
59
|
+
" echo 'unmask: an existing npm function was left unchanged; guarded npm was not enabled' >&2",
|
|
60
|
+
"else",
|
|
61
|
+
"function npm --wraps npm --description 'unmask guarded npm install/ci'",
|
|
62
|
+
" set -l rest $argv",
|
|
63
|
+
" set -e rest[1]",
|
|
64
|
+
" if not set -q UNMASK_GUARD_ACTIVE",
|
|
65
|
+
' switch "$argv[1]"',
|
|
66
|
+
` case ${INSTALL.join(" ")}`,
|
|
67
|
+
" command unmask install $rest",
|
|
68
|
+
" return $status",
|
|
69
|
+
` case ${CI.join(" ")}`,
|
|
70
|
+
" command unmask ci $rest",
|
|
71
|
+
" return $status",
|
|
72
|
+
" end",
|
|
73
|
+
" end",
|
|
74
|
+
" command npm $argv",
|
|
75
|
+
"end",
|
|
76
|
+
`set -g ${OWNED} (functions npm | string collect)`,
|
|
77
|
+
`set -gx ${SHELL_ENV} ${SHELL_ENV_VALUE}`,
|
|
78
|
+
"end",
|
|
79
|
+
].join("\n"),
|
|
80
|
+
disable: () =>
|
|
81
|
+
[
|
|
82
|
+
SHELL_MARKER,
|
|
83
|
+
`if functions -q npm; and test (functions npm | string collect) = "$${OWNED}"`,
|
|
84
|
+
" functions -e npm",
|
|
85
|
+
"end",
|
|
86
|
+
`set -e ${OWNED}`,
|
|
87
|
+
`set -e ${SHELL_ENV}`,
|
|
88
|
+
].join("\n"),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export function shellIntegrationScript(action, shell) {
|
|
92
|
+
if (!["enable", "disable"].includes(action) || !SHELLS.includes(shell))
|
|
93
|
+
throw new TypeError("Unsupported shell integration request");
|
|
94
|
+
return (shell === "fish" ? fish : posix)[action]() + "\n";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function runShellIntegration(action, args, runtime = {}) {
|
|
98
|
+
const env = runtime.env ?? process.env;
|
|
99
|
+
const stdout = runtime.stdout ?? process.stdout;
|
|
100
|
+
const stderr = runtime.stderr ?? process.stderr;
|
|
101
|
+
const detected = path.basename(env.SHELL ?? "");
|
|
102
|
+
const shell = args[0] ?? (SHELLS.includes(detected) ? detected : undefined);
|
|
103
|
+
if (args.length > 1 || !SHELLS.includes(shell)) {
|
|
104
|
+
stderr.write(
|
|
105
|
+
`usage: unmask ${action} <${SHELLS.join("|")}> prints shell code; nothing is written to shell startup files.\n`,
|
|
106
|
+
);
|
|
107
|
+
return 2;
|
|
108
|
+
}
|
|
109
|
+
stdout.write(shellIntegrationScript(action, shell));
|
|
110
|
+
if (stdout.isTTY) {
|
|
111
|
+
const load =
|
|
112
|
+
shell === "fish"
|
|
113
|
+
? `unmask ${action} fish | source`
|
|
114
|
+
: `eval "$(unmask ${action} ${shell})"`;
|
|
115
|
+
stderr.write(
|
|
116
|
+
action === "enable"
|
|
117
|
+
? `To guard npm install/ci in this shell run: ${load}\nTo persist, add that line to your shell startup file yourself. Undo with: ${load.replace("enable", "disable")}\n`
|
|
118
|
+
: `To remove the guarded npm function from this shell run: ${load}\n`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
const states = ["byte-identical", "changed", "artifact-only", "not-compared"];
|
|
2
|
+
const object = (value) =>
|
|
3
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
4
|
+
const list = (value) => (Array.isArray(value) ? value : []);
|
|
5
|
+
const count = (value) =>
|
|
6
|
+
Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
7
|
+
|
|
8
|
+
export function safeText(value, max = 160) {
|
|
9
|
+
const text = String(value ?? "").replace(
|
|
10
|
+
/[\u0000-\u001f\u007f-\u009f\u061c\u180e\u200b-\u200f\u2028-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g,
|
|
11
|
+
(char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`,
|
|
12
|
+
);
|
|
13
|
+
return text.length > max ? text.slice(0, max - 1) + "..." : text;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatBytes(bytes) {
|
|
17
|
+
const value = count(bytes);
|
|
18
|
+
if (value < 1024) return `${value} B`;
|
|
19
|
+
const units = ["KB", "MB", "GB"];
|
|
20
|
+
let scaled = value / 1024;
|
|
21
|
+
let unit = 0;
|
|
22
|
+
while (scaled >= 1024 && unit < units.length - 1) {
|
|
23
|
+
scaled /= 1024;
|
|
24
|
+
unit++;
|
|
25
|
+
}
|
|
26
|
+
return `${scaled.toFixed(1)} ${units[unit]}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function identity(report) {
|
|
30
|
+
const claim = report.inputs?.claim;
|
|
31
|
+
const manifest = report.manifests?.artifact?.value;
|
|
32
|
+
const name = claim?.name ?? manifest?.name;
|
|
33
|
+
const version = claim?.version ?? manifest?.version;
|
|
34
|
+
return name && version ? `${name}@${version}` : "local artifact";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function verdict(exitCode) {
|
|
38
|
+
if (exitCode === 0)
|
|
39
|
+
return "No deterministic findings in the inspected scope (exit 0)";
|
|
40
|
+
if (exitCode === 3) return "Deterministic findings need review (exit 3)";
|
|
41
|
+
return "Incomplete analysis; no verdict is claimed (exit 2)";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function coverageLines(report) {
|
|
45
|
+
const coverage = report.coverage ?? {};
|
|
46
|
+
const inventory = coverage.inventory ?? {};
|
|
47
|
+
const totals = states
|
|
48
|
+
.map((state) => `${count(coverage.states?.[state]?.files)} ${state}`)
|
|
49
|
+
.join(", ");
|
|
50
|
+
const lines = [
|
|
51
|
+
`Coverage ${coverage.complete ? "complete" : "incomplete"}: ${inventory.lowerBound ? "at least " : ""}${count(inventory.files)} file${count(inventory.files) === 1 ? "" : "s"}, ${formatBytes(inventory.bytes)}`,
|
|
52
|
+
` ${totals}`,
|
|
53
|
+
];
|
|
54
|
+
const source = report.source ?? {};
|
|
55
|
+
const commit =
|
|
56
|
+
typeof source.commit === "string"
|
|
57
|
+
? ` at ${source.commit.slice(0, 12)}`
|
|
58
|
+
: "";
|
|
59
|
+
lines.push(
|
|
60
|
+
`Source ${safeText(source.status ?? "unknown")} via ${safeText(source.method ?? "unknown")}${safeText(commit)}${coverage.sourceAvailable === false ? " (no source compared)" : ""}`,
|
|
61
|
+
);
|
|
62
|
+
return lines;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function findingLines(report) {
|
|
66
|
+
const findings = list(report.policy?.findings);
|
|
67
|
+
const failures = list(report.failures);
|
|
68
|
+
const lines = [];
|
|
69
|
+
if (findings.length) {
|
|
70
|
+
lines.push(`Findings ${findings.length}`);
|
|
71
|
+
for (const finding of findings.slice(0, 10)) {
|
|
72
|
+
const detail = finding.script
|
|
73
|
+
? ` (script ${finding.script})`
|
|
74
|
+
: finding.fields
|
|
75
|
+
? ` (${list(finding.fields).join(", ")})`
|
|
76
|
+
: "";
|
|
77
|
+
lines.push(` - ${safeText(finding.ruleId)}${safeText(detail)}`);
|
|
78
|
+
}
|
|
79
|
+
if (findings.length > 10)
|
|
80
|
+
lines.push(` ... ${findings.length - 10} more in the full report`);
|
|
81
|
+
} else lines.push("Findings none");
|
|
82
|
+
if (failures.length) {
|
|
83
|
+
lines.push(`Failures ${failures.length}`);
|
|
84
|
+
for (const failure of failures.slice(0, 5))
|
|
85
|
+
lines.push(
|
|
86
|
+
` - ${safeText(failure.code ?? "failure")}${failure.path ? ` ${safeText(failure.path, 80)}` : ""}`,
|
|
87
|
+
);
|
|
88
|
+
if (failures.length > 5)
|
|
89
|
+
lines.push(` ... ${failures.length - 5} more in the full report`);
|
|
90
|
+
}
|
|
91
|
+
return lines;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function modelLines(model) {
|
|
95
|
+
if (!object(model)) return ["AI not reported"];
|
|
96
|
+
if (model.status === "complete") {
|
|
97
|
+
const hypotheses = list(model.hypotheses);
|
|
98
|
+
const lines = [
|
|
99
|
+
`AI complete, ${hypotheses.length} ${hypotheses.length === 1 ? "hypothesis" : "hypotheses"} (separate from deterministic evidence)`,
|
|
100
|
+
];
|
|
101
|
+
if (typeof model.summary === "string")
|
|
102
|
+
lines.push(` Summary: ${safeText(model.summary, 600)}`);
|
|
103
|
+
const assessment = model.assessment;
|
|
104
|
+
if (object(assessment) && assessment.source === "model") {
|
|
105
|
+
const scope = assessment.checkedScope;
|
|
106
|
+
if (typeof scope === "string")
|
|
107
|
+
lines.push(` Scope: ${safeText(scope, 200)}`);
|
|
108
|
+
else if (object(scope)) {
|
|
109
|
+
const notChecked = list(scope.notChecked)
|
|
110
|
+
.slice(0, 8)
|
|
111
|
+
.map((item) => safeText(item, 40));
|
|
112
|
+
lines.push(
|
|
113
|
+
` Scope: ${count(scope.evidenceSupplied)} evidence items supplied, ${count(scope.evidenceOmitted)} omitted${notChecked.length ? `; not checked: ${notChecked.join(", ")}` : ""}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (typeof assessment.uncertainty === "string")
|
|
117
|
+
lines.push(` Uncertainty: ${safeText(assessment.uncertainty, 200)}`);
|
|
118
|
+
}
|
|
119
|
+
for (const hypothesis of hypotheses.slice(0, 3))
|
|
120
|
+
lines.push(` - ${safeText(hypothesis?.explanation, 200)}`);
|
|
121
|
+
if (hypotheses.length > 3)
|
|
122
|
+
lines.push(` ... ${hypotheses.length - 3} more in the full report`);
|
|
123
|
+
return lines;
|
|
124
|
+
}
|
|
125
|
+
const reason = model.reason ? ` (${safeText(model.reason, 60)})` : "";
|
|
126
|
+
return [
|
|
127
|
+
`AI ${safeText(model.status, 40)}${reason}; no AI summary for this check`,
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const behaviorCues = (report) =>
|
|
132
|
+
list(report.cues).filter((cue) => cue?.module === "behavior");
|
|
133
|
+
const highCue = (cue) => cue?.priority === "HIGH";
|
|
134
|
+
|
|
135
|
+
function behaviorLines(report) {
|
|
136
|
+
const cues = behaviorCues(report);
|
|
137
|
+
if (!cues.length) return [];
|
|
138
|
+
const paths = new Map(
|
|
139
|
+
list(report.evidence).map((item) => [item?.id, item?.path]),
|
|
140
|
+
);
|
|
141
|
+
const high = cues.filter(highCue);
|
|
142
|
+
const lines = [
|
|
143
|
+
`Behavior ${high.length} HIGH and ${cues.length - high.length} review-level static cues (suspicion, not proof; exit code unchanged)`,
|
|
144
|
+
];
|
|
145
|
+
for (const cue of [...high, ...cues.filter((c) => !highCue(c))].slice(0, 5)) {
|
|
146
|
+
const path = paths.get(list(cue.evidenceIds)[0]);
|
|
147
|
+
const hooks = list(cue.installHookTarget)
|
|
148
|
+
.slice(0, 3)
|
|
149
|
+
.map((hook) => safeText(hook, 24));
|
|
150
|
+
lines.push(
|
|
151
|
+
` - ${highCue(cue) ? "HIGH " : ""}${safeText(cue.ruleId, 64)}${typeof path === "string" ? ` ${safeText(path, 80)}` : ""}${hooks.length ? ` (runs at ${hooks.join(", ")})` : ""}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (cues.length > 5)
|
|
155
|
+
lines.push(` ... ${cues.length - 5} more in the full report`);
|
|
156
|
+
return lines;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function advisoryItems(advisories) {
|
|
160
|
+
const lines = advisories.slice(0, 5).map((item) => {
|
|
161
|
+
const label =
|
|
162
|
+
typeof item?.databaseSeverity === "string"
|
|
163
|
+
? ` [${item.databaseSeverity}]`
|
|
164
|
+
: "";
|
|
165
|
+
return ` - ${safeText(item?.id, 64)}${safeText(label, 24)} ${safeText(item?.summary ?? "", 120)}`.trimEnd();
|
|
166
|
+
});
|
|
167
|
+
if (advisories.length > 5)
|
|
168
|
+
lines.push(` ... ${advisories.length - 5} more in the full details`);
|
|
169
|
+
return lines;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const knownAdvisories = (check) => list(check?.advisories).length > 0;
|
|
173
|
+
|
|
174
|
+
function advisoryLines(details) {
|
|
175
|
+
const check = details?.vulnerabilityCheck;
|
|
176
|
+
if (!object(check)) return ["Advisory not assessed in this check"];
|
|
177
|
+
const advisories = list(check.advisories);
|
|
178
|
+
const source = safeText(check.source ?? "OSV", 20);
|
|
179
|
+
if (check.status === "none-reported")
|
|
180
|
+
return [`Advisory none reported by ${source} for this exact version`];
|
|
181
|
+
const reasons = list(check.reasons)
|
|
182
|
+
.slice(0, 3)
|
|
183
|
+
.map((r) => safeText(r, 40));
|
|
184
|
+
const why = reasons.length ? ` (${reasons.join(", ")})` : "";
|
|
185
|
+
if (check.status === "found")
|
|
186
|
+
return [
|
|
187
|
+
`Advisory ${advisories.length} known for this exact version (${source})`,
|
|
188
|
+
...advisoryItems(advisories),
|
|
189
|
+
];
|
|
190
|
+
if (advisories.length)
|
|
191
|
+
return [
|
|
192
|
+
`Advisory ${advisories.length} known for this exact version so far; lookup ${safeText(check.status ?? "unavailable", 30)}${why}, more may exist (${source})`,
|
|
193
|
+
...advisoryItems(advisories),
|
|
194
|
+
];
|
|
195
|
+
return [`Advisory ${safeText(check.status ?? "unavailable", 30)}${why}`];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function nextActions(report, { details, outDir }) {
|
|
199
|
+
const actions = [];
|
|
200
|
+
const exitCode = report.policy?.exitCode;
|
|
201
|
+
if (knownAdvisories(details?.vulnerabilityCheck))
|
|
202
|
+
actions.push(
|
|
203
|
+
"Known advisories exist: do not install this version until each advisory is reviewed.",
|
|
204
|
+
);
|
|
205
|
+
if (exitCode === 3)
|
|
206
|
+
actions.push(
|
|
207
|
+
"Review each finding and its cited evidence before installing.",
|
|
208
|
+
);
|
|
209
|
+
if (exitCode === 2)
|
|
210
|
+
actions.push(
|
|
211
|
+
"Coverage is incomplete: treat this as unverified and retry or inspect the failures.",
|
|
212
|
+
);
|
|
213
|
+
if (behaviorCues(report).some(highCue))
|
|
214
|
+
actions.push(
|
|
215
|
+
"Review each HIGH behavior cue and its cited file before installing.",
|
|
216
|
+
);
|
|
217
|
+
if (exitCode === 0)
|
|
218
|
+
actions.push(
|
|
219
|
+
"No deterministic finding is not proof of safety; review advisories and AI notes.",
|
|
220
|
+
);
|
|
221
|
+
const assessment = report.model?.assessment;
|
|
222
|
+
if (
|
|
223
|
+
report.model?.status === "complete" &&
|
|
224
|
+
assessment?.source === "model" &&
|
|
225
|
+
typeof assessment.nextAction === "string"
|
|
226
|
+
)
|
|
227
|
+
actions.push(`AI suggests: ${safeText(assessment.nextAction, 200)}`);
|
|
228
|
+
if (report.model?.status !== "complete")
|
|
229
|
+
actions.push(
|
|
230
|
+
"AI explanation was not produced; rely on deterministic evidence only.",
|
|
231
|
+
);
|
|
232
|
+
actions.push(
|
|
233
|
+
outDir
|
|
234
|
+
? `Open ${safeText(outDir, 200)}/report.html for full evidence.`
|
|
235
|
+
: "Add --out DIR to save the full JSON and HTML evidence.",
|
|
236
|
+
);
|
|
237
|
+
return actions;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function renderAdvisoryOnly(
|
|
241
|
+
result,
|
|
242
|
+
spec,
|
|
243
|
+
{ style = terminalStyle() } = {},
|
|
244
|
+
) {
|
|
245
|
+
const details = { vulnerabilityCheck: result.vulnerabilityCheck };
|
|
246
|
+
const reason = result.artifactVerification?.reason;
|
|
247
|
+
const found = knownAdvisories(result.vulnerabilityCheck);
|
|
248
|
+
const lines = [
|
|
249
|
+
`UNMASK ${safeText(spec, 120)}`,
|
|
250
|
+
style.paint(
|
|
251
|
+
found ? "yellow" : "red",
|
|
252
|
+
"Result Advisory-only: artifact not inspected; no verdict is claimed (exit 2)",
|
|
253
|
+
),
|
|
254
|
+
`${style.paint("bold", "Artifact")} not inspected${reason ? ` (${safeText(reason, 80)})` : ""}; source comparison not performed`,
|
|
255
|
+
...advisoryLines(details).map((line) =>
|
|
256
|
+
line.startsWith("Advisory")
|
|
257
|
+
? style.paint("bold", "Advisory") + line.slice(8)
|
|
258
|
+
: line,
|
|
259
|
+
),
|
|
260
|
+
`${style.paint("bold", "AI")} not run; no artifact evidence to explain`,
|
|
261
|
+
`${style.paint("bold", "Notice")} ${safeText(result.notice, 400)}`,
|
|
262
|
+
style.paint("bold", "Next"),
|
|
263
|
+
found
|
|
264
|
+
? " - Known advisories exist: do not install this version until each advisory is reviewed."
|
|
265
|
+
: " - No current advisory is not evidence of safety; the artifact itself was not checked.",
|
|
266
|
+
" - Prefer a different exact version that UNMASK can fully analyze.",
|
|
267
|
+
];
|
|
268
|
+
return lines.join("\n") + "\n";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const codes = { bold: 1, red: 31, yellow: 33, cyan: 36, dim: 2 };
|
|
272
|
+
const label =
|
|
273
|
+
/^(UNMASK|Result|Coverage|Source|Findings|Failures|Behavior|Advisory|AI|Next)\b/;
|
|
274
|
+
|
|
275
|
+
export function terminalStyle(stream, env = process.env) {
|
|
276
|
+
const tty = stream?.isTTY === true && env.TERM !== "dumb";
|
|
277
|
+
const color = tty && !env.NO_COLOR;
|
|
278
|
+
return {
|
|
279
|
+
tty,
|
|
280
|
+
color,
|
|
281
|
+
paint: (name, text) =>
|
|
282
|
+
color ? `\u001b[${codes[name]}m${text}\u001b[0m` : text,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function resultColor(report, details) {
|
|
287
|
+
if (report.policy?.exitCode === 2) return "red";
|
|
288
|
+
if (
|
|
289
|
+
report.policy?.exitCode === 3 ||
|
|
290
|
+
knownAdvisories(details?.vulnerabilityCheck) ||
|
|
291
|
+
behaviorCues(report).some(highCue)
|
|
292
|
+
)
|
|
293
|
+
return "yellow";
|
|
294
|
+
return "bold";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function renderCheckSummary(
|
|
298
|
+
report,
|
|
299
|
+
{ details, outDir, style = terminalStyle() } = {},
|
|
300
|
+
) {
|
|
301
|
+
const lines = [
|
|
302
|
+
`UNMASK ${safeText(identity(report), 120)}`,
|
|
303
|
+
`Result ${verdict(report.policy?.exitCode)}`,
|
|
304
|
+
...coverageLines(report),
|
|
305
|
+
...findingLines(report),
|
|
306
|
+
...behaviorLines(report),
|
|
307
|
+
...advisoryLines(details),
|
|
308
|
+
...modelLines(report.model),
|
|
309
|
+
"Next",
|
|
310
|
+
...nextActions(report, { details, outDir }).map(
|
|
311
|
+
(action) => ` - ${action}`,
|
|
312
|
+
),
|
|
313
|
+
];
|
|
314
|
+
const tone = resultColor(report, details);
|
|
315
|
+
return (
|
|
316
|
+
lines
|
|
317
|
+
.map((line) => {
|
|
318
|
+
const match = label.exec(line);
|
|
319
|
+
if (!match) return line;
|
|
320
|
+
const rest = line.slice(match[0].length);
|
|
321
|
+
if (match[0] === "Result") return style.paint(tone, line);
|
|
322
|
+
if (match[0] === "Advisory" && / known for /.test(rest))
|
|
323
|
+
return style.paint("yellow", line);
|
|
324
|
+
return style.paint("bold", match[0]) + rest;
|
|
325
|
+
})
|
|
326
|
+
.join("\n") + "\n"
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const frames = ["|", "/", "-", "\\"];
|
|
331
|
+
const phases = {
|
|
332
|
+
submitting: "Submitting exact package to the hosted service",
|
|
333
|
+
queued: "Queued on the hosted service",
|
|
334
|
+
running: "Hosted analysis running",
|
|
335
|
+
details: "Fetching package details and advisories",
|
|
336
|
+
local: "Analyzing local artifact",
|
|
337
|
+
model: "Requesting AI explanation",
|
|
338
|
+
writing: "Writing report files",
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
export function createProgress(
|
|
342
|
+
stream,
|
|
343
|
+
{ env = process.env, now = Date.now, intervalMs = 120, subject = "" } = {},
|
|
344
|
+
) {
|
|
345
|
+
const style = terminalStyle(stream, env);
|
|
346
|
+
const started = now();
|
|
347
|
+
let phase,
|
|
348
|
+
frame = 0,
|
|
349
|
+
timer;
|
|
350
|
+
const message = () =>
|
|
351
|
+
`${phases[phase] ?? safeText(phase, 60)}${subject ? ` ${safeText(subject, 120)}` : ""}`;
|
|
352
|
+
const draw = () => {
|
|
353
|
+
const seconds = Math.floor((now() - started) / 1000);
|
|
354
|
+
stream.write(
|
|
355
|
+
`\r\u001b[2K${style.paint("cyan", frames[frame++ % frames.length])} ${message()} ${style.paint("dim", `${seconds}s`)}`,
|
|
356
|
+
);
|
|
357
|
+
};
|
|
358
|
+
return {
|
|
359
|
+
update(next) {
|
|
360
|
+
if (next === phase) return;
|
|
361
|
+
phase = next;
|
|
362
|
+
if (!style.tty) {
|
|
363
|
+
stream.write(`unmask: ${message()}\n`);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
draw();
|
|
367
|
+
if (!timer) {
|
|
368
|
+
timer = setInterval(draw, intervalMs);
|
|
369
|
+
timer.unref?.();
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
stop() {
|
|
373
|
+
clearInterval(timer);
|
|
374
|
+
timer = undefined;
|
|
375
|
+
if (style.tty && phase !== undefined) stream.write("\r\u001b[2K");
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const hints = {
|
|
381
|
+
usage: "Run unmask --help for commands and usage.",
|
|
382
|
+
"unknown-option": "See docs/cli.md for the supported check options.",
|
|
383
|
+
"missing-value": "Each option needs a value, for example --out ./report.",
|
|
384
|
+
"output-write-failed":
|
|
385
|
+
"Choose a fresh --out folder; existing report files are never replaced.",
|
|
386
|
+
"hosted-unavailable": "Check your network or UNMASK_SERVER_URL, then retry.",
|
|
387
|
+
"hosted-timeout": "Retry later; the hosted service may be busy.",
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
export function renderError(code, message, style) {
|
|
391
|
+
const head = style.paint("red", `unmask error ${safeText(code, 60)}`);
|
|
392
|
+
const hint = hints[code]
|
|
393
|
+
? `\n${style.paint("dim", `Hint: ${hints[code]}`)}`
|
|
394
|
+
: "";
|
|
395
|
+
return `${head}: ${safeText(message, 400)}${hint}\n`;
|
|
396
|
+
}
|