@coworker-jp/aidr 0.1.299 → 0.1.302
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/package.json +1 -1
- package/src/cli.mjs +27 -1
- package/src/scanner-state.mjs +152 -0
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -80,6 +80,11 @@ async function cmdInstall(opts) {
|
|
|
80
80
|
// the relevant options (agent, all, scope, yes, dry-run) to the uninstall
|
|
81
81
|
// flow. --key isn't needed but commander requires it via requiredOption,
|
|
82
82
|
// so we accept and ignore it.
|
|
83
|
+
//
|
|
84
|
+
// `--purge` is deliberately NOT reachable through this alias (issue #1958):
|
|
85
|
+
// it is the one option that deletes the quarantined package archives, and a
|
|
86
|
+
// convenience alias is the wrong place to be able to destroy evidence from.
|
|
87
|
+
// `aidr uninstall --purge` is the only spelling.
|
|
83
88
|
if (opts.uninstall) {
|
|
84
89
|
return cmdUninstall({
|
|
85
90
|
agent: opts.agent,
|
|
@@ -468,6 +473,18 @@ async function cmdUninstall(opts) {
|
|
|
468
473
|
}
|
|
469
474
|
}
|
|
470
475
|
|
|
476
|
+
// ai-scanner's own state (issue #1958). Resolved BEFORE phase 3: on an
|
|
477
|
+
// endpoint that only ever ran `aidr install`, the per-agent copies are the
|
|
478
|
+
// only ai-scanner binaries on the machine, and phase 3 deletes them.
|
|
479
|
+
const { scannerBinaryCandidates, findScannerBinary, cleanScannerState, describeScannerState } =
|
|
480
|
+
await import("./scanner-state.mjs");
|
|
481
|
+
const scannerBinary = await findScannerBinary(
|
|
482
|
+
scannerBinaryCandidates(home, plans.map(({ mod }) => mod?.meta?.agentDir))
|
|
483
|
+
);
|
|
484
|
+
for (const line of describeScannerState({ outcome: scannerBinary ? "dry-run" : "no-binary", binary: scannerBinary }, { purge: opts.purge })) {
|
|
485
|
+
console.error(line);
|
|
486
|
+
}
|
|
487
|
+
|
|
471
488
|
if (opts.dryRun) return;
|
|
472
489
|
|
|
473
490
|
// Phase 2: confirm
|
|
@@ -484,7 +501,15 @@ async function cmdUninstall(opts) {
|
|
|
484
501
|
}
|
|
485
502
|
}
|
|
486
503
|
|
|
487
|
-
// Phase
|
|
504
|
+
// Phase 3a: the scanner's own state, while its binaries still exist.
|
|
505
|
+
for (const line of describeScannerState(
|
|
506
|
+
cleanScannerState({ binary: scannerBinary, purge: opts.purge }),
|
|
507
|
+
{ purge: opts.purge }
|
|
508
|
+
)) {
|
|
509
|
+
console.log(line);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Phase 3b: execute
|
|
488
513
|
await Promise.all(plans.map(async ({ name, mod }) => {
|
|
489
514
|
try {
|
|
490
515
|
const res = await mod.uninstall(base, { dryRun: false });
|
|
@@ -834,6 +859,7 @@ export async function run(argv) {
|
|
|
834
859
|
.option("--scope <project|user>", "scope: user ($HOME) or project (cwd)", "user")
|
|
835
860
|
.option("-y, --yes", "proceed without confirmation prompt", false)
|
|
836
861
|
.option("--dry-run", "print what would be removed without touching disk", false)
|
|
862
|
+
.option("--purge", "also delete the quarantined package archives (they are evidence, so an ordinary uninstall keeps them)", false)
|
|
837
863
|
.action(cmdUninstall);
|
|
838
864
|
|
|
839
865
|
const redTeam = program
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ai-scanner's own on-disk state, at uninstall time (issue #1958).
|
|
3
|
+
*
|
|
4
|
+
* `aidr uninstall` used to remove hooks, adapter scripts and the per-agent
|
|
5
|
+
* binaries, and nothing else. The scanner's state directory
|
|
6
|
+
* (`~/.coworker/aidr/{ai-scanner,quarantine}`) survived every uninstall, which
|
|
7
|
+
* is wrong in both directions:
|
|
8
|
+
*
|
|
9
|
+
* - **Reinstalling did not reset anything.** The 7-day-rule verdict cache
|
|
10
|
+
* came back with the endpoint, so "uninstall it and install it again" —
|
|
11
|
+
* the support step a customer is told to take — did not do what it says.
|
|
12
|
+
* - **The quarantined package archives were left with no owner.** They are
|
|
13
|
+
* specimens of the packages this endpoint blocked, i.e. evidence, sitting
|
|
14
|
+
* in a directory of a product that is no longer installed and that nothing
|
|
15
|
+
* tells the user about.
|
|
16
|
+
*
|
|
17
|
+
* ## What decides what goes
|
|
18
|
+
*
|
|
19
|
+
* Nothing here. The rule — caches go, archives stay unless a purge was asked
|
|
20
|
+
* for — lives in `ScannerPaths::uninstall_state` (docker/scanner/src/paths.rs)
|
|
21
|
+
* and is reached by running the binary's own `uninstall-state` subcommand. The
|
|
22
|
+
* debian `postrm`, the Windows uninstaller and the macOS teardown call the same
|
|
23
|
+
* subcommand for the same reason: a policy transcribed into four languages
|
|
24
|
+
* drifts in three of them, and the direction it drifts in is "somebody deleted
|
|
25
|
+
* the evidence".
|
|
26
|
+
*
|
|
27
|
+
* ## Why the binary must be run BEFORE the agent directories are removed
|
|
28
|
+
*
|
|
29
|
+
* On an endpoint that only ever ran `aidr install`, the per-agent copies are
|
|
30
|
+
* the only ai-scanner binaries on the machine. Remove them first and there is
|
|
31
|
+
* nothing left to ask.
|
|
32
|
+
*/
|
|
33
|
+
import path from "path";
|
|
34
|
+
import fs from "fs/promises";
|
|
35
|
+
import { spawnSync } from "child_process";
|
|
36
|
+
|
|
37
|
+
/** Executable name, per platform. */
|
|
38
|
+
export const SCANNER_EXE =
|
|
39
|
+
process.platform === "win32" ? "ai-scanner.exe" : "ai-scanner";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Locations an OS-native installer or the sentinel daemon provisions, in
|
|
43
|
+
* preference order. Mirrors `scheduler::default_scanner_paths()` on the Rust
|
|
44
|
+
* side; kept as its own function so a test can assert both platforms without
|
|
45
|
+
* being run on both.
|
|
46
|
+
*/
|
|
47
|
+
export function systemScannerPaths(platform = process.platform, env = process.env) {
|
|
48
|
+
if (platform === "win32") {
|
|
49
|
+
const pf = env.ProgramFiles || "C:\\Program Files";
|
|
50
|
+
return [path.join(pf, "coworker", "aidr", "bin", "ai-scanner.exe")];
|
|
51
|
+
}
|
|
52
|
+
return ["/opt/coworker/aidr/bin/ai-scanner", "/usr/local/bin/ai-scanner"];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every place this uninstall could find a scanner binary: the agents being
|
|
57
|
+
* uninstalled first (they are the copies `aidr install` put there, and the
|
|
58
|
+
* only ones on an agent-only endpoint), then the OS-native locations.
|
|
59
|
+
*/
|
|
60
|
+
export function scannerBinaryCandidates(home, agentDirs, platform = process.platform, env = process.env) {
|
|
61
|
+
const exe = platform === "win32" ? "ai-scanner.exe" : "ai-scanner";
|
|
62
|
+
const perAgent = (agentDirs || [])
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.map((d) => path.join(home, d, "bin", exe));
|
|
65
|
+
return [...perAgent, ...systemScannerPaths(platform, env)];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** First candidate that exists, or `null`. */
|
|
69
|
+
export async function findScannerBinary(candidates, { access } = {}) {
|
|
70
|
+
const probe = access || ((p) => fs.access(p, fs.constants.X_OK));
|
|
71
|
+
for (const candidate of candidates) {
|
|
72
|
+
try {
|
|
73
|
+
await probe(candidate);
|
|
74
|
+
return candidate;
|
|
75
|
+
} catch {
|
|
76
|
+
// Try the next one. A candidate that is missing, or present but not
|
|
77
|
+
// executable, is equally unusable here.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Ask the scanner to remove its own state.
|
|
85
|
+
*
|
|
86
|
+
* Returns `{ outcome, binary, status, stdout, stderr }` where `outcome` is one
|
|
87
|
+
* of `"cleaned"`, `"unresolved"` (the binary ran and reported something is
|
|
88
|
+
* still on the machine), `"failed"` (it could not be run) or `"no-binary"`.
|
|
89
|
+
*
|
|
90
|
+
* **`"no-binary"` is not success.** It is the case where we do not know what
|
|
91
|
+
* is on this machine, and the caller has to say so rather than print the same
|
|
92
|
+
* reassuring line it prints for a clean sweep.
|
|
93
|
+
*/
|
|
94
|
+
export function cleanScannerState({ binary, purge = false, dryRun = false, run = spawnSync }) {
|
|
95
|
+
if (!binary) return { outcome: "no-binary", binary: null };
|
|
96
|
+
if (dryRun) return { outcome: "dry-run", binary };
|
|
97
|
+
const args = purge ? ["uninstall-state", "--purge"] : ["uninstall-state"];
|
|
98
|
+
const res = run(binary, args, { encoding: "utf8" });
|
|
99
|
+
if (res.error) {
|
|
100
|
+
return { outcome: "failed", binary, error: res.error.message };
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
outcome: res.status === 0 ? "cleaned" : "unresolved",
|
|
104
|
+
binary,
|
|
105
|
+
status: res.status,
|
|
106
|
+
stdout: res.stdout || "",
|
|
107
|
+
stderr: res.stderr || "",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The lines a user sees. Split out from `cleanScannerState` so the wording can
|
|
113
|
+
* be asserted without spawning anything.
|
|
114
|
+
*
|
|
115
|
+
* Every branch ends on what to do next. A line that stops at "could not" reads,
|
|
116
|
+
* to someone who has just uninstalled a security product, as "so there is
|
|
117
|
+
* nothing to worry about" — and in the `"no-binary"` branch we specifically do
|
|
118
|
+
* not know that.
|
|
119
|
+
*/
|
|
120
|
+
export function describeScannerState(result, { purge = false } = {}) {
|
|
121
|
+
switch (result.outcome) {
|
|
122
|
+
case "dry-run":
|
|
123
|
+
return [
|
|
124
|
+
` [scanner] ${purge ? "remove" : "clear"} this endpoint's scanner state via ${result.binary} uninstall-state${purge ? " --purge" : ""}`,
|
|
125
|
+
purge
|
|
126
|
+
? " (cached verdicts AND the quarantined package archives)"
|
|
127
|
+
: " (cached verdicts; quarantined package archives are kept and their location is printed)",
|
|
128
|
+
];
|
|
129
|
+
case "cleaned":
|
|
130
|
+
return (result.stdout || "").split("\n").filter(Boolean).map((l) => `[scanner] ${l}`);
|
|
131
|
+
case "unresolved":
|
|
132
|
+
return [
|
|
133
|
+
...(result.stdout || "").split("\n").filter(Boolean).map((l) => `[scanner] ${l}`),
|
|
134
|
+
...(result.stderr || "").split("\n").filter(Boolean).map((l) => `[scanner] ${l}`),
|
|
135
|
+
"[scanner] Some of this endpoint's scanner state is still on the machine (listed above).",
|
|
136
|
+
"[scanner] Next: delete those paths yourself, or ask your IT administrator to.",
|
|
137
|
+
];
|
|
138
|
+
case "failed":
|
|
139
|
+
return [
|
|
140
|
+
`[scanner] ${result.binary} could not be run (${result.error}), so this endpoint's scanner state was left where it is.`,
|
|
141
|
+
"[scanner] It is under ~/.coworker/aidr/ unless AI_SCANNER_DATA_DIR / AI_SCANNER_QUARANTINE_DIR name another location.",
|
|
142
|
+
"[scanner] Next: delete that directory yourself, or ask your IT administrator to.",
|
|
143
|
+
];
|
|
144
|
+
case "no-binary":
|
|
145
|
+
default:
|
|
146
|
+
return [
|
|
147
|
+
"[scanner] No ai-scanner binary was found on this endpoint, so its state was left where it is.",
|
|
148
|
+
"[scanner] It is under ~/.coworker/aidr/ unless AI_SCANNER_DATA_DIR / AI_SCANNER_QUARANTINE_DIR name another location, and it holds the quarantined package archives this endpoint kept as evidence.",
|
|
149
|
+
"[scanner] Next: delete that directory yourself once you no longer need it, or ask your IT administrator to.",
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
}
|