
Security News
Free Certified Patch for Critical Rails Active Storage Vulnerability
Socket has released a free Certified Patch for a critical Rails Active Storage vulnerability, giving developers time to upgrade to a secure Rails version.
@cp949/projectfile
Advanced tools
프로젝트 파일의 인코더와 디코더다.
Blob 하나로 만들고, 그 Blob을 항목 목록으로 되돌린다.manifest.json 본문을 섞는다(scramble). 옵션으로 ZIP 전체를 섞을 수 있다("scramble 범위").Blob, Response, TextEncoder, TextDecoder 전역이 필요하다.fflate 하나다.사양의 원천은 저장소 루트의 docs/format.md다. 포맷, 검사 순서, 한도 규칙이 거기에 있다.
pnpm add @cp949/projectfile
engines.node는 >=24.18이다.npm pack tarball을 설치해 실행한다.chrome75다.Blob.prototype.arrayBuffer()는 Chrome 76부터라서 Response로 읽는다(src/blob.ts).check-types는 tsconfig.build.json의 lib(ES2019 + DOM)로 ES2020 이후 API 사용을 잡는다.check:escompat은 dist/index.js와 fflate의 ESM 산출물이 ES2019 문법인지 parse로 검사한다.check:escompat은 scripts/check-banned-api.mjs로 src에서 하한에 없는 API 이름도 찾는다. 주석은 뺀다. 대상은 Blob의 arrayBuffer·text·stream, Object.hasOwn, .at(, structuredClone, replaceAll 같은 이름이다. 목록은 스크립트의 RULES다.obj[name]())과 목록에 없는 API는 못 잡는다. 목록에 없는 새 API는 리뷰에서 확인한다.텍스트 하나를 인코딩하고 디코딩하는 최소 예다. kind, rules, limits는 필수 옵션이다. 옵션 설명은 "rules", "limits", "kind"에 있다.
import { decodeProjectFile, encodeProjectFile } from "@cp949/projectfile";
const rules = { maxPathLength: 256, isCode: () => false };
const limits = {
maxFiles: 100,
maxFolders: 100,
maxAssetBytes: 1024 * 1024,
maxAssetsTotalBytes: 1024 * 1024,
maxCodeBytes: 1024 * 1024,
maxUncompressedBytes: 2 * 1024 * 1024,
};
const options = { kind: "acme.blocks", rules, limits };
const entries = [{ path: "a.json", type: "text" as const, data: "{}" }];
const encoded = await encodeProjectFile({ ...options, entries });
if (!encoded.ok) throw new Error(encoded.failure.reason);
const decoded = await decodeProjectFile(encoded.value, options);
if (!decoded.ok) throw new Error(decoded.failure.reason);
console.log(decoded.value.entries); // [{ path: "a.json", type: "text", data: "{}" }]
인코딩:
import {
encodeProjectFile,
type ProjectFileLimits,
type ProjectFileRules,
} from "@cp949/projectfile";
const rules: ProjectFileRules = {
maxPathLength: 256,
isCode: (path) => path === "project.json",
};
const limits: ProjectFileLimits = {
maxFiles: 200,
maxFolders: 200,
maxAssetBytes: 5 * 1024 * 1024,
maxAssetsTotalBytes: 50 * 1024 * 1024,
maxCodeBytes: 1024 * 1024,
maxUncompressedBytes: 51 * 1024 * 1024,
};
const result = await encodeProjectFile({
kind: "acme.blocks",
entries: [
{ path: "project.json", type: "text", data: '{"items":[]}' },
{ path: "media/cat.png", type: "asset", data: catBlob },
],
folders: ["media", "empty-folder"],
rules,
limits,
});
if (result.ok) {
// result.value는 application/octet-stream Blob이다.
} else {
// result.failure.reason으로 안내를 고른다.
}
limits 값은 예시다.
maxUncompressedBytes 51 MiB는 maxAssetsTotalBytes 50 MiB와 maxCodeBytes 1 MiB의 합이다.maxUncompressedBytes를 넘지 않는다.디코딩:
import { decodeProjectFile } from "@cp949/projectfile";
const result = await decodeProjectFile(fileOrBytes, {
kind: "acme.blocks",
rules,
limits,
});
if (result.ok) {
const { kind, entries, folders } = result.value;
// text 항목의 data는 string, asset 항목의 data는 Blob이다.
}
Blob 또는 Uint8Array다. 입력 Uint8Array는 바꾸지 않는다.Blob 입력은 필요한 범위만 읽는다. asset 본문은 읽지 않는다. 범위는 docs/format.md의 "읽기 방식"에 있다. ZIP 전체를 섞은 파일(포맷 버전 2)은 입력 전체를 읽는다("scramble 범위").data는 Blob 또는 Uint8Array다. text data는 string이다.data의 짝 없는 서로게이트(U+D800..U+DFFF 코드 유닛)는 UTF-8 인코딩에서 U+FFFD로 바뀌어 저장된다.
invalid-char로 거부한다./로 이은 이름이다(media/cat.png). files/ 같은 접두사를 붙이지 않는다.folders에 적는다. 파일 경로의 조상 폴더는 적지 않아도 생긴다.folders는 ZIP의 폴더 항목이다. 인코딩은 파일의 조상 폴더도 폴더 항목으로 담는다. 그래서 인코딩 때 folders에 적지 않은 조상 폴더도 돌아온다.ProjectFileFailure를 ok: false로 돌려준다.Blob을 읽지 못해도 던지지 않는다. read-failed와 읽지 못한 asset의 path를 돌려준다.호출자 버그는 던진다. 코덱이 검사해 던지는 조건은 아래 표가 전부다.
| 조건 | 던지는 값 | 메시지 앞부분 | 함수 |
|---|---|---|---|
kind가 정규식 ^[a-z0-9][a-z0-9._-]{0,63}$를 어긴다. 문자열이 아니어도 같다. | Error | kind가 규칙을 어겼다: | encodeProjectFile, decodeProjectFile |
scrambleKey가 길이 16의 Uint8Array가 아니다. | RangeError | scrambleKey는 길이 16의 Uint8Array여야 한다 | encodeProjectFile |
scrambleScope가 "metadata"나 "all"이 아니다. | Error | scrambleScope가 규칙을 어겼다: | encodeProjectFile |
scrambleKeys가 키 하나 이상인 배열이 아니다. | RangeError | scrambleKeys는 키가 하나 이상인 배열이어야 한다 | decodeProjectFile |
scrambleKeys의 키가 길이 16의 Uint8Array가 아니다. | RangeError | scrambleKeys의 키는 길이 16의 Uint8Array여야 한다 | decodeProjectFile |
scrambleKeys에 키 확인값이 같은 키가 둘 이상이다. | RangeError | scrambleKeys에 키 확인값이 같은 키가 둘 이상이다 | decodeProjectFile |
rules.maxPathLength가 1 이상 1,024 이하의 정수가 아니다. | RangeError | maxPathLength는 1 이상 1024 이하의 정수여야 한다: | encodeProjectFile, decodeProjectFile |
limits의 항목 하나가 0 이상의 안전한 정수(Number.isSafeInteger)가 아니다. | RangeError | <항목 이름>는 0 이상의 정수여야 한다: | encodeProjectFile, decodeProjectFile, maxProjectFileBytes |
limits.maxUncompressedBytes가 MAX_UNCOMPRESSED_BYTES_CEILING(256 MiB)을 넘는다. | RangeError | maxUncompressedBytes는 268435456 이하여야 한다: | encodeProjectFile, decodeProjectFile, maxProjectFileBytes |
limits.maxFiles + limits.maxFolders가 65,533을 넘는다. | RangeError | maxFiles + maxFolders + 2는 65535 이하여야 한다: | encodeProjectFile, decodeProjectFile, maxProjectFileBytes |
encodeProjectFile과 decodeProjectFile은 async라서 동기로 던지지 않고 반환한 Promise를 reject한다.maxProjectFileBytes는 동기로 던진다.kind, 키, scrambleScope, rules, limits다. 여러 조건을 어기면 먼저 걸린 하나만 던진다.entries 등)는 검사하지 않는다. rules의 함수가 던진 오류는 바꾸지 않고 전파한다.Blob을 복사하지 않고 출력에 넣는다. CRC32를 계산하려고 1 MiB씩 읽는다.data는 Blob이다.
Blob 입력이면 입력을 slice한 Blob이다.Uint8Array 입력이면 그 범위를 복사해 new Blob으로 만든 것이다.Blob이다. 입력의 slice가 아니다.File이나 Blob을 그대로 넘긴다. Uint8Array 입력은 asset마다 범위를 복사한다.Blob 총량에 한계가 있다. 이 저장소 측정에서는 약 500 MiB였다(ADR-0001).
Blob은 읽을 때 실패한다.Blob과 asset Blob의 참조는 놓는다.Blob 입력의 원본이 바뀌거나 사라진 뒤에 asset Blob을 읽으면 읽기가 실패할 수 있다. 입력 Blob과 asset Blob의 수명은 호출자 책임이다.라이브러리가 모르는 정책을 호출자가 주입한다.
| 항목 | 내용 |
|---|---|
maxPathLength | 항목 경로의 최대 길이(UTF-16 코드 유닛). MAX_PATH_LENGTH_CEILING(1,024) 이하다. |
checkSegment | 선택. 경로 조각 하나를 검사한다. 통과하면 null, 아니면 거부 이유다. |
isCode(path) | 텍스트 항목이 코드 한도(maxCodeBytes)에 세는 항목이면 참이다. |
const rules: ProjectFileRules = {
maxPathLength: 256,
checkSegment: (kind, segment) =>
kind === "file" && !segment.endsWith(".json") ? "invalid-extension" : null,
isCode: (path) => path === "project.json",
};
checkSegment의 kind는 file, folder, asset이다. 마지막 조각만 항목 종류를 따르고 나머지는 folder다.ProjectNameCause다.MAX_PATH_LENGTH_CEILING(1,024)은 입력 크기 상한(maxProjectFileBytes)의 근거다. 올리면 상한이 커지고, 옛 버전은 새 상한으로 만든 큰 파일을 거부한다. 그래서 값을 올리지 않는다.CON, : 등)을 직접 더 검사한다. 무결성 규칙은 경로 탈출(.., \, 앞 /)만 막는다.rules와 무관하게 항상 강제한다. checkSegment로 풀 수 없다.
.·.., \, 제어 문자, 짝 없는 서로게이트, 앞뒤 공백.maxPathLength 초과.모든 항목이 필수다. 기본값은 없다.
| 항목 | 내용 |
|---|---|
maxFiles | 파일(텍스트와 asset) 개수. 폴더는 세지 않는다. |
maxFolders | 폴더 개수. 파일 경로의 조상으로만 생기는 폴더도 센다. |
maxAssetBytes | asset 하나의 바이트. isCode가 거짓인 텍스트도 asset으로 센다. |
maxAssetsTotalBytes | asset의 바이트 합. |
maxCodeBytes | isCode가 참인 텍스트의 UTF-8 바이트 합. |
maxUncompressedBytes | 압축을 풀었을 때 모든 파일의 바이트 합. |
maxFiles + maxFolders + 2와 비교한다. 넘으면 zip-corrupt다. 인코더는 한도 안에서 이 수를 넘는 파일을 만들지 않는다. 합 이하에서 maxFiles나 maxFolders 한 항목만 넘으면 limit-exceeded다.maxProjectFileBytes(limits)와 비교한다. 넘으면 input-too-large다. 풀린 크기 합계가 maxUncompressedBytes를 넘으면 limit-exceeded다.manifest.json(파일 경로 목록)은 1 MiB 이하여야 한다. 넘으면 인코딩이 manifest-too-large로 거부한다. 경로가 모두 1,024 코드 유닛의 비 ASCII이면 파일 약 340개에서 넘는다. 긴 경로를 허용하면서 maxFiles를 크게 잡으면 한도 안의 입력도 거부될 수 있다.절대 상한:
maxUncompressedBytes는 MAX_UNCOMPRESSED_BYTES_CEILING(256 MiB) 이하여야 한다. 넘으면 인코딩과 디코딩이 던진다.maxProjectFileBytes)과 해제 메모리를 묶는다.input-too-large나 limit-exceeded로 거부할 수 있다.입력 크기 상한:
maxProjectFileBytes(limits)는 한도 안의 항목을 인코딩한 파일이 넘지 않는 크기(바이트)다. 한도가 잘못되면 인코딩·디코딩과 같이 던진다.MAX_HEADER_BYTES, 86바이트)로 잡는다. 계산식은 docs/format.md의 "입력 크기 상한"에 있다.사용처가 정하는 문자열이다. 헤더와 manifest가 적는다. 디코딩은 기대한 kind와 다르면 kind-mismatch로 거부한다. kind 자체가 규칙을 어기면 kind-invalid다.
^[a-z0-9][a-z0-9._-]{0,63}$다. 1자 이상 64자 이하이고 대문자를 받지 않는다.acme.blocks처럼 앞에 이름을 붙이기를 권한다. 강제하지 않는다.kind가 규칙을 어기면 던진다.kind-invalid다.인코딩 옵션 scrambleScope가 scramble하는 범위를 정한다.
scrambleScope | 포맷 버전 | 섞는 범위 |
|---|---|---|
"metadata"(기본값) | 1 | ZIP 메타데이터(로컬 헤더, 중앙 디렉터리, 끝 레코드)와 manifest 본문 |
"all" | 2 | ZIP 전체. 텍스트와 asset 본문도 섞는다 |
import { encodeProjectFile } from "@cp949/projectfile";
const result = await encodeProjectFile({
kind: "acme.blocks",
entries,
rules,
limits,
scrambleScope: "all",
});
"all"을 권장한다. 보안 장비가 압축되지 않은 텍스트를 보고 차단하는 일을 막는다."metadata"를 권장한다. asset을 한 번 더 읽지 않고 입력의 slice를 그대로 쓴다.scramble에 쓰는 키는 16바이트 Uint8Array다. 길이가 다르면 던진다.
| 옵션 | 내용 |
|---|---|
scrambleKey | 인코딩. 비우면 DEFAULT_SCRAMBLE_KEY다. |
scrambleKeys | 디코딩. 풀 수 있는 키의 목록이다. 비우면 [DEFAULT_SCRAMBLE_KEY]다. |
scramble-key-mismatch다.사용처 키를 쓰는 예:
// 키는 한 번 만들어 보관한다. 키를 잃으면 그 키로 만든 파일을 열 수 없다.
const scrambleKey = crypto.getRandomValues(new Uint8Array(16));
await encodeProjectFile({
kind: "acme.blocks",
entries,
rules,
limits,
scrambleKey,
});
await decodeProjectFile(file, {
kind: "acme.blocks",
rules,
limits,
scrambleKeys: [scrambleKey],
});
키 없이 헤더만 읽고 검사한다. 서버가 업로드된 파일의 헤더만 검사하는 용도다.
import { readProjectFileHeader } from "@cp949/projectfile";
const result = await readProjectFileHeader(fileOrBytes);
if (result.ok) {
const { version, kind, payloadLength, headerBytes, keyCheck } = result.value;
// kind가 서버가 받는 종류인지는 호출자가 비교한다.
} else {
// result.failure.reason: read-failed, bad-magic, length-mismatch,
// unsupported-version, kind-invalid
}
Blob 입력은 앞 86바이트(MAX_HEADER_BYTES)까지만 읽는다.maxProjectFileBytes(limits)로 직접 검사한다.docs/format.md의 "readProjectFileHeader"에 있다.돌려주는 필드:
| 필드 | 내용 |
|---|---|
version | 포맷 버전. 1이나 2 |
kind | 헤더의 kind 문자열 |
payloadLength | payload(ZIP 전체)의 바이트 길이 |
headerBytes | 헤더의 바이트 길이. 22 + kind 길이 |
keyCheck | 봉인한 scramble 키 16바이트의 CRC32. 0 이상의 uint32 정수 |
decodeProjectFile로 디코딩한 뒤 검사한다.포맷 버전은 1과 2다. 헤더와 manifest가 적는다. 바이트 배치와 검사 순서는 docs/format.md에 있다.
OCOD, 포맷 버전, kind, payload 길이, CRC32, 키 확인값이다. scramble하지 않는다.unsupported-version으로 거부한다.crc-mismatch로 거부한다.scrambleScope로 버전을 고른다("scramble 범위"). 디코딩은 헤더 버전을 보고 알아서 연다.ProjectFileFailure.reason이다.
reason | 뜻 |
|---|---|
read-failed | Blob을 읽지 못했다. 인코딩은 path에 읽지 못한 asset 경로를 담는다. |
bad-magic | 앞 4바이트가 OCOD가 아니다. |
unsupported-version | 헤더 버전을 모른다. version 필드가 있다. |
kind-invalid | 헤더의 kind 길이나 문자열이 kind 규칙을 어긴다. |
kind-mismatch | 헤더 kind는 규칙을 지켰지만 기대한 kind와 다르다. |
length-mismatch | 파일 길이가 헤더와 맞지 않는다. 잘렸거나 뒤에 덧붙었다. |
scramble-key-mismatch | 헤더의 키 확인값과 같은 키가 디코딩 키 목록에 없다. |
crc-mismatch | 복원한 범위의 CRC32가 헤더 값과 다르다. |
zip-corrupt | ZIP 손상, ZIP 항목 수 상한 초과, 내용과 선언의 불일치, 저장 방식이 아닌 asset. |
manifest-invalid | manifest.json이 크기 상한을 넘거나 형식이 틀리거나 ZIP 항목과 맞지 않는다. |
manifest-kind-mismatch | 헤더 kind와 manifest kind가 다르다. |
manifest-too-large | 인코딩만 돌려준다. 만들 manifest가 1 MiB(1,048,576바이트)를 넘는다. |
invalid-name | 이름 규칙 위반. path와 cause(ProjectNameCause)가 있다. |
input-too-large | 디코딩 입력이 입력 크기 상한을 넘는다. maxBytes에 상한이 있다. |
limit-exceeded | 한도 초과. limit에 넘은 한도 이름이 있다. |
readProjectFileHeader는 read-failed, bad-magic, length-mismatch, unsupported-version, kind-invalid만 돌려준다.
기밀성이 필요하면 encodeProjectFile의 출력 Blob을 표준 암호로 감싼다. 아래는 WebCrypto AES-GCM 예다. 복호화한 뒤 decodeProjectFile에 넘긴다.
import {
decodeProjectFile,
encodeProjectFile,
maxProjectFileBytes,
} from "@cp949/projectfile";
// aesKey는 AES-GCM `CryptoKey`다. 키 관리는 호출자 몫이다.
const IV_BYTES = 12;
const TAG_BYTES = 16;
// 암호화: iv(12바이트) + 암호문(원문 + 태그 16바이트)을 Blob 하나로 묶는다.
async function seal(aesKey: CryptoKey): Promise<Blob | null> {
const encoded = await encodeProjectFile({ kind, entries, rules, limits });
if (!encoded.ok) return null;
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
const plain = await new Response(encoded.value).arrayBuffer();
const cipher = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
aesKey,
plain,
);
return new Blob([iv, cipher]);
}
// 복호화: 복호화하기 전에 암호문 크기를 직접 검사한다.
async function open(sealed: Blob, aesKey: CryptoKey) {
if (sealed.size > maxProjectFileBytes(limits) + IV_BYTES + TAG_BYTES) {
// 한도 안의 파일이 만들 수 없는 크기다. 복호화하지 않고 거부한다.
return null;
}
const buffer = await new Response(sealed).arrayBuffer();
let plain: ArrayBuffer;
try {
plain = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: new Uint8Array(buffer, 0, IV_BYTES) },
aesKey,
new Uint8Array(buffer, IV_BYTES),
);
} catch {
// 키가 다르거나, 암호문이 바뀌었거나, IV보다 짧다.
return null;
}
return decodeProjectFile(new Uint8Array(plain), { kind, rules, limits });
}
decrypt가 거부한다. 예는 이때 던지지 않고 null을 돌려준다.maxProjectFileBytes(limits) + 암호화 증가분으로 검사한다. decodeProjectFile의 입력 크기 검사는 복호화한 평문에만 걸리기 때문이다.encrypt와 decrypt는 스트리밍이 없다. 입력 전체와 결과가 메모리에 올라간다.readProjectFileHeader로 읽을 수 없다. 헤더가 암호문 안에 있다.저장소 루트 test-vectors/는 다른 언어 구현이 같은 결과를 내는지 확인하는 시험 파일과 기대 결과다. npm 패키지에는 들어가지 않는다. 용도는 docs/format.md의 "test-vectors", 파일 형식은 test-vectors/README.md에 있다.
pnpm --filter @cp949/projectfile test
pnpm --filter @cp949/projectfile test:browser
pnpm --filter @cp949/projectfile check-types
pnpm --filter @cp949/projectfile build
pnpm --filter @cp949/projectfile check:escompat
exports는 dist를 가리킨다. 소비하기 전에 build가 필요하다.check:escompat은 dist를 검사한다. build 뒤에 실행한다.test:browser는 Chromium에서 Blob 경로 시험 일부를 실행한다. Chromium 설치와 범위는 저장소 루트 README.md와 docs/agents/extra-checks.md에 있다.dist는 Git에 넣지 않는다.pnpm --filter @cp949/projectfile publish:npm다. 절차는 docs/release.md에 있다.큰 프로젝트는 Worker에서 호출한다. 메인 스레드를 비우려는 용도다.
encodeProjectFile의 압축(deflate)은 fflate의 동기 함수 deflateSync다(src/zip-write.ts).decodeProjectFile의 해제(inflate)는 fflate의 동기 클래스 Inflate다(src/zip.ts).docs/format.md의 "scramble"이다.호출 하나가 끝날 때까지 걸린 시간이다. Chrome에서 쟀다(ADR-0001):
| 입력(풀린 크기 256 MiB) | 인코딩 | 디코딩 |
|---|---|---|
| 텍스트 위주: 코드 아닌 텍스트 224 MiB, 코드 16 MiB, asset 16 MiB | 5.42 s | 1.79 s |
| asset 위주: asset 240 MiB, 코드 16 MiB | 0.85 s | 0.14 s |
Blob 읽기도 들어 있다.docs/adr/0001-uncompressed-bytes-ceiling.md에 있다.Worker에서 인코딩해 Blob을 메인에 돌려주는 예:
// encode.worker.ts
import {
encodeProjectFile,
type ProjectEntry,
type ProjectFileResult,
} from "@cp949/projectfile";
// "사용"의 kind, rules, limits를 내보내는 모듈이다.
import { kind, limits, rules } from "./project-config";
export type WorkerReply = ProjectFileResult<Blob> | { error: string };
function reply(message: WorkerReply) {
self.postMessage(message);
}
// rules는 함수를 담아 postMessage로 넘길 수 없다. Worker 안에서 만든 값을 쓴다.
self.onmessage = async (
event: MessageEvent<{ entries: ProjectEntry[]; folders: string[] }>,
) => {
try {
// 실패(ok: false)는 던지지 않고 값으로 오므로 그대로 메인에 보낸다.
reply(await encodeProjectFile({ ...event.data, kind, rules, limits }));
} catch (error) {
// 호출자 버그는 던진다. async 처리기에서 던지면 메인이 응답을 받지 못하므로 메시지로 보낸다.
reply({ error: String(error) });
}
};
// main.ts
import type { ProjectEntry } from "@cp949/projectfile";
import type { WorkerReply } from "./encode.worker";
const worker = new Worker(new URL("./encode.worker.ts", import.meta.url), {
type: "module",
});
// 한 번에 하나만 보낸다.
function encodeInWorker(entries: ProjectEntry[], folders: string[]) {
return new Promise<WorkerReply>((resolve, reject) => {
worker.onmessage = (event: MessageEvent<WorkerReply>) =>
resolve(event.data);
worker.onerror = (event) =>
reject(new Error(event.message || "Worker 오류"));
worker.postMessage({ entries, folders });
});
}
const result = await encodeInWorker(entries, ["media"]);
if ("error" in result) {
// 호출자 버그. Worker가 던진 오류다.
} else if (result.ok) {
// result.value는 Blob이다.
} else {
// result.failure.reason으로 안내를 고른다.
}
Blob을 보내고 Worker가 decodeProjectFile을 부른다.Blob은 postMessage로 구조화 복제되어 넘어간다.Uint8Array는 postMessage의 두 번째 인자 transfer 목록에 buffer를 넣으면 소유권이 넘어간다. 보낸 쪽 buffer는 쓸 수 없게 된다.type: "module" Worker는 Chrome 80부터 지원된다. 지원 하한(Chrome 75)까지 맞추려면 Worker를 classic Worker로 만들어야 한다.FAQs
프로젝트 파일(소스 코드와 asset을 담은 파일 하나)의 인코더와 디코더
We found that @cp949/projectfile demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
Socket has released a free Certified Patch for a critical Rails Active Storage vulnerability, giving developers time to upgrade to a secure Rails version.

Security News
/Threat Research
A malicious Ruby gem runs a credential-stealing worm during installation, poisons AI coding tools, and can wipe files when a stolen GitHub token is revoked.

Security News
GitHub is tightening GitHub Actions security with new restrictions on privileged pull request workflows. Here's what changes and how maintainers are affected.