context-armor 0.1.0
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/LICENSE +190 -0
- package/README.md +260 -0
- package/action.yml +34 -0
- package/bin/context-armor.js +9 -0
- package/package.json +52 -0
- package/src/cli.js +290 -0
- package/src/config.js +78 -0
- package/src/documents.js +154 -0
- package/src/ignore.js +87 -0
- package/src/index.js +4 -0
- package/src/policy.js +85 -0
- package/src/reporters.js +148 -0
- package/src/rules.js +225 -0
- package/src/scanner.js +289 -0
- package/src/validators.js +48 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { parseSize } from "./config.js";
|
|
6
|
+
import { protectedPathsFromFindings, writeProtectionPolicies } from "./policy.js";
|
|
7
|
+
import { formatJson, formatPretty, formatSarif } from "./reporters.js";
|
|
8
|
+
import { scan, summarize } from "./scanner.js";
|
|
9
|
+
import { severityRank } from "./rules.js";
|
|
10
|
+
|
|
11
|
+
const packageJson = JSON.parse(
|
|
12
|
+
await fs.readFile(new URL("../package.json", import.meta.url), "utf8"),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const HELP = `Context Armor ${packageJson.version}
|
|
16
|
+
Local-first preflight scanning before AI agents read your workspace.
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
context-armor scan [path] [options]
|
|
20
|
+
context-armor protect [path] [options]
|
|
21
|
+
context-armor init [path]
|
|
22
|
+
context-armor doctor
|
|
23
|
+
|
|
24
|
+
Scan options:
|
|
25
|
+
--format <pretty|json|sarif> Output format (default: pretty)
|
|
26
|
+
--output <file> Write the report to a file
|
|
27
|
+
--fail-on <level|none> Exit 1 at critical, high, medium, or low
|
|
28
|
+
--max-file-size <size> Per-file limit, e.g. 8mb
|
|
29
|
+
--max-files <count> Stop after this many discovered files
|
|
30
|
+
--config <file> Use an explicit JSON config
|
|
31
|
+
--baseline <file> Suppress matching fingerprints
|
|
32
|
+
--write-baseline <file> Save current fingerprints as a baseline
|
|
33
|
+
--ocr Scan images with local Tesseract
|
|
34
|
+
--ocr-languages <value> Tesseract languages (default: eng+kor)
|
|
35
|
+
--no-documents Do not extract Office/PDF documents
|
|
36
|
+
--no-gitignore Do not respect .gitignore
|
|
37
|
+
--no-hidden Skip hidden files
|
|
38
|
+
--no-color Disable ANSI colors
|
|
39
|
+
--quiet Print only errors
|
|
40
|
+
-h, --help Show help
|
|
41
|
+
-v, --version Show version
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
npx context-armor scan .
|
|
45
|
+
npx context-armor scan . --format sarif --output results.sarif
|
|
46
|
+
npx context-armor protect . --fail-on critical
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
function readValue(args, index, flag) {
|
|
50
|
+
if (index + 1 >= args.length) throw new Error(`${flag} requires a value`);
|
|
51
|
+
return args[index + 1];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseArgs(args) {
|
|
55
|
+
const parsed = {
|
|
56
|
+
command: "scan",
|
|
57
|
+
target: ".",
|
|
58
|
+
format: "pretty",
|
|
59
|
+
color: undefined,
|
|
60
|
+
};
|
|
61
|
+
let index = 0;
|
|
62
|
+
if (args[index] && !args[index].startsWith("-")) {
|
|
63
|
+
parsed.command = args[index];
|
|
64
|
+
index += 1;
|
|
65
|
+
}
|
|
66
|
+
if (["scan", "protect", "init"].includes(parsed.command) && args[index] && !args[index].startsWith("-")) {
|
|
67
|
+
parsed.target = args[index];
|
|
68
|
+
index += 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
while (index < args.length) {
|
|
72
|
+
const flag = args[index];
|
|
73
|
+
if (flag === "-h" || flag === "--help") parsed.help = true;
|
|
74
|
+
else if (flag === "-v" || flag === "--version") parsed.version = true;
|
|
75
|
+
else if (flag === "--ocr") parsed.ocr = true;
|
|
76
|
+
else if (flag === "--no-documents") parsed.documents = false;
|
|
77
|
+
else if (flag === "--no-gitignore") parsed.respectGitignore = false;
|
|
78
|
+
else if (flag === "--no-hidden") parsed.includeHidden = false;
|
|
79
|
+
else if (flag === "--no-color") parsed.color = false;
|
|
80
|
+
else if (flag === "--quiet") parsed.quiet = true;
|
|
81
|
+
else if (flag === "--format") {
|
|
82
|
+
parsed.format = readValue(args, index, flag);
|
|
83
|
+
index += 1;
|
|
84
|
+
} else if (flag === "--output") {
|
|
85
|
+
parsed.output = readValue(args, index, flag);
|
|
86
|
+
index += 1;
|
|
87
|
+
} else if (flag === "--fail-on") {
|
|
88
|
+
parsed.failOn = readValue(args, index, flag);
|
|
89
|
+
index += 1;
|
|
90
|
+
} else if (flag === "--max-file-size") {
|
|
91
|
+
parsed.maxFileSize = parseSize(readValue(args, index, flag));
|
|
92
|
+
index += 1;
|
|
93
|
+
} else if (flag === "--max-files") {
|
|
94
|
+
parsed.maxFiles = Number(readValue(args, index, flag));
|
|
95
|
+
index += 1;
|
|
96
|
+
} else if (flag === "--config") {
|
|
97
|
+
parsed.configPath = readValue(args, index, flag);
|
|
98
|
+
index += 1;
|
|
99
|
+
} else if (flag === "--baseline") {
|
|
100
|
+
parsed.baseline = readValue(args, index, flag);
|
|
101
|
+
index += 1;
|
|
102
|
+
} else if (flag === "--write-baseline") {
|
|
103
|
+
parsed.writeBaseline = readValue(args, index, flag);
|
|
104
|
+
index += 1;
|
|
105
|
+
} else if (flag === "--ocr-languages") {
|
|
106
|
+
parsed.ocrLanguages = readValue(args, index, flag);
|
|
107
|
+
index += 1;
|
|
108
|
+
} else {
|
|
109
|
+
throw new Error(`Unknown option: ${flag}`);
|
|
110
|
+
}
|
|
111
|
+
index += 1;
|
|
112
|
+
}
|
|
113
|
+
return parsed;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function loadBaseline(file) {
|
|
117
|
+
if (!file) return new Set();
|
|
118
|
+
const value = JSON.parse(await fs.readFile(path.resolve(file), "utf8"));
|
|
119
|
+
const fingerprints = Array.isArray(value) ? value : value.fingerprints;
|
|
120
|
+
if (!Array.isArray(fingerprints)) throw new Error(`Invalid baseline file: ${file}`);
|
|
121
|
+
return new Set(fingerprints);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function applyBaseline(result, baselineFile) {
|
|
125
|
+
const fingerprints = await loadBaseline(baselineFile);
|
|
126
|
+
if (!fingerprints.size) return result;
|
|
127
|
+
const original = result.findings.length;
|
|
128
|
+
result.findings = result.findings.filter(
|
|
129
|
+
(finding) => !fingerprints.has(finding.fingerprint),
|
|
130
|
+
);
|
|
131
|
+
result.suppressed = original - result.findings.length;
|
|
132
|
+
result.summary = summarize(result.findings);
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function writeBaseline(file, result) {
|
|
137
|
+
const value = {
|
|
138
|
+
schemaVersion: "1.0",
|
|
139
|
+
generatedAt: new Date().toISOString(),
|
|
140
|
+
fingerprints: result.findings.map((finding) => finding.fingerprint).sort(),
|
|
141
|
+
};
|
|
142
|
+
await fs.writeFile(path.resolve(file), `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function shouldFail(result, failOn) {
|
|
146
|
+
if (!failOn || failOn === "none") return false;
|
|
147
|
+
if (!(failOn in severityRank)) throw new Error(`Invalid --fail-on level: ${failOn}`);
|
|
148
|
+
return result.findings.some(
|
|
149
|
+
(finding) => severityRank[finding.severity] <= severityRank[failOn],
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function initProject(target) {
|
|
154
|
+
const root = path.resolve(target);
|
|
155
|
+
await fs.mkdir(root, { recursive: true });
|
|
156
|
+
const files = {
|
|
157
|
+
"context-armor.config.json": `${JSON.stringify(
|
|
158
|
+
{
|
|
159
|
+
failOn: "high",
|
|
160
|
+
maxFileSize: "8mb",
|
|
161
|
+
documents: true,
|
|
162
|
+
ocr: false,
|
|
163
|
+
respectGitignore: true,
|
|
164
|
+
ignore: ["fixtures/public/**"],
|
|
165
|
+
disabledRules: [],
|
|
166
|
+
severity: {},
|
|
167
|
+
},
|
|
168
|
+
null,
|
|
169
|
+
2,
|
|
170
|
+
)}\n`,
|
|
171
|
+
".contextarmorignore":
|
|
172
|
+
"# Context Armor-only exclusions\n# Add generated fixtures or intentionally public data here.\n",
|
|
173
|
+
".github/workflows/context-armor.yml": `name: Context Armor
|
|
174
|
+
on:
|
|
175
|
+
pull_request:
|
|
176
|
+
push:
|
|
177
|
+
branches: [main]
|
|
178
|
+
permissions:
|
|
179
|
+
contents: read
|
|
180
|
+
security-events: write
|
|
181
|
+
jobs:
|
|
182
|
+
scan:
|
|
183
|
+
runs-on: ubuntu-latest
|
|
184
|
+
steps:
|
|
185
|
+
- uses: actions/checkout@v4
|
|
186
|
+
- uses: feelyday/context-armor@v0
|
|
187
|
+
with:
|
|
188
|
+
path: .
|
|
189
|
+
fail_on: high
|
|
190
|
+
output: context-armor-results.sarif
|
|
191
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
192
|
+
if: always()
|
|
193
|
+
with:
|
|
194
|
+
sarif_file: context-armor-results.sarif
|
|
195
|
+
`,
|
|
196
|
+
};
|
|
197
|
+
const created = [];
|
|
198
|
+
for (const [relative, contents] of Object.entries(files)) {
|
|
199
|
+
const file = path.join(root, relative);
|
|
200
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
201
|
+
try {
|
|
202
|
+
await fs.writeFile(file, contents, { encoding: "utf8", flag: "wx" });
|
|
203
|
+
created.push(relative);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (error?.code !== "EEXIST") throw error;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return created;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function commandAvailable(command, args = ["--version"]) {
|
|
212
|
+
const result = spawnSync(command, args, { encoding: "utf8", timeout: 5_000 });
|
|
213
|
+
return {
|
|
214
|
+
available: !result.error && result.status === 0,
|
|
215
|
+
version: (result.stdout || result.stderr || "").trim().split("\n")[0],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function formatResult(result, format, color) {
|
|
220
|
+
if (format === "pretty") return formatPretty(result, { color });
|
|
221
|
+
if (format === "json") return formatJson(result);
|
|
222
|
+
if (format === "sarif") return formatSarif(result);
|
|
223
|
+
throw new Error(`Unknown format: ${format}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function runCli(args) {
|
|
227
|
+
const parsed = parseArgs(args);
|
|
228
|
+
if (parsed.version) {
|
|
229
|
+
process.stdout.write(`${packageJson.version}\n`);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (parsed.help || parsed.command === "help") {
|
|
233
|
+
process.stdout.write(HELP);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (parsed.command === "doctor") {
|
|
237
|
+
const checks = [
|
|
238
|
+
["Node.js", { available: Number(process.versions.node.split(".")[0]) >= 20, version: process.version }],
|
|
239
|
+
["pdftotext", commandAvailable("pdftotext")],
|
|
240
|
+
["Tesseract (optional)", commandAvailable("tesseract")],
|
|
241
|
+
["Git", commandAvailable("git")],
|
|
242
|
+
];
|
|
243
|
+
for (const [name, result] of checks) {
|
|
244
|
+
process.stdout.write(`${result.available ? "✓" : "○"} ${name}${result.version ? ` — ${result.version}` : ""}\n`);
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (parsed.command === "init") {
|
|
249
|
+
const created = await initProject(parsed.target);
|
|
250
|
+
process.stdout.write(
|
|
251
|
+
created.length
|
|
252
|
+
? `Created ${created.join(", ")}\n`
|
|
253
|
+
: "Context Armor is already initialized; no files changed.\n",
|
|
254
|
+
);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (!["scan", "protect"].includes(parsed.command)) {
|
|
258
|
+
throw new Error(`Unknown command: ${parsed.command}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let result = await scan(parsed.target, {
|
|
262
|
+
configPath: parsed.configPath,
|
|
263
|
+
failOn: parsed.failOn,
|
|
264
|
+
maxFileSize: parsed.maxFileSize,
|
|
265
|
+
maxFiles: parsed.maxFiles,
|
|
266
|
+
ocr: parsed.ocr,
|
|
267
|
+
ocrLanguages: parsed.ocrLanguages,
|
|
268
|
+
documents: parsed.documents,
|
|
269
|
+
respectGitignore: parsed.respectGitignore,
|
|
270
|
+
includeHidden: parsed.includeHidden,
|
|
271
|
+
toolVersion: packageJson.version,
|
|
272
|
+
});
|
|
273
|
+
result = await applyBaseline(result, parsed.baseline);
|
|
274
|
+
if (parsed.writeBaseline) await writeBaseline(parsed.writeBaseline, result);
|
|
275
|
+
|
|
276
|
+
if (parsed.command === "protect") {
|
|
277
|
+
const paths = protectedPathsFromFindings(result.findings, parsed.failOn || "high");
|
|
278
|
+
const files = await writeProtectionPolicies(path.resolve(parsed.target), paths);
|
|
279
|
+
result.protection = { paths: paths.length, files };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const output = formatResult(result, parsed.format, parsed.color);
|
|
283
|
+
if (parsed.output) await fs.writeFile(path.resolve(parsed.output), output, "utf8");
|
|
284
|
+
else if (!parsed.quiet) process.stdout.write(output);
|
|
285
|
+
|
|
286
|
+
const effectiveFailOn = parsed.failOn || result.policy?.failOn || "high";
|
|
287
|
+
if (shouldFail(result, effectiveFailOn)) process.exitCode = 1;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export { applyBaseline, initProject, parseArgs, shouldFail };
|
package/src/config.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const defaultConfig = Object.freeze({
|
|
5
|
+
failOn: "high",
|
|
6
|
+
maxFileSize: 8 * 1024 * 1024,
|
|
7
|
+
maxFiles: 100_000,
|
|
8
|
+
documents: true,
|
|
9
|
+
ocr: false,
|
|
10
|
+
respectGitignore: true,
|
|
11
|
+
includeHidden: true,
|
|
12
|
+
ignore: [
|
|
13
|
+
"**/.git/**",
|
|
14
|
+
"**/node_modules/**",
|
|
15
|
+
"**/vendor/**",
|
|
16
|
+
"**/.next/**",
|
|
17
|
+
"**/dist/**",
|
|
18
|
+
"**/build/**",
|
|
19
|
+
"**/coverage/**",
|
|
20
|
+
"**/.context-armor/**",
|
|
21
|
+
],
|
|
22
|
+
disabledRules: [],
|
|
23
|
+
severity: {},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function parseSize(value) {
|
|
27
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
28
|
+
const match = String(value).trim().match(/^(\d+(?:\.\d+)?)\s*(b|kb|kib|mb|mib|gb|gib)?$/i);
|
|
29
|
+
if (!match) throw new Error(`Invalid size: ${value}`);
|
|
30
|
+
const units = {
|
|
31
|
+
b: 1,
|
|
32
|
+
kb: 1000,
|
|
33
|
+
kib: 1024,
|
|
34
|
+
mb: 1000 ** 2,
|
|
35
|
+
mib: 1024 ** 2,
|
|
36
|
+
gb: 1000 ** 3,
|
|
37
|
+
gib: 1024 ** 3,
|
|
38
|
+
};
|
|
39
|
+
return Math.floor(Number(match[1]) * (units[(match[2] || "b").toLowerCase()] || 1));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function loadConfig(root, explicitPath) {
|
|
43
|
+
const candidates = explicitPath
|
|
44
|
+
? [path.resolve(explicitPath)]
|
|
45
|
+
: [
|
|
46
|
+
path.join(root, "context-armor.config.json"),
|
|
47
|
+
path.join(root, ".contextarmorrc.json"),
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
let userConfig = {};
|
|
51
|
+
let configPath = null;
|
|
52
|
+
for (const candidate of candidates) {
|
|
53
|
+
try {
|
|
54
|
+
userConfig = JSON.parse(await fs.readFile(candidate, "utf8"));
|
|
55
|
+
configPath = candidate;
|
|
56
|
+
break;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error?.code === "ENOENT") continue;
|
|
59
|
+
if (error instanceof SyntaxError) {
|
|
60
|
+
throw new Error(`Invalid JSON in ${candidate}: ${error.message}`);
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const config = {
|
|
67
|
+
...defaultConfig,
|
|
68
|
+
...userConfig,
|
|
69
|
+
ignore: [...defaultConfig.ignore, ...(userConfig.ignore || [])],
|
|
70
|
+
disabledRules: [...(userConfig.disabledRules || [])],
|
|
71
|
+
severity: { ...(userConfig.severity || {}) },
|
|
72
|
+
};
|
|
73
|
+
config.maxFileSize = parseSize(config.maxFileSize);
|
|
74
|
+
|
|
75
|
+
return { config, configPath };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export { parseSize };
|
package/src/documents.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { inflateRawSync } from "node:zlib";
|
|
5
|
+
|
|
6
|
+
const OFFICE_EXTENSIONS = new Set([".docx", ".xlsx", ".pptx"]);
|
|
7
|
+
const IMAGE_EXTENSIONS = new Set([
|
|
8
|
+
".png",
|
|
9
|
+
".jpg",
|
|
10
|
+
".jpeg",
|
|
11
|
+
".webp",
|
|
12
|
+
".tif",
|
|
13
|
+
".tiff",
|
|
14
|
+
".bmp",
|
|
15
|
+
".heic",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
function decodeXml(value) {
|
|
19
|
+
return value
|
|
20
|
+
.replace(/<w:tab\/>/g, "\t")
|
|
21
|
+
.replace(/<w:br\/>/g, "\n")
|
|
22
|
+
.replace(/<\/(?:w:p|a:p|row)>/g, "\n")
|
|
23
|
+
.replace(/<[^>]+>/g, " ")
|
|
24
|
+
.replace(/</g, "<")
|
|
25
|
+
.replace(/>/g, ">")
|
|
26
|
+
.replace(/"/g, '"')
|
|
27
|
+
.replace(/'/g, "'")
|
|
28
|
+
.replace(/&/g, "&")
|
|
29
|
+
.replace(/&#(\d+);/g, (_, number) => String.fromCodePoint(Number(number)))
|
|
30
|
+
.replace(/[ \t]+/g, " ")
|
|
31
|
+
.replace(/\n[ \t]+/g, "\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function findEndOfCentralDirectory(buffer) {
|
|
35
|
+
const signature = 0x06054b50;
|
|
36
|
+
const start = Math.max(0, buffer.length - 65_557);
|
|
37
|
+
for (let offset = buffer.length - 22; offset >= start; offset -= 1) {
|
|
38
|
+
if (buffer.readUInt32LE(offset) === signature) return offset;
|
|
39
|
+
}
|
|
40
|
+
return -1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function readZipEntries(buffer, limits = {}) {
|
|
44
|
+
const maxEntries = limits.maxEntries ?? 10_000;
|
|
45
|
+
const maxOutput = limits.maxOutput ?? 32 * 1024 * 1024;
|
|
46
|
+
const eocd = findEndOfCentralDirectory(buffer);
|
|
47
|
+
if (eocd < 0) throw new Error("ZIP central directory not found");
|
|
48
|
+
|
|
49
|
+
const entryCount = Math.min(buffer.readUInt16LE(eocd + 10), maxEntries);
|
|
50
|
+
let offset = buffer.readUInt32LE(eocd + 16);
|
|
51
|
+
let outputBytes = 0;
|
|
52
|
+
const entries = [];
|
|
53
|
+
|
|
54
|
+
for (let index = 0; index < entryCount; index += 1) {
|
|
55
|
+
if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== 0x02014b50) break;
|
|
56
|
+
const method = buffer.readUInt16LE(offset + 10);
|
|
57
|
+
const compressedSize = buffer.readUInt32LE(offset + 20);
|
|
58
|
+
const uncompressedSize = buffer.readUInt32LE(offset + 24);
|
|
59
|
+
const nameLength = buffer.readUInt16LE(offset + 28);
|
|
60
|
+
const extraLength = buffer.readUInt16LE(offset + 30);
|
|
61
|
+
const commentLength = buffer.readUInt16LE(offset + 32);
|
|
62
|
+
const localOffset = buffer.readUInt32LE(offset + 42);
|
|
63
|
+
const name = buffer.subarray(offset + 46, offset + 46 + nameLength).toString("utf8");
|
|
64
|
+
offset += 46 + nameLength + extraLength + commentLength;
|
|
65
|
+
|
|
66
|
+
if (
|
|
67
|
+
name.endsWith("/") ||
|
|
68
|
+
uncompressedSize > maxOutput ||
|
|
69
|
+
outputBytes + uncompressedSize > maxOutput ||
|
|
70
|
+
localOffset + 30 > buffer.length ||
|
|
71
|
+
buffer.readUInt32LE(localOffset) !== 0x04034b50
|
|
72
|
+
) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const localNameLength = buffer.readUInt16LE(localOffset + 26);
|
|
77
|
+
const localExtraLength = buffer.readUInt16LE(localOffset + 28);
|
|
78
|
+
const dataStart = localOffset + 30 + localNameLength + localExtraLength;
|
|
79
|
+
const dataEnd = dataStart + compressedSize;
|
|
80
|
+
if (dataEnd > buffer.length) continue;
|
|
81
|
+
|
|
82
|
+
const compressed = buffer.subarray(dataStart, dataEnd);
|
|
83
|
+
let data;
|
|
84
|
+
if (method === 0) data = compressed;
|
|
85
|
+
else if (method === 8) data = inflateRawSync(compressed, { maxOutputLength: maxOutput });
|
|
86
|
+
else continue;
|
|
87
|
+
|
|
88
|
+
outputBytes += data.length;
|
|
89
|
+
entries.push({ name, data });
|
|
90
|
+
}
|
|
91
|
+
return entries;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function extractOfficeText(file, maxBytes) {
|
|
95
|
+
const buffer = await fs.readFile(file);
|
|
96
|
+
const entries = readZipEntries(buffer, { maxOutput: Math.max(maxBytes * 4, 32 * 1024 * 1024) });
|
|
97
|
+
const relevant = entries.filter(({ name }) => {
|
|
98
|
+
if (!name.endsWith(".xml")) return false;
|
|
99
|
+
return /^(?:word\/|xl\/(?:sharedStrings|worksheets\/)|ppt\/(?:slides|notesSlides)\/)/.test(
|
|
100
|
+
name,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
return relevant.map(({ data }) => decodeXml(data.toString("utf8"))).join("\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function commandText(command, args, maxBytes) {
|
|
107
|
+
const result = spawnSync(command, args, {
|
|
108
|
+
encoding: "utf8",
|
|
109
|
+
timeout: 20_000,
|
|
110
|
+
maxBuffer: maxBytes,
|
|
111
|
+
windowsHide: true,
|
|
112
|
+
});
|
|
113
|
+
if (result.error || result.status !== 0) return null;
|
|
114
|
+
return result.stdout || "";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function extractDocument(file, options) {
|
|
118
|
+
const extension = path.extname(file).toLowerCase();
|
|
119
|
+
if (OFFICE_EXTENSIONS.has(extension)) {
|
|
120
|
+
try {
|
|
121
|
+
return {
|
|
122
|
+
text: await extractOfficeText(file, options.maxFileSize),
|
|
123
|
+
inspected: true,
|
|
124
|
+
extractor: "office-xml",
|
|
125
|
+
};
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return { text: "", inspected: false, extractor: "office-xml", error: error.message };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (extension === ".pdf") {
|
|
131
|
+
const text = commandText("pdftotext", ["-q", "-enc", "UTF-8", file, "-"], options.maxFileSize * 4);
|
|
132
|
+
return text === null
|
|
133
|
+
? { text: "", inspected: false, extractor: "pdftotext" }
|
|
134
|
+
: { text, inspected: true, extractor: "pdftotext" };
|
|
135
|
+
}
|
|
136
|
+
if (IMAGE_EXTENSIONS.has(extension)) {
|
|
137
|
+
if (!options.ocr) return { text: "", inspected: false, extractor: "ocr-disabled" };
|
|
138
|
+
const text = commandText(
|
|
139
|
+
"tesseract",
|
|
140
|
+
[file, "stdout", "-l", options.ocrLanguages || "eng+kor", "--psm", "6"],
|
|
141
|
+
options.maxFileSize * 4,
|
|
142
|
+
);
|
|
143
|
+
return text === null
|
|
144
|
+
? { text: "", inspected: false, extractor: "tesseract" }
|
|
145
|
+
: { text, inspected: true, extractor: "tesseract" };
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function isDocumentExtension(extension) {
|
|
151
|
+
return OFFICE_EXTENSIONS.has(extension) || extension === ".pdf" || IMAGE_EXTENSIONS.has(extension);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export { decodeXml, IMAGE_EXTENSIONS, OFFICE_EXTENSIONS };
|
package/src/ignore.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
function normalize(value) {
|
|
5
|
+
return value.split(path.sep).join("/").replace(/^\.\//, "");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function globToRegExp(glob) {
|
|
9
|
+
let source = "";
|
|
10
|
+
const raw = normalize(glob);
|
|
11
|
+
const directoryOnly = raw.endsWith("/");
|
|
12
|
+
const normalized = directoryOnly ? raw.slice(0, -1) : raw;
|
|
13
|
+
for (let index = 0; index < normalized.length; index += 1) {
|
|
14
|
+
const character = normalized[index];
|
|
15
|
+
const next = normalized[index + 1];
|
|
16
|
+
if (character === "*" && next === "*") {
|
|
17
|
+
const after = normalized[index + 2];
|
|
18
|
+
if (after === "/") {
|
|
19
|
+
source += "(?:.*/)?";
|
|
20
|
+
index += 2;
|
|
21
|
+
} else {
|
|
22
|
+
source += ".*";
|
|
23
|
+
index += 1;
|
|
24
|
+
}
|
|
25
|
+
} else if (character === "*") {
|
|
26
|
+
source += "[^/]*";
|
|
27
|
+
} else if (character === "?") {
|
|
28
|
+
source += "[^/]";
|
|
29
|
+
} else if (character === "/") {
|
|
30
|
+
source += "\\/";
|
|
31
|
+
} else {
|
|
32
|
+
source += character.replace(/[|\\{}()[\]^$+?.-]/g, "\\$&");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const anchored = normalized.startsWith("/");
|
|
37
|
+
const prefix = anchored ? "^" : "(?:^|/)";
|
|
38
|
+
const suffix = directoryOnly ? "(?:/.*)?$" : "(?:$|/.*$)";
|
|
39
|
+
return new RegExp(`${prefix}${source.replace(/^\\\//, "")}${suffix}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseIgnoreLines(contents) {
|
|
43
|
+
return contents
|
|
44
|
+
.split(/\r?\n/)
|
|
45
|
+
.map((line) => line.trim())
|
|
46
|
+
.filter((line) => line && !line.startsWith("#"))
|
|
47
|
+
.map((line) => ({
|
|
48
|
+
negated: line.startsWith("!"),
|
|
49
|
+
pattern: line.startsWith("!") ? line.slice(1) : line,
|
|
50
|
+
}))
|
|
51
|
+
.filter((entry) => entry.pattern);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readOptional(file) {
|
|
55
|
+
try {
|
|
56
|
+
return await fs.readFile(file, "utf8");
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error?.code === "ENOENT") return "";
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function createIgnoreMatcher(root, config) {
|
|
64
|
+
const entries = config.ignore.map((pattern) => ({ pattern, negated: false }));
|
|
65
|
+
const ownIgnore = await readOptional(path.join(root, ".contextarmorignore"));
|
|
66
|
+
entries.push(...parseIgnoreLines(ownIgnore));
|
|
67
|
+
if (config.respectGitignore) {
|
|
68
|
+
const gitIgnore = await readOptional(path.join(root, ".gitignore"));
|
|
69
|
+
entries.push(...parseIgnoreLines(gitIgnore));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const compiled = entries.map((entry) => ({
|
|
73
|
+
...entry,
|
|
74
|
+
regex: globToRegExp(entry.pattern),
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
return (relativePath, isDirectory = false) => {
|
|
78
|
+
const candidate = normalize(relativePath) + (isDirectory ? "/" : "");
|
|
79
|
+
let ignored = false;
|
|
80
|
+
for (const entry of compiled) {
|
|
81
|
+
if (entry.regex.test(candidate)) ignored = !entry.negated;
|
|
82
|
+
}
|
|
83
|
+
return ignored;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export { normalize, parseIgnoreLines };
|
package/src/index.js
ADDED
package/src/policy.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { severityRank } from "./rules.js";
|
|
4
|
+
|
|
5
|
+
const BLOCK_START = "# context-armor:start";
|
|
6
|
+
const BLOCK_END = "# context-armor:end";
|
|
7
|
+
|
|
8
|
+
async function readOptional(file) {
|
|
9
|
+
try {
|
|
10
|
+
return await fs.readFile(file, "utf8");
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (error?.code === "ENOENT") return "";
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function writeMarkedBlock(file, paths) {
|
|
18
|
+
const existing = await readOptional(file);
|
|
19
|
+
const start = existing.indexOf(BLOCK_START);
|
|
20
|
+
const end = existing.indexOf(BLOCK_END);
|
|
21
|
+
let clean = existing;
|
|
22
|
+
if (start >= 0 && end >= start) {
|
|
23
|
+
clean = `${existing.slice(0, start)}${existing.slice(end + BLOCK_END.length)}`;
|
|
24
|
+
}
|
|
25
|
+
clean = clean.trimEnd();
|
|
26
|
+
const block = [BLOCK_START, "# Generated by Context Armor. Re-run `context-armor protect`.", ...paths, BLOCK_END].join(
|
|
27
|
+
"\n",
|
|
28
|
+
);
|
|
29
|
+
await fs.writeFile(file, `${clean ? `${clean}\n\n` : ""}${block}\n`, "utf8");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function writeClaudePolicy(root, protectedPaths) {
|
|
33
|
+
const directory = path.join(root, ".claude");
|
|
34
|
+
const file = path.join(directory, "settings.local.json");
|
|
35
|
+
await fs.mkdir(directory, { recursive: true });
|
|
36
|
+
let settings = {};
|
|
37
|
+
const current = await readOptional(file);
|
|
38
|
+
if (current.trim()) {
|
|
39
|
+
try {
|
|
40
|
+
settings = JSON.parse(current);
|
|
41
|
+
} catch {
|
|
42
|
+
throw new Error(`Cannot update invalid JSON: ${file}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
settings.permissions ||= {};
|
|
46
|
+
const existing = new Set(settings.permissions.deny || []);
|
|
47
|
+
for (const protectedPath of protectedPaths) {
|
|
48
|
+
existing.add(`Read(${protectedPath})`);
|
|
49
|
+
existing.add(`Edit(${protectedPath})`);
|
|
50
|
+
}
|
|
51
|
+
settings.permissions.deny = [...existing].sort();
|
|
52
|
+
await fs.writeFile(file, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function protectedPathsFromFindings(findings, threshold = "high") {
|
|
56
|
+
const thresholdRank = severityRank[threshold];
|
|
57
|
+
return [
|
|
58
|
+
...new Set(
|
|
59
|
+
findings
|
|
60
|
+
.filter((finding) => severityRank[finding.severity] <= thresholdRank)
|
|
61
|
+
.map((finding) => finding.path),
|
|
62
|
+
),
|
|
63
|
+
].sort();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function writeProtectionPolicies(root, protectedPaths) {
|
|
67
|
+
const stateDirectory = path.join(root, ".context-armor");
|
|
68
|
+
await fs.mkdir(stateDirectory, { recursive: true });
|
|
69
|
+
await fs.writeFile(
|
|
70
|
+
path.join(stateDirectory, "protected-paths.txt"),
|
|
71
|
+
`${protectedPaths.join("\n")}${protectedPaths.length ? "\n" : ""}`,
|
|
72
|
+
"utf8",
|
|
73
|
+
);
|
|
74
|
+
await Promise.all([
|
|
75
|
+
writeMarkedBlock(path.join(root, ".cursorignore"), protectedPaths),
|
|
76
|
+
writeMarkedBlock(path.join(root, ".geminiignore"), protectedPaths),
|
|
77
|
+
writeClaudePolicy(root, protectedPaths),
|
|
78
|
+
]);
|
|
79
|
+
return [
|
|
80
|
+
".context-armor/protected-paths.txt",
|
|
81
|
+
".cursorignore",
|
|
82
|
+
".geminiignore",
|
|
83
|
+
".claude/settings.local.json",
|
|
84
|
+
];
|
|
85
|
+
}
|