reaproof 0.3.0 → 0.4.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/src/report.mjs CHANGED
@@ -1,8 +1,7 @@
1
1
  import { isFixable } from './fix.mjs';
2
- import { groupByRule, SOURCE_MAP_RULES, STANDALONE_MAP_RULES, worstSeverity } from './rules.mjs';
2
+ import { groupByRule, LEAK_SEVERITIES, SOURCE_MAP_RULES, STANDALONE_MAP_RULES, worstSeverity } from './rules.mjs';
3
3
  import { toDisplayPath } from './scan.mjs';
4
4
 
5
- const LEAK_SEVERITIES = ['critical', 'high', 'medium'];
6
5
  const MAX_LISTED_PATHS = 5;
7
6
  const MAX_LISTED_ADDRESSES = 10;
8
7
  const INDENT = ' ';
@@ -12,28 +11,43 @@ const KO = {
12
11
  severity: { critical: '심각', high: '높음', medium: '보통', info: '안내' },
13
12
  severityName: { critical: '심각', high: '높음', medium: '보통' },
14
13
  target: (paths) => `대상: ${paths}`,
14
+ pickedByName: '(package.json 옆에서 이름으로 고른 결과물 폴더)',
15
15
  scanned: (count) => `읽어 본 파일 ${count}개`,
16
+ ofSeen: (count) => `만난 파일 ${count}개 중`,
17
+ programsAmong: (count) => `실행 파일 ${count}개 포함`,
16
18
  leaksLabel: '찾은 누수',
17
19
  noLeaks: '없음',
18
20
  leakKinds: (count) => `${count}종류`,
21
+ // By exit code, the ones the help below lists.
22
+ closings: {
23
+ 1: (level, name) => `종료 코드 1: ${name} 이상의 누수가 있어 멈춥니다 (--fail-on ${level}).`,
24
+ 3: () => '종료 코드 3: 판정하지 못한 채 끝납니다.',
25
+ },
19
26
  riskLabel: 'rea 위험도',
20
- riskLevels: { 'very-high': '매우 높음', high: '높음', moderate: '보통', baseline: '기본' },
27
+ riskLevels: { 'very-high': '매우 높음', high: '높음', moderate: '보통', baseline: '기본', unrated: '판정 없음' },
21
28
  riskSummaries: {
22
29
  'very-high': 'rea 같은 도구로 원본 코드나 비밀 값을 그대로 가져갈 수 있습니다.',
23
- high: '비밀에 가까운 값이나 데이터, 실행 중인 앱의 속이 드러나 있습니다.',
24
- moderate: '코드의 이름과 구조가 쉽게 읽힙니다.',
30
+ high: '비밀에 가까운 값이나 데이터, 또는 실행 중인 프로그램의 속이 드러나 있습니다.',
31
+ moderate: '비밀 값은 나오지 않았지만, 아래 항목이 코드를 읽거나 실행 중인 앱에 손대기 더 쉽게 만듭니다.',
25
32
  baseline:
26
- '새는 곳은 찾지 못했습니다. 그래도 압축된 코드, 서버 주소, 화면 구조는 읽힙니다. 어떤 앱이든 지는 위험입니다.',
33
+ '이 도구가 찾는 누수는 나오지 않았습니다. 그래도 코드의 구조, 그 안의 글과 값, 서버 주소는 읽힙니다. 어떤 앱이든 지는 위험입니다.',
34
+ unrated: '출시물의 본체를 읽지 못해 판정하지 않았습니다. 새는 곳이 없다는 뜻이 아닙니다.',
27
35
  },
28
36
  andMore: (count) => `외 ${count}개`,
29
37
  originals: (names) => `원본: ${names}`,
38
+ notSigned: '서명 없음',
39
+ namesIn: (architecture, count) => `${architecture}: 이름 ${count}개`,
40
+ sourcesIn: (count, sample) => `원본 경로 ${count}개, 예: ${sample}`,
30
41
  // Listed roughly from most to least damaging: a report shows the rules in this order.
31
42
  rules: {
32
43
  'stray-file': {
33
44
  title: '배포물에 있으면 안 되는 파일이 섞여 있습니다',
34
- body: () => [
35
- '설정 파일, 저장소 폴더, 열쇠 보관 파일, DB 파일은 그 자체로 원본이나 비밀을 담고 있습니다.',
36
- '결과물에서 지우고, 어디서 복사돼 들어오는지(예: public 폴더, 묶을 파일 목록) 확인하세요.',
45
+ body: ({ debugSymbols }) => [
46
+ '설정 파일, 저장소 폴더, 열쇠 보관 파일, DB 파일, 디버그 정보는 그 자체로 원본이나 비밀을 담고 있습니다.',
47
+ '배포물에서 빼고, 어디서 섞여 들어오는지(예: 통째로 복사되는 폴더, 묶을 파일 목록) 확인하세요.',
48
+ ...(debugSymbols > 0
49
+ ? ['디버그 정보(.dSYM)는 지우지 말고 배포물 밖에 보관하세요. 사용자에게서 온 오류 기록을 풀 때 씁니다.']
50
+ : []),
37
51
  ],
38
52
  },
39
53
  secret: {
@@ -66,10 +80,10 @@ const KO = {
66
80
  ],
67
81
  },
68
82
  'macos-entitlement': {
69
- title: '개발용 권한이 켜진 채 서명됐습니다',
83
+ title: '실행 중인 앱의 속을 여는 권한이 켜진 채 서명됐습니다',
70
84
  body: () => [
71
- 'get-task-allow 권한이 있으면 다른 프로그램이 실행 중인 앱에 붙어 속을 들여다볼 수 있습니다.',
72
- '출시용 서명에서는 이 권한을 빼세요.',
85
+ 'get-task-allow 는 다른 프로그램이 실행 중인 앱에 붙어 속을 들여다보게 합니다. disable-library-validation 은 누가 서명했든 플러그인과 라이브러리를 앱 안에 싣게 합니다.',
86
+ '꼭 필요한 것이 아니면 출시용 서명의 권한 목록(entitlements)에서 빼세요.',
73
87
  ],
74
88
  },
75
89
  'sourcemap-names': {
@@ -79,11 +93,22 @@ const KO = {
79
93
  '압축된 코드를 원래 구조로 되짚기가 쉬워집니다.',
80
94
  ],
81
95
  },
96
+ 'native-symbols': {
97
+ title: '실행 파일에 개발할 때 쓰는 이름표가 그대로 남아 있습니다',
98
+ body: ({ files, names, sources, builders }) => [
99
+ `실행 파일 ${files}개에 함수와 변수 이름 ${names}개가 남아 있습니다. 코드가 무슨 일을 하는지 이름만으로 읽힙니다.`,
100
+ ...(builders > 0 ? ['원본 파일의 경로도 들어 있어, 빌드한 컴퓨터의 계정 이름과 폴더 구조가 그대로 보입니다.'] : []),
101
+ ...(sources > 0 && builders === 0 ? ['원본 파일의 경로도 들어 있어, 코드의 폴더 구조가 그대로 보입니다.'] : []),
102
+ '서명하기 전에 strip 으로 떼세요. 실행 파일은 옵션 없는 strip 이 이름을 모두 떼고, 다른 파일이 불러 쓰는 라이브러리는 strip -x 가 밖에서 쓰는 이름만 남깁니다. strip -S 는 경로와 디버그 표식만 뗍니다.',
103
+ '떼기 전에 디버그 정보(.dSYM)를 배포물 밖에 보관해 두면 오류 기록은 계속 풀 수 있습니다.',
104
+ '뗀 뒤 reaproof 를 다시 돌려 이 항목이 사라지면 된 것입니다. 직접 세어 보려면 nm --no-dyldinfo -a <파일> 을 쓰세요. 옵션 없는 nm 은 이름을 뗀 프로그램에 <redacted function N> 을 지어 붙입니다.',
105
+ ],
106
+ },
82
107
  'macos-runtime': {
83
108
  title: '강화 실행 환경(Hardened Runtime)이 꺼져 있습니다',
84
109
  body: () => [
85
- '꺼져 있으면 실행 중인 앱에 다른 코드를 끼워 넣기 쉽습니다.',
86
- '서명할 때 Hardened Runtime 옵션을 켜세요.',
110
+ '꺼져 있거나 서명이 없으면 실행 중인 앱에 다른 코드를 끼워 넣기 쉽습니다.',
111
+ '서명할 때 켜세요(codesign 은 --options runtime). 실행 파일마다 따로 서명되므로 앱 안의 도우미 프로그램도 확인하세요.',
87
112
  ],
88
113
  },
89
114
  'electron-fuse': {
@@ -114,6 +139,13 @@ const KO = {
114
139
  '빌드 도구의 압축(minify) 옵션을 켜세요. 읽는 수고를 늘릴 뿐 숨겨 주지는 않습니다.',
115
140
  ],
116
141
  },
142
+ 'developer-file': {
143
+ title: '개발하는 사람이 보려고 쓴 문서와 스크립트가 함께 들어 있습니다',
144
+ body: ({ files }) => [
145
+ `문서와 스크립트 파일 ${files}개가 들어 있습니다. 웹사이트라면 올라가는 순간 누구나 주소로 열어 볼 수 있습니다.`,
146
+ '올릴 필요가 없으면 배포에서 빼세요(예: 호스팅의 제외 목록, 묶을 파일 목록). 일부러 넣은 것이면 그대로 두면 됩니다.',
147
+ ],
148
+ },
117
149
  endpoint: {
118
150
  title: '코드에서 그대로 읽히는 서버 주소',
119
151
  body: ({ addresses }) => [
@@ -162,22 +194,42 @@ const KO = {
162
194
  limits: [
163
195
  '화면 구조와 서버 요청 주소는 사용자 기기로 내려가는 이상 가릴 수 없습니다.',
164
196
  '가격 계산, 권한 판단처럼 베껴지면 안 되는 로직은 서버에서 계산하세요.',
165
- '실제로 무엇이 읽히는지는 reaproof measure 로 볼 수 있습니다.',
166
197
  ],
198
+ namesThatStay:
199
+ '실행 파일은 이름을 떼어도 실행에 필요한 이름은 남습니다. 불러 쓰는 라이브러리 함수의 이름이 그렇고, Swift 와 Objective-C 에서는 자기 타입, 속성, 클래스, 메서드 이름도 남습니다.',
200
+ measureHint: 'JavaScript 에서 실제로 무엇이 읽히는지는 reaproof measure 로 볼 수 있습니다.',
167
201
  deployedScope: '이 점검은 배포된 지도 파일만 봅니다. 열쇠와 섞인 파일은 결과물 폴더에서 reaproof 로 점검하세요.',
168
202
  skippedOtherOrigins: (count) => `다른 주소에서 불러오는 파일 ${count}개는 보지 않았습니다.`,
169
203
  skippedLarge: (count) => `너무 커서 건너뛴 파일 ${count}개`,
170
204
  unreadableArchives: (count) => `읽지 못한 ASAR 꾸러미 ${count}개`,
205
+ unopenedContainers: (count, names) =>
206
+ `열지 못한 꾸러미 ${count}개(${names}): 이 종류는 안을 읽지 못합니다. 안에 든 앱이나 폴더를 꺼내 직접 가리켜 주세요.`,
207
+ diskImageHint: 'DMG 는 hdiutil attach -readonly -nobrowse <파일> 로 연 뒤, /Volumes 아래에 나타난 앱을 가리키면 됩니다.',
208
+ textless: (count) => `그림, 소리, 영상, 글꼴, 압축 파일처럼 읽어도 찾을 것이 없는 ${count}개는 읽지 않았습니다.`,
171
209
  errors: {
172
210
  'path-not-found': (path) => [`경로를 찾지 못했습니다: ${path}`],
173
- 'no-build-output': (path, buildCommand = 'npm run build') => [
211
+ 'no-build-output': (path, { buildCommand }) => [
174
212
  `빌드 결과물을 찾지 못했습니다: ${path}`,
175
- `먼저 빌드하세요(${buildCommand}). 결과물 폴더를 직접 알려 줘도 됩니다(예: reaproof dist).`,
213
+ `먼저 빌드하세요(${buildCommand?.join(' ') ?? 'npm run build'}). 결과물 폴더를 직접 알려 줘도 됩니다(예: npx reaproof dist).`,
214
+ ],
215
+ 'artifact-needed': (path, { projectFile }) => [
216
+ `이 폴더는 소스 프로젝트로 보입니다(${projectFile}): ${path}`,
217
+ '무엇이 출시물인지는 짐작하지 않습니다. 출시물을 직접 가리켜 주세요(예: npx reaproof 경로/MyApp.app).',
176
218
  ],
177
219
  'build-failed': () => ['빌드가 끝까지 되지 않았습니다. 위에 나온 오류를 먼저 해결하세요.'],
178
220
  'page-unreachable': (url) => [`주소를 열지 못했습니다: ${url}`],
179
221
  'local-only': () => ['이 명령은 주소에는 쓸 수 없습니다. 결과물 폴더에서 실행하세요.'],
180
222
  },
223
+ because: (reason) => `이유: ${reason}`,
224
+ unreachableReasons: {
225
+ INVALID_ADDRESS: '주소 모양이 올바르지 않습니다',
226
+ TIMEOUT: '기다려도 답이 없었습니다',
227
+ TOO_LARGE: '첫 화면이 너무 커서 읽지 않았습니다',
228
+ HTTP: '서버가 오류로 답했습니다',
229
+ ENOTFOUND: '그 이름의 사이트를 찾지 못했습니다',
230
+ ECONNREFUSED: '서버가 연결을 받지 않았습니다',
231
+ ECONNRESET: '연결이 도중에 끊겼습니다. 아직 배포 전이거나 보안 인증서가 준비되지 않은 사이트에서 흔합니다',
232
+ },
181
233
  fixPlanTitle: (count) => `지도 파일 ${count}개를 배포물에서 뺍니다:`,
182
234
  buildQuestion: (command) => `빌드 결과물이 없습니다. 지금 빌드할까요? (${command}) (y/N) `,
183
235
  confirmQuestion: '진행할까요? (y/N) ',
@@ -209,11 +261,12 @@ const KO = {
209
261
  },
210
262
  measureFailed: (reason) => `rea 를 실행하지 못했습니다: ${reason}`,
211
263
  usageError: (reason) => `명령을 이해하지 못했습니다: ${reason}`,
264
+ failOnError: (value) => `--fail-on 은 critical, high, medium 가운데 하나여야 합니다 (받은 값: ${value}).`,
212
265
  help: [
213
266
  'Reaproof: 출시물에서 쉽게 읽히는 것을 찾아 닫습니다.',
214
267
  '',
215
268
  '사용법:',
216
- ' reaproof [경로] 결과물을 점검합니다 (경로를 빼면 지금 폴더)',
269
+ ' reaproof [경로] 결과물 폴더나 앱을 점검합니다 (경로를 빼면 지금 폴더)',
217
270
  ' reaproof <https://주소> 배포된 사이트에 지도 파일이 공개돼 있는지 봅니다',
218
271
  ' reaproof fix [경로] 지도 파일을 배포물에서 뺍니다 (되돌릴 수 있음)',
219
272
  ' reaproof undo [경로] 마지막 fix 를 되돌립니다',
@@ -222,10 +275,17 @@ const KO = {
222
275
  '옵션:',
223
276
  ' --json 결과를 JSON 으로 출력',
224
277
  ' -y, --yes fix 와 measure 를 묻지 않고 진행',
278
+ ' --fail-on 등급 이 등급부터 종료 코드 1 (critical, high, medium 중 하나. 기본은 high)',
225
279
  ' --lang ko|en 출력 언어',
226
280
  ' -h, --help 도움말',
227
281
  ' -v, --version 버전',
228
282
  '',
283
+ '종료 코드:',
284
+ ' 0 멈출 누수 없음 (보통 등급은 --fail-on medium 을 줘야 1 이 됩니다)',
285
+ ' 1 --fail-on 등급 이상의 누수 있음',
286
+ ' 2 쓰는 법이 틀렸거나 대상을 정하지 못함',
287
+ ' 3 읽은 것이 없음 (열지 못한 주소, 열지 못하는 종류의 파일)',
288
+ '',
229
289
  'AI 에게 맡길 때:',
230
290
  ' "npx reaproof --json 을 돌리고 결과대로 고쳐 줘" 라고 하면 됩니다.',
231
291
  ' 결과 안에 위험도와 항목별 조치가 함께 들어 있습니다.',
@@ -236,28 +296,42 @@ const EN = {
236
296
  severity: { critical: 'CRITICAL', high: 'HIGH', medium: 'MEDIUM', info: 'NOTE' },
237
297
  severityName: { critical: 'critical', high: 'high', medium: 'medium' },
238
298
  target: (paths) => `Target: ${paths}`,
299
+ pickedByName: '(output folders picked by name, next to package.json)',
239
300
  scanned: (count) => `Files read: ${count}`,
301
+ ofSeen: (count) => `of ${count} met`,
302
+ programsAmong: (count) => `${count} of them programs`,
240
303
  leaksLabel: 'Leaks found',
241
304
  noLeaks: 'none',
242
305
  leakKinds: (count) => `${count} kind${count === 1 ? '' : 's'}`,
306
+ closings: {
307
+ 1: (level, name) => `Exit code 1: a leak at ${name} or above was found (--fail-on ${level}).`,
308
+ 3: () => 'Exit code 3: ended without a rating.',
309
+ },
243
310
  riskLabel: 'rea risk',
244
- riskLevels: { 'very-high': 'very high', high: 'high', moderate: 'moderate', baseline: 'baseline' },
311
+ riskLevels: { 'very-high': 'very high', high: 'high', moderate: 'moderate', baseline: 'baseline', unrated: 'not rated' },
245
312
  riskSummaries: {
246
313
  'very-high': 'A tool like rea can take your original source or your secret values as they are.',
247
- high: 'Near-secret values, data, or the inside of the running app are exposed.',
248
- moderate: 'The names and structure of your code read easily.',
314
+ high: 'Near-secret values, data, or the inside of the running program are exposed.',
315
+ moderate: 'No secret value was found, but the findings below make the code easier to read or the running app easier to get into.',
249
316
  baseline:
250
- 'No leak was found. Minified code, server addresses and screen structure are still readable, as in any shipped app.',
317
+ 'None of the leaks this tool looks for was found. Code structure, the text and values in it, and server addresses are still readable, as in any shipped app.',
318
+ unrated: 'What ships could not be read, so no rating is given. That does not mean nothing leaks.',
251
319
  },
252
320
  andMore: (count) => `and ${count} more`,
253
321
  originals: (names) => `originals: ${names}`,
322
+ notSigned: 'not signed',
323
+ namesIn: (architecture, count) => `${architecture}: ${count} names`,
324
+ sourcesIn: (count, sample) => `${count} original path(s), for example ${sample}`,
254
325
  // In the same order as the Korean table, which sets the order a report shows them in.
255
326
  rules: {
256
327
  'stray-file': {
257
328
  title: 'Files that should never ship are in the output',
258
- body: () => [
259
- 'Settings files, repository folders, key stores and database files carry your source or your secrets as they are.',
260
- 'Delete them from the output and find out how they get copied in (a public folder, a packaging file list).',
329
+ body: ({ debugSymbols }) => [
330
+ 'Settings files, repository folders, key stores, database files and debug information carry your source or your secrets as they are.',
331
+ 'Take them out of what ships and find out how they get in (a folder copied whole, a packaging file list).',
332
+ ...(debugSymbols > 0
333
+ ? ['Do not delete the debug information (.dSYM): keep it outside what ships. It is what makes crash reports from users readable.']
334
+ : []),
261
335
  ],
262
336
  },
263
337
  secret: {
@@ -290,10 +364,10 @@ const EN = {
290
364
  ],
291
365
  },
292
366
  'macos-entitlement': {
293
- title: 'The app is signed with a development entitlement',
367
+ title: 'The app is signed with entitlements that open it up while it runs',
294
368
  body: () => [
295
- 'With get-task-allow, another program can attach to the running app and look inside it.',
296
- 'Leave this entitlement out of release signing.',
369
+ 'get-task-allow lets another program attach to the running app and look inside it. disable-library-validation lets the app load plug-ins and libraries whoever signed them.',
370
+ 'Leave out of release signing whichever of them the app does not need.',
297
371
  ],
298
372
  },
299
373
  'sourcemap-names': {
@@ -303,11 +377,26 @@ const EN = {
303
377
  'That makes it much easier to work back from the minified code to its original structure.',
304
378
  ],
305
379
  },
380
+ 'native-symbols': {
381
+ title: 'Names meant for development are still in the program',
382
+ body: ({ files, names, sources, builders }) => [
383
+ `${files} program file(s) keep ${names} names of functions and variables. What the code does reads from the names alone.`,
384
+ ...(builders > 0
385
+ ? ['They also hold the paths of original files, which show the account name and folder layout of the machine that built them.']
386
+ : []),
387
+ ...(sources > 0 && builders === 0
388
+ ? ['They also hold the paths of original files, which show the folder layout of the code.']
389
+ : []),
390
+ 'Strip them before signing. For a program, strip with no options removes every name; for a library that other files load, strip -x keeps only the names used from outside. strip -S removes just the paths and the debugging entries.',
391
+ 'Keep the debug information (.dSYM) outside what ships first, and crash reports can still be read.',
392
+ 'Run reaproof again afterwards: when this finding is gone, it worked. To count the names yourself, use nm --no-dyldinfo -a <file>; plain nm adds entries of its own, <redacted function N>, to a stripped program.',
393
+ ],
394
+ },
306
395
  'macos-runtime': {
307
396
  title: 'The Hardened Runtime is off',
308
397
  body: () => [
309
- 'Without it, other code is easier to load into the running app.',
310
- 'Turn on the Hardened Runtime option when signing.',
398
+ 'When it is off, or the program is not signed, other code is easier to load into the running app.',
399
+ 'Turn it on when signing (codesign --options runtime). Each program is signed by itself, so check the helper programs inside the app too.',
311
400
  ],
312
401
  },
313
402
  'electron-fuse': {
@@ -338,6 +427,13 @@ const EN = {
338
427
  'Turn on minification in your build tool. It adds reading effort; it does not hide anything.',
339
428
  ],
340
429
  },
430
+ 'developer-file': {
431
+ title: 'Notes and scripts written for developers are included',
432
+ body: ({ files }) => [
433
+ `${files} notes or script file(s) are included. On a website, anyone can open them by address once they are deployed.`,
434
+ 'Leave them out of the deploy if they do not need to be there (a hosting ignore list, a packaging file list). If they ship on purpose, move on.',
435
+ ],
436
+ },
341
437
  endpoint: {
342
438
  title: 'Server addresses that read straight from the code',
343
439
  body: ({ addresses }) => [
@@ -386,22 +482,43 @@ const EN = {
386
482
  limits: [
387
483
  'Screen structure and the server addresses you call cannot be hidden once they reach a user device.',
388
484
  'Compute anything that must not be copied, such as pricing or permission checks, on a server.',
389
- 'To see what is actually readable, run reaproof measure.',
390
485
  ],
486
+ namesThatStay:
487
+ 'A stripped program still carries the names it needs to run: those of the library functions it calls and, in Swift and Objective-C, of its own types, properties, classes and methods.',
488
+ measureHint: 'To see what is actually readable in the JavaScript, run reaproof measure.',
391
489
  deployedScope: 'This check looks at deployed source maps only. Check keys and stray files in the build output with reaproof.',
392
490
  skippedOtherOrigins: (count) => `Files loaded from other addresses were not checked: ${count}`,
393
491
  skippedLarge: (count) => `Files skipped for size: ${count}`,
394
492
  unreadableArchives: (count) => `ASAR archives that could not be read: ${count}`,
493
+ unopenedContainers: (count, names) =>
494
+ `Containers that could not be opened: ${count} (${names}). This scanner does not read inside this kind. Take out the app or folder inside and point at that.`,
495
+ diskImageHint: 'For a DMG, open it with hdiutil attach -readonly -nobrowse <file> and point at the app that appears under /Volumes.',
496
+ textless: (count) =>
497
+ `Not read: ${count} file(s) such as pictures, sound, video, fonts and compressed files, in which there is nothing to find.`,
395
498
  errors: {
396
499
  'path-not-found': (path) => [`Path not found: ${path}`],
397
- 'no-build-output': (path, buildCommand = 'npm run build') => [
500
+ 'no-build-output': (path, { buildCommand }) => [
398
501
  `No build output found in: ${path}`,
399
- `Build first (${buildCommand}), or point at the output folder (for example: reaproof dist).`,
502
+ `Build first (${buildCommand?.join(' ') ?? 'npm run build'}), or point at the output folder (for example: npx reaproof dist).`,
503
+ ],
504
+ 'artifact-needed': (path, { projectFile }) => [
505
+ `This folder looks like a source project (${projectFile}): ${path}`,
506
+ 'What it ships is not guessed. Point at the thing that ships (for example: npx reaproof path/to/MyApp.app).',
400
507
  ],
401
508
  'build-failed': () => ['The build did not finish. Fix the errors shown above first.'],
402
509
  'page-unreachable': (url) => [`Could not open the address: ${url}`],
403
510
  'local-only': () => ['This command does not work on an address. Run it on the build output.'],
404
511
  },
512
+ because: (reason) => `Reason: ${reason}`,
513
+ unreachableReasons: {
514
+ INVALID_ADDRESS: 'that is not a well-formed address',
515
+ TIMEOUT: 'no answer came in time',
516
+ TOO_LARGE: 'the first page is too large to read',
517
+ HTTP: 'the server answered with an error',
518
+ ENOTFOUND: 'no site with that name was found',
519
+ ECONNREFUSED: 'the server did not accept the connection',
520
+ ECONNRESET: 'the connection was cut off, as often happens with a site that is not deployed yet or whose certificate is not ready',
521
+ },
405
522
  fixPlanTitle: (count) => `${count} map file(s) will be taken out of the output:`,
406
523
  buildQuestion: (command) => `There is no build output. Build now? (${command}) (y/N) `,
407
524
  confirmQuestion: 'Continue? (y/N) ',
@@ -433,11 +550,12 @@ const EN = {
433
550
  },
434
551
  measureFailed: (reason) => `Could not run rea: ${reason}`,
435
552
  usageError: (reason) => `Could not understand the command: ${reason}`,
553
+ failOnError: (value) => `--fail-on takes critical, high or medium (got: ${value}).`,
436
554
  help: [
437
555
  'Reaproof: finds what is easy to read in what you ship, and closes it.',
438
556
  '',
439
557
  'Usage:',
440
- ' reaproof [path] check the build output (defaults to the current folder)',
558
+ ' reaproof [path] check a build output or an app (defaults to the current folder)',
441
559
  ' reaproof <https://site> check whether a deployed site serves its source maps',
442
560
  ' reaproof fix [path] take map files out of the output (can be undone)',
443
561
  ' reaproof undo [path] undo the last fix',
@@ -446,10 +564,17 @@ const EN = {
446
564
  'Options:',
447
565
  ' --json print results as JSON',
448
566
  ' -y, --yes run fix and measure without asking',
567
+ ' --fail-on LEVEL exit with 1 from this level up (critical, high or medium; high by default)',
449
568
  ' --lang ko|en output language',
450
569
  ' -h, --help help',
451
570
  ' -v, --version version',
452
571
  '',
572
+ 'Exit codes:',
573
+ ' 0 no leak to stop for (a moderate result is 1 only with --fail-on medium)',
574
+ ' 1 a leak at or above the --fail-on level',
575
+ ' 2 wrong usage, or no target could be settled on',
576
+ ' 3 nothing was read (an address that did not answer, a kind of file that cannot be opened)',
577
+ '',
453
578
  'With an AI agent:',
454
579
  ' Ask it to run "npx reaproof --json" and act on the result.',
455
580
  ' The result carries the risk level and what to do about each finding.',
@@ -474,8 +599,9 @@ export function pickLanguage(requested) {
474
599
  export function createReporter(language) {
475
600
  const t = MESSAGES[language];
476
601
  const indent = (lines) => lines.map((line) => INDENT + line);
477
- const capList = (lines, limit) =>
478
- lines.length <= limit ? lines : [...lines.slice(0, limit), `- ${t.andMore(lines.length - limit)}`];
602
+ const capWith = (items, limit, toLast) =>
603
+ items.length <= limit ? items : [...items.slice(0, limit), toLast(items.length - limit)];
604
+ const capList = (lines, limit) => capWith(lines, limit, (count) => `- ${t.andMore(count)}`);
479
605
 
480
606
  /** "none", or "2 kinds (critical 1, medium 1)": never a score that could read as "safe". */
481
607
  function describeLeaks(leaks) {
@@ -492,9 +618,18 @@ export function createReporter(language) {
492
618
  if (rule === 'stray-file') return `- ${path} (${t.strayKinds[detail.kind]})`;
493
619
  if (rule === 'electron-fuse') return `- ${path}: ${detail.enabled.join(', ')}`;
494
620
  if (rule === 'prompt-candidate') return `- ${path}: "${detail.preview}…"`;
495
- if (SOURCE_MAP_RULES.has(rule) && detail.sampleSources.length > 0) {
496
- return `- ${path} (${t.originals(detail.sampleSources.join(', '))})`;
621
+ if (rule === 'macos-entitlement') return `- ${path}: ${detail.entitlements.join(', ')}`;
622
+ if (rule === 'macos-runtime') {
623
+ // Written the way `codesign -dv` shows it, so the two can be put side by side.
624
+ const slices = detail.slices.map(({ architecture, flags }) => `${architecture}: ${flags ? `flags=${flags}` : t.notSigned}`);
625
+ return `- ${path} (${slices.join('; ')})`;
626
+ }
627
+ if (rule === 'native-symbols') {
628
+ const facts = [t.namesIn(detail.architecture, detail.names)];
629
+ if (detail.sourceCount > 0) facts.push(t.sourcesIn(detail.sourceCount, detail.sampleSources[0]));
630
+ return `- ${path} (${facts.join(', ')})`;
497
631
  }
632
+ if (detail.sampleSources?.length > 0) return `- ${path} (${t.originals(detail.sampleSources.join(', '))})`;
498
633
  return `- ${path}`;
499
634
  }
500
635
 
@@ -502,15 +637,55 @@ export function createReporter(language) {
502
637
  function summarizeRule(findings) {
503
638
  // An address list is about the addresses, not about which file each one sits in.
504
639
  const addresses = [...new Set(findings.flatMap(({ detail }) => detail.addresses ?? []))];
640
+ const sumOf = (field) => findings.reduce((sum, { detail }) => sum + (detail[field] ?? 0), 0);
505
641
  const totals = {
506
642
  files: findings.length,
507
- embedded: findings.reduce((sum, { detail }) => sum + (detail.embeddedCount ?? 0), 0),
508
- sources: findings.reduce((sum, { detail }) => sum + (detail.sourceCount ?? 0), 0),
643
+ embedded: sumOf('embeddedCount'),
644
+ sources: sumOf('sourceCount'),
645
+ names: sumOf('names'),
646
+ builders: findings.filter(({ detail }) => detail.namesBuilder).length,
647
+ debugSymbols: findings.filter(({ detail }) => detail.kind === 'debug-symbols').length,
509
648
  addresses: addresses.length,
510
649
  };
511
650
  return { addresses, totals };
512
651
  }
513
652
 
653
+ /** What was met and could not be read. It changes how everything after it reads, so it comes first. */
654
+ function listUnread(result) {
655
+ const unread = [];
656
+ if (result.unopenedContainers.length > 0) {
657
+ const names = capWith(result.unopenedContainers, MAX_LISTED_PATHS, t.andMore);
658
+ unread.push(t.unopenedContainers(result.unopenedContainers.length, names.join(', ')));
659
+ if (result.unopenedContainers.some((name) => /\.dmg$/i.test(name))) unread.push(t.diskImageHint);
660
+ }
661
+ if (result.skippedLargeFiles.length > 0) unread.push(t.skippedLarge(result.skippedLargeFiles.length));
662
+ if (result.unreadableArchives.length > 0) unread.push(t.unreadableArchives(result.unreadableArchives.length));
663
+ if (result.skippedOtherOrigins > 0) unread.push(t.skippedOtherOrigins(result.skippedOtherOrigins));
664
+ // On disk, what is left after those was passed over for its kind.
665
+ const namedCount = result.unopenedContainers.length + result.skippedLargeFiles.length + result.unreadableArchives.length;
666
+ const passedOver = result.filesSeen - result.filesScanned - namedCount;
667
+ if (!result.isDeployed && passedOver > 0) unread.push(t.textless(passedOver));
668
+ return unread;
669
+ }
670
+
671
+ /**
672
+ * What cannot be hidden is said about what was read: with nothing read it is beside the
673
+ * point, and what holds for programs is said where a program was read. `measure` runs
674
+ * rea's JavaScript analysis on files someone holds, so it is offered only where that applies.
675
+ */
676
+ function listLimits(result) {
677
+ if (result.risk === 'unrated') return [];
678
+ const limits = [...t.limits];
679
+ if (result.programsScanned > 0) limits.push(t.namesThatStay);
680
+ if (result.scriptsScanned > 0 && !result.isDeployed) limits.push(t.measureHint);
681
+ return limits;
682
+ }
683
+
684
+ function describeUnreachable(reason) {
685
+ const kind = reason.startsWith('HTTP ') ? 'HTTP' : reason;
686
+ return Object.hasOwn(t.unreachableReasons, kind) ? `${t.unreachableReasons[kind]} (${reason})` : reason;
687
+ }
688
+
514
689
  function renderRule(rule, findings, isDeployed) {
515
690
  const { addresses, totals } = summarizeRule(findings);
516
691
  const listed =
@@ -557,15 +732,21 @@ export function createReporter(language) {
557
732
  const targets = isDeployed
558
733
  ? result.root
559
734
  : result.targets.map((target) => toDisplayPath(result.root, target)).join(', ');
735
+ const asides = [];
736
+ if (result.filesSeen > result.filesScanned) asides.push(t.ofSeen(result.filesSeen));
737
+ if (result.programsScanned > 0) asides.push(t.programsAmong(result.programsScanned));
738
+ const scanned = t.scanned(result.filesScanned);
560
739
  const lines = [
561
- t.target(targets),
562
- t.scanned(result.filesScanned),
740
+ result.targetsPickedByName ? `${t.target(targets)} ${t.pickedByName}` : t.target(targets),
741
+ asides.length > 0 ? `${scanned} (${asides.join(', ')})` : scanned,
742
+ ...indent(listUnread(result)),
563
743
  '',
564
744
  `${t.riskLabel}: ${t.riskLevels[result.risk]}`,
565
745
  INDENT + t.riskSummaries[result.risk],
566
- `${t.leaksLabel}: ${describeLeaks(result.leaks)}`,
567
- '',
568
746
  ];
747
+ // With nothing read, "none found" would read as "clean".
748
+ if (result.risk !== 'unrated') lines.push(`${t.leaksLabel}: ${describeLeaks(result.leaks)}`);
749
+ lines.push('');
569
750
 
570
751
  for (const rule of Object.keys(t.rules)) {
571
752
  const findings = findingsByRule.get(rule);
@@ -574,16 +755,10 @@ export function createReporter(language) {
574
755
  if (result.findings.some(({ rule }) => SOURCE_MAP_RULES.has(rule))) {
575
756
  lines.push(...nextBuildHints(result.frameworks), '');
576
757
  }
577
- lines.push(`[${t.severity.info}] ${t.limitsTitle}`, ...indent(t.limits));
578
- if (isDeployed) lines.push('', t.deployedScope);
579
- if (result.skippedOtherOrigins > 0) lines.push(t.skippedOtherOrigins(result.skippedOtherOrigins));
580
- if (result.skippedLargeFiles.length > 0) {
581
- lines.push('', t.skippedLarge(result.skippedLargeFiles.length));
582
- }
583
- if (result.unreadableArchives.length > 0) {
584
- lines.push('', t.unreadableArchives(result.unreadableArchives.length));
585
- }
586
- return lines.join('\n');
758
+ const limits = listLimits(result);
759
+ if (limits.length > 0) lines.push(`[${t.severity.info}] ${t.limitsTitle}`, ...indent(limits), '');
760
+ if (isDeployed) lines.push(t.deployedScope);
761
+ return lines.join('\n').trimEnd();
587
762
  },
588
763
 
589
764
  fixPlan(root, mapPaths) {
@@ -596,6 +771,7 @@ export function createReporter(language) {
596
771
  t.fixDone(applied.movedCount),
597
772
  `${t.riskLabel}: ${t.riskLevels[before.risk]} → ${t.riskLevels[after.risk]}`,
598
773
  `${t.leaksLabel}: ${describeLeaks(before.leaks)} → ${describeLeaks(after.leaks)}`,
774
+ ...listUnread(after),
599
775
  t.backupAt(applied.backupDir),
600
776
  t.undoHint,
601
777
  '',
@@ -642,13 +818,21 @@ export function createReporter(language) {
642
818
  fixCommand: findings.some(isFixable) ? FIX_COMMAND : null,
643
819
  };
644
820
  }
645
- return { risk: t.riskSummaries[result.risk], rules, limits: t.limits };
821
+ return { risk: t.riskSummaries[result.risk], unread: listUnread(result), rules, limits: listLimits(result) };
822
+ },
823
+
824
+ /** @returns {string | null} the line a text answer closes with when the command does not end with 0 */
825
+ closing(exitCode, failOn) {
826
+ return t.closings[exitCode]?.(failOn, t.severityName[failOn]) ?? null;
646
827
  },
647
828
 
648
- scanError(error) {
649
- return t.errors[error.code](error.path, error.buildCommand?.join(' ')).join('\n');
829
+ scanError({ code, path, details }) {
830
+ const lines = t.errors[code](path, details);
831
+ if (details.reason) lines.push(t.because(describeUnreachable(details.reason)));
832
+ return lines.join('\n');
650
833
  },
651
834
  measureFailed: (error) => t.measureFailed(error.message),
652
- usageError: (error) => `${t.usageError(error.message)}\n\n${t.help.join('\n')}`,
835
+ usageError: (error) => t.usageError(error.message),
836
+ failOnError: t.failOnError,
653
837
  };
654
838
  }
package/src/rules.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { checkEndpoints, checkPromptCandidates, checkUnminifiedCode } from './checks/content.mjs';
2
- import { checkStrayFile, checkUnshippedSource } from './checks/paths.mjs';
2
+ import { checkDeveloperFile, checkStrayFile, checkUnshippedSource } from './checks/paths.mjs';
3
3
  import { checkSecrets } from './checks/secrets.mjs';
4
4
  import { checkInlineMaps, checkMapFile } from './checks/sourcemaps.mjs';
5
5
 
@@ -14,10 +14,12 @@ const MAP_EXTENSIONS = new Set(['.map']);
14
14
  const SCRIPT_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']);
15
15
  const CODE_EXTENSIONS = new Set([...SCRIPT_EXTENSIONS, '.css']);
16
16
  const SECRET_SCAN_EXTENSIONS = new Set([...SCRIPT_EXTENSIONS, '.html', '.json', '.pem', '.key']);
17
- const SEVERITIES_ASCENDING = ['info', 'medium', 'high', 'critical'];
17
+ // Leaks from the worst down. A note (`info`) is not one.
18
+ export const LEAK_SEVERITIES = ['critical', 'high', 'medium'];
19
+ const SEVERITIES_ASCENDING = ['info', ...[...LEAK_SEVERITIES].reverse()];
18
20
  const RISK_BY_WORST_SEVERITY = { critical: 'very-high', high: 'high', medium: 'moderate', info: 'baseline' };
19
21
 
20
- /** Tells the scanner which files are worth reading; every file still gets the name checks. */
22
+ /** Tells the scanner which files are read whole, for every check; the rest are searched for keys only. */
21
23
  export function hasContentChecks(extension) {
22
24
  return (
23
25
  MAP_EXTENSIONS.has(extension) ||
@@ -26,6 +28,11 @@ export function hasContentChecks(extension) {
26
28
  );
27
29
  }
28
30
 
31
+ /** JavaScript is what `measure` can show more of, so the scanner keeps count of it. */
32
+ export function isScript(extension) {
33
+ return SCRIPT_EXTENSIONS.has(extension);
34
+ }
35
+
29
36
  /** @param {{ severity: Severity }[]} findings */
30
37
  export function worstSeverity(findings) {
31
38
  const rank = (severity) => SEVERITIES_ASCENDING.indexOf(severity);
@@ -47,19 +54,28 @@ export function groupByRule(findings) {
47
54
  * The two things a result says at a glance.
48
55
  *
49
56
  * `risk` is how exposed the build is to an agent with a tool like rea. Its lowest level is
50
- * "baseline", never "safe": minified code, server addresses and screens are readable in
51
- * any shipped app.
57
+ * "baseline", never "safe": code structure, text and server addresses are readable in any
58
+ * shipped app. When what ships could not be read and no leak showed anyway, there is no
59
+ * level at all: "no leak found" would be a statement about nothing.
52
60
  *
53
61
  * `leaks` counts kinds of leak, each at its worst severity: three hundred exposed maps are
54
62
  * the same mistake as one, not three hundred times as bad. Notes are not leaks.
63
+ *
64
+ * @param {boolean} wasRead whether the scan got to read what ships
55
65
  */
56
- export function summarizeFindings(findings) {
57
- const leaks = { critical: 0, high: 0, medium: 0 };
66
+ export function summarizeFindings(findings, wasRead) {
67
+ const leaks = Object.fromEntries(LEAK_SEVERITIES.map((severity) => [severity, 0]));
58
68
  for (const sameRule of groupByRule(findings).values()) {
59
69
  const severity = worstSeverity(sameRule);
60
70
  if (severity !== 'info') leaks[severity] += 1;
61
71
  }
62
- return { risk: RISK_BY_WORST_SEVERITY[worstSeverity(findings)], leaks };
72
+ const worst = worstSeverity(findings);
73
+ return { risk: !wasRead && worst === 'info' ? 'unrated' : RISK_BY_WORST_SEVERITY[worst], leaks };
74
+ }
75
+
76
+ /** Whether a result's leak counts hold one at this severity or worse. */
77
+ export function hasLeakAtOrAbove(leaks, severity) {
78
+ return LEAK_SEVERITIES.slice(0, LEAK_SEVERITIES.indexOf(severity) + 1).some((worse) => leaks[worse] > 0);
63
79
  }
64
80
 
65
81
  /** An ASAR archive is not something to fix, only a fact its owner should know. */
@@ -74,7 +90,7 @@ export function archiveFinding(path, fileCount) {
74
90
  * @returns {Finding[]}
75
91
  */
76
92
  export function checkPath(file) {
77
- return [...checkStrayFile(file), ...checkUnshippedSource(file)];
93
+ return [...checkStrayFile(file), ...checkUnshippedSource(file), ...checkDeveloperFile(file)];
78
94
  }
79
95
 
80
96
  /**