@aikidosec/safe-chain 1.1.5 → 1.1.7

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 CHANGED
@@ -41,6 +41,7 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps:
41
41
  When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, or `bunx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. If any malware is detected, it will prompt you to exit the command.
42
42
 
43
43
  You can check the installed version by running:
44
+
44
45
  ```shell
45
46
  safe-chain --version
46
47
  ```
@@ -75,17 +76,16 @@ To uninstall the Aikido Safe Chain, you can run the following command:
75
76
 
76
77
  # Configuration
77
78
 
78
- ## Malware Action
79
+ ## Logging
79
80
 
80
- You can control how Aikido Safe Chain responds when malware is detected using the `--safe-chain-malware-action` flag:
81
+ You can control the output from Aikido Safe Chain using the `--safe-chain-logging` flag:
81
82
 
82
- - `--safe-chain-malware-action=block` (**default**) - Automatically blocks installation and exits with an error when malware is detected
83
- - `--safe-chain-malware-action=prompt` - Prompts the user to decide whether to continue despite the malware detection
83
+ - `--safe-chain-logging=silent` - Suppresses all Aikido Safe Chain output except when malware is blocked. The package manager output is written to stdout as normal, and Safe Chain only writes a short message if it has blocked malware and causes the process to exit.
84
84
 
85
85
  Example usage:
86
86
 
87
87
  ```shell
88
- npm install suspicious-package --safe-chain-malware-action=prompt
88
+ npm install express --safe-chain-logging=silent
89
89
  ```
90
90
 
91
91
  # Usage in CI/CD
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikidosec/safe-chain",
3
- "version": "1.1.5",
3
+ "version": "1.1.7",
4
4
  "scripts": {
5
5
  "test": "node --test --experimental-test-module-mocks 'src/**/*.spec.js'",
6
6
  "test:watch": "node --test --watch --experimental-test-module-mocks 'src/**/*.spec.js'",
package/src/api/npmApi.js CHANGED
@@ -25,6 +25,10 @@ export async function resolvePackageVersion(packageName, versionRange) {
25
25
  return distTags[versionRange];
26
26
  }
27
27
 
28
+ if (!packageInfo.versions) {
29
+ return null;
30
+ }
31
+
28
32
  // If the version range is not a dist-tag, we need to resolve the highest version matching the range.
29
33
  // This is useful for ranges like "^1.0.0" or "~2.3.4".
30
34
  const availableVersions = Object.keys(packageInfo.versions);
@@ -1,12 +1,12 @@
1
1
  const state = {
2
- malwareAction: undefined,
2
+ loggingLevel: undefined,
3
3
  };
4
4
 
5
5
  const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-";
6
6
 
7
7
  export function initializeCliArguments(args) {
8
8
  // Reset state on each call
9
- state.malwareAction = undefined;
9
+ state.loggingLevel = undefined;
10
10
 
11
11
  const safeChainArgs = [];
12
12
  const remainingArgs = [];
@@ -19,21 +19,11 @@ export function initializeCliArguments(args) {
19
19
  }
20
20
  }
21
21
 
22
- setMalwareAction(safeChainArgs);
22
+ setLoggingLevel(safeChainArgs);
23
23
 
24
24
  return remainingArgs;
25
25
  }
26
26
 
27
- function setMalwareAction(args) {
28
- const safeChainMalwareActionArg = SAFE_CHAIN_ARG_PREFIX + "malware-action=";
29
-
30
- const action = getLastArgEqualsValue(args, safeChainMalwareActionArg);
31
- if (!action) {
32
- return;
33
- }
34
- state.malwareAction = action.toLowerCase();
35
- }
36
-
37
27
  function getLastArgEqualsValue(args, prefix) {
38
28
  for (var i = args.length - 1; i >= 0; i--) {
39
29
  const arg = args[i];
@@ -45,6 +35,16 @@ function getLastArgEqualsValue(args, prefix) {
45
35
  return undefined;
46
36
  }
47
37
 
48
- export function getMalwareAction() {
49
- return state.malwareAction;
38
+ function setLoggingLevel(args) {
39
+ const safeChainLoggingArg = SAFE_CHAIN_ARG_PREFIX + "logging=";
40
+
41
+ const level = getLastArgEqualsValue(args, safeChainLoggingArg);
42
+ if (!level) {
43
+ return;
44
+ }
45
+ state.loggingLevel = level.toLowerCase();
46
+ }
47
+
48
+ export function getLoggingLevel() {
49
+ return state.loggingLevel;
50
50
  }
@@ -1,14 +1,14 @@
1
1
  import * as cliArguments from "./cliArguments.js";
2
2
 
3
- export function getMalwareAction() {
4
- const action = cliArguments.getMalwareAction();
3
+ export function getLoggingLevel() {
4
+ const level = cliArguments.getLoggingLevel();
5
5
 
6
- if (action === MALWARE_ACTION_PROMPT) {
7
- return MALWARE_ACTION_PROMPT;
6
+ if (level === LOGGING_SILENT) {
7
+ return LOGGING_SILENT;
8
8
  }
9
9
 
10
- return MALWARE_ACTION_BLOCK;
10
+ return LOGGING_NORMAL;
11
11
  }
12
12
 
13
- export const MALWARE_ACTION_BLOCK = "block";
14
- export const MALWARE_ACTION_PROMPT = "prompt";
13
+ export const LOGGING_SILENT = "silent";
14
+ export const LOGGING_NORMAL = "normal";
@@ -1,18 +1,28 @@
1
1
  // oxlint-disable no-console
2
2
  import chalk from "chalk";
3
3
  import ora from "ora";
4
- import { createInterface } from "readline";
5
4
  import { isCi } from "./environment.js";
5
+ import { getLoggingLevel, LOGGING_SILENT } from "../config/settings.js";
6
+
7
+ function isSilentMode() {
8
+ return getLoggingLevel() === LOGGING_SILENT;
9
+ }
6
10
 
7
11
  function emptyLine() {
12
+ if (isSilentMode()) return;
13
+
8
14
  writeInformation("");
9
15
  }
10
16
 
11
17
  function writeInformation(message, ...optionalParams) {
18
+ if (isSilentMode()) return;
19
+
12
20
  console.log(message, ...optionalParams);
13
21
  }
14
22
 
15
23
  function writeWarning(message, ...optionalParams) {
24
+ if (isSilentMode()) return;
25
+
16
26
  if (!isCi()) {
17
27
  message = chalk.yellow(message);
18
28
  }
@@ -26,7 +36,24 @@ function writeError(message, ...optionalParams) {
26
36
  console.error(message, ...optionalParams);
27
37
  }
28
38
 
39
+ function writeExitWithoutInstallingMaliciousPackages() {
40
+ let message = "Safe-chain: Exiting without installing malicious packages.";
41
+ if (!isCi()) {
42
+ message = chalk.red(message);
43
+ }
44
+ console.error(message);
45
+ }
46
+
29
47
  function startProcess(message) {
48
+ if (isSilentMode()) {
49
+ return {
50
+ succeed: () => {},
51
+ fail: () => {},
52
+ stop: () => {},
53
+ setText: () => {},
54
+ };
55
+ }
56
+
30
57
  if (isCi()) {
31
58
  return {
32
59
  succeed: (message) => {
@@ -59,39 +86,11 @@ function startProcess(message) {
59
86
  }
60
87
  }
61
88
 
62
- async function confirm(config) {
63
- if (isCi()) {
64
- return Promise.resolve(config.default);
65
- }
66
-
67
- const rl = createInterface({
68
- input: process.stdin,
69
- output: process.stdout,
70
- });
71
-
72
- return new Promise((resolve) => {
73
- const defaultText = config.default ? " (Y/n)" : " (y/N)";
74
- rl.question(`${config.message}${defaultText} `, (answer) => {
75
- rl.close();
76
-
77
- const normalizedAnswer = answer.trim().toLowerCase();
78
-
79
- if (normalizedAnswer === "y" || normalizedAnswer === "yes") {
80
- resolve(true);
81
- } else if (normalizedAnswer === "n" || normalizedAnswer === "no") {
82
- resolve(false);
83
- } else {
84
- resolve(config.default);
85
- }
86
- });
87
- });
88
- }
89
-
90
89
  export const ui = {
91
90
  writeInformation,
92
91
  writeWarning,
93
92
  writeError,
93
+ writeExitWithoutInstallingMaliciousPackages,
94
94
  emptyLine,
95
95
  startProcess,
96
- confirm,
97
96
  };
package/src/main.js CHANGED
@@ -11,6 +11,21 @@ export async function main(args) {
11
11
  const proxy = createSafeChainProxy();
12
12
  await proxy.startServer();
13
13
 
14
+ // Global error handlers to log unhandled errors
15
+ process.on("uncaughtException", (error) => {
16
+ ui.writeError(`Safe-chain: Uncaught exception: ${error.message}`);
17
+ ui.writeVerbose(`Stack trace: ${error.stack}`);
18
+ process.exit(1);
19
+ });
20
+
21
+ process.on("unhandledRejection", (reason) => {
22
+ ui.writeError(`Safe-chain: Unhandled promise rejection: ${reason}`);
23
+ if (reason instanceof Error) {
24
+ ui.writeVerbose(`Stack trace: ${reason.stack}`);
25
+ }
26
+ process.exit(1);
27
+ });
28
+
14
29
  try {
15
30
  // This parses all the --safe-chain arguments and removes them from the args array
16
31
  args = initializeCliArguments(args);
@@ -1,6 +1,7 @@
1
1
  import https from "https";
2
2
  import { generateCertForHost } from "./certUtils.js";
3
3
  import { HttpsProxyAgent } from "https-proxy-agent";
4
+ import { ui } from "../environment/userInteraction.js";
4
5
 
5
6
  export function mitmConnect(req, clientSocket, isAllowed) {
6
7
  const { hostname } = new URL(`http://${req.url}`);
@@ -13,6 +14,15 @@ export function mitmConnect(req, clientSocket, isAllowed) {
13
14
 
14
15
  const server = createHttpsServer(hostname, isAllowed);
15
16
 
17
+ server.on("error", (err) => {
18
+ ui.writeError(`Safe-chain: HTTPS server error: ${err.message}`);
19
+ if (!clientSocket.headersSent) {
20
+ clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
21
+ } else if (clientSocket.writable) {
22
+ clientSocket.end();
23
+ }
24
+ });
25
+
16
26
  // Establish the connection
17
27
  clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
18
28
 
@@ -37,13 +47,15 @@ function createHttpsServer(hostname, isAllowed) {
37
47
  forwardRequest(req, hostname, res);
38
48
  }
39
49
 
40
- return https.createServer(
50
+ const server = https.createServer(
41
51
  {
42
52
  key: cert.privateKey,
43
53
  cert: cert.certificate,
44
54
  },
45
55
  handleRequest
46
56
  );
57
+
58
+ return server;
47
59
  }
48
60
 
49
61
  function getRequestPathAndQuery(url) {
@@ -62,6 +74,11 @@ function forwardRequest(req, hostname, res) {
62
74
  res.end("Bad Gateway");
63
75
  });
64
76
 
77
+ req.on("error", (err) => {
78
+ ui.writeError(`Safe-chain: Error reading client request: ${err.message}`);
79
+ proxyReq.destroy();
80
+ });
81
+
65
82
  req.on("data", (chunk) => {
66
83
  proxyReq.write(chunk);
67
84
  });
@@ -88,6 +105,16 @@ function createProxyRequest(hostname, req, res) {
88
105
  }
89
106
 
90
107
  const proxyReq = https.request(options, (proxyRes) => {
108
+ proxyRes.on("error", (err) => {
109
+ ui.writeError(
110
+ `Safe-chain: Error reading upstream response: ${err.message}`
111
+ );
112
+ if (!res.headersSent) {
113
+ res.writeHead(502);
114
+ res.end("Bad Gateway");
115
+ }
116
+ });
117
+
91
118
  res.writeHead(proxyRes.statusCode, proxyRes.headers);
92
119
  proxyRes.pipe(res);
93
120
  });
@@ -43,8 +43,13 @@ export function handleHttpProxyRequest(req, res) {
43
43
  }
44
44
  )
45
45
  .on("error", (err) => {
46
- res.writeHead(502);
47
- res.end(`Bad Gateway: ${err.message}`);
46
+ if (!res.headersSent) {
47
+ res.writeHead(502);
48
+ res.end(`Bad Gateway: ${err.message}`);
49
+ } else {
50
+ // Headers already sent, just destroy the response
51
+ res.destroy();
52
+ }
48
53
  });
49
54
 
50
55
  req.on("error", () => {
@@ -153,7 +153,7 @@ function verifyNoMaliciousPackages() {
153
153
  }
154
154
 
155
155
  ui.emptyLine();
156
- ui.writeError("Exiting without installing malicious packages.");
156
+ ui.writeExitWithoutInstallingMaliciousPackages();
157
157
  ui.emptyLine();
158
158
 
159
159
  return false;
@@ -24,12 +24,6 @@ export function tunnelRequest(req, clientSocket, head) {
24
24
  function tunnelRequestToDestination(req, clientSocket, head) {
25
25
  const { port, hostname } = new URL(`http://${req.url}`);
26
26
 
27
- clientSocket.on("error", () => {
28
- // NO-OP
29
- // This can happen if the client TCP socket sends RST instead of FIN.
30
- // Not subscribing to 'close' event will cause node to throw and crash.
31
- });
32
-
33
27
  const serverSocket = net.connect(port || 443, hostname, () => {
34
28
  clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
35
29
  serverSocket.write(head);
@@ -37,6 +31,14 @@ function tunnelRequestToDestination(req, clientSocket, head) {
37
31
  clientSocket.pipe(serverSocket);
38
32
  });
39
33
 
34
+ clientSocket.on("error", () => {
35
+ // This can happen if the client TCP socket sends RST instead of FIN.
36
+ // Not subscribing to 'error' event will cause node to throw and crash.
37
+ if (serverSocket.writable) {
38
+ serverSocket.end();
39
+ }
40
+ });
41
+
40
42
  serverSocket.on("error", (err) => {
41
43
  ui.writeError(
42
44
  `Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
@@ -103,6 +105,13 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) {
103
105
  if (clientSocket.writable) {
104
106
  clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
105
107
  }
108
+ } else {
109
+ ui.writeError(
110
+ `Safe-chain: proxy socket error after connection - ${err.message}`
111
+ );
112
+ if (clientSocket.writable) {
113
+ clientSocket.end();
114
+ }
106
115
  }
107
116
  });
108
117
 
@@ -4,7 +4,6 @@ import { setTimeout } from "timers/promises";
4
4
  import chalk from "chalk";
5
5
  import { getPackageManager } from "../packagemanager/currentPackageManager.js";
6
6
  import { ui } from "../environment/userInteraction.js";
7
- import { getMalwareAction, MALWARE_ACTION_PROMPT } from "../config/settings.js";
8
7
 
9
8
  export function shouldScanCommand(args) {
10
9
  if (!args || args.length === 0) {
@@ -65,7 +64,8 @@ export async function scanCommand(args) {
65
64
  return 0;
66
65
  } else {
67
66
  printMaliciousChanges(audit.disallowedChanges, spinner);
68
- return await onMalwareFound();
67
+ onMalwareFound();
68
+ return 1;
69
69
  }
70
70
  }
71
71
 
@@ -77,23 +77,8 @@ function printMaliciousChanges(changes, spinner) {
77
77
  }
78
78
  }
79
79
 
80
- async function onMalwareFound() {
80
+ function onMalwareFound() {
81
81
  ui.emptyLine();
82
-
83
- if (getMalwareAction() === MALWARE_ACTION_PROMPT) {
84
- const continueInstall = await ui.confirm({
85
- message:
86
- "Malicious packages were found. Do you want to continue with the installation?",
87
- default: false,
88
- });
89
-
90
- if (continueInstall) {
91
- ui.writeWarning("Continuing with the installation despite the risks...");
92
- return 0;
93
- }
94
- }
95
-
96
- ui.writeError("Exiting without installing malicious packages.");
82
+ ui.writeExitWithoutInstallingMaliciousPackages();
97
83
  ui.emptyLine();
98
- return 1;
99
84
  }
@@ -1,22 +1,77 @@
1
- import { spawn } from "child_process";
1
+ import { spawn, execSync } from "child_process";
2
+ import os from "os";
2
3
 
3
- function escapeArg(arg) {
4
- // If argument contains spaces or quotes, wrap in double quotes and escape double quotes
5
- if (arg.includes(" ") || arg.includes('"') || arg.includes("'")) {
6
- return '"' + arg.replaceAll('"', '\\"') + '"';
4
+ function sanitizeShellArgument(arg) {
5
+ // If argument contains shell metacharacters, wrap in double quotes
6
+ // and escape characters that are special even inside double quotes
7
+ if (hasShellMetaChars(arg)) {
8
+ // Inside double quotes, we need to escape: " $ ` \
9
+ return '"' + escapeDoubleQuoteContent(arg) + '"';
7
10
  }
8
11
  return arg;
9
12
  }
10
13
 
14
+ function hasShellMetaChars(arg) {
15
+ // Shell metacharacters that need escaping
16
+ // These characters have special meaning in shells and need to be quoted
17
+ // Whenever one of these characters is present, we should quote the argument
18
+ // Characters: space, ", &, ', |, ;, <, >, (, ), $, `, \, !, *, ?, [, ], {, }, ~, #
19
+ const shellMetaChars = /[ "&'|;<>()$`\\!*?[\]{}~#]/;
20
+ return shellMetaChars.test(arg);
21
+ }
22
+
23
+ function escapeDoubleQuoteContent(arg) {
24
+ // Escape special characters for shell safety
25
+ // This escapes ", $, `, and \ by prefixing them with a backslash
26
+ return arg.replace(/(["`$\\])/g, "\\$1");
27
+ }
28
+
11
29
  function buildCommand(command, args) {
12
- const escapedArgs = args.map(escapeArg);
30
+ if (args.length === 0) {
31
+ return command;
32
+ }
33
+
34
+ const escapedArgs = args.map(sanitizeShellArgument);
35
+
13
36
  return `${command} ${escapedArgs.join(" ")}`;
14
37
  }
15
38
 
39
+ function resolveCommandPath(command) {
40
+ // command will be "npm", "yarn", etc.
41
+ // Use 'command -v' to find the full path
42
+ const fullPath = execSync(`command -v ${command}`, {
43
+ encoding: "utf8",
44
+ shell: true,
45
+ }).trim();
46
+
47
+ if (!fullPath) {
48
+ throw new Error(`Command not found: ${command}`);
49
+ }
50
+
51
+ return fullPath;
52
+ }
53
+
16
54
  export async function safeSpawn(command, args, options = {}) {
17
- const fullCommand = buildCommand(command, args);
55
+ // The command is always one of our supported package managers.
56
+ // It should always be alphanumeric or _ or -
57
+ // Reject any command names with suspicious characters
58
+ if (!/^[a-zA-Z0-9_-]+$/.test(command)) {
59
+ throw new Error(`Invalid command name: ${command}`);
60
+ }
61
+
18
62
  return new Promise((resolve, reject) => {
19
- const child = spawn(fullCommand, { ...options, shell: true });
63
+ // Windows requires shell: true because .bat and .cmd files are not executable
64
+ // without a terminal. On Unix/macOS, we resolve the full path first, then use
65
+ // array args (safer, no escaping needed).
66
+ // See: https://nodejs.org/api/child_process.html#child_processspawncommand-args-options
67
+ let child;
68
+ if (os.platform() === "win32") {
69
+ const fullCommand = buildCommand(command, args);
70
+ child = spawn(fullCommand, { ...options, shell: true });
71
+ } else {
72
+ const fullPath = resolveCommandPath(command);
73
+ child = spawn(fullPath, args, options);
74
+ }
20
75
 
21
76
  // When stdio is piped, we need to collect the output
22
77
  let stdout = "";