Practice & recall

Flashcards & hands-on labs

Scenario questions that test whether you actually understand the kernel mechanics, and terminal labs where you write the command yourself before checking the answer.

Flashcards
76
Labs
12
Decks
4
Format
Self-check

Linux permissions & kernel credentials

Five production scenarios covering stale credentials, fsuid, SGID inheritance, the 16-bit i_mode bitmask, and root override order.

Linux Internals

Device-locked Microsoft 365 (Entra + Intune)

Seven scenarios covering report-only vs enforced Conditional Access, the Intune service-app exclusions, device-ID approval, compliance defaults, the Android work-profile boundary, paste semantics and the iOS enrollment chain.

Cloud & Identity

RHEL 10 storage, permissions & SELinux

Fifty-five cards covering LVM provisioning and growth, SUID/SGID/sticky semantics, POSIX ACLs, polkit, Pacemaker ACLs and the SELinux toolchain, aimed at RHCSA EX200 and RHCE EX294.

Red Hat Certification

AWS NACLs vs security groups

Nine scenarios on the VPC packet path: stateful SG versus stateless NACL, the ephemeral port trap, first-match-wins rule numbering, and diagnosing REJECTs with Flow Logs and Reachability Analyzer.

Cloud Networking

Hands-on labs

Each lab gives you a scenario and a set of requirements. Write the command first, then open the solution to check your reasoning.

introLinux Operations
~5 min

Starting and managing background processes

The server is stable again. Just as you are about to take a break, a developer messages you: they need a long-running script, data_processor.sh, started on the server. You cannot keep your terminal session open for hours just to babysit it, so it has to survive you logging out.

Tasks

  • Start data_processor.sh so it runs in the background and is immune to hangups.

Requirements

  • You must be in the ~/project directory.
  • Use nohup to run the script.
  • Use the & operator to send the process to the background.
  • Redirect both standard output and standard error to processor.log.

Solution

cd ~/project
nohup ./data_processor.sh > processor.log 2>&1 &

Why

nohup makes the process ignore SIGHUP, the signal the kernel sends to a session’s children when the controlling terminal goes away. Without it, logging out kills the script.

& detaches the job so the shell returns a prompt immediately instead of blocking.

The redirect order matters. > processor.log 2>&1 first points stdout at the file, then points stderr at *wherever stdout now goes*. Writing 2>&1 > processor.log instead sends stderr to the original terminal and only stdout to the file — a classic way to lose exactly the error output you wanted.

If you omit the redirect entirely, nohup quietly appends to nohup.out in the current directory, which is rarely what you want on a shared box.

Verify

jobs            # shows the job while the shell lives
ps -ef | grep data_processor
tail -f processor.log
coreLinux Internals
~8 min

Build a shared directory that keeps its group

Two engineers keep creating files in a shared deploy directory that land under their own primary groups, so the other one cannot write to them. You need new files and new subdirectories to inherit the devops group automatically, without anyone remembering to run chgrp.

Tasks

  • Create /srv/devops owned by deploy:devops.
  • Make it group-writable and ensure new entries inherit the devops group.
  • Confirm the inheritance also applies to subdirectories created later.

Requirements

  • The directory must be owned by user deploy and group devops.
  • New files inside must get group devops, not the creator’s primary group.
  • Use a single numeric chmod that sets the special bit.

Solution

sudo mkdir -p /srv/devops
sudo chown deploy:devops /srv/devops
sudo chmod 2775 /srv/devops

Why

The leading 2 is the SGID bit. On a directory it changes the inheritance rule for anything created inside: new entries take their group from the parent directory instead of from the creating process’s primary group.

New *subdirectories* also inherit the SGID bit itself, so the behaviour propagates down the tree with no further chmod.

SGID only governs the group, not the permission bits — those still come from each user’s umask. Pair this with a 002 umask, or a group-writable default ACL, or you will still get rw-r--r-- files that the other engineer cannot edit.

Verify

ls -ld /srv/devops     # expect drwxrwsr-x, note the s
sudo -u other touch /srv/devops/t && ls -l /srv/devops/t
advancedSecurity & Hardening
~10 min

Audit every setuid-root binary on the system

Security review week. You need a written inventory of every setuid-root executable on the box, because each one is a potential privilege-escalation path if it has a bug. Noise from unreadable directories must not pollute the report.

Tasks

  • Find every regular file under /usr owned by root with the setuid bit set.
  • Write the list to suid-audit.txt in your home directory.
  • Suppress permission-denied noise from the search itself.

Requirements

  • Match the setuid bit specifically, not any arbitrary permission.
  • Restrict results to regular files owned by root.
  • Send find’s own stderr to /dev/null, not the results.

Solution

find /usr -type f -user root -perm -4000 -print 2>/dev/null \
  > ~/suid-audit.txt

Why

-perm -4000 uses the leading dash, which means "at least these bits are set". Plain -perm 4000 would match only files whose mode is *exactly* 4000, which is essentially nothing, and /4000 means "any of these bits".

-type f excludes directories, where the 4000 bit means something entirely different, and -user root narrows it to the binaries that actually escalate to root.

2>/dev/null discards only find’s error stream, so unreadable directories stay quiet while the result list on stdout still reaches the file. Redirecting stdout instead would throw away the report.

Review the output against a known-good baseline. Anything unexpected — especially an interpreter, an editor or something under a user-writable path — is worth investigating immediately.

Verify

wc -l ~/suid-audit.txt
ls -l $(head -1 ~/suid-audit.txt)   # expect -rwsr-xr-x
coreCloud & Identity
~10 min

Create the missing Intune Enrollment service principal via Graph

Enrollment on a new phone dies with "You can’t get there from here" and the sign-in log shows error 53003 against `Microsoft Intune Enrollment`. In the Conditional Access resource picker the app cannot be selected at all, because it has no service principal in the tenant yet.

Tasks

  • Check whether a service principal already exists for app ID d4ebce55-015a-49b5-a083-c84d1797ae8c.
  • Create the service principal through Microsoft Graph if it is missing.
  • Read the service principal back and confirm its display name is Microsoft Intune Enrollment.

Requirements

  • Use az rest against Microsoft Graph, not the portal click-path.
  • Sign in with an account that has Application.ReadWrite.All consented.
  • GET before POST: creating a duplicate service principal fails with 409.

Solution

az rest --method get --url "https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq 'd4ebce55-015a-49b5-a083-c84d1797ae8c'&$select=id,appId,displayName" -o json
az rest --method post --url "https://graph.microsoft.com/v1.0/servicePrincipals" --body "{\"appId\": \"d4ebce55-015a-49b5-a083-c84d1797ae8c\"}"

Why

The trap is creating an app registration (POST /applications) instead of a service principal (POST /servicePrincipals). The Conditional Access picker lists service principals; an app registration alone does not make Microsoft Intune Enrollment selectable. The two objects share the same appId but are different directory objects.

The app ID d4ebce55-015a-49b5-a083-c84d1797ae8c is Microsoft’s published first-party constant for Intune Enrollment, so the POST body needs no tenant-specific value. Microsoft Intune itself is 0000000a-0000-0000-c000-000000000000.

The GET-first pattern matters operationally: re-running the POST after the service principal exists returns 409 Conflict, so a repeatable script checks value | length first and only creates when zero.

Verify

az rest --method get --url "https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq 'd4ebce55-015a-49b5-a083-c84d1797ae8c'&$select=appId,displayName" | jq -r '.value[0].displayName'   # "Microsoft Intune Enrollment"
az rest --method get --url "https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq 'd4ebce55-015a-49b5-a083-c84d1797ae8c'" | jq -e '.value | length == 1'   # exactly one SP, no duplicates
advancedCloud & Identity
~15 min

Audit Conditional Access policies and their Intune exclusions

Before rollout you owe a written audit: which Conditional Access policies are still report-only, which grant "require device to be marked as compliant", and whether the mobile policy really excludes the Intune service apps. Doing this in the portal is five clicks per policy and impossible to repeat; do it against Graph.

Tasks

  • List every Conditional Access policy with its id, state and display name.
  • For the mobile require-compliant policy, confirm the grant control and that both Intune first-party app IDs are in the excluded applications.

Requirements

  • Read-only: GET requests only.
  • Use az rest piped to jq.
  • Treat state enabledForReportingButNotEnforced as report-only, not enforcement.

Solution

az rest --method get --url "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies" -o json > /tmp/ca-policies.json
jq -r '.value[] | [.id, .state, .displayName] | @tsv' /tmp/ca-policies.json
jq '.value[] | select(.displayName == "Contoso - Mobile - Require compliant device") | {state, grant: .grantControls.builtInControls, excludedApps: .conditions.applications.excludeApplications}' /tmp/ca-policies.json

Why

Graph state values are enabled, disabled and enabledForReportingButNotEnforced. The trap is reading `enabledForReportingButNotEnforced` as "on" — it is report-only: sign-ins are logged as would-be success/failure but nothing is blocked. Only enabled enforces.

Exclusions live in conditions.applications.excludeApplications as app IDs, not display names — that is why the missing service principal mattered: you cannot exclude an app the tenant cannot resolve. The mobile policy must list d4ebce55-015a-49b5-a083-c84d1797ae8c and 0000000a-0000-0000-c000-000000000000 at minimum.

The per-user laptop rule keeps its approved-device list in conditions.devices.deviceFilter as a rule expression over device.deviceId — worth auditing in the same pass, since a mistyped ID there silently blocks an approved laptop.

Verify

jq -r '.value[] | select(.state == "enabledForReportingButNotEnforced") | .displayName' /tmp/ca-policies.json   # lists the report-only policies
jq -e '.value[] | select(.displayName == "Contoso - Mobile - Require compliant device") | .conditions.applications.excludeApplications | index("d4ebce55-015a-49b5-a083-c84d1797ae8c") and index("0000000a-0000-0000-c000-000000000000")' /tmp/ca-policies.json   # exit 0 only if both Intune app IDs are excluded
jq -r '.value[] | select(.displayName == "Contoso - Mobile - Require compliant device") | .grantControls.builtInControls[]' /tmp/ca-policies.json   # includes "compliant"
advancedCloud & Identity
~20 min

Enforce a report-only policy and revoke stale sessions

Every Report-only row in the sign-in logs for the pilot user looks right. Time to enforce `Contoso - Mobile - Require compliant device`. Last time a policy was switched On, users with already-open Outlook sessions kept working for hours because the old tokens were still valid.

Tasks

  • Set the policy state to enabled via Graph.
  • Revoke the pilot user’s sign-in sessions so tokens issued before enforcement die immediately.
  • Verify the policy state, and that a fresh sign-in from an unmanaged phone now fails with error 53003.

Requirements

  • Use az rest PATCH/POST against Graph.
  • Know the policy id and the pilot user’s UPN beforehand.
  • The 53003 check needs a real new sign-in attempt after the change; log rows from before do not count.

Solution

az rest --method patch --url "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies/<policy-id>" --body "{\"state\": \"enabled\"}"
az rest --method post --url "https://graph.microsoft.com/v1.0/users/alex@contoso.com/revokeSignInSessions"

Why

The trap is stopping at the PATCH. Flipping state to enabled changes evaluation for *new* token issuance; refresh tokens already issued keep working until they expire or are revoked. POST /users/{upn}/revokeSignInSessions is the second half of enforcement and is what makes the block effective immediately.

After enforcement, a fresh sign-in from an unmanaged phone fails the Require device to be marked as compliant grant and surfaces as error 53003 in auditLogs/signIns — the same error code the report-only Failure predicted, which is how you confirm the prediction was right.

Rollback is the reverse and takes effect within minutes: PATCH state back to disabled (or enabledForReportingButNotEnforced to keep observing). Intune-side policies (compliance, app protection) can stay in place during rollback; they do not gate sign-in on their own.

Verify

az rest --method get --url "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies/<policy-id>?$select=state" | jq -r '.state'   # "enabled"
az rest --method get --url "https://graph.microsoft.com/v1.0/auditLogs/signIns?$filter=userPrincipalName eq 'alex@contoso.com' and status/errorCode eq 53003&$top=5" | jq '.value[] | {createdDateTime, appDisplayName, error: .status.errorCode}'   # fresh 53003 rows after enforcement
coreRed Hat Certification
~20 min

Provision an LVM-backed shared directory with SGID inheritance

EX200-style task: build `/srv/app_data` on a fresh disk. A spare disk `/dev/sdb` is attached to your RHEL 10 VM, the team `devops` and users `dev_user` (member of devops) and `auditor_user` already exist or must be created, and the directory must survive reboots via fstab.

Tasks

  • Create PV, VG vg_projects and a 5 GB LV lv_appdata, then format it XFS.
  • Mount it persistently at /srv/app_data.
  • Set ownership root:devops and a mode that gives the group full access plus SGID inheritance.

Requirements

  • RHEL 10 VM with a spare disk at /dev/sdb.
  • Use a single numeric chmod that includes the special bit.
  • The mount must be registered in /etc/fstab, not just mounted by hand.
  • Do not put /srv/app_data on the root file system.

Solution

sudo pvcreate /dev/sdb
sudo vgcreate vg_projects /dev/sdb
sudo lvcreate -n lv_appdata -L 5G vg_projects
sudo mkfs.xfs /dev/vg_projects/lv_appdata
sudo mkdir -p /srv/app_data
echo "/dev/vg_projects/lv_appdata /srv/app_data xfs defaults 0 0" | sudo tee -a /etc/fstab
sudo mount -a
sudo chown root:devops /srv/app_data
sudo chmod 2770 /srv/app_data

Why

The order matters: PV then VG then LV then file system then mount — each layer consumes the one below it. mkfs.xfs goes on the LV device /dev/vg_projects/lv_appdata, and the fstab entry must match that path exactly or mount -a fails at boot.

The trap is `chmod 770` instead of `2770`. The leading 2 is the SGID bit; without it new files land in each creator’s primary group and the other teammates cannot write to them. SGID affects only the group, not the permission bits, which is why the mode is 2770 and not something exotic.

Validate fstab before rebooting with findmnt --verify — a typo there is a classic "boots to emergency mode" exam fail. mount -a after appending the line proves the entry parses.

Verify

ls -ld /srv/app_data        # expect drwxrws--- root devops (note the s)
findmnt /srv/app_data       # shows the LV as source, xfs, mounted
lvs                         # lv_appdata present in vg_projects
sudo -u dev_user touch /srv/app_data/probe && ls -l /srv/app_data/probe   # group is devops, not the creator’s primary group
advancedRed Hat Certification
~20 min

Grant auditor read-only access with ACLs and make the SELinux context persistent

On the `/srv/app_data` volume from the previous lab, `auditor_user` must be able to read the directory and every file created in it, but must never get write access. At the same time a web service needs to write there, so the directory needs a persistent `httpd_sys_rw_content_t` label that survives a relabel.

Tasks

  • Grant auditor_user read+execute on the directory via an access ACL.
  • Add a default ACL so new files are automatically readable by the auditor.
  • Write a persistent SELinux file-context rule for /srv/app_data(/.*)? and apply it.

Requirements

  • RHEL 10 with SELinux enforcing.
  • The SELinux change must be done through semanage fcontext, not chcon.
  • The auditor gets read-only — never write — through the default ACL.

Solution

sudo setfacl -m u:auditor_user:rx /srv/app_data
sudo setfacl -m d:u:auditor_user:r /srv/app_data
sudo semanage fcontext -a -t httpd_sys_rw_content_t "/srv/app_data(/.*)?"
sudo restorecon -Rv /srv/app_data

Why

Two different inheritance mechanisms are in play. The default ACL (d:u:auditor_user:r) propagates read access to new files at creation time; the SGID bit from the previous lab propagates the *group*. They compose: a new file gets group devops from SGID and the auditor’s named entry from the default ACL.

The trap is using `chcon -t httpd_sys_rw_content_t /srv/app_data`. That changes only the live label; the next touch /.autorelabel + reboot (or fixfiles -F onboot) resets it, because chcon writes nothing to the policy database. semanage fcontext -a records the rule persistently, and restorecon -Rv applies it now — that pair is the exam-correct answer.

Note the default ACL grants r only, on top of the directory’s rx: the auditor can list and read, never write, which is exactly the read-only auditing requirement.

Verify

getfacl /srv/app_data       # shows user:auditor_user:r-x and default:user:auditor_user:r--
ls -ld /srv/app_data        # shows a trailing + (ACL present)
ls -Zd /srv/app_data        # shows ...:httpd_sys_rw_content_t:...
semanage fcontext -l | grep app_data   # rule listed as persistent (local, not chcon)
sudo -u auditor_user cat /srv/app_data/<a file dev_user created>   # read works
coreRed Hat Certification
~10 min

Grow the XFS volume online after extending the LV

`/srv/app_data` on `/dev/vg_projects/lv_appdata` is at 95% capacity. You already ran `sudo lvextend -L +3G /dev/vg_projects/lv_appdata` and `lvs` shows the bigger LV, but `df -h /srv/app_data` still shows the old 5 GB.

Tasks

  • Expand the XFS file system to fill the enlarged LV, online, with the volume still mounted.
  • Prove the file system now reports the larger size.

Requirements

  • The volume must stay mounted the whole time (online grow).
  • Do not reformat or unmount.
  • Remember XFS cannot shrink — only grow commands are valid here.

Solution

sudo xfs_growfs /srv/app_data

Why

The trap is passing the device instead of the mount point. xfs_growfs operates on the mounted file system and takes the *mount point*: xfs_growfs /srv/app_data. xfs_growfs /dev/vg_projects/lv_appdata fails because the file system is mounted. (ext4 is the opposite: resize2fs takes the device.)

The one-liner alternative is lvextend -L +3G --resizefs /dev/vg_projects/lv_appdata, which grows the LV and the file system in a single step. Both are valid; knowing both is what the exam rewards.

XFS growth is online and instant because XFS allocates in AGs (allocation groups); shrinking would require rewriting those structures, which is why XFS refuses lvreduce on a live file system.

Verify

df -h /srv/app_data         # now shows ~8G instead of 5G
xfs_info /srv/app_data      # block counts grew, mount still active
lvs -o lv_name,lv_size,vg_name | grep lv_appdata
coreCloud Networking
~15 min

Audit a subnet’s NACL and an instance’s SG for the stateless trap

A web instance in a public subnet times out for users even though its security group allows inbound 443 from 0.0.0.0/0. Before changing anything you want a written picture of both firewalls: the NACL rules on the subnet (in and out, by rule number) and the SG rules on the instance.

Tasks

  • List the NACL entries of the instance’s subnet, ingress and egress separately, sorted by rule number.
  • List the security group rules of the instance and confirm whether any egress rule exists at all.
  • Decide from the output alone whether return traffic can leave the subnet.

Requirements

  • AWS CLI v2 configured against a lab account (aws sts get-caller-identity works).
  • jq installed.
  • Read-only: describe-* calls only, no changes.
  • You know the subnet ID and the security group ID.

Solution

SUBNET_ID=subnet-11111111111111111
SG_ID=sg-11111111111111111
aws ec2 describe-network-acls --filters Name=association.subnet-id,Values=$SUBNET_ID --query 'NetworkAcls[0].NetworkAclId' --output text
aws ec2 describe-network-acls --network-acl-ids $(aws ec2 describe-network-acls --filters Name=association.subnet-id,Values=$SUBNET_ID --query 'NetworkAcls[0].NetworkAclId' --output text) --query 'NetworkAcls[0].Entries | sort_by(@, &RuleNumber) | [?(Egress==`true`)] | .[] | {rule: RuleNumber, action: RuleAction, proto: Protocol, ports: PortRange, cidr: CidrBlock}' --output json
aws ec2 describe-security-groups --group-ids $SG_ID --query 'SecurityGroups[0].{in: IpPermissions, out: IpPermissionsEgress}' --output json

Why

The trap is trusting the SG view alone. A stateful SG with inbound 443 allowed needs *no* outbound rule for replies — but the subnet NACL is stateless and must explicitly allow egress to the client’s ephemeral range (TCP 1024–65535 to be safe across Linux/Windows/ELB clients). If the egress list lacks it, replies die at the NACL and users see a timeout.

Sorting NACL entries by RuleNumber matters because evaluation is lowest number first, first match wins — the order you read is the order AWS applies.

Also note what SGs *cannot* show: deny rules do not exist there, so any explicit block you see must live in the NACL.

Verify

aws ec2 describe-network-acls --filters Name=association.subnet-id,Values=subnet-11111111111111111 --query "NetworkAcls[0].Entries[?Egress==`true` && RuleAction==`allow`].PortRange" --output json   # must include an ephemeral range for return traffic
aws ec2 describe-security-groups --group-ids sg-11111111111111111 --query "SecurityGroups[0].IpPermissionsEgress" --output json   # SG egress may be open by default; that is NOT what blocks the reply
coreCloud Networking
~15 min

Add a NACL deny that actually fires, plus ephemeral egress

An abusive range, 203.0.113.0/24, is hammering the load balancer in your public subnet. The subnet’s NACL currently has rule 100 allowing inbound TCP 443 from 0.0.0.0/0, and nothing else. You need the bad range blocked *and* legitimate return traffic working.

Tasks

  • Add a NACL ingress deny for 203.0.113.0/24 at a rule number that is evaluated before the existing allow at 100.
  • Add a NACL egress allow for TCP 1024–65535 (return traffic to ephemeral client ports).
  • Add a NACL ingress allow for TCP 1024–65535 (responses to instance-initiated connections).

Requirements

  • A lab VPC with a custom NACL on a public subnet; do this in a lab account only.
  • Rules numbered in steps of 100 so future rules can slot in between.
  • The deny must have a lower rule number than any matching allow.

Solution

aws ec2 create-network-acl-entry --network-acl-id acl-11111111111111111 --ingress --rule-number 90 --protocol -1 --cidr-block 203.0.113.0/24 --rule-action deny
aws ec2 create-network-acl-entry --network-acl-id acl-11111111111111111 --ingress --rule-number 110 --protocol tcp --port-range From=1024,To=65535 --cidr-block 0.0.0.0/0 --rule-action allow
aws ec2 create-network-acl-entry --network-acl-id acl-11111111111111111 --egress --rule-number 100 --protocol tcp --port-range From=1024,To=65535 --cidr-block 0.0.0.0/0 --rule-action allow

Why

The trap is rule numbering. NACL rules evaluate lowest-number-first and stop at the first match. A deny at 200 behind an allow-all at 100 never fires; at 90 it blocks before the allow is read. Numbering in steps of 100 leaves room to slot future rules (e.g. 95) without renumbering.

The second trap is forgetting direction. A NACL is stateless: the egress allow for 1024–65535 is what lets replies to inbound 443 reach the client’s ephemeral port, and the ingress allow for 1024–65535 lets responses to instance-initiated outbound connections (yum, APIs) back in.

Remember the terminal * rule denies everything not matched — on a *custom* NACL that is every packet until you add rules, so add rules before associating it with a subnet.

Verify

aws ec2 describe-network-acls --network-acl-ids acl-11111111111111111 --query "sort_by(NetworkAcls[0].Entries, &RuleNumber)[].{rule: RuleNumber, egress: Egress, action: RuleAction, proto: Protocol, ports: PortRange.From, cidr: CidrBlock}" --output table   # deny 90 first, ephemeral allows present both directions
aws ec2 describe-network-acls --network-acl-ids acl-11111111111111111 --query "NetworkAcls[0].Entries[?RuleNumber==`90` && RuleAction==`deny`].CidrBlock" --output text   # 203.0.113.0/24
advancedCloud Networking
~20 min

Diagnose a blocked path with Flow Logs and Reachability Analyzer

Users report that https://your-app times out. The instance’s SG allows 443 inbound, the subnet NACL looks plausible, and you want a definitive answer about *which component* drops the packets — without rebooting or changing anything.

Tasks

  • Query recent VPC Flow Logs for the instance and find REJECT rows, noting direction.
  • Create a Network Insights Path from the internet gateway (or a test instance) to the instance on TCP 443 and run the analysis.
  • Read the analysis findings and name the blocking component.

Requirements

  • VPC Flow Logs enabled and delivered to CloudWatch Logs or S3, in a lab account.
  • AWS CLI v2 + jq.
  • An instance ID, its subnet ID, and an internet gateway ID (igw-...).
  • The insights-path creation is the only mutating call; the diagnosis itself is read-only.

Solution

aws ec2 create-network-insights-path --source igw-11111111111111111 --destination i-11111111111111111 --protocol tcp --destination-port 443 --tag-specifications 'ResourceType=network-insights-path,Tags=[{Key=Name,Value=diag-443}]'
aws ec2 start-network-insights-analysis --network-insights-path-id $(aws ec2 describe-network-insights-paths --filters Name=tag:Name,Values=diag-443 --query 'NetworkInsightsPaths[0].NetworkInsightsPathId' --output text)
aws ec2 describe-network-insights-analyses --network-insights-path-id $(aws ec2 describe-network-insights-paths --filters Name=tag:Name,Values=diag-443 --query 'NetworkInsightsPaths[0].NetworkInsightsPathId' --output text) --query 'NetworkInsightsAnalyses[0].{status: Status, findings: NetworkPathFound, explanations: Explanations}' --output json
aws logs filter-log-events --log-group-name /vpc/flowlogs --filter-pattern 'REJECT' --query 'events[].message' --output text | tail -20

Why

Flow Logs give the signature, Reachability Analyzer gives the culprit. Inbound ACCEPT + outbound REJECT on the flow logs = the stateless NACL egress gap (missing ephemeral rule). A REJECT on the first inbound packet = SG or NACL ingress. Reachability Analyzer walks route table, NACL entries by number, and SG rules, and returns the exact blocking component in Explanations.

The trap is trusting "the SG is fine" as the whole diagnosis. The SG can be perfect and the NACL still drops the reply; only the two-directional view (flow logs both ways, or the analyzer) separates the two firewalls.

Network Insights Paths need the source to be an IGW, instance, ENI, or VPN — an IGW-to-instance path on TCP 443 exercises NACL ingress + SG ingress in one analysis.

Verify

aws ec2 describe-network-insights-analyses --network-insights-path-id $(aws ec2 describe-network-insights-paths --filters Name=tag:Name,Values=diag-443 --query "NetworkInsightsPaths[0].NetworkInsightsPathId" --output text) --query "NetworkInsightsAnalyses[0].Status" --output text   # eventually "succeeded"
aws logs filter-log-events --log-group-name /vpc/flowlogs --filter-pattern "REJECT" --query "length(events[])" --output text   # count of rejected flows; correlate src/dst with the instance ENI