Audit filesystem and cloud IAM permissions for least-privilege violations — zero dependency core.
Two surfaces, one coherent API. Filesystem checks use only Node.js built-ins (zero deps). Cloud adapters are separate entry points that peer-dep on the relevant SDK — you only pay for what you import. All results share the same PermissionResult shape with fully discriminated violation types.
npm install is-unsafe-permissions# AWS
npm install is-unsafe-permissions @aws-sdk/client-iam @aws-sdk/client-s3
# GCP
npm install is-unsafe-permissions @google-cloud/iam
# Azure
npm install is-unsafe-permissions @azure/arm-authorizationimport { checkFsPerms } from 'is-unsafe-permissions'
// Single file — baseline world-writable check
const result = await checkFsPerms('/home/user/.ssh/id_rsa')
// { unsafe: false, violations: [] }
// With a built-in rule set
const result = await checkFsPerms('/home/user/.ssh/id_rsa', { ruleSet: 'ssh-keys' })
// {
// unsafe: true,
// violations: [
// { type: 'fs', rule: 'world-readable', severity: 'high',
// path: '/home/user/.ssh/id_rsa', actual: '0644', expected: '0600', detail: '...' }
// ]
// }
// Recursive directory scan
const result = await checkFsPerms('/etc/ssl', {
recursive: true,
ruleSet: 'certs',
depth: 3,
})import { aws } from 'is-unsafe-permissions/aws'
const result = await aws.checkPolicy({
Statement: [{ Effect: 'Allow', Action: '*', Resource: '*' }],
})
// {
// unsafe: true,
// violations: [
// { type: 'cloud', rule: 'wildcard-action', severity: 'critical', resource: '*', detail: '...' },
// { type: 'cloud', rule: 'wildcard-resource', severity: 'critical', resource: '*', detail: '...' },
// ]
// }import { checkFsPerms } from 'is-unsafe-permissions'| Option | Type | Default | Description |
|---|---|---|---|
ruleSet |
FsRuleSet |
— | Named rule set to apply (see table below) |
expect |
string | (stat, path) => string |
— | Expected octal mode, e.g. '0600' |
recursive |
boolean |
false |
Recursively scan directories |
depth |
number |
Infinity |
Max recursion depth |
followSymlinks |
boolean |
false |
Follow symbolic links |
checkSuid |
boolean |
true |
Check for SUID/SGID bits |
suidAllowlist |
string[] |
built-in list | Paths exempt from SUID/SGID violations |
// Custom expected mode — string
await checkFsPerms('/var/myapp/.env', { expect: '0600' })
// Custom expected mode — callback (dynamic rules per file)
await checkFsPerms('/etc/ssl', {
recursive: true,
expect: (stat, filePath) => filePath.endsWith('.key') ? '0600' : '0644',
})
// SUID allowlist
await checkFsPerms('/usr/local/bin', {
recursive: true,
suidAllowlist: ['/usr/local/bin/my-suid-tool'],
})Windows: Returns a platform-unsupported violation with severity: 'low' instead of throwing.
import { aws } from 'is-unsafe-permissions/aws'
// or named imports:
import { checkPolicy, checkBucket, checkRole } from 'is-unsafe-permissions/aws'Lint a raw IAM policy document (object or JSON string). Useful for Terraform/CloudFormation CI.
import { S3Client } from '@aws-sdk/client-s3'
const result = await aws.checkBucket('my-bucket', {
s3Client: new S3Client({ region: 'us-east-1' }),
checks: ['public-acl', 'encryption', 'versioning', 'mfa-delete', 'public-block'],
})Fetches and lints the trust policy plus all attached and inline policies.
import { IAMClient } from '@aws-sdk/client-iam'
const result = await aws.checkRole('arn:aws:iam::123456789:role/MyRole', {
iamClient: new IAMClient({ region: 'us-east-1' }),
})import { gcp } from 'is-unsafe-permissions/gcp'Accepts a single binding { role, members } or a full policy with a bindings array.
Checks for public access and missing uniform bucket-level access.
import { azure } from 'is-unsafe-permissions/azure'Accepts a single role assignment, an array, or a JSON string.
Checks for public blob access, shared key access, and HTTPS enforcement.
interface PermissionResult {
unsafe: boolean
violations: PermissionViolation[] // FsViolation | CloudViolation
error?: PermissionCheckError // present when the check itself failed
}All violations are tagged with a type field. This discriminates path (always present on 'fs') from resource (always present on 'cloud'), so TypeScript narrows correctly in a switch/if-else without optional chaining.
// FsViolation — from checkFsPerms
interface FsViolation {
type: 'fs'
rule: FsRule // autocomplete-friendly; accepts custom strings too
severity: Severity
path: string // always present
resource?: never // never present — narrows cleanly
actual?: string
expected?: string
detail?: string
}
// CloudViolation — from any cloud adapter
interface CloudViolation {
type: 'cloud'
rule: AwsRule | GcpRule | AzureRule // autocomplete-friendly
severity: Severity
resource: string // always present
path?: never // never present
actual?: string
expected?: string
detail?: string
}Example narrowing:
import { checkFsPerms, SEVERITY_WEIGHTS } from 'is-unsafe-permissions'
import { aws } from 'is-unsafe-permissions/aws'
const results = await Promise.all([
checkFsPerms('/etc/ssl', { recursive: true, ruleSet: 'certs' }),
aws.checkPolicy(policyDoc),
])
for (const { violations } of results) {
for (const v of violations) {
if (v.type === 'fs') {
console.log(v.path) // string — no ?. needed
} else {
console.log(v.resource) // string — no ?. needed
}
}
}A numeric weight map for sorting violations or setting CI thresholds.
import { SEVERITY_WEIGHTS } from 'is-unsafe-permissions'
// { critical: 4, high: 3, medium: 2, low: 1 }
// Sort highest severity first
violations.sort((a, b) => SEVERITY_WEIGHTS[b.severity] - SEVERITY_WEIGHTS[a.severity])
// Keep only high and above
const important = violations.filter(v => SEVERITY_WEIGHTS[v.severity] >= SEVERITY_WEIGHTS.high)Thrown (rather than silently swallowed) when a check encounters an operational problem — missing SDK, API throttling, malformed JSON, etc.
import { PermissionCheckError, ErrorCode } from 'is-unsafe-permissions'
try {
await aws.checkBucket('my-bucket', { s3Client })
} catch (err) {
if (err instanceof PermissionCheckError) {
switch (err.code) {
case ErrorCode.RESOURCE_NOT_FOUND: // bucket doesn't exist
case ErrorCode.PERMISSION_DENIED: // IAM doesn't allow GetBucketAcl
case ErrorCode.RATE_LIMITED: // API throttled
case ErrorCode.SDK_NOT_FOUND: // forgot to install peer dep
case ErrorCode.INVALID_POLICY: // malformed JSON passed to checkPolicy
case ErrorCode.TIMEOUT:
case ErrorCode.NETWORK_ERROR:
case ErrorCode.STAT_ERROR: // fs.lstat failed (checkFsPerms only)
}
console.error(err.cause) // original SDK/fs error is preserved
}
}All error codes are available as ErrorCode.* constants. The cause field always holds the original underlying error when one exists.
Every rule string is typed as a specific literal union, which means your editor autocompletes rule names and catches typos at compile time. The (string & {}) escape hatch means custom rule strings from user-defined checks still type-check without casting.
import type { FsRule, AwsRule, GcpRule, AzureRule, Rule } from 'is-unsafe-permissions'
// Filter to only critical AWS violations by rule
const wildcards = violations.filter(
(v): v is CloudViolation => v.type === 'cloud' &&
(v.rule === 'wildcard-action' || v.rule === 'wildcard-resource')
)| Rule set | Description |
|---|---|
ssh-keys |
Expect 0600; flag anything looser |
env-files |
.env, .env.local → expect 0600 |
certs |
Public certs 0644 ok, private keys must be 0600 |
shared-dirs |
/tmp-style dirs must have the sticky bit |
strict |
Flags anything world-accessible (read, write, or execute) |
web-root |
HTML/assets 0644 ok, no exec bits on content files |
log-dirs |
Writable by owner/group only, not world |
| Rule | Severity | Description |
|---|---|---|
wildcard-action |
critical | Action: * — grants all actions |
wildcard-resource |
critical | Resource: * — applies to all resources |
notaction-wildcard-resource |
critical | NotAction + Resource: * — effectively wildcard |
sensitive-action-no-condition |
high | IAM/KMS/STS actions with no Condition block |
cross-account-trust-no-condition |
high | Cross-account trust without ExternalId or MFA condition |
public-s3-acl |
critical | Bucket ACL grants access to AllUsers or AuthenticatedUsers |
unencrypted-s3-bucket |
medium | No server-side encryption configured |
no-mfa-delete |
medium | Versioned bucket without MFA Delete |
public-access-block-incomplete |
high | Missing one or more S3 Block Public Access settings |
| Rule | Severity | Description |
|---|---|---|
primitive-role-on-non-admin |
high | roles/owner or roles/editor on a non-service-account member |
all-users-binding |
critical | Any role granted to allUsers |
all-authenticated-users-binding |
high | Any role granted to allAuthenticatedUsers |
public-gcs-bucket |
critical | allUsers in a bucket IAM binding |
missing-uniform-bucket-level-access |
medium | Uniform bucket-level access not enabled |
| Rule | Severity | Description |
|---|---|---|
broad-role-subscription-scope |
high | Owner or Contributor at subscription scope |
owner-role-assigned |
high | Owner role assigned at any scope |
public-blob-access |
critical | allowBlobPublicAccess: true on storage account |
shared-key-access-enabled |
medium | Shared Key auth not disabled |
http-traffic-allowed |
high | supportsHttpsTrafficOnly not enforced |
import { checkFsPerms, SEVERITY_WEIGHTS } from 'is-unsafe-permissions'
import { aws } from 'is-unsafe-permissions/aws'
async function ciCheck() {
const [fsResult, iamResult] = await Promise.all([
checkFsPerms('/etc/ssl', { recursive: true, ruleSet: 'certs' }),
aws.checkPolicy(JSON.parse(readFileSync('./infra/iam-policy.json', 'utf8'))),
])
const all = [...fsResult.violations, ...iamResult.violations]
const criticals = all.filter(v => SEVERITY_WEIGHTS[v.severity] >= SEVERITY_WEIGHTS.critical)
if (criticals.length > 0) {
console.error('Critical permission violations:')
criticals.forEach(v => console.error(
` [${v.severity.toUpperCase()}] ${v.rule} — ${'path' in v ? v.path : v.resource}\n ${v.detail}`
))
process.exit(1)
}
}
ciCheck()terraform plan -out=plan.tfplan
terraform show -json plan.tfplan > plan.jsonimport { aws } from 'is-unsafe-permissions/aws'
import { PermissionCheckError } from 'is-unsafe-permissions'
import { readFileSync } from 'fs'
const plan = JSON.parse(readFileSync('plan.json', 'utf8'))
const policies = plan.resource_changes
.filter(r => r.type === 'aws_iam_policy')
.map(r => JSON.parse(r.change.after.policy))
for (const policy of policies) {
try {
const result = await aws.checkPolicy(policy)
if (result.unsafe) {
result.violations.forEach(v => console.error(`[${v.severity}] ${v.rule}: ${v.detail}`))
process.exit(1)
}
} catch (err) {
if (err instanceof PermissionCheckError && err.code === 'INVALID_POLICY') {
console.error('Malformed policy JSON in plan:', err.message)
process.exit(2)
}
throw err
}
}POSIX only (Linux / macOS) for filesystem checks. On Windows, checkFsPerms returns a single { type: 'fs', rule: 'platform-unsupported', severity: 'low' } violation rather than throwing. Cloud adapters work on all platforms.
src/
index.js ← checkFsPerms, SEVERITY_WEIGHTS, PermissionCheckError
types.d.ts ← all TypeScript types
errors.js ← PermissionCheckError, ErrorCode, classifyCloudError
fs/
check.js ← checkFsPerms() — Node.js fs only, zero dep
rules/
mode.js ← octal mode parser and comparator
rule-sets.js ← built-in named rule sets
suid.js ← SUID/SGID detection + allowlist
cloud/
aws/
index.js ← aws entry point
policy.js ← policy-only static analysis (zero dep)
bucket.js ← S3 live checks (peer-dep: @aws-sdk/client-s3)
role.js ← IAM live checks (peer-dep: @aws-sdk/client-iam)
gcp/
index.js ← gcp entry point
azure/
index.js ← azure entry point
specs/
fs-mode.spec.js
fs-check.spec.js
fs-recursive.spec.js
fs-rule-sets.spec.js
fs-suid.spec.js
cloud-aws-policy.spec.js
cloud-gcp.spec.js
cloud-azure.spec.js
result-type.spec.js
platform.spec.js