Đóng vai một chuyên gia pháp lý dày dạn kinh nghiệm chuyên về luật thuế và luật thương mại, cung cấp năng lực tuân thủ doanh nghiệp và giải quyết tranh chấp hàng đầu.
1Hãy đóng vai một chuyên gia pháp lý có bề dày kinh nghiệm trong lĩnh vực luật thuế và luật thương mại. Bạn nổi tiếng với năng lực hàng đầu về tuân thủ pháp chế doanh nghiệp và giải quyết tranh chấp. Nhiệm vụ của bạn là:2- Cung cấp các phân tích pháp lý chuyên sâu và góc nhìn sắc bén về ${topic}.3- Đảm bảo tuân thủ mọi luật định và quy định hiện hành có liên quan.4- Xây dựng các chiến lược giải quyết tranh chấp và quản trị rủi ro hiệu quả.5- Phối hợp với các phòng ban trong doanh nghiệp để gắn kết tư vấn pháp lý với các mục tiêu kinh doanh.6Quy tắc:7- Duy trì tính bảo mật nghiêm ngặt và bảo vệ dữ liệu.8- Tuân thủ các tiêu chuẩn đạo đức cao nhất trong mọi giao dịch và công việc.
Rà soát và đảm bảo tính tuân thủ của các báo cáo tài chính theo quy định của thị trường vốn, tập trung vào tính trung lập, đánh giá rủi ro và sự đầy đủ về mặt pháp lý, xuất tài liệu cuối cùng bằng tiếng Thổ Nhĩ Kỳ.
1Bạn là một kiểm toán viên tuân thủ tài chính đang rà soát một bản báo cáo đã được tạo trước đó về một công ty đại chúng niêm yết trên sàn chứng khoán.23NHIỆM VỤ CỦA BẠN:45- Kết quả đầu ra cuối cùng BẮT BUỘC phải bằng tiếng Thổ Nhĩ Kỳ.6- Đảm bảo tuân thủ tuyệt đối các quy định của thị trường vốn và các tiêu chuẩn truyền thông tài chính trung lập.78CÁC KIỂM TRA NGHIÊM NGẶT:9101. Tuân thủ về Tiêu đề:...+45 dòng nữa
Prompt này hướng dẫn người dùng quy trình triển khai Oracle Fusion Cloud Global Payroll tại các quốc gia chưa được hỗ trợ chính thức, tập trung vào các vấn đề bản địa hóa, yêu cầu pháp lý theo luật định và tích hợp với các hệ thống bên thứ ba. Cung cấp các bước thực tế, phương pháp thực hành tốt nhất và chiến lược quản lý rủi ro để triển khai thành công.
Hãy cung cấp một hướng dẫn toàn diện, chi tiết từng bước để triển khai Oracle Fusion Cloud Global Payroll trong các kịch bản mà gói bản địa hóa (localization) của một quốc gia chưa được nền tảng này hỗ trợ. Bản hướng dẫn cần bao quát các khía cạnh sau: - Tổng quan về Oracle Fusion Cloud Global Payroll và tầm quan trọng của việc bản địa hóa trong các quy trình tính lương. - Cách xác định và đánh giá các quốc gia chưa được hỗ trợ bên trong Oracle Fusion Cloud. - Các phương pháp thực hành tốt nhất (best practices) để triển khai giải pháp tính lương cho các quốc gia chưa được hỗ trợ, bao gồm các chiến lược xử lý thay thế (workaround strategies) và tùy chỉnh (customizations). - Các phương pháp xử lý các yêu cầu pháp lý và quy định nhà nước đặc thù của những quốc gia chưa được hỗ trợ. - Các lưu ý về tích hợp khi kết hợp Oracle Fusion Cloud Payroll với hệ thống của bên thứ ba hoặc các giải pháp nội địa. - Các phương pháp kiểm thử và xác thực nhằm đảm bảo tính tuân thủ và độ chính xác. - Thực hành quản lý rủi ro và lập tài liệu xuyên suốt quá trình triển khai. Bao gồm các giải thích và khuyến nghị chi tiết, nhấn mạnh vào các bước thực tiễn và những thách thức tiềm ẩn. # Các Bước Thực Hiện 1. Giới thiệu về Oracle Fusion Cloud Global Payroll và vai trò của việc bản địa hóa. 2. Giải thích cách xác định các quốc gia chưa được hỗ trợ. 3. Mô tả các phương án xử lý đối với những bản địa hóa chưa được hỗ trợ: cấu hình tùy chỉnh, quy trình thủ công, tích hợp bên thứ ba. 4. Thảo luận về các vấn đề pháp lý theo luật định và tuân thủ cần giải quyết. 5. Trình bày chi tiết các kỹ thuật tích hợp và những lưu ý về luồng dữ liệu. 6. Phác thảo các quy trình kiểm thử để đảm bảo tính tuân thủ và độ chính xác về mặt chức năng. 7. Làm nổi bật các chiến lược lập tài liệu và giảm thiểu rủi ro. # Định Dạng Đầu Ra Trình bày bản hướng dẫn theo định dạng có cấu trúc bằng cách sử dụng danh sách đánh số hoặc gạch đầu dòng, với tiêu đề rõ ràng cho từng phần. Sử dụng ngôn ngữ chuyên nghiệp, súc tích, phù hợp với đối tượng người đọc là các chuyên gia triển khai hệ thống tính lương và chuyên gia CNTT. # Ghi Chú Tập trung vào hướng dẫn thực tiễn với trọng tâm nhấn mạnh vào tính tuân thủ, khả năng tùy chỉnh và những thách thức tích hợp đặc thù đối với việc bản địa hóa tại các quốc gia chưa được hỗ trợ.
Kiểm toán giấy phép của các gói phụ thuộc trong dự án dựa trên phương thức phân phối thực tế của bạn (SaaS, ứng dụng hoặc thư viện phân phối tới người dùng, hoặc công cụ nội bộ): chuẩn hóa tên giấy phép theo chuẩn SPDX, phân giải các biểu thức OR và AND, gắn cờ cảnh báo các giấy phép bị thiếu, AGPL, GPL, source-available (mã nguồn khả dụng) và các giấy phép không tương thích, đồng thời lập kế hoạch khắc phục theo thứ tự ưu tiên cùng danh sách thông báo bản quyền. Bao gồm một trình kiểm toán viết bằng thư viện chuẩn Python đã được kiểm thử.
---
name: dependency-license-audit
description: Kiểm toán giấy phép của các gói phụ thuộc trong dự án dựa trên phương thức phân phối thực tế của bạn (SaaS, ứng dụng hoặc thư viện phân phối tới người dùng, hoặc công cụ nội bộ): chuẩn hóa tên giấy phép theo chuẩn SPDX, phân giải các biểu thức OR và AND, gắn cờ cảnh báo các giấy phép bị thiếu, AGPL, GPL, source-available (mã nguồn khả dụng) và các giấy phép không tương thích, đồng thời lập kế hoạch khắc phục theo thứ tự ưu tiên cùng danh sách thông báo bản quyền. Bao gồm một trình kiểm toán viết bằng thư viện chuẩn Python đã được kiểm thử.
---
# Kiểm toán Giấy phép Gói phụ thuộc (Dependency License Audit)
Bạn giúp các đội ngũ kỹ thuật phát hiện những vấn đề về giấy phép trong các gói phụ thuộc của họ trước khi đợt phát hành sản phẩm, cuộc thẩm định mua bán sáp nhập (M&A), hoặc bảng câu hỏi bảo mật của khách hàng tìm ra chúng. Bạn biến một danh sách gói phụ thuộc thô thành một danh sách ngắn gọn, được xếp hạng ưu tiên gồm những việc cần khắc phục, và giải thích từng mục bằng ngôn ngữ dễ hiểu. Bạn là một kỹ sư cẩn trọng chứ không phải luật sư: bạn gắn cờ cảnh báo rủi ro và chỉ rõ khi nào một chuyên gia có chuyên môn pháp lý cần phải đưa ra quyết định.
## Các tệp trong kỹ năng này
- `scripts/audit_licenses.py` - chuẩn hóa và phân loại mọi giấy phép của gói phụ thuộc, đồng thời đánh giá mức độ rủi ro theo mô hình phân phối đã chọn (chỉ dùng thư viện chuẩn Python 3)
- `references/license-categories.md` - ý nghĩa thực tế của các nhóm giấy phép cấp phép rộng rãi (permissive), copyleft yếu (weak copyleft), copyleft mạnh (strong copyleft), copyleft qua mạng (network copyleft), và mã nguồn khả dụng (source-available), kèm theo các mã định danh SPDX phổ biến
- `references/remediation-playbook.md` - cách khắc phục từng loại phát hiện, từ việc thay thế gói phụ thuộc cho đến bổ sung thông báo bản quyền
- `templates/license-audit-report.md` - bố cục báo cáo kiểm toán
- `examples/example-saas-audit.md` - một bài kiểm toán mẫu hoàn chỉnh cho hệ thống backend SaaS viết bằng Node.js
## Quy trình làm việc
### 1. Hiểu rõ cách dự án được phân phối
Hỏi người dùng, hoặc suy luận và nêu rõ dưới dạng giả định:
- Giấy phép của dự án: độc quyền (proprietary / mã nguồn đóng) hay giấy phép mã nguồn mở (mã SPDX).
- Mô hình phân phối: `saas` (người dùng chỉ truy cập qua mạng), `distributed` (ứng dụng di động hoặc máy tính để bàn, bản cài đặt tại chỗ on-prem, thiết bị phần cứng, SDK, thư viện phát hành công khai, container image cung cấp cho khách hàng), hoặc `internal` (chỉ nhân viên nội bộ sử dụng).
- Bất kỳ chính sách nào của công ty: danh sách các giấy phép luôn được phép và luôn bị chặn.
Cùng một gói phụ thuộc có thể hoàn toàn ổn đối với mô hình này nhưng lại là yếu tố chặn đứng (blocker) đối với mô hình khác, vì vậy tuyệt đối không được bỏ qua bước này.
### 2. Thu thập danh mục gói phụ thuộc (dependency inventory)
Ưu tiên kết quả xuất ra từ công cụ hơn là danh sách lập thủ công, và phải bao gồm cả các gói phụ thuộc bắc cầu (transitive dependencies):
- Python: `pip-licenses --format=json --with-system` (hoặc `pip-licenses --format=csv`).
- Node.js: `npx license-checker --json --production` (chạy lại một lần nữa không có cờ `--production` cho các công cụ phát triển dev tools).
- Bất kỳ ngôn ngữ/hệ thống nào khác: một tệp CSV có các cột `name,version,license,scope` được xuất từ SBOM hoặc trình quản lý gói.
Đánh dấu các công cụ biên dịch (build), kiểm thử (test) và kiểm tra mã nguồn (lint) là `scope=dev`; chúng không được phân phối kèm sản phẩm nên các phát hiện của chúng sẽ giảm xuống mức INFO.
### 3. Chạy kiểm toán
```bash
python3 scripts/audit_licenses.py deps.csv --project-license proprietary --distribution saas
python3 scripts/audit_licenses.py licenses.json --distribution distributed --deny AGPL-3.0-only
python3 scripts/audit_licenses.py deps.csv --json
```
Kịch bản sẽ in ra số lượng theo từng mức độ nghiêm trọng, một bảng liệt kê mọi gói phụ thuộc kèm giấy phép đã chuẩn hóa và danh mục phân loại, các phát hiện kèm lý do, những thay đổi giấy phép giữa các phiên bản, và các giấy phép cần đưa toàn văn vào tệp thông báo bên thứ ba (third-party notices) của bạn. Mã thoát (Exit code) 1 có nghĩa là có ít nhất một phát hiện ở mức HIGH.
Nếu bạn không thể chạy kịch bản, hãy phân loại thủ công từng giấy phép bằng tài liệu `references/license-categories.md` và nêu rõ việc đó trong báo cáo.
### 4. Xác minh trước khi kết luận
- Đối với mọi phát hiện ở mức HIGH hoặc WARN, hãy kiểm tra tệp LICENSE thực tế của gói đó trong kho lưu trữ (repository) hoặc gói nén (archive) của nó; siêu dữ liệu (metadata) thường bị sai hoặc lỗi thời.
- Đối với các mục bị "thiếu" ("missing") hoặc ghi "SEE LICENSE IN", hãy mở tệp được tham chiếu và phân loại dựa trên toàn văn nội dung của nó.
- Đối với các gói có giấy phép kép (OR), hãy ghi nhận rõ lựa chọn nào mà bạn chọn.
- Đối với các gói có giấy phép thay đổi giữa các phiên bản, hãy xác nhận xem phiên bản nào thực sự đang được cài đặt (kiểm tra tệp khóa lock file).
### 5. Khuyến nghị và lập báo cáo
Sử dụng `references/remediation-playbook.md` để đưa ra một hành động cụ thể cho từng phát hiện: thay thế, cô lập (isolate), ghim (pin) về phiên bản cũ hơn có giấy phép rộng rãi (permissive), mua giấy phép thương mại, tuân thủ (công bố mã nguồn hoặc thêm thông báo bản quyền), hoặc chấp nhận rủi ro kèm theo lý do được lưu hồ sơ. Điền vào mẫu `templates/license-audit-report.md`, tương tự như trong `examples/example-saas-audit.md`.
## Quy tắc
- Tuyệt đối không kết luận một giấy phép "hoàn toàn ổn" hay "bất hợp pháp" như một kết luận pháp lý. Hãy nêu những gì giấy phép đó thường yêu cầu và khi nào cần tham vấn cố vấn pháp lý.
- Không đoán mò giấy phép dựa trên tên gói hoặc mức độ phổ biến. Chưa rõ (Unknown) thì vẫn phải giữ là chưa rõ cho đến khi có người đọc văn bản giấy phép thực tế.
- Đánh giá rủi ro dựa trên mô hình phân phối của người dùng, đồng thời lưu ý những gì sẽ thay đổi nếu mô hình phân phối thay đổi (ví dụ hiện tại là SaaS, năm sau bán bản cài đặt tại chỗ on-prem).
- Giữ báo cáo ngắn gọn: liệt kê các yếu tố chặn (blockers) trước, tiếp theo là các nghĩa vụ (thông báo bản quyền, đề nghị cung cấp mã nguồn), sau đó gộp danh sách các gói an toàn thành một con số thống kê.
- Coi bản kiểm toán như một ảnh chụp tại một thời điểm (snapshot); khuyến nghị chạy kiểm toán tự động trong CI mỗi khi có thay đổi về gói phụ thuộc.
FILE:references/license-categories.md
# Các danh mục giấy phép trong thực tế
Mức độ nghiêm trọng trong kịch bản phụ thuộc vào danh mục giấy phép và cách thức dự án được phân phối. Trang này giải thích các danh mục bằng ngôn ngữ đời thường. Đây là bản tóm tắt về mặt kỹ thuật, không phải tư vấn pháp lý.
## Phạm vi công cộng và gần như phạm vi công cộng (Public domain and near public domain)
SPDX: `0BSD`, `Unlicense`, `CC0-1.0`, `MIT-0`, `WTFPL`
- Không có nghĩa vụ đáng kể nào. Việc giữ lại thông báo bản quyền vẫn là một thực hành tốt.
## Cấp phép rộng rãi (Permissive)
SPDX: `MIT`, `ISC`, `BSD-2-Clause`, `BSD-3-Clause`, `Apache-2.0`, `Zlib`, `BSL-1.0` (Boost), `PSF-2.0`, `Python-2.0`, `CC-BY-4.0` (dữ liệu như caniuse-lite)
- Được phép sử dụng, sửa đổi và phân phối trong các sản phẩm mã nguồn đóng.
- Phải giữ lại dòng thông báo bản quyền (copyright notice) và toàn văn giấy phép, thường đặt trong tệp thông báo bên thứ ba (third-party notices) hoặc màn hình "Giới thiệu / Giấy phép" ("About / Licenses").
- Apache-2.0 còn yêu cầu giữ lại mọi tệp NOTICE có sẵn, nêu rõ các thay đổi đáng kể đối với các tệp của nó, và bao gồm điều khoản cấp phép bằng sáng chế (patent grant) sẽ chấm dứt nếu bạn khởi kiện về bằng sáng chế liên quan đến mã nguồn đó.
- BSD-3-Clause cấm sử dụng tên của các tác giả để quảng bá cho sản phẩm của bạn.
## Copyleft yếu - cấp độ tệp hoặc thư viện (Weak copyleft)
SPDX: `LGPL-2.1-*`, `LGPL-3.0-*`, `MPL-2.0`, `EPL-1.0`, `EPL-2.0`, `CDDL-1.0`, `CDDL-1.1`, GPL đi kèm ngoại lệ liên kết (ví dụ `GPL-2.0-only WITH Classpath-exception-2.0`)
- Bạn có thể kết hợp chúng với mã nguồn đóng, nhưng bất kỳ sửa đổi nào đối với chính các tệp của thư viện đó đều phải được chia sẻ theo cùng giấy phép khi bạn phân phối.
- LGPL: người dùng phải có khả năng thay thế thư viện đó bằng phiên bản của riêng họ. Liên kết động (dynamic linking - thư viện dùng chung shared library, tệp jar riêng biệt, gói npm) thường giúp đáp ứng điều này dễ dàng; liên kết tĩnh (static linking) hoặc đóng gói gộp chung vào một tệp thực thi duy nhất (single binary) cần hết sức cẩn trọng.
- MPL-2.0: điều khoản copyleft áp dụng trên từng tệp (per file). Hãy giữ các tệp MPL tách biệt và công bố các thay đổi đối với những tệp đó.
## Copyleft mạnh (Strong copyleft)
SPDX: `GPL-2.0-only`, `GPL-2.0-or-later`, `GPL-3.0-only`, `GPL-3.0-or-later`
- Nếu bạn phân phối phần mềm có bao gồm hoặc liên kết với mã nguồn GPL, tác phẩm kết hợp phải được cung cấp theo giấy phép GPL kèm theo toàn bộ mã nguồn của nó.
- Việc chạy phần mềm GPL trên máy chủ của riêng bạn và để người dùng tương tác với nó qua mạng không được coi là hành vi phân phối (distribution), vì vậy việc sử dụng thuần túy cho mô hình SaaS thường được phép. Việc chuyển giao một ứng dụng, bộ cài đặt tại chỗ (on-prem installer), phần sụn thiết bị (device firmware), hoặc container image cho khách hàng được tính là hành vi phân phối.
- Phiên bản đóng vai trò rất quan trọng: mã nguồn `GPL-2.0-only` không thể kết hợp với mã nguồn GPL-3.0 hoặc Apache-2.0 trong cùng một tác phẩm. Hậu tố `-or-later` mang lại sự linh hoạt cao hơn.
## Copyleft qua mạng (Network copyleft)
SPDX: `AGPL-3.0-only`, `AGPL-3.0-or-later`, `SSPL-1.0` (chưa được OSI phê duyệt)
- Tương tự như GPL, cộng thêm điều khoản: nếu người dùng tương tác với một phiên bản đã sửa đổi qua mạng, bạn bắt buộc phải cung cấp mã nguồn của phiên bản đó cho họ.
- Đối với một sản phẩm SaaS mã nguồn đóng, đây là yếu tố chặn (blocker) phổ biến nhất. Ngay cả việc sử dụng bản gốc không sửa đổi cũng kích hoạt quy trình rà soát nội bộ tại hầu hết các công ty.
- SSPL còn đi xa hơn: việc cung cấp phần mềm đó dưới dạng dịch vụ (as a service) có thể đòi hỏi bạn phải công bố mã nguồn của toàn bộ ngăn xếp dịch vụ (service stack) của mình.
## Mã nguồn khả dụng và phi thương mại (Source-available and non-commercial)
SPDX hoặc tên thường gặp: `BUSL-1.1` (Business Source License), `Elastic-2.0`, các điều khoản bổ sung Commons Clause, `CC-BY-NC-*`, `PolyForm-Noncommercial-1.0.0`
- Mã nguồn được hiển thị công khai, nhưng quyền sử dụng bị hạn chế: thường cấm dùng để cung cấp dịch vụ lưu trữ cạnh tranh (competing hosted service), cấm sử dụng trong môi trường production vượt quá giới hạn cho phép, hoặc cấm hoàn toàn mục đích thương mại, đôi khi áp dụng cho đến một ngày chuyển đổi giấy phép (change date) nhất định.
- Luôn cần phải rà soát từng điều khoản chính xác. Nhiều giấy phép dạng này sẽ chuyển sang giấy phép mở sau một vài năm, vì vậy phiên bản cũ hơn hoặc mới hơn có thể mang điều khoản khác nhau.
## Chưa xác định (Unknown)
- Các trường giấy phép để trống, `UNKNOWN`, `NOASSERTION`, `UNLICENSED`, `SEE LICENSE IN <file>`, `LicenseRef-*`, hoặc một cái tên mà kịch bản không nhận diện được.
- "Không có giấy phép" ("No license") đồng nghĩa với việc tác giả giữ toàn bộ bản quyền (all rights reserved): bạn không có quyền sử dụng mã nguồn đó. Hãy tìm điều khoản cấp phép thực sự hoặc thay thế gói phụ thuộc đó.
## Biểu thức kết hợp (Expressions)
- `MIT OR Apache-2.0`: bạn có thể chọn một trong hai; hãy chọn giấy phép phù hợp với bạn và lưu hồ sơ lựa chọn đó.
- `MIT AND BSD-3-Clause`: bạn bắt buộc phải tuân thủ cả hai; giấy phép nghiêm ngặt nhất sẽ quyết định danh mục phân loại.
- `GPL-2.0-only WITH <exception>`: điều khoản ngoại lệ giúp thu hẹp phạm vi copyleft; hãy đọc kỹ nội dung ngoại lệ đó.
FILE:references/remediation-playbook.md
# Cẩm nang khắc phục (Remediation playbook)
Chọn một hành động cho mỗi phát hiện và ghi lại kèm theo người chịu trách nhiệm (owner). Ưu tiên hành động tốn ít chi phí nhất mà loại bỏ được rủi ro.
## Thứ tự ưu tiên xử lý
1. Các giấy phép chưa xác định (unknown) và bị thiếu (bạn có thể hoàn toàn không có quyền sử dụng mã nguồn đó).
2. Các gói thuộc nhóm copyleft qua mạng (network copyleft) và mã nguồn khả dụng (source-available) nằm trong mã nguồn được phân phối hoặc lưu trữ trên máy chủ dịch vụ (hosted code).
3. Nhóm copyleft mạnh (strong copyleft) trong các sản phẩm phân phối tới khách hàng, và sự không tương thích giữa các phiên bản giấy phép trong các dự án mã nguồn mở.
4. Các nghĩa vụ đối với nhóm copyleft yếu (liên kết động, công bố các thay đổi).
5. Thông báo bản quyền (notices) cho tất cả các gói còn lại.
## Các hành động cụ thể
### Tìm giấy phép thực sự
- Mở kho lưu trữ (repository) và gói nén (archive) của gói phụ thuộc, đọc tệp LICENSE, COPYING, hoặc phần tiêu đề (header) của tệp mã nguồn chính.
- Kiểm tra xem liệu siêu dữ liệu (metadata) có bị lỗi thời hay không (trường hợp rất phổ biến đối với các gói cũ và các bản fork).
- Nếu thực sự không có giấy phép nào, hãy nhờ người bảo trì (maintainer) bổ sung giấy phép (đính kèm liên kết tới choosealicense.com trong yêu cầu) hoặc thay thế gói phụ thuộc đó.
### Thay thế gói phụ thuộc
- Tìm kiếm một giải pháp thay thế đang được bảo trì tốt, có giấy phép rộng rãi (permissive) và API tương tự.
- Ước tính công sức thực hiện theo số giờ và đưa vào danh sách công việc tồn đọng (backlog) gắn với phiên bản phát hành bị nó chặn.
- Đối với các gói phụ thuộc bắc cầu (transitive dependencies), hãy tìm gói phụ thuộc trực tiếp đã kéo nó vào; việc đổi sang một phiên bản khác hoặc bật/tắt một cờ cấu hình thường sẽ loại bỏ được nó.
### Ghim (Pin) phiên bản theo giấy phép cũ
- Khi một dự án chuyển từ giấy phép rộng rãi sang giấy phép mã nguồn khả dụng (hoặc ngược lại), các phiên bản cũ vẫn giữ nguyên giấy phép cũ của chúng.
- Việc ghim phiên bản chỉ là giải pháp ngắn hạn: bạn sẽ không nhận được các bản cập nhật bảo mật. Hãy lên lịch thay thế triệt để.
### Cô lập gói phụ thuộc (Isolate it)
- Chạy công cụ mang giấy phép copyleft dưới dạng một tiến trình hoặc dịch vụ riêng biệt giao tiếp với mã nguồn của bạn qua dòng lệnh (CLI), giao thức HTTP, hoặc hàng đợi tin nhắn (queue), thay vì liên kết (link) trực tiếp nó vào mã nguồn của bạn.
- Giữ nguyên bản gốc không sửa đổi nếu có thể. Ghi lại tài liệu rõ ràng về ranh giới giao tiếp này.
- Đây là cách phổ biến để sử dụng các công cụ dòng lệnh mang giấy phép GPL, nhưng hãy nhờ cố vấn pháp lý xác nhận đối với trường hợp AGPL và SSPL.
### Mua giấy phép thương mại
- Nhiều dự án cấp phép kép (mã nguồn mở song song với bản thương mại) có bán giấy phép thương mại giúp loại bỏ các nghĩa vụ copyleft. Hãy lưu ý kỹ mức giá và điều khoản gia hạn.
### Tuân thủ các điều khoản (Comply)
- Copyleft yếu (Weak copyleft): giữ thư viện dưới dạng một tệp hoặc gói riêng biệt, có thể thay thế được; công bố bất kỳ thay đổi nào đối với chính các tệp của thư viện đó; đính kèm giấy phép của nó.
- GPL trong sản phẩm phân phối tới khách hàng: chỉ khả thi nếu toàn bộ sản phẩm có thể mở mã nguồn theo các điều khoản tương thích. Đây là một quyết định kinh doanh, không phải quyết định kỹ thuật.
- AGPL trong dịch vụ lưu trữ (hosted service): cung cấp mã nguồn tương ứng cho người dùng dịch vụ, bao gồm cả những sửa đổi của bạn.
### Chấp nhận kèm lý do được lưu hồ sơ
- Các công cụ chỉ phục vụ phát triển (linters, trình chạy kiểm thử, công cụ biên dịch) không bao giờ được phân phối kèm sản phẩm thường không gây rủi ro. Hãy ghi nhận rõ: "chỉ dùng cho dev, không phân phối".
- Việc sử dụng nội bộ các công cụ GPL hoặc AGPL thường không gây vấn đề. Hãy ghi nhận rõ ai là người sử dụng và xác nhận rằng công cụ đó không phơi bày ra cho người dùng bên ngoài.
## Thông báo bản quyền bên thứ ba (Third-party notices)
- Tạo một tệp thông báo bản quyền từ danh mục gói phụ thuộc: tên gói, phiên bản, giấy phép, dòng bản quyền (copyright line), toàn văn giấy phép (mỗi nội dung giấy phép khác nhau chỉ cần lưu một bản).
- Đính kèm nguyên trạng các tệp NOTICE của giấy phép Apache.
- Phân phối tệp này bên trong ứng dụng ("Cài đặt > Giới thiệu > Giấy phép"), bộ cài đặt và container image; đối với SaaS, hãy đặt liên kết tới nó ở chân trang (footer) hoặc trong trang tài liệu.
## Duy trì sự minh bạch và sạch sẽ
- Chạy kiểm toán tự động trong CI mỗi khi có thay đổi đối với tệp khóa (lock file) và cho dừng lỗi quá trình biên dịch (fail build) nếu có phát hiện mức HIGH.
- Duy trì một danh sách cho phép (allow list - ví dụ: MIT, BSD, ISC, Apache-2.0) và một danh sách chặn (deny list - ví dụ: AGPL-3.0, SSPL-1.0, BUSL-1.1) đã được thống nhất với cố vấn pháp lý, và truyền chúng vào kịch bản qua tham số `--allow` và `--deny`.
FILE:templates/license-audit-report.md
# Báo cáo Kiểm toán Giấy phép Gói phụ thuộc: <tên dự án>
Ngày: <YYYY-MM-DD>
Giấy phép dự án: <proprietary | mã SPDX>
Mô hình phân phối: <saas | distributed | internal> (<một dòng mô tả cách người dùng tiếp cận phần mềm>)
Nguồn danh mục: <công cụ và câu lệnh, mã commit của tệp khóa lock file>
Số gói đã kiểm toán: <n> (<runtime n>, <dev n>)
## Tóm tắt
- Các yếu tố chặn - Blockers (HIGH): <n> - <một dòng liệt kê tên các gói>
- Các nghĩa vụ - Obligations (WARN): <n> - <một dòng tóm tắt>
- Lưu ý - Notes (INFO): <n>
- Hợp lệ - Clean (OK): <n>
- Kết luận (Verdict): <sẵn sàng phát hành | phát hành sau khi khắc phục | bị chặn> bởi vì <lý do>.
## Các yếu tố chặn (Blockers)
| Gói | Phiên bản | Giấy phép (theo phát hiện) | Danh mục | Tại sao lại quan trọng với chúng ta | Hành động | Người phụ trách | Hạn chót |
|---|---|---|---|---|---|---|---|
| | | | | | | | |
## Các nghĩa vụ (Obligations)
| Gói | Giấy phép | Việc chúng ta phải làm | Đã hoàn thành? |
|---|---|---|---|
| | | | |
## Đã xác minh thủ công
- <tên gói>: siêu dữ liệu ghi là <x>, tệp LICENSE thực tế ghi là <y>.
## Các quyết định và giả định
- Các gói có giấy phép kép và phương án đã chọn: <tên gói: phương án>
- Các rủi ro được chấp nhận kèm lý do: <tên gói: lý do, người phê duyệt>
- Các giả định: <ví dụ: "bảng điều khiển quản trị chỉ dùng trong nội bộ">
## Điều gì sẽ thay đổi nếu chúng ta thay đổi phương thức phân phối
- <ví dụ: "Nếu chúng ta phát hành phiên bản cài đặt tại chỗ (on-prem), gói readline-sync (GPL-3.0) sẽ trở thành yếu tố chặn.">
## Thông báo bản quyền bên thứ ba (Third-party notices)
Các giấy phép cần đưa toàn văn vào tệp thông báo: <danh sách>
Vị trí tệp thông báo: <đường dẫn hoặc URL>
## Đợt kiểm toán tiếp theo
Chạy tự động trong CI mỗi khi tệp khóa (lock file) thay đổi; rà soát toàn diện trước <đợt phát hành tiếp theo hoặc ngày cụ thể>.
_Báo cáo này là bước sàng lọc kỹ thuật, không phải tư vấn pháp lý. Các yếu tố chặn bắt buộc phải được cố vấn pháp lý xác nhận trước khi dựa vào bất kỳ điều khoản ngoại lệ nào._
FILE:examples/example-saas-audit.md
# Ví dụ: Kiểm toán giấy phép cho hệ thống backend SaaS bằng Node.js
## Yêu cầu
"Chúng tôi là một dịch vụ B2B SaaS mã nguồn đóng. Các khách hàng doanh nghiệp liên tục gửi các bảng câu hỏi bảo mật hỏi về giấy phép mã nguồn mở, và năm tới chúng tôi có thể sẽ bán thêm phiên bản cài đặt tại chỗ (on-prem). Đây là bản xuất danh sách các gói phụ thuộc của chúng tôi. Có điều gì chúng tôi cần lo lắng không?"
Danh mục gói phụ thuộc (`deps.csv`, được xuất từ tệp khóa lock file, 18 dòng):
```
name,version,license,scope
express,4.19.2,MIT,runtime
lodash,4.17.21,MIT,runtime
pg,8.12.0,MIT,runtime
sharp,0.33.4,Apache-2.0,runtime
dompurify,3.1.6,(MPL-2.0 OR Apache-2.0),runtime
ghostscript-wrapper,2.1.0,AGPL-3.0-only,runtime
chart-kit-pro,1.4.0,SEE LICENSE IN LICENSE.md,runtime
readline-sync,1.4.10,GPL-3.0,runtime
mariadb-connector,3.3.1,LGPL-2.1-or-later,runtime
fast-csv-lite,0.9.0,,runtime
redis-om,0.4.3,Business Source License 1.1,runtime
jest,29.7.0,MIT,dev
eslint-plugin-gpl-thing,1.0.2,GPL-2.0-only,dev
argparse,2.0.1,Python-2.0,runtime
tslib,2.6.3,0BSD,runtime
lodash,3.10.1,MIT,runtime
search-client,7.10.2,Apache-2.0,runtime
search-client,8.1.0,Elastic License 2.0,runtime
```
## Kết quả chạy kịch bản
```
$ python3 scripts/audit_licenses.py deps.csv --project-license proprietary --distribution saas
Dependency license audit: 18 packages, project license proprietary, distribution saas
HIGH 5 WARN 1 INFO 2 OK 10
...
HIGH chart-kit-pro@1.4.0 SEE LICENSE IN LICENSE.md unknown runtime
HIGH fast-csv-lite@0.9.0 (missing) unknown runtime
HIGH redis-om@0.4.3 BUSL-1.1 source-available runtime
HIGH search-client@8.1.0 Elastic-2.0 source-available runtime
HIGH ghostscript-wrapper@2.1.0 AGPL-3.0-only network-copyleft runtime
WARN readline-sync@1.4.10 GPL-3.0-only strong-copyleft runtime
INFO eslint-plugin-gpl-thing@1.0.2 GPL-2.0-only strong-copyleft dev
INFO mariadb-connector@3.3.1 LGPL-2.1-or-later weak-copyleft runtime
...
Notes:
- search-client: different licenses across versions (Apache-2.0, Elastic-2.0); check which version you ship.
```
Mã thoát (Exit code) 1 (có các phát hiện mức HIGH).
## Báo cáo (mẫu đã điền thông tin, rút gọn)
# Báo cáo Kiểm toán Giấy phép Gói phụ thuộc: acme-api
Giấy phép dự án: proprietary (độc quyền). Mô hình phân phối: saas (khách hàng sử dụng ứng dụng web và REST API; hiện tại không có gì được cài đặt trên máy của khách hàng).
Số gói đã kiểm toán: 18 (16 runtime, 2 dev).
### Tóm tắt
- Các yếu tố chặn - Blockers (HIGH): 5 - hai gói không có giấy phép sử dụng hợp lệ, hai gói thuộc nhóm mã nguồn khả dụng (source-available), một gói mang giấy phép AGPL.
- Các nghĩa vụ - Obligations (WARN): 1 - một gói mang giấy phép GPL-3.0 hoàn toàn ổn đối với mô hình SaaS nhưng sẽ chặn đứng phiên bản cài đặt tại chỗ (on-prem) đang lên kế hoạch.
- Kết luận (Verdict): phát hành sau khi khắc phục. Không có vấn đề nào ở đây là bất thường, và mỗi mục đều có cách khắc phục ít tốn kém.
### Các yếu tố chặn (Blockers)
| Gói | Giấy phép (theo phát hiện) | Tại sao lại quan trọng với chúng ta | Hành động |
|---|---|---|---|
| chart-kit-pro 1.4.0 | SEE LICENSE IN LICENSE.md | Tệp LICENSE.md là một giấy phép thương mại chỉ cho phép sử dụng khi có khóa trả phí (paid key). Chúng ta chưa ghi nhận khóa nào trong hồ sơ. | Xác nhận lại với nhóm đã mua gói này, hoặc thay thế bằng một thư viện vẽ biểu đồ có giấy phép rộng rãi (permissive). |
| fast-csv-lite 0.9.0 | (missing) | Không có giấy phép đồng nghĩa với việc không được phép sử dụng. | Yêu cầu người bảo trì bổ sung giấy phép; trong lúc chờ đợi, chuyển sang sử dụng một trình phân tích CSV đang được bảo trì tốt có điều khoản MIT. |
| redis-om 0.4.3 | BUSL-1.1 | Giấy phép Business Source License giới hạn việc sử dụng trên môi trường production cho đến ngày chuyển đổi giấy phép của nó; các điều khoản cụ thể thay đổi tùy theo từng dự án. | Đọc kỹ điều khoản "Additional Use Grant" chính xác. Nếu nó cấm trường hợp sử dụng của chúng ta, hãy thay thế hoặc mua giấy phép thương mại. |
| search-client 8.1.0 | Elastic-2.0 | Giấy phép Elastic License 2.0 cấm việc cung cấp phần mềm này dưới dạng một dịch vụ được quản lý (managed service). Phiên bản 7.10.2 nằm trong cùng cây phụ thuộc lại mang giấy phép Apache-2.0. | Kiểm tra tệp khóa (lock file): những module nào đang import phiên bản 8.1.0? Ghim cả hai về nhánh phiên bản dùng Apache-2.0 hoặc chuyển sang một nhánh fork được cấp phép theo Apache. |
| ghostscript-wrapper 2.1.0 | AGPL-3.0-only | Chúng ta dùng nó để kết xuất (render) tệp PDF của khách hàng ngay bên trong API; việc sử dụng qua mạng đối với AGPL đòi hỏi phải cung cấp mã nguồn cho người dùng. | Chuyển việc kết xuất PDF sang một dịch vụ worker tách biệt, giữ nguyên không sửa đổi và nhờ cố vấn pháp lý xác nhận, hoặc mua giấy phép thương mại. |
### Các nghĩa vụ (Obligations)
| Gói | Giấy phép | Việc chúng ta phải làm |
|---|---|---|
| readline-sync 1.4.10 | GPL-3.0-only | Hoàn toàn ổn đối với mô hình SaaS. Nó sẽ trở thành yếu tố chặn nếu phiên bản cài đặt tại chỗ (on-prem) được đóng gói kèm theo nó. Gói này chỉ được sử dụng bởi một kịch bản CLI quản trị, vì vậy hãy gỡ bỏ nó khỏi server image. |
| mariadb-connector 3.3.1 | LGPL-2.1-or-later | Giữ nó dưới dạng một gói phụ thuộc npm thông thường (có thể thay thế được); công bố các thay đổi nếu chúng ta có vá (patch) mã nguồn của nó. |
### Đã xác minh thủ công
- dompurify: cấp phép kép `MPL-2.0 OR Apache-2.0`; chúng ta chọn Apache-2.0.
- eslint-plugin-gpl-thing: chỉ dùng cho môi trường dev, không bao giờ được phân phối. Chấp nhận.
### Điều gì sẽ thay đổi nếu chúng ta phân phối bản cài đặt tại chỗ (on-prem)
- readline-sync (GPL-3.0) sẽ chuyển thành mức HIGH, mariadb-connector (LGPL) cần được kiểm tra khả năng thay thế thư viện, và các gói Apache-2.0 cần đưa các tệp NOTICE của chúng vào trong bộ cài đặt. Hãy chạy lại kiểm toán với tham số `--distribution distributed` trước đợt phát hành đó.
### Thông báo bản quyền bên thứ ba (Third-party notices)
Các giấy phép cần đưa toàn văn: Apache-2.0, LGPL-2.1-or-later, MIT, Python-2.0. Đặt liên kết tới trang thông báo bản quyền ở phần chân trang (footer) của ứng dụng.
_Sàng lọc kỹ thuật, không phải tư vấn pháp lý._
## Tại sao đây là một câu trả lời tốt
- Đánh giá mọi gói phụ thuộc dựa trên mô hình phân phối thực tế của công ty này, đồng thời chỉ rõ những gì sẽ thay đổi đối với kế hoạch phát hành bản on-prem.
- Kiểm tra những dữ kiện thực tế mà siêu dữ liệu (metadata) không thể hiển thị (tệp LICENSE.md thương mại, các phiên bản search-client bị trùng lặp).
- Mọi yếu tố chặn đều đi kèm một hành động giải quyết cụ thể và ít tốn kém.
FILE:scripts/audit_licenses.py
#!/usr/bin/env python3
"""Audit third-party dependency licenses against how a project is shipped.
Usage:
python3 audit_licenses.py deps.csv --project-license proprietary --distribution saas
python3 audit_licenses.py licenses.json --distribution distributed [--json]
cat deps.csv | python3 audit_licenses.py - [--allow LIC,LIC] [--deny LIC,LIC]
Input (detected automatically):
CSV with a header: name,version,license[,scope] (scope: runtime | dev; default runtime)
JSON list: [{"name": "...", "version": "...", "license": "...", "scope": "dev"}]
pip-licenses --format=json: [{"Name": "...", "Version": "...", "License": "..."}]
license-checker --json (npm): {"pkg@1.2.3": {"licenses": "MIT"}, ...}
Options:
--project-license your project's license: proprietary (default) or an SPDX id such as MIT, Apache-2.0, GPL-3.0-only
--distribution saas (network service, default) | distributed (apps, binaries, devices, libraries) | internal
--allow / --deny comma-separated SPDX ids that your policy always allows or always blocks
--json machine-readable output
License names are normalized to SPDX ids, expressions with OR take the most
permissive option and AND takes the most restrictive one. Each package gets a
category and a severity (OK, INFO, WARN, HIGH) for the chosen distribution.
Dev-only packages are not shipped, so their findings drop to INFO.
Exit code: 0 no HIGH findings, 1 at least one HIGH, 2 usage or input error.
This is an engineering triage aid, not legal advice. Standard library only.
"""
import argparse
import csv
import io
import json
import re
import sys
ALIASES = { # lowercased common names -> SPDX id
"mit": "MIT", "mit license": "MIT", "the mit license": "MIT", "expat": "MIT",
"isc": "ISC", "isc license": "ISC", "isc license (iscl)": "ISC",
"apache 2.0": "Apache-2.0", "apache-2": "Apache-2.0", "apache 2": "Apache-2.0", "apache2": "Apache-2.0",
"apache license 2.0": "Apache-2.0", "apache license, version 2.0": "Apache-2.0",
"apache software license": "Apache-2.0", "apache license version 2.0": "Apache-2.0",
"bsd": "BSD-3-Clause", "bsd license": "BSD-3-Clause", "new bsd": "BSD-3-Clause", "bsd-3": "BSD-3-Clause",
"3-clause bsd": "BSD-3-Clause", "bsd 3-clause": "BSD-3-Clause", "simplified bsd": "BSD-2-Clause",
"bsd-2": "BSD-2-Clause", "2-clause bsd": "BSD-2-Clause",
"python software foundation license": "PSF-2.0", "psf": "PSF-2.0", "psfl": "PSF-2.0",
"mozilla public license 2.0 (mpl 2.0)": "MPL-2.0", "mpl 2.0": "MPL-2.0", "mpl-2": "MPL-2.0", "mpl2": "MPL-2.0",
"gplv2": "GPL-2.0-only", "gpl-2.0": "GPL-2.0-only", "gpl v2": "GPL-2.0-only", "gpl2": "GPL-2.0-only",
"gplv2+": "GPL-2.0-or-later", "gpl-2.0+": "GPL-2.0-or-later",
"gplv3": "GPL-3.0-only", "gpl-3.0": "GPL-3.0-only", "gpl v3": "GPL-3.0-only", "gpl3": "GPL-3.0-only",
"gplv3+": "GPL-3.0-or-later", "gpl-3.0+": "GPL-3.0-or-later", "gpl": "GPL-3.0-or-later",
"gnu general public license v2 (gplv2)": "GPL-2.0-only", "gnu general public license v3 (gplv3)": "GPL-3.0-only",
"gnu general public license v2 or later (gplv2+)": "GPL-2.0-or-later",
"gnu general public license v3 or later (gplv3+)": "GPL-3.0-or-later",
"lgpl": "LGPL-2.1-or-later", "lgplv2": "LGPL-2.1-only", "lgplv2+": "LGPL-2.1-or-later", "lgpl-2.1": "LGPL-2.1-only",
"lgplv3": "LGPL-3.0-only", "lgplv3+": "LGPL-3.0-or-later", "lgpl-3.0": "LGPL-3.0-only",
"gnu lesser general public license v2 (lgplv2)": "LGPL-2.1-only",
"gnu lesser general public license v3 (lgplv3)": "LGPL-3.0-only",
"gnu lesser general public license v2 or later (lgplv2+)": "LGPL-2.1-or-later",
"agpl": "AGPL-3.0-only", "agplv3": "AGPL-3.0-only", "agpl-3.0": "AGPL-3.0-only",
"gnu affero general public license v3": "AGPL-3.0-only", "gnu affero general public license v3 (agplv3)": "AGPL-3.0-only",
"eclipse public license 2.0": "EPL-2.0", "epl 2.0": "EPL-2.0", "epl-2": "EPL-2.0",
"public domain": "Unlicense", "unlicense": "Unlicense", "the unlicense": "Unlicense", "cc0": "CC0-1.0",
"boost software license 1.0": "BSL-1.0", "zlib license": "Zlib", "zlib": "Zlib",
"business source license 1.1": "BUSL-1.1", "elastic license 2.0": "Elastic-2.0",
"server side public license": "SSPL-1.0", "sspl": "SSPL-1.0",
}
CATEGORY = { # SPDX id prefix -> category
"MIT": "permissive", "MIT-0": "public-domain", "ISC": "permissive", "BSD-2-Clause": "permissive",
"BSD-3-Clause": "permissive", "Apache-2.0": "permissive", "PSF-2.0": "permissive", "Python-2.0": "permissive",
"Zlib": "permissive", "BSL-1.0": "permissive", "X11": "permissive", "BlueOak-1.0.0": "permissive",
"CC-BY-4.0": "permissive", "CC-BY-3.0": "permissive", "Unicode-DFS-2016": "permissive", "Unicode-3.0": "permissive",
"0BSD": "public-domain", "Unlicense": "public-domain", "CC0-1.0": "public-domain", "WTFPL": "public-domain",
"MPL-2.0": "weak-copyleft", "EPL-1.0": "weak-copyleft", "EPL-2.0": "weak-copyleft", "CDDL-1.0": "weak-copyleft",
"CDDL-1.1": "weak-copyleft", "LGPL-2.1": "weak-copyleft", "LGPL-3.0": "weak-copyleft", "CC-BY-SA-4.0": "weak-copyleft",
"GPL-2.0": "strong-copyleft", "GPL-3.0": "strong-copyleft",
"AGPL-3.0": "network-copyleft", "SSPL-1.0": "network-copyleft",
"BUSL-1.1": "source-available", "Elastic-2.0": "source-available", "Commons-Clause": "source-available",
"CC-BY-NC-4.0": "source-available", "CC-BY-NC-SA-4.0": "source-available", "PolyForm-Noncommercial-1.0.0": "source-available",
}
RANK = {"public-domain": 0, "permissive": 1, "weak-copyleft": 2, "strong-copyleft": 3,
"network-copyleft": 4, "source-available": 5, "unknown": 6}
SEV_ORDER = {"HIGH": 0, "WARN": 1, "INFO": 2, "OK": 3}
NOTICE_LICENSES = {"Apache-2.0"}
def unwrap(text):
"""Remove parentheses that wrap the whole expression: '(MIT OR X)' -> 'MIT OR X'."""
t = text.strip()
while t.startswith("(") and t.endswith(")"):
depth = 0
for i, ch in enumerate(t):
depth += ch == "("
depth -= ch == ")"
if depth == 0 and i < len(t) - 1:
return t
t = t[1:-1].strip()
return t
def normalize(token):
t = unwrap(token)
if not t:
return ""
key = t.lower()
if key in ALIASES:
return ALIASES[key]
for spdx in CATEGORY:
if key == spdx.lower() or key.startswith(spdx.lower() + "-") or key == spdx.lower() + "+":
return t if key.startswith(spdx.lower() + "-") else spdx
return t
def category_of(spdx):
if spdx.upper().startswith("LICENSEREF") or spdx.upper().startswith("SEE LICENSE"):
return "unknown"
for prefix, cat in sorted(CATEGORY.items(), key=lambda kv: -len(kv[0])):
if spdx == prefix or spdx.startswith(prefix + "-") or spdx.startswith(prefix + "+"):
return cat
return "unknown"
def classify(expr):
"""Return (normalized expression, chosen license, category, exception) for a license expression."""
raw = (expr or "").strip()
if not raw or raw.upper() in ("UNKNOWN", "NONE", "NOASSERTION", "UNLICENSED", "CUSTOM", "N/A"):
return raw or "(missing)", raw or "(missing)", "unknown", None
if raw.upper().startswith("SEE LICENSE"):
return raw, raw, "unknown", None
whole = unwrap(raw)
if whole.lower() in ALIASES: # long names such as "... v2 or later (LGPLv2+)" are one license
spdx = ALIASES[whole.lower()]
return spdx, spdx, category_of(spdx), None
text = re.sub(r"\s+", " ", whole.replace("/", " OR ").replace(";", " OR "))
exception = None
m = re.search(r"\bWITH\s+([\w.+-]+)", text, re.I)
if m:
exception = m.group(1)
text = text[:m.start()] + text[m.end():]
if re.search(r"\sor\s", text, re.I) and not re.search(r"\sand\s", text, re.I):
parts, mode = re.split(r"\s+or\s+", text, flags=re.I), "or"
elif re.search(r"\sand\s", text, re.I) and not re.search(r"\sor\s", text, re.I):
parts, mode = re.split(r"\s+and\s+", text, flags=re.I), "and"
elif re.search(r"\s(or|and)\s", text, re.I):
parts, mode = re.split(r"\s+(?:or|and)\s+", text, flags=re.I), "and" # mixed: be conservative
else:
parts, mode = [text], "single"
lics = [normalize(p) for p in parts if normalize(p)]
if not lics:
return raw, raw, "unknown", exception
scored = sorted(lics, key=lambda l: RANK[category_of(l)])
chosen = scored[0] if mode == "or" else scored[-1]
cat = category_of(chosen)
if exception and cat == "strong-copyleft" and re.search(r"classpath|linking|gcc|runtime|autoconf|bison|bootloader", exception, re.I):
cat = "weak-copyleft"
joiner = " OR " if mode == "or" else " AND "
return joiner.join(lics) + (f" WITH {exception}" if exception else ""), chosen, cat, exception
def assess(cat, chosen, distribution, project):
proj_cat = "proprietary" if project.lower() == "proprietary" else category_of(normalize(project))
project_spdx = normalize(project)
if cat == "unknown":
return "HIGH", "License is missing or not recognized; find the real license text before shipping."
if cat == "source-available":
return "HIGH", "Source-available or non-commercial terms usually restrict commercial use or hosting; needs a license review."
if project_spdx.startswith("GPL-2.0-only") and chosen.startswith("Apache-2.0"):
return "HIGH", "Apache-2.0 is generally considered incompatible with GPL-2.0-only projects."
gpl3_family = re.match(r"(A|L)?GPL-3\.0", chosen)
if project_spdx.startswith("GPL-2.0-only") and gpl3_family:
return "HIGH", f"{chosen} cannot be combined with a GPL-2.0-only project; the project would need GPL-3.0 terms."
if re.match(r"GPL-3\.0|AGPL-3\.0", project_spdx) and chosen.startswith("GPL-2.0-only"):
return "HIGH", "GPL-2.0-only code cannot be relicensed under GPL-3.0; ask upstream for 'or later' terms or replace it."
if proj_cat in ("strong-copyleft", "network-copyleft") and cat in ("strong-copyleft", "network-copyleft"):
if cat == "network-copyleft" and proj_cat != "network-copyleft" and distribution == "saas":
return "WARN", "AGPL/SSPL in a GPL project run as a service: the whole service source must be offered to users."
return "OK", "Copyleft dependency in a copyleft project; check version compatibility (for example GPL-2.0-only vs GPL-3.0)."
if cat == "network-copyleft":
if distribution == "internal":
return "WARN", "Network copyleft: fine for purely internal use, but exposing it to outside users triggers source obligations."
return "HIGH", "Network copyleft (AGPL/SSPL): offering the service or shipping it requires releasing the corresponding source."
if cat == "strong-copyleft":
if distribution == "distributed":
return "HIGH", "GPL code distributed with your product requires the combined work to be released under the GPL."
if distribution == "saas":
return "WARN", "GPL is not triggered by network use alone, but any future distribution (apps, on-prem, SDKs) would be."
return "INFO", "GPL for internal use only has no distribution obligations; keep it out of anything you ship."
if cat == "weak-copyleft":
if distribution == "distributed":
return "WARN", "Weak copyleft: ship the license, keep the library replaceable (dynamic linking for LGPL), and publish changes to its files."
return "INFO", "Weak copyleft: publish any changes you make to this library's own files if you distribute it later."
if chosen in NOTICE_LICENSES and distribution == "distributed":
return "INFO", "Apache-2.0: include the license and any NOTICE file in your distribution."
return "OK", "Permissive: keep the copyright notice and license text in your third-party notices."
def load(text):
stripped = text.lstrip()
if not stripped:
raise ValueError("input is empty")
if stripped[0] in "[{":
data = json.loads(text)
rows = []
if isinstance(data, dict): # license-checker
for key, val in data.items():
name, _, ver = key.rpartition("@")
if not name:
name, ver = key, ""
lic = val.get("licenses", "") if isinstance(val, dict) else val
if isinstance(lic, list):
lic = " OR ".join(lic)
rows.append({"name": name, "version": ver, "license": str(lic), "scope": "runtime"})
return rows
for item in data:
if not isinstance(item, dict):
raise ValueError("JSON list items must be objects")
low = {k.lower(): v for k, v in item.items()}
lic = low.get("license", low.get("licenses", ""))
if isinstance(lic, list):
lic = " OR ".join(lic)
rows.append({"name": str(low.get("name", "")), "version": str(low.get("version", "")),
"license": str(lic or ""), "scope": str(low.get("scope", "runtime") or "runtime").lower()})
return rows
reader = csv.DictReader(io.StringIO(text))
fields = [f.strip().lower() for f in (reader.fieldnames or [])]
if "name" not in fields or "license" not in fields:
raise ValueError(f"CSV needs a header with at least 'name' and 'license' columns, got: {', '.join(fields) or '(none)'}")
rows = []
for r in reader:
r = {(k or "").strip().lower(): (v or "").strip() for k, v in r.items()}
if not r.get("name"):
continue
rows.append({"name": r["name"], "version": r.get("version", ""), "license": r.get("license", ""),
"scope": (r.get("scope") or "runtime").lower()})
return rows
def audit(rows, args):
allow = {normalize(x) for x in args.allow.split(",") if x.strip()} if args.allow else set()
deny = {normalize(x) for x in args.deny.split(",") if x.strip()} if args.deny else set()
results, seen = [], {}
for r in rows:
expr, chosen, cat, exc = classify(r["license"])
sev, why = assess(cat, chosen, args.distribution, args.project_license)
if exc:
why += f" License exception {exc} applies; read its exact scope."
if chosen in deny:
sev, why = "HIGH", f"{chosen} is on your deny list."
elif chosen in allow and sev != "OK":
sev, why = "OK", f"{chosen} is on your allow list ({why})"
dev = r["scope"] in ("dev", "development", "test", "build")
if dev and sev in ("HIGH", "WARN") and chosen not in deny:
sev, why = "INFO", "Dev-only (not shipped): " + why
results.append({"name": r["name"], "version": r["version"], "license_raw": r["license"],
"license": expr, "effective": chosen, "category": cat, "scope": "dev" if dev else "runtime",
"severity": sev, "reason": why})
seen.setdefault(r["name"].lower(), set()).add(expr)
notes = []
for name, lics in seen.items():
if len(lics) > 1:
notes.append(f"{name}: different licenses across versions ({', '.join(sorted(lics))}); check which version you ship.")
results.sort(key=lambda x: (SEV_ORDER[x["severity"]], -RANK[x["category"]], x["name"].lower()))
counts = {s: sum(1 for x in results if x["severity"] == s) for s in SEV_ORDER}
cats = {}
for x in results:
cats[x["category"]] = cats.get(x["category"], 0) + 1
attribution = sorted({x["effective"] for x in results if x["scope"] == "runtime" and x["category"] in ("permissive", "weak-copyleft")})
return {"project_license": args.project_license, "distribution": args.distribution, "packages": len(results),
"severity_counts": counts, "category_counts": cats, "attribution_licenses": attribution,
"notes": notes, "results": results}
def print_text(rep):
print(f"Dependency license audit: {rep['packages']} packages, project license {rep['project_license']}, distribution {rep['distribution']}")
c = rep["severity_counts"]
print(f"HIGH {c['HIGH']} WARN {c['WARN']} INFO {c['INFO']} OK {c['OK']}")
print("Categories: " + ", ".join(f"{k} {v}" for k, v in sorted(rep["category_counts"].items(), key=lambda kv: RANK[kv[0]])))
print()
w = max([len(f"{x['name']}@{x['version']}") for x in rep["results"]] + [7])
print(f"{'SEV':<5} {'PACKAGE':<{w}} {'LICENSE':<28} {'CATEGORY':<17} SCOPE")
for x in rep["results"]:
pkg = f"{x['name']}@{x['version']}" if x["version"] else x["name"]
print(f"{x['severity']:<5} {pkg:<{w}} {x['license'][:28]:<28} {x['category']:<17} {x['scope']}")
findings = [x for x in rep["results"] if x["severity"] in ("HIGH", "WARN", "INFO")]
if findings:
print("\nFindings:")
for x in findings:
print(f"- [{x['severity']}] {x['name']} ({x['license_raw'] or 'no license field'}): {x['reason']}")
if rep["notes"]:
print("\nNotes:")
for n in rep["notes"]:
print(f"- {n}")
if rep["attribution_licenses"]:
print("\nThird-party notices must include license texts for: " + ", ".join(rep["attribution_licenses"]))
print("\nTriage aid only, not legal advice.")
def main():
p = argparse.ArgumentParser(description="Audit dependency licenses.", add_help=True)
p.add_argument("path", help="CSV or JSON file, or - for stdin")
p.add_argument("--project-license", default="proprietary")
p.add_argument("--distribution", default="saas", choices=["saas", "distributed", "internal"])
p.add_argument("--allow", default="")
p.add_argument("--deny", default="")
p.add_argument("--json", action="store_true")
args = p.parse_args()
try:
text = sys.stdin.read() if args.path == "-" else open(args.path, encoding="utf-8").read()
rows = load(text)
except FileNotFoundError:
print(f"error: file not found: {args.path}", file=sys.stderr)
return 2
except (ValueError, json.JSONDecodeError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
if not rows:
print("error: no dependencies found in the input", file=sys.stderr)
return 2
rep = audit(rows, args)
if args.json:
print(json.dumps(rep, indent=2))
else:
print_text(rep)
return 1 if rep["severity_counts"]["HIGH"] else 0
if __name__ == "__main__":
sys.exit(main())Chuyển đổi thực đơn và ghi chú công thức của quán cà phê hoặc nhà hàng thành một gói thông tin dị ứng chuẩn JSON nghiêm ngặt: ma trận món ăn theo chất gây dị ứng không bao giờ đoán mò là 'không chứa', các nguồn gây dị ứng tiềm ẩn, rủi ro nhiễm chéo từ nồi chiên và thớt dùng chung, câu hỏi cho nhà cung cấp, biện pháp kiểm soát trong bếp, kịch bản trả lời của nhân viên và các phương án thay thế an toàn.
1{2 "role": "Chuyên gia tư vấn an toàn thực phẩm chuyên xây dựng thông tin chất gây dị ứng rõ ràng, có thể kiểm toán cho các nhà hàng và quán cà phê nhỏ",3 "task": "Chuyển đổi thực đơn và ghi chú công thức của tôi thành một gói thông tin chất gây dị ứng chuẩn JSON nghiêm ngặt duy nhất: ma trận món ăn theo chất gây dị ứng chỉ dựa trên các nguyên liệu tôi liệt kê, cảnh báo chất gây dị ứng tiềm ẩn, rủi ro nhiễm chéo trong bếp của tôi, các câu hỏi mở cho nhà cung cấp, kịch bản trả lời của nhân viên và ý tưởng thay thế nguyên liệu an toàn. Không bao giờ đoán mò rằng một món ăn không chứa chất gây dị ứng.",4 "context": {5 "venue": "${venue:small neighborhood cafe with an open kitchen}",6 "region_and_rules": "${region:European Union, 14 regulated allergens}",7 "menu_and_recipes": "${menu:paste each dish with its full ingredient list, sauces, garnishes, and frying oil}",8 "supplier_products": "${suppliers:list branded products such as stock cubes, bread, sauces, spice mixes, with label notes if known}",9 "kitchen_setup": "${kitchen:one shared fryer, one grill, shared chopping boards, separate gluten-free toaster}",10 "goal": "${goal:printable allergen chart for customers and a staff briefing sheet}"...+110 dòng nữa
Đánh giá cấu hình chính sách mật khẩu theo tiêu chuẩn NIST SP 800-63B-4, bao gồm độ dài tối thiểu, không áp đặt quy tắc kết hợp ký tự hay bắt buộc đổi mật khẩu định kỳ, kiểm tra danh sách chặn (blocklist), giới hạn tốc độ thử (rate limiting), hỗ trợ dán (paste) và Unicode, không dùng gợi ý mật khẩu hay câu hỏi bảo mật, cùng các tham số băm (hashing) an toàn. Ước tính entropy của các mật khẩu mẫu giả định và chỉ ra những mật khẩu yếu nào vẫn được chính sách hiện tại chấp nhận. Không bao giờ yêu cầu mật khẩu thật. Đi kèm công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ --self-test.
---
name: password-policy-and-entropy-checker
description: Đánh giá cấu hình chính sách mật khẩu theo tiêu chuẩn NIST SP 800-63B-4, bao gồm độ dài tối thiểu, không áp đặt quy tắc kết hợp ký tự hay bắt buộc đổi mật khẩu định kỳ, kiểm tra danh sách chặn (blocklist), giới hạn tốc độ thử (rate limiting), hỗ trợ dán (paste) và Unicode, không dùng gợi ý mật khẩu hay câu hỏi bảo mật, cùng các tham số băm (hashing) an toàn. Ước tính entropy của các mật khẩu mẫu giả định và chỉ ra những mật khẩu yếu nào vẫn được chính sách hiện tại chấp nhận. Không bao giờ yêu cầu mật khẩu thật. Đi kèm công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ --self-test.
---
# Trình Kiểm tra Chính sách Mật khẩu và Entropy
Bạn giúp các lập trình viên, quản trị viên CNTT và chuyên gia đánh giá bảo mật điều chỉnh chính sách mật khẩu phù hợp với hướng dẫn NIST hiện hành (SP 800-63B-4, 2025), đồng thời giải thích tại sao các quy tắc cũ (8 ký tự, chữ hoa+chữ thường+chữ số+ký tự đặc biệt, đổi mỗi 90 ngày) lại tạo ra những mật khẩu yếu và dễ đoán. Bạn chứng minh điều đó bằng những con số cụ thể: ước tính entropy cho các mật khẩu mẫu giả định và danh sách các mẫu mật khẩu yếu mà chính sách hiện tại vẫn cho phép lọt qua.
## Các tệp trong kỹ năng này
- `scripts/pw_policy.py` - kiểm tra tệp JSON chính sách đối chiếu với NIST SP 800-63B-4 và mức tối thiểu về băm mật khẩu của OWASP, đồng thời ước tính entropy cho các mẫu tổng hợp, mặc định được che (mask) ở đầu ra (chỉ dùng thư viện chuẩn Python 3; không kết nối mạng, không lưu trữ bất cứ thứ gì)
- `references/nist-800-63b-rules.md` - các yêu cầu về mật khẩu (SHALL / SHOULD) cùng ý nghĩa thực tế của từng yêu cầu
- `references/entropy-and-hashing.md` - cách thức hoạt động của hai phương pháp ước tính entropy, giới hạn của chúng, các giả định về tốc độ đoán mật khẩu và các tham số băm
- `templates/policy-recommendation.md` - bản khuyến nghị bạn trả về: thiết lập hiện tại so với thiết lập khuyến nghị, lý do và kế hoạch triển khai
- `examples/legacy-portal-policy.json` và `examples/synthetic-samples.txt` - một chính sách cổng thông tin khách hàng kiểu cũ và 8 mật khẩu mẫu giả định
## Quy trình làm việc
### 1. Thu thập chính sách, tuyệt đối không thu thập mật khẩu thật
Yêu cầu người dùng cung cấp các thiết lập chính sách (từ nhà cung cấp danh tính - IdP, chính sách mật khẩu chi tiết của Active Directory, cấu hình framework như `AUTH_PASSWORD_VALIDATORS` của Django, hoặc tài liệu chính sách bằng văn bản) và chuyển đổi chúng thành các khóa JSON như trong docstring của script. Hỏi xem mật khẩu là yếu tố xác thực duy nhất hay luôn được kết hợp với MFA (`single_factor`). Nếu người dùng muốn kiểm tra độ mạnh, hãy yêu cầu họ tự nghĩ ra các mẫu giống với cách người dùng thường đặt. Nếu ai đó dán nội dung trông giống mật khẩu thật, tuyệt đối không lặp lại mật khẩu đó, hãy nhắc họ đổi mật khẩu ngay và tiếp tục làm việc bằng các mẫu giả định.
### 2. Chạy trình kiểm tra
```
python3 scripts/pw_policy.py examples/legacy-portal-policy.json --samples examples/synthetic-samples.txt --context northwind
python3 scripts/pw_policy.py policy.json --samples synthetic.txt --blocklist top-10k.txt --json
python3 scripts/pw_policy.py --self-test
```
`--context` thêm tên dịch vụ, thương hiệu và các từ tương tự vào bước kiểm tra danh sách chặn (blocklist). Mã thoát (exit code) 1 có nghĩa là có phát hiện mức HIGH, 2 có nghĩa là đầu vào không hợp lệ.
### 3. Giải thích các phát hiện
Bắt đầu với những gì thay đổi đối với người dùng: độ dài tối thiểu lớn hơn (15 ký tự nếu dùng đơn lẻ, 8 ký tự nếu có MFA), bỏ quy tắc bắt buộc đủ nhóm ký tự, bỏ bắt buộc đổi mật khẩu định kỳ, từ chối mật khẩu nằm trong danh sách chặn kèm lý do rõ ràng. Tiếp theo là phần hậu cần (back end): giới hạn tốc độ thử (rate limiting), thuật toán và tham số băm, gỡ bỏ gợi ý mật khẩu và câu hỏi bảo mật. Sử dụng bảng mẫu để chỉ ra rằng các mật khẩu kiểu `Password1!` vượt qua được các quy tắc kết hợp ký tự trong khi một cụm mật khẩu (passphrase) gồm bốn từ lại bị chúng từ chối. Sử dụng `references/nist-800-63b-rules.md` để trích dẫn yêu cầu chính xác và `references/entropy-and-hashing.md` khi người dùng hỏi "mạnh bao nhiêu là đủ".
### 4. Bàn giao kết quả
Điền vào `templates/policy-recommendation.md` với từng thiết lập (hiện tại, khuyến nghị, tham chiếu NIST), lộ trình chuyển đổi thuật toán băm (băm lại trong lần đăng nhập tiếp theo) và kế hoạch triển khai: áp dụng quy tắc mới vào lần đổi mật khẩu tiếp theo, gỡ bỏ ngay lập tức quy định hết hạn định kỳ, và kiểm tra mật khẩu bị lộ lọt ngay khi đăng nhập ở những nơi có thể thực hiện.
## Kết quả ví dụ
Đối với chính sách cổng thông tin kiểu cũ cùng 8 mẫu tổng hợp và `--context northwind`: 11 phát hiện HIGH, 5 MEDIUM, 2 LOW. Mức HIGH: độ dài tối thiểu 8 (cần 15 khi không có MFA), có quy tắc kết hợp ký tự, hết hạn sau 90 ngày, không có danh sách chặn, dùng gợi ý mật khẩu, dùng câu hỏi bảo mật, và 5 mẫu yếu được chính sách chấp nhận (Password1!, Summer2025!, Northwind#1, Qwerty123$, P@ssw0rd2026, mỗi mẫu chỉ khoảng ~15-20 bit). Mức MEDIUM: PBKDF2 ở mức 10000 vòng lặp, độ dài tối đa 20, chặn dấu cách và thao tác dán, không có hướng dẫn độ mạnh. Cụm mật khẩu 4 từ (~54 bit) và cụm 5 từ (~67 bit) bị quy tắc hiện tại từ chối nhưng hoàn toàn hợp lệ theo chuẩn NIST.
## Quy tắc
- Không bao giờ yêu cầu, lưu trữ hoặc lặp lại mật khẩu thật, mã băm (hash) thật hay tệp mật khẩu thật. Chỉ làm việc với các mẫu giả định.
- Các con số entropy là mức ước tính để so sánh, không phải sự đảm bảo tuyệt đối; hãy nêu rõ điều đó.
- Trích dẫn chính xác mức SHALL và SHOULD của NIST; không trình bày các con số khuyến nghị băm của OWASP như thể đó là yêu cầu bắt buộc của NIST.
- MFA chống giả mạo (phishing-resistant MFA) hoặc passkey vượt trội hơn bất kỳ quy tắc mật khẩu nào; hãy khuyến nghị chúng ở những nơi mức độ rủi ro đòi hỏi.
- Không khuyến nghị làm suy yếu một biện pháp kiểm soát (ví dụ: giới hạn tốc độ thử) để cải thiện tính tiện dụng mà không có biện pháp thay thế.
FILE:references/nist-800-63b-rules.md
# Quy tắc Mật khẩu NIST SP 800-63B-4 (bản chính thức, 2025)
Mục 3.1.1.2 "Password Verifiers" áp dụng cho mật khẩu được xác minh tập trung (biểu mẫu đăng nhập được kiểm tra bởi máy chủ). Mã PIN mở khóa cục bộ là "activation secrets" (bí mật kích hoạt) với các quy tắc khác (Mục 3.2.10). SHALL = bắt buộc, SHOULD = khuyến nghị.
| # | Yêu cầu | Mức độ | Mã trong script | Trong thực tế |
|---|---|---|---|---|
| 1 | Tối thiểu 15 ký tự khi mật khẩu là yếu tố duy nhất; tối thiểu 8 ký tự nếu chỉ dùng kèm MFA | SHALL | MIN_LENGTH | Đặt 15 cho đăng nhập chỉ dùng mật khẩu; mức 8 chỉ chấp nhận được nếu MFA luôn được áp dụng bắt buộc |
| 2 | Cho phép độ dài tối đa ít nhất 64 ký tự | SHOULD | MAX_LENGTH | Cụm mật khẩu và đầu ra của trình quản lý mật khẩu cần không gian; kích thước lưu trữ đằng nào cũng cố định theo mã băm |
| 3 | Chấp nhận tất cả các ký tự ASCII in được và dấu cách | SHOULD | NO_SPACES | Không từ chối `"`, `'`, `<`, dấu cách; thay vào đó hãy xử lý thoát ký tự (escape) đúng cách |
| 4 | Chấp nhận Unicode; đếm mỗi điểm mã (code point) là một ký tự | SHOULD | NO_UNICODE | Áp dụng chuẩn hóa NFC trước khi băm (NO_NORMALIZATION) |
| 5 | Không áp đặt các quy tắc kết hợp ký tự khác (trộn các nhóm ký tự) | SHALL NOT | COMPOSITION_RULES | Gỡ bỏ quy định "1 chữ hoa, 1 chữ số, 1 ký tự đặc biệt" |
| 6 | Không bắt buộc đổi mật khẩu định kỳ; chỉ bắt buộc đổi khi có bằng chứng lộ lọt | SHALL NOT | PERIODIC_EXPIRY | Gỡ bỏ thời hạn 90 ngày; đặt lại mật khẩu khi có bằng chứng rò rỉ |
| 7 | Không cung cấp gợi ý mật khẩu mà người dùng chưa xác thực có thể truy cập | SHALL NOT | HINTS | Xóa các gợi ý mật khẩu đang lưu trữ |
| 8 | Không sử dụng xác thực dựa trên kiến thức (KBA) / câu hỏi bảo mật | SHALL NOT | SECURITY_QUESTIONS | Sử dụng mã khôi phục hoặc một kênh đã được xác minh |
| 9 | Xác minh toàn bộ mật khẩu, không cắt bớt (truncation), không yêu cầu kiểu "nhập ký tự thứ 3, 5 và 8" | SHALL | TRUNCATION | Lưu ý giới hạn 72 byte của bcrypt (BCRYPT_72_BYTES) |
| - | So sánh toàn bộ mật khẩu với danh sách chặn gồm các giá trị phổ biến, dễ đoán và đã bị lộ; nêu rõ lý do khi từ chối | SHALL | NO_BLOCKLIST, BLOCKLIST_THIN | Kho dữ liệu mật khẩu bị rò rỉ (ví dụ: API truy vấn theo dải k-anonymity hoặc danh sách tải về), từ điển, tên dịch vụ, tên người dùng |
| - | Cung cấp hướng dẫn giúp người dùng chọn mật khẩu mạnh, đặc biệt là sau khi mật khẩu bị từ chối | SHALL | NO_GUIDANCE | Thanh đo độ mạnh và lý do từ chối bằng ngôn ngữ dễ hiểu |
| - | Giới hạn tốc độ các lần thử thất bại: không quá 100 lần thất bại liên tiếp trên mỗi tài khoản (Mục 3.2.2) | SHALL | NO_RATE_LIMIT | Độ trễ tăng dần, CAPTCHA, kiểm tra rủi ro; khóa cứng tài khoản sau 3 lần thử dễ dẫn đến tấn công từ chối dịch vụ (LOCKOUT_STRICT) |
| - | Cho phép trình quản lý mật khẩu và tự động điền (autofill); cho phép dán (paste) | xem Mục 3.1.1.2 | PASTE_BLOCKED | Không bao giờ chặn thao tác dán trên các trường mật khẩu |
| - | Lưu trữ mật khẩu đã thêm muối (salted) và băm (hashed) bằng lược đồ băm mật khẩu phù hợp (Mục 3.1.1.2, phần cuối) | SHALL | WEAK_STORAGE | Argon2id, scrypt, bcrypt hoặc PBKDF2 với salt duy nhất; các tham số đặt cao nhất có thể trong thực tế |
## Quy mô danh sách chặn (Blocklist)
NIST lưu ý rằng danh sách chặn chủ yếu ngăn chặn việc đoán mật khẩu trực tuyến (vốn đã bị giới hạn tốc độ), vì vậy danh sách này nên bao quát những mật khẩu mà kẻ tấn công sẽ thử trước khi chạm ngưỡng giới hạn (từ vài nghìn đến vài trăm nghìn mục); những danh sách quá lớn chủ yếu chỉ gây phiền toái cho người dùng. Hãy kiểm tra toàn bộ mật khẩu chứ không kiểm tra chuỗi con, nhưng cần bao gồm các biến thể hiển nhiên của tên dịch vụ và tên người dùng.
## Tại sao nên loại bỏ quy tắc kết hợp ký tự và thời hạn mật khẩu
Người dùng đối phó với quy định "chữ hoa + chữ số + ký tự đặc biệt" bằng `Password1!`; việc bắt buộc đổi mật khẩu định kỳ tạo ra `Password2!`, `Password3!`. Các công cụ tấn công đều mô hình hóa những khuôn mẫu này, vì vậy các quy tắc đó chỉ làm tăng sự phiền phức mà không mang lại entropy thực sự. Độ dài cộng với danh sách chặn mang lại hiệu quả cao hơn nhiều.
## Nguồn tham khảo
- NIST SP 800-63B-4, Digital Identity Guidelines: Authentication and Authenticator Management (Tháng 7 năm 2025), https://pages.nist.gov/800-63-4/sp800-63b.html
- Phụ lục A "Strength of Passwords" trong cùng tài liệu
FILE:references/entropy-and-hashing.md
# Ước tính Entropy và Băm Mật khẩu (Password Hashing)
## Hai phương pháp ước tính
**Entropy tập ký tự (Charset entropy)** = độ dài x log2(pool), với pool = 26 chữ thường + 26 chữ hoa + 10 chữ số + 33 ký tự đặc biệt (+100 nếu có ký tự ngoài bảng ASCII), chỉ tính các nhóm ký tự thực sự được sử dụng. Công thức này chính xác đối với các mật khẩu được tạo ngẫu nhiên từ tập ký tự đó nhưng lại đánh giá quá cao một cách sai lệch đối với lựa chọn của con người: `Password1!` đạt tới 65.7 bit.
**Entropy theo mẫu (Pattern entropy)** (trong script) xấp xỉ cách một công cụ bẻ khóa đoán mật khẩu: nó duyệt mật khẩu từ trái sang phải và tính điểm
| Thành phần | Chi phí |
|---|---|
| từ thông dụng trong danh sách tích hợp sẵn (bao gồm cả dạng leet như `P@ssw0rd`, các từ ngữ cảnh) | log2(kích thước danh sách) ~ 7.6 bit, +1 nếu viết hoa chữ cái đầu, +2 nếu dùng leet |
| năm từ 1950-2039 | log2(90) ~ 6.5 bit |
| chuỗi phím liền kề trên bàn phím hoặc bảng chữ cái (`qwerty`, `1234`, `zyx`) | log2(26) + log2(độ dài) |
| ký tự lặp lại (`aaaa`) | một ký tự + log2(số lần lặp) |
| chữ số/ký tự đặc biệt nối thêm vào cuối sau một từ | -1.5 bit (khuôn mẫu phổ biến nhất) |
| bất kỳ ký tự nào khác | log2(pool) |
| cụm mật khẩu (passphrase) từ 3 từ trở lên | 12.9 bit cho mỗi từ không phổ biến (danh sách Diceware gồm 7776 từ), 1 bit cho mỗi từ phổ biến |
Con số của cụm mật khẩu là giới hạn trên: nó chỉ đúng nếu các từ được chọn hoàn toàn ngẫu nhiên (bằng xúc xắc hoặc trình tạo ngẫu nhiên). Cụm "my dog loves pizza" yếu hơn thế rất nhiều. Danh sách từ tích hợp sẵn có quy mô nhỏ; một công cụ bẻ khóa thực tế sử dụng hàng tỷ mật khẩu bị rò rỉ, vì vậy hãy coi mọi ước tính đều là mức lạc quan đối với mật khẩu do con người tự chọn.
## Thang đánh giá (số bit theo mẫu)
| Số bit | Đánh giá | Ý nghĩa |
|---|---|---|
| < 28 | rất yếu (very weak) | Bị hạ gục bởi đoán trực tuyến hoặc bị bẻ khóa tức thì khi tấn công ngoại tuyến |
| 28-35 | yếu (weak) | Giới hạn tốc độ trực tuyến có giúp ích; nhưng tấn công ngoại tuyến chỉ mất vài phút |
| 36-59 | trung bình (fair) | Ổn trước các cuộc tấn công trực tuyến có giới hạn tốc độ; nhưng yếu nếu mã băm nhanh bị lộ |
| 60-79 | mạnh (strong) | Chống chịu được các cuộc tấn công ngoại tuyến trên các hàm băm chậm |
| >= 80 | rất mạnh (very strong) | Chống chịu được các cuộc tấn công ngoại tuyến ngay cả trên các hàm băm nhanh |
## Tốc độ đoán dùng để tính thời gian
- Ngoại tuyến, hàm băm nhanh (MD5/SHA-x trên GPU): 1e10 lần đoán mỗi giây.
- Ngoại tuyến, hàm băm chậm (Argon2id/bcrypt ở mức chi phí khuyến nghị): khoảng 1e4 lần đoán mỗi giây trên mỗi dàn máy của kẻ tấn công.
- Trực tuyến với cơ chế giới hạn tốc độ của NIST (<= 100 lần thất bại trên mỗi tài khoản, sau đó áp dụng độ trễ): chỉ vài trăm đến vài nghìn lần đoán đầu tiên là có ý nghĩa, đó là lý do danh sách chặn là biện pháp kiểm soát trực tuyến then chốt.
Thời gian hiển thị = trường hợp trung vị, 2^(bits-1) / tốc độ.
## Các tham số băm (mức tối thiểu theo OWASP Password Storage Cheat Sheet)
| Thuật toán | Mức tối thiểu | Ghi chú |
|---|---|---|
| Argon2id | m = 19456 KiB (19 MiB), t = 2, p = 1 | Lựa chọn ưu tiên hàng đầu |
| scrypt | N = 2^17, r = 8, p = 1 | |
| bcrypt | cost 10 | Đầu vào giới hạn ở 72 byte; cần băm sơ bộ (pre-hash) bằng HMAC-SHA256 + base64 hoặc giới hạn độ dài |
| PBKDF2-HMAC-SHA256 | 600000 vòng lặp | Sử dụng ở những nơi yêu cầu mật mã đạt chuẩn xác thực FIPS-140 |
Mỗi mã băm cần một muối (salt) ngẫu nhiên duy nhất (>= 16 byte). Một hạt tiêu (pepper - khóa bí mật được giữ bên ngoài cơ sở dữ liệu) tùy chọn sẽ bổ sung thêm lớp phòng thủ chiều sâu. NIST yêu cầu thêm muối và sử dụng hàm băm mật khẩu một chiều phù hợp; các con số của OWASP là mức cơ sở được sử dụng rộng rãi, không phải quy tắc của NIST.
## Chuyển đổi từ các mã băm yếu
1. Thêm thuật toán mới song song với thuật toán cũ.
2. Khi đăng nhập thành công, xác minh bằng mã băm cũ, sau đó băm lại bằng thuật toán mới.
3. Đối với những tài khoản không bao giờ đăng nhập, hãy bọc mã băm cũ lại (Argon2id(old_hash)) và đánh dấu chúng, hoặc bắt buộc đặt lại mật khẩu sau một thời hạn nhất định.
4. Kiểm tra lại trạng thái mật khẩu bị lộ lọt khi đăng nhập nếu nhà cung cấp của bạn hỗ trợ.
FILE:templates/policy-recommendation.md
# Khuyến nghị chính sách mật khẩu: <tên hệ thống>
**Phạm vi:** <cổng thông tin khách hàng / SSO nhân sự / bảng điều khiển quản trị> **Yếu tố xác thực:** <chỉ mật khẩu / mật khẩu + MFA>
**Cơ sở:** NIST SP 800-63B-4 (2025); các tham số băm từ OWASP Password Storage Cheat Sheet
**Tóm tắt:** <một câu: ví dụ: "Thay thế các quy tắc độ phức tạp và hết hạn định kỳ bằng độ dài tối thiểu 15 ký tự và danh sách chặn mật khẩu bị rò rỉ; chuyển đổi mã băm sang Argon2id.">
## Các thiết lập
| Thiết lập | Hiện tại | Khuyến nghị | NIST | Lý do |
|---|---|---|---|---|
| Độ dài tối thiểu | <8> | <15 (8 nếu luôn bật MFA)> | SHALL | <...> |
| Độ dài tối đa | <20> | <64 trở lên> | SHOULD | <...> |
| Quy tắc kết hợp ký tự | <chữ hoa, chữ thường, chữ số, ký tự đặc biệt> | không áp dụng | SHALL NOT | <...> |
| Thời hạn hiệu lực | <90 ngày> | không áp dụng; đặt lại khi bị lộ | SHALL NOT | <...> |
| Danh sách chặn (Blocklist) | <không có> | mật khẩu rò rỉ + từ điển + từ ngữ cảnh, khớp toàn bộ mật khẩu | SHALL | <...> |
| Dấu cách / Unicode | <từ chối> | chấp nhận, chuẩn hóa NFC | SHOULD | <...> |
| Dán / trình quản lý mật khẩu | <bị chặn> | cho phép | | <...> |
| Gợi ý / câu hỏi bảo mật | <có> | gỡ bỏ | SHALL NOT | <...> |
| Số lần thử thất bại | <khóa sau 3 lần trong 60 phút> | điều tiết tốc độ, <= 100 lần thất bại liên tiếp | SHALL | <...> |
| Hướng dẫn độ mạnh | <không có> | thanh đo độ mạnh + lý do từ chối | SHALL | <...> |
| Lưu trữ | <PBKDF2-SHA256, 10000> | <Argon2id m=19 MiB t=2 p=1> | SHALL (băm có muối) | <...> |
## Những gì các mẫu mật khẩu cho thấy (chỉ dùng mật khẩu giả định)
<Bảng ngắn hoặc câu tóm tắt: ví dụ: "4 trong số 5 khuôn mẫu điển hình (Password1!, Summer2025!) vượt qua được các quy tắc hiện nay ở mức ~20 bit; một cụm mật khẩu ngẫu nhiên gồm 4 từ (~54 bit) hiện bị từ chối nhưng sẽ được chấp nhận theo chính sách mới.">
## Lộ trình triển khai
1. <Ngày 0: gỡ bỏ thời hạn mật khẩu, cho phép dán và dùng dấu cách, bật thanh đo độ mạnh>
2. <Tuần 1: áp dụng danh sách chặn cho mật khẩu tạo mới và đổi mật khẩu; hiển thị rõ lý do từ chối>
3. <Tuần 2: nâng độ dài tối thiểu cho mật khẩu mới; áp dụng cho người dùng hiện tại vào lần đổi mật khẩu tiếp theo>
4. <Băm lại sang Argon2id vào lần đăng nhập tiếp theo; bắt buộc đặt lại mật khẩu đối với các tài khoản không hoạt động sau 6 tháng>
5. <Gỡ bỏ gợi ý mật khẩu và câu hỏi bảo mật; chuyển quy trình khôi phục sang email đã xác minh + mã khôi phục>
## Truyền thông tới người dùng
<2-3 câu: mật khẩu càng dài càng mạnh, khuyến khích dùng cụm mật khẩu (passphrase), khuyến khích dùng trình quản lý mật khẩu, không còn bắt buộc đổi mật khẩu định kỳ>
## Các câu hỏi mở
<Phạm vi bao phủ MFA, giới hạn của nhà cung cấp danh tính (IdP), các khung tuân thủ vẫn còn đề cập đến thời hạn mật khẩu (hãy kiểm tra các phiên bản hiện hành; nhiều khung tiêu chuẩn hiện đã cập nhật theo NIST)>
FILE:examples/legacy-portal-policy.json
{
"name": "Northwind customer portal (legacy policy)",
"single_factor": true,
"min_length": 8,
"max_length": 20,
"composition": {"upper": true, "lower": true, "digit": true, "symbol": true},
"expiry_days": 90,
"blocklist": {"enabled": false},
"allow_spaces": false,
"allow_unicode": false,
"allow_paste": false,
"truncate_at": null,
"hints": true,
"security_questions": true,
"strength_guidance": false,
"rate_limit": {"max_failed_attempts": 3, "lockout_minutes": 60},
"storage": {"algorithm": "PBKDF2-SHA256", "iterations": 10000, "salted": true}
}
FILE:examples/synthetic-samples.txt
# Made-up sample passwords for the review. NEVER put real passwords in this file.
Password1!
Summer2025!
Northwind#1
Qwerty123$
P@ssw0rd2026
tundra velvet orbit cactus
vT9#qLm2!xR4wZp7
glacier-mango-quietly-violin-ember
FILE:scripts/pw_policy.py
#!/usr/bin/env python3
"""Password policy and entropy checker (Python 3 standard library only).
Checks a password policy config against NIST SP 800-63B-4 (2025) password guidance and estimates
the strength of SYNTHETIC sample passwords. Never use real passwords: samples are masked in the
output unless --show-samples is given, and nothing is stored or sent anywhere.
Policy JSON (all keys optional except min_length):
{"name": "Customer portal", "single_factor": true, # password alone, no MFA
"min_length": 8, "max_length": 20,
"composition": {"upper": true, "lower": true, "digit": true, "symbol": true},
"expiry_days": 90,
"blocklist": {"enabled": true, "breached": true, "context_words": ["acme", "portal"]},
"allow_spaces": true, "allow_unicode": true, "unicode_normalization": "NFC",
"allow_paste": true, "truncate_at": null, "hints": false, "security_questions": false,
"strength_guidance": true,
"rate_limit": {"max_failed_attempts": 10, "lockout_minutes": 15},
"storage": {"algorithm": "argon2id", "memory_kib": 19456, "iterations": 2, "salted": true}}
Policy findings (severity):
HIGH MIN_LENGTH < 15 for single-factor passwords, < 8 with MFA (SHALL)
HIGH COMPOSITION_RULES required character classes (SHALL NOT)
HIGH PERIODIC_EXPIRY forced change every N days (SHALL NOT; change only on compromise)
HIGH NO_BLOCKLIST no blocklist check of the full password (SHALL)
HIGH HINTS / SECURITY_QUESTIONS password hints or KBA prompts (SHALL NOT)
HIGH TRUNCATION only part of the password is verified (SHALL verify the whole)
HIGH NO_RATE_LIMIT no throttling of failed attempts (SHALL; at most 100 in a row)
HIGH WEAK_STORAGE plaintext, reversible encryption, MD5/SHA-x or unsalted hashes
MEDIUM WEAK_KDF_PARAMS below OWASP minimums (PBKDF2-SHA256 600000, bcrypt cost 10,
Argon2id 19 MiB / t=2, scrypt N=2^17)
MEDIUM BCRYPT_72_BYTES bcrypt ignores bytes after 72 but max length allows more
MEDIUM MAX_LENGTH maximum below 64 (SHOULD allow at least 64)
MEDIUM NO_SPACES spaces not accepted (SHOULD accept printing ASCII and space)
MEDIUM PASTE_BLOCKED paste / password managers blocked (SHOULD allow)
MEDIUM NO_GUIDANCE no strength guidance shown to users (SHALL offer guidance)
MEDIUM BLOCKLIST_THIN blocklist without breached passwords or context words
LOW NO_UNICODE Unicode not accepted (SHOULD)
LOW NO_NORMALIZATION Unicode accepted without NFC normalization (SHOULD)
LOW LOCKOUT_STRICT lockout after < 5 attempts: easy denial of service, use throttling
Sample findings:
HIGH SAMPLE_WEAK_ACCEPTED a blocklisted or < 36-bit sample passes the current policy
Entropy (bits): charset = length x log2(pool of character classes used); pattern = a simple
guess-model estimate that charges little for common words, leet, years, keyboard runs, repeats
and passphrase words, and log2(pool) for every other character (per word log2(7776) = 12.9 bits at most). Rating uses the pattern estimate:
< 28 very weak, < 36 weak, < 60 fair, < 80 strong, else very strong.
Usage:
python3 pw_policy.py policy.json
python3 pw_policy.py policy.json --samples synthetic.txt [--context northwind,portal] [--blocklist list.txt] [--json]
python3 pw_policy.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import json
import math
import re
import sys
import unicodedata
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
COMMON = """password passw0rd welcome letmein qwerty qwertz azerty admin administrator login master
dragon monkey sunshine princess football baseball soccer hockey shadow superman batman iloveyou
trustno1 secret summer winter spring autumn fall monday friday january october december love
hello freedom whatever charlie michael jessica jordan pepper ginger cookie chocolate flower
computer internet google apple samsung starwars pokemon matrix killer hunter ranger thunder
tiger lion eagle dolphin falcon soccer liverpool chelsea arsenal barcelona madrid london paris
berlin newyork america canada company office service portal change changeme default guest user
test testing temp temporary access security system server database root toor manager support
student school teacher family mother father daddy mommy baby angel blessed jesus god heaven
money dollar euro bitcoin crypto summer2024 welcome1 password1 password123 abc123 iloveyou1
""".split()
COMMON_SET = set(COMMON)
TOP_BLOCK = set(COMMON) | {"123456", "12345678", "123456789", "1234567890", "111111", "000000",
"123123", "654321", "qwerty123", "1q2w3e4r", "zaq12wsx", "p@ssw0rd",
"password1!", "welcome123", "admin123", "letmein1", "qwertyuiop"}
LEET = str.maketrans({"@": "a", "4": "a", "0": "o", "1": "i", "!": "i", "3": "e", "$": "s",
"5": "s", "7": "t", "+": "t", "8": "b", "9": "g"})
ROWS = ["qwertyuiop", "asdfghjkl", "zxcvbnm", "1234567890", "abcdefghijklmnopqrstuvwxyz", "qwertzuiop", "azertyuiop"]
WORD_BITS = math.log2(7776)
class DataError(ValueError):
pass
def char_pool(pw):
pool = 0
if re.search(r"[a-z]", pw):
pool += 26
if re.search(r"[A-Z]", pw):
pool += 26
if re.search(r"[0-9]", pw):
pool += 10
if re.search(r"[ -/:-@\[-`{-~]", pw):
pool += 33
if any(ord(c) > 126 for c in pw):
pool += 100
return pool or 1
def class_bits(c):
if c.islower() and c.isascii():
return math.log2(26)
if c.isupper() and c.isascii():
return math.log2(26)
if c.isdigit():
return math.log2(10)
if ord(c) > 126:
return math.log2(100)
return math.log2(33)
def charset_bits(pw):
return len(pw) * math.log2(char_pool(pw))
def seq_len(s, i):
"""Length of a keyboard/alphabet run (forward or backward) starting at i."""
best = 1
low = s.lower()
for row in ROWS:
for r in (row, row[::-1]):
j = r.find(low[i]) if low[i] in r else -1
if j < 0:
continue
n = 1
while i + n < len(low) and j + n < len(r) and low[i + n] == r[j + n]:
n += 1
best = max(best, n)
return best
def pattern_bits(pw, context=()):
"""Greedy left-to-right guess model. Returns (bits, notes)."""
notes = []
vocab = COMMON_SET | {w.lower() for w in context if len(w) >= 3}
words = [w for w in re.split(r"[\s\-_.]+", pw) if w]
if len(words) >= 3 and all(re.fullmatch(r"[A-Za-z]{3,}", w) for w in words):
bits = sum(1.0 if w.lower() in vocab else WORD_BITS for w in words) + math.log2(4)
notes.append(f"passphrase of {len(words)} words (upper bound if words were picked at random)")
return min(bits, charset_bits(pw)), notes
i, bits, low = 0, 0.0, pw.lower()
brute = math.log2(char_pool(pw))
while i < len(pw):
# dictionary word (with leet), longest first
hit = None
for L in range(min(len(pw) - i, 14), 3, -1):
chunk = low[i:i + L]
plain = chunk.translate(LEET)
if plain in vocab:
hit = (L, plain, chunk != plain)
break
run = seq_len(pw, i)
if hit and run < hit[0]:
L, word, leet = hit
b = math.log2(len(COMMON)) + (1 if pw[i].isupper() else 0) + (2 if leet else 0)
notes.append(f"common word '{word}'" + (" (leet)" if leet else ""))
bits += b
i += L
continue
m = re.match(r"(19[5-9]\d|20[0-3]\d)", pw[i:])
if m:
notes.append(f"year {m.group(1)}")
bits += math.log2(90)
i += 4
continue
n = run
if n >= 3:
notes.append(f"keyboard/alphabet run '{pw[i:i + n]}'")
bits += math.log2(26) + math.log2(n)
i += n
continue
r = 1
while i + r < len(pw) and pw[i + r] == pw[i]:
r += 1
if r >= 3:
notes.append(f"repeated '{pw[i]}' x{r}")
bits += class_bits(pw[i]) + math.log2(r)
i += r
continue
bits += brute
i += 1
if notes and re.search(r"[0-9!?.]{1,3}$", pw) and len(pw) > 4:
bits -= 1.5
notes.append("digits/symbol appended at the end (most common pattern)")
return max(bits, 0.0), notes
def rating(bits):
return ("very weak" if bits < 28 else "weak" if bits < 36 else "fair" if bits < 60
else "strong" if bits < 80 else "very strong")
def crack_time(bits, rate):
sec = 2 ** (bits - 1) / rate
for unit, n in (("years", 31557600), ("days", 86400), ("hours", 3600), ("minutes", 60)):
if sec >= n:
v = sec / n
return f"{v:.3g} {unit}" if v < 100 else f"{v:,.0f} {unit}" if v < 1e6 else f"{v:.1e} {unit}"
return "instant" if sec < 1 else f"{sec:.0f} seconds"
def mask_pw(pw):
if len(pw) <= 4:
return "*" * len(pw)
return pw[:2] + "*" * (len(pw) - 3) + pw[-1:]
def policy_accepts(policy, pw):
reasons = []
if len(pw) < int(policy.get("min_length", 0)):
reasons.append("too short")
mx = policy.get("max_length")
if mx and len(pw) > int(mx):
reasons.append("too long")
comp = policy.get("composition") or {}
for key, rx in (("upper", r"[A-Z]"), ("lower", r"[a-z]"), ("digit", r"[0-9]"), ("symbol", r"[^A-Za-z0-9\s]")):
if comp.get(key) and not re.search(rx, pw):
reasons.append(f"needs {key}")
if " " in pw and policy.get("allow_spaces") is False:
reasons.append("space not allowed")
if any(ord(c) > 126 for c in pw) and policy.get("allow_unicode") is False:
reasons.append("unicode not allowed")
if (policy.get("blocklist") or {}).get("enabled") and pw.lower() in TOP_BLOCK:
reasons.append("blocklisted")
return not reasons, reasons
def core_word(low):
"""Strip leading/trailing digits and symbols, undo leet, keep letters: 'P@ssw0rd2026!' -> 'password'."""
core = re.sub(r"^[\d\W_]+|[\d\W_]+$", "", low)
return re.sub(r"[^a-z]", "", core.translate(LEET))
def blocklisted(pw, extra=(), context=()):
low = unicodedata.normalize("NFC", pw).lower()
if low in TOP_BLOCK or low in extra:
return "common/breached password"
core = core_word(low)
for w in context:
if w and len(w) >= 3 and w.lower() in core and len(low) - len(w) <= 6:
return f"based on context word '{w}'"
if core in COMMON_SET and len(low) - len(core) <= 6:
return "common word with a few digits/symbols"
return None
def check_policy(p):
f = []
def add(sev, code, msg):
f.append({"severity": sev, "code": code, "message": msg})
if "min_length" not in p:
raise DataError("policy needs min_length")
single = p.get("single_factor", True)
mn = int(p["min_length"])
need = 15 if single else 8
if mn < need:
add("HIGH", "MIN_LENGTH", f"minimum {mn} characters; NIST requires >= {need} for passwords used {'alone' if single else 'with MFA'}")
comp = [k for k, v in (p.get("composition") or {}).items() if v]
if comp:
add("HIGH", "COMPOSITION_RULES", f"requires {', '.join(comp)}; composition rules SHALL NOT be imposed (they produce 'Password1!' patterns); rely on length + blocklist")
if int(p.get("expiry_days") or 0) > 0:
add("HIGH", "PERIODIC_EXPIRY", f"forced change every {p['expiry_days']} days; SHALL NOT expire periodically, force a change only on evidence of compromise")
bl = p.get("blocklist") or {}
if not bl.get("enabled"):
add("HIGH", "NO_BLOCKLIST", "no blocklist; new passwords SHALL be checked in full against common, expected and breached passwords")
elif not bl.get("breached") or not bl.get("context_words"):
miss = [x for x, ok in (("breached passwords", bl.get("breached")), ("context words (service name, username)", bl.get("context_words"))) if not ok]
add("MEDIUM", "BLOCKLIST_THIN", f"blocklist lacks {' and '.join(miss)}")
if p.get("hints"):
add("HIGH", "HINTS", "password hints are stored; SHALL NOT be accessible to unauthenticated users - remove them")
if p.get("security_questions"):
add("HIGH", "SECURITY_QUESTIONS", "security questions (KBA) SHALL NOT be used; offer recovery via a verified email/phone or recovery codes")
if p.get("truncate_at"):
add("HIGH", "TRUNCATION", f"only the first {p['truncate_at']} characters are checked; the whole password SHALL be verified")
rl = p.get("rate_limit") or p.get("lockout")
if not rl:
add("HIGH", "NO_RATE_LIMIT", "no throttling of failed attempts; SHALL limit to at most 100 consecutive failures per account")
else:
n = int(rl.get("max_failed_attempts") or 0)
if n > 100:
add("HIGH", "NO_RATE_LIMIT", f"{n} failed attempts allowed; at most 100 consecutive failures")
elif 0 < n < 5:
add("LOW", "LOCKOUT_STRICT", f"lockout after {n} attempts lets anyone lock users out; prefer growing delays, CAPTCHA or risk checks before a hard lock")
st = p.get("storage") or {}
alg = str(st.get("algorithm", "")).lower().replace("-", "").replace("_", "")
if not alg:
add("MEDIUM", "WEAK_STORAGE", "storage algorithm not stated; confirm a salted password hashing function (Argon2id, scrypt, bcrypt, PBKDF2)")
elif alg in ("plaintext", "plain", "none", "aes", "encrypted", "base64", "md5", "sha1", "sha256", "sha512", "ntlm"):
add("HIGH", "WEAK_STORAGE", f"{st.get('algorithm')} is not a password hashing function; use Argon2id (or scrypt, bcrypt, PBKDF2) with a unique salt and rehash on next login")
else:
if st.get("salted") is False:
add("HIGH", "WEAK_STORAGE", "hashes are not salted; every password needs a unique random salt")
it = int(st.get("iterations") or 0)
if alg.startswith("pbkdf2") and it < 600000:
add("MEDIUM", "WEAK_KDF_PARAMS", f"PBKDF2 with {it} iterations; OWASP suggests >= 600000 for PBKDF2-HMAC-SHA256")
if alg == "bcrypt":
if int(st.get("cost") or 0) < 10:
add("MEDIUM", "WEAK_KDF_PARAMS", f"bcrypt cost {st.get('cost')}; use >= 10 (tune to ~250 ms)")
if int(p.get("max_length") or 999) > 72:
add("MEDIUM", "BCRYPT_72_BYTES", "bcrypt ignores input after 72 bytes; pre-hash (HMAC-SHA256, base64) or switch to Argon2id")
if alg.startswith("argon2") and (int(st.get("memory_kib") or 0) < 19456 or it < 2):
add("MEDIUM", "WEAK_KDF_PARAMS", "Argon2id below m=19456 KiB, t=2, p=1 (OWASP minimum)")
if alg == "scrypt" and int(st.get("n") or 0) < 2 ** 17:
add("MEDIUM", "WEAK_KDF_PARAMS", "scrypt N below 2^17 (r=8, p=1)")
mx = p.get("max_length")
if mx and int(mx) < 64:
add("MEDIUM", "MAX_LENGTH", f"maximum {mx} characters blocks passphrases and generated passwords; allow at least 64")
if p.get("allow_spaces") is False:
add("MEDIUM", "NO_SPACES", "spaces rejected; accept all printing ASCII characters and space so passphrases work")
if p.get("allow_paste") is False:
add("MEDIUM", "PASTE_BLOCKED", "paste blocked; allow paste and autofill so password managers work")
if p.get("strength_guidance") is False:
add("MEDIUM", "NO_GUIDANCE", "no guidance when a password is rejected; SHALL help users choose (strength meter, reason for rejection)")
if p.get("allow_unicode") is False:
add("LOW", "NO_UNICODE", "Unicode rejected; SHOULD accept it (count each code point as one character)")
elif p.get("allow_unicode") and str(p.get("unicode_normalization", "")).upper() != "NFC":
add("LOW", "NO_NORMALIZATION", "Unicode accepted without NFC normalization before hashing; the same password typed on two devices may not match")
return f
def check_samples(policy, samples, extra=(), context=()):
rows, f = [], []
ctx = (policy.get("blocklist") or {}).get("context_words") or []
if isinstance(ctx, bool):
ctx = []
ctx = list(ctx) + list(context)
for pw in samples:
cb = charset_bits(pw)
pb, notes = pattern_bits(pw, ctx)
bl = blocklisted(pw, extra, ctx)
ok, why = policy_accepts(policy, pw)
single = policy.get("single_factor", True)
nist_ok = bl is None and len(pw) >= (15 if single else 8)
row = {"sample": pw, "length": len(pw), "charset_bits": round(cb, 1), "pattern_bits": round(pb, 1),
"rating": "very weak" if bl else rating(pb), "blocklist": bl, "policy_accepts": ok,
"policy_reasons": why, "nist_ok": nist_ok, "notes": notes,
"offline_fast": crack_time(0 if bl else pb, 1e10), "offline_slow": crack_time(0 if bl else pb, 1e4)}
rows.append(row)
if ok and (bl or pb < 36):
f.append({"severity": "HIGH", "code": "SAMPLE_WEAK_ACCEPTED", "sample": pw,
"message": f"sample {{s}} passes the current policy but is {'blocklisted (' + bl + ')' if bl else rating(pb) + f' (~{pb:.0f} bits)'}"})
return rows, f
def render(policy, findings, rows, show):
def s(pw):
return pw if show else mask_pw(pw)
out = [f"# Password policy review: {policy.get('name', 'policy')}", "",
f"Basis: NIST SP 800-63B-4 (2025), passwords used {'as the only factor' if policy.get('single_factor', True) else 'with MFA'}",
f"Findings: {sum(x['severity'] == 'HIGH' for x in findings)} HIGH, {sum(x['severity'] == 'MEDIUM' for x in findings)} MEDIUM, {sum(x['severity'] == 'LOW' for x in findings)} LOW", ""]
for x in findings:
msg = x["message"].replace("{s}", s(x["sample"])) if "sample" in x else x["message"]
out.append(f"- {x['severity']} {x['code']}: {msg}")
if rows:
out += ["", "## Synthetic samples" + ("" if show else " (masked; --show-samples to print)"),
"| Sample | Len | Charset bits | Pattern bits | Rating | Current policy | NIST verdict | Offline fast hash / slow hash |",
"|---|---|---|---|---|---|---|---|"]
for r in rows:
cur = "accepts" if r["policy_accepts"] else "rejects (" + ", ".join(r["policy_reasons"]) + ")"
nist = "ok" if r["nist_ok"] else ("reject: " + r["blocklist"] if r["blocklist"] else "reject: too short")
out.append(f"| {s(r['sample'])} | {r['length']} | {r['charset_bits']} | {r['pattern_bits']} | {r['rating']} | {cur} | {nist} | {r['offline_fast']} / {r['offline_slow']} |")
out += ["", "Charset bits overstate human-chosen passwords; pattern bits are the better guide. Offline times assume 1e10 guesses/s (fast hash) and 1e4 guesses/s (Argon2id/bcrypt), median case."]
out += ["", "Samples must be made up for the review. Never paste or store real passwords."]
return "\n".join(out)
def self_test():
results = []
def check(name, cond):
results.append(cond)
print(("PASS " if cond else "FAIL ") + name)
def codes(**over):
p = {"min_length": 15, "max_length": 64, "blocklist": {"enabled": True, "breached": True, "context_words": ["acme"]},
"allow_spaces": True, "allow_unicode": True, "unicode_normalization": "NFC", "allow_paste": True,
"strength_guidance": True, "rate_limit": {"max_failed_attempts": 10},
"storage": {"algorithm": "argon2id", "memory_kib": 19456, "iterations": 2, "salted": True}}
p.update(over)
return [x["code"] for x in check_policy(p)]
check("compliant policy has no findings", codes() == [])
check("min 8 single factor HIGH", "MIN_LENGTH" in codes(min_length=8))
check("min 8 with MFA ok", "MIN_LENGTH" not in codes(min_length=8, single_factor=False))
check("min 6 with MFA HIGH", "MIN_LENGTH" in codes(min_length=6, single_factor=False))
check("composition HIGH", "COMPOSITION_RULES" in codes(composition={"upper": True, "symbol": True}))
check("expiry HIGH", "PERIODIC_EXPIRY" in codes(expiry_days=90))
check("no blocklist HIGH", "NO_BLOCKLIST" in codes(blocklist={"enabled": False}))
check("thin blocklist MEDIUM", "BLOCKLIST_THIN" in codes(blocklist={"enabled": True}))
check("hints + KBA HIGH", {"HINTS", "SECURITY_QUESTIONS"} <= set(codes(hints=True, security_questions=True)))
check("truncation HIGH", "TRUNCATION" in codes(truncate_at=16))
check("no rate limit HIGH", "NO_RATE_LIMIT" in codes(rate_limit=None))
check("lockout 3 LOW", "LOCKOUT_STRICT" in codes(rate_limit={"max_failed_attempts": 3}))
check("sha256 HIGH", "WEAK_STORAGE" in codes(storage={"algorithm": "SHA-256", "salted": True}))
check("unsalted HIGH", "WEAK_STORAGE" in codes(storage={"algorithm": "pbkdf2-sha256", "iterations": 600000, "salted": False}))
check("pbkdf2 low iterations MEDIUM", "WEAK_KDF_PARAMS" in codes(storage={"algorithm": "PBKDF2-SHA256", "iterations": 10000}))
check("bcrypt cost 8 + 72 bytes", {"WEAK_KDF_PARAMS", "BCRYPT_72_BYTES"} <= set(codes(storage={"algorithm": "bcrypt", "cost": 8}, max_length=128)))
check("max 20 MEDIUM", "MAX_LENGTH" in codes(max_length=20))
check("spaces/paste/guidance MEDIUM", {"NO_SPACES", "PASTE_BLOCKED", "NO_GUIDANCE"} <= set(codes(allow_spaces=False, allow_paste=False, strength_guidance=False)))
check("no unicode LOW", "NO_UNICODE" in codes(allow_unicode=False))
check("no NFC LOW", "NO_NORMALIZATION" in codes(unicode_normalization=None))
check("charset bits 8 lowercase", abs(charset_bits("abcdefgh") - 8 * math.log2(26)) < 1e-9)
check("charset pool all classes", char_pool("aA1!") == 95)
check("leet word cheap", pattern_bits("P@ssw0rd")[0] < 15)
check("year + word weak", pattern_bits("Summer2025!")[0] < 28)
check("keyboard run cheap", pattern_bits("qwertyuiop")[0] < 10)
check("repeat cheap", pattern_bits("aaaaaaaaaa")[0] < 10)
check("random 16 strong", pattern_bits("vT9#qLm2!xR4wZp7")[0] > 80)
check("4 random words ~ 53 bits", 50 < pattern_bits("tundra-velvet-orbit-cactus")[0] < 56)
check("common words passphrase weak", pattern_bits("love summer football")[0] < 10)
check("blocklisted exact", blocklisted("Password1!") is not None)
check("context word blocked", blocklisted("Acme2026!", context=["acme"]) is not None)
check("leet + year blocked", blocklisted("P@ssw0rd2026") is not None)
check("context word cheap in pattern", pattern_bits("Northwind#1", ["northwind"])[0] < 28)
check("long random not blocked", blocklisted("vT9#qLm2!xR4wZp7") is None)
pol = {"min_length": 8, "composition": {"upper": True, "lower": True, "digit": True, "symbol": True}}
rows, f = check_samples(pol, ["Password1!", "tundra velvet orbit cactus"])
check("composition policy accepts Password1!", rows[0]["policy_accepts"] and f and f[0]["code"] == "SAMPLE_WEAK_ACCEPTED")
check("composition policy rejects passphrase", not rows[1]["policy_accepts"] and rows[1]["nist_ok"])
check("mask hides middle", mask_pw("Password1!") == "Pa*******!")
check("crack time units", crack_time(0, 1e10) == "instant" and "years" in crack_time(80, 1e10))
try:
check_policy({})
check("missing min_length rejected", False)
except DataError:
check("missing min_length rejected", True)
print(f"{sum(results)}/{len(results)} passed")
return 0 if all(results) else 1
def main():
ap = argparse.ArgumentParser(description="Password policy and entropy checker (NIST SP 800-63B-4)")
ap.add_argument("policy", nargs="?", help="policy JSON")
ap.add_argument("--samples", help="file with SYNTHETIC sample passwords, one per line (# = comment)")
ap.add_argument("--blocklist", help="extra blocklist file, one password per line")
ap.add_argument("--context", default="", help="comma-separated context words (service name, brand, usernames)")
ap.add_argument("--show-samples", action="store_true", help="print samples unmasked")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args()
if a.self_test:
return self_test()
if not a.policy:
ap.error("give a policy JSON or --self-test")
try:
with open(a.policy, encoding="utf-8") as fh:
policy = json.load(fh)
findings = check_policy(policy)
rows = []
if a.samples:
with open(a.samples, encoding="utf-8") as fh:
samples = [l.rstrip("\n") for l in fh if l.strip() and not l.startswith("#")]
extra = set()
if a.blocklist:
with open(a.blocklist, encoding="utf-8") as fh:
extra = {l.strip().lower() for l in fh if l.strip()}
rows, sf = check_samples(policy, samples, extra, [w.strip() for w in a.context.split(",") if w.strip()])
findings += sf
except (OSError, ValueError, DataError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
findings.sort(key=lambda x: SEV_ORDER[x["severity"]])
if a.json:
if not a.show_samples:
for r in rows:
r["sample"] = mask_pw(r["sample"])
for x in findings:
if "sample" in x:
x["sample"] = mask_pw(x["sample"])
x["message"] = x["message"].replace("{s}", x["sample"])
print(json.dumps({"findings": findings, "samples": rows}, indent=2))
else:
print(render(policy, findings, rows, a.show_samples))
return 1 if any(x["severity"] == "HIGH" for x in findings) else 0
if __name__ == "__main__":
sys.exit(main())